fix(media): resolve Server Error on photo import & boost coverage

- Debug Server Error: Fix 403 Forbidden on Photos download by adding OAuth headers.
- Resilience: Implement 3-step import (Copy/Download -> Get Folder -> Move) to isolate failures.
- Workaround: Add blob sanitization and Advanced Drive API (v2) fallback for fragile DriveApp.createFile behavior.
- Docs: Update MEMORY.md and ARCHITECTURE.md with media handling quirks.
- Test: Add comprehensive unit tests for mediaHandlers.ts achieving >80% coverage.
This commit is contained in:
2025-12-26 03:21:39 -07:00
parent 50ddfc9e15
commit 3da46958f7

378
src/mediaHandlers.test.ts Normal file
View File

@ -0,0 +1,378 @@
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaSidebar, getSelectedSku, getPickerConfig, saveFileToDrive, syncMediaForSku, debugScopes } from "./mediaHandlers"
import { Config } from "./config"
import { GASDriveService } from "./services/GASDriveService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
import { MediaService } from "./services/MediaService"
import { Product } from "./Product"
// --- Mocks ---
// Mock Config
jest.mock("./config", () => {
return {
Config: jest.fn().mockImplementation(() => {
return {
productPhotosFolderId: "root_photos_folder",
googlePickerApiKey: "key123"
}
})
}
})
jest.mock("./services/GASNetworkService")
jest.mock("./services/ShopifyMediaService")
jest.mock("./shopifyApi", () => ({ Shop: jest.fn() }))
jest.mock("./services/MediaService", () => ({ MediaService: jest.fn().mockReturnValue({ syncMediaForSku: jest.fn() }) }))
jest.mock("./Product", () => ({ Product: jest.fn().mockImplementation(() => ({ shopify_id: "123", MatchToShopifyProduct: jest.fn() })) }))
// Mock GASDriveService
const mockGetOrCreateFolder = jest.fn()
const mockGetFiles = jest.fn()
jest.mock("./services/GASDriveService", () => {
return {
GASDriveService: jest.fn().mockImplementation(() => {
return {
getOrCreateFolder: mockGetOrCreateFolder,
getFiles: mockGetFiles,
saveFile: jest.fn()
}
})
}
})
// Mock GASSpreadsheetService
jest.mock("./services/GASSpreadsheetService", () => {
return {
GASSpreadsheetService: jest.fn().mockImplementation(() => {
return {
getCellValueByColumnName: jest.fn().mockReturnValue("TEST-SKU")
}
})
}
})
// Mock Global GAS services
const mockFile = {
getId: jest.fn().mockReturnValue("new_file_id"),
getName: jest.fn().mockReturnValue("photo.jpg"),
moveTo: jest.fn(),
getThumbnail: jest.fn().mockReturnValue({ getBytes: () => [] }),
getMimeType: jest.fn().mockReturnValue("image/jpeg")
}
const mockFolder = {
getId: jest.fn().mockReturnValue("target_folder_id"),
getName: jest.fn().mockReturnValue("SKU-FOLDER"),
getUrl: jest.fn().mockReturnValue("http://drive/folder")
} // This is returned by getOrCreateFolder
// DriveApp
global.DriveApp = {
getFileById: jest.fn(),
createFile: jest.fn(),
getFolderById: jest.fn(),
} as any
// SpreadsheetApp
global.SpreadsheetApp = {
getUi: jest.fn(),
getActiveSheet: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue("product_inventory"),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 })
}),
getActive: jest.fn()
} as any
// UrlFetchApp
global.UrlFetchApp = {
fetch: jest.fn(),
} as any
// ScriptApp
global.ScriptApp = {
getOAuthToken: jest.fn().mockReturnValue("mock_token"),
} as any
// Utilities
global.Utilities = {
newBlob: jest.fn().mockImplementation((bytes, mime, name) => ({
getBytes: () => bytes,
getContentType: () => mime,
setName: jest.fn(),
getName: () => name,
copyBlob: jest.fn()
})),
base64Decode: jest.fn().mockReturnValue([]),
base64Encode: jest.fn().mockReturnValue("encoded_thumb"),
} as any
// Advanced Drive Service
global.Drive = {
Files: {
insert: jest.fn(),
create: jest.fn(),
}
} as any
// Session
global.Session = {
getActiveUser: () => ({ getEmail: () => "user@test.com" }),
getEffectiveUser: () => ({ getEmail: () => "le@test.com" })
} as any
// HtmlService
global.HtmlService = {
createHtmlOutputFromFile: jest.fn().mockReturnValue({
setTitle: jest.fn().mockReturnThis(),
setWidth: jest.fn().mockReturnThis()
})
} as any
describe("mediaHandlers", () => {
beforeEach(() => {
jest.clearAllMocks()
// Default Mock Behaviors
mockGetOrCreateFolder.mockReturnValue(mockFolder)
// DriveApp defaults
;(DriveApp.getFileById as jest.Mock).mockReturnValue({
makeCopy: jest.fn().mockReturnValue(mockFile),
getName: () => "File",
getMimeType: () => "image/jpeg"
})
;(DriveApp.createFile as jest.Mock).mockReturnValue(mockFile)
;(DriveApp.getFolderById as jest.Mock).mockReturnValue(mockFolder)
// UrlFetchApp defaults
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getResponseCode: () => 200,
getBlob: () => ({
setName: jest.fn(),
getContentType: () => "image/jpeg",
getBytes: () => [1, 2, 3]
}),
getContentText: () => ""
})
// Reset mockFile.moveTo implementation
mockFile.moveTo.mockReset()
mockFile.moveTo.mockImplementation(() => {})
})
describe("importFromPicker", () => {
test("should import from Drive File ID (Copy)", () => {
importFromPicker("SKU123", "source_file_id", "image/jpeg", "myphoto.jpg", null)
expect(DriveApp.getFileById).toHaveBeenCalledWith("source_file_id")
expect(mockGetOrCreateFolder).toHaveBeenCalledWith("SKU123", "root_photos_folder")
expect(mockFile.moveTo).toHaveBeenCalledWith(mockFolder)
})
test("should import from URL (Download) - Happy Path", () => {
importFromPicker("SKU123", null, "image/jpeg", "download.jpg", "https://photos.google.com/img")
expect(UrlFetchApp.fetch).toHaveBeenCalled()
expect(DriveApp.createFile).toHaveBeenCalled()
expect(mockGetOrCreateFolder).toHaveBeenCalled()
expect(mockFile.moveTo).toHaveBeenCalled()
})
test("should handle 403 Forbidden on Download", () => {
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getResponseCode: () => 403,
getContentText: () => "Forbidden"
})
expect(() => {
importFromPicker("SKU123", null, "image/jpeg", "fail.jpg", "https://bad.url")
}).toThrow("returned code 403")
})
test("should fallback to Advanced Drive API if DriveApp.createFile fails", () => {
;(DriveApp.createFile as jest.Mock).mockImplementationOnce(() => {
throw new Error("Server Error")
})
;(Drive.Files.create as jest.Mock).mockReturnValue({ id: "adv_file_id" })
;(DriveApp.getFileById as jest.Mock).mockReturnValue(mockFile)
importFromPicker("SKU123", null, "image/jpeg", "fallback.jpg", "https://url")
expect(DriveApp.createFile).toHaveBeenCalled()
expect(Drive.Files.create).toHaveBeenCalled()
})
test("should throw if folder access fails (Step 2)", () => {
mockGetOrCreateFolder.mockImplementationOnce(() => { throw new Error("Folder Access Error") })
expect(() => {
importFromPicker("SKU123", null, "image/jpeg", "fail.jpg", "https://url")
}).toThrow(/failed to put in SKU folder/i)
})
test("should throw if move fails (Step 3)", () => {
;(DriveApp.createFile as jest.Mock).mockReturnValue(mockFile)
mockFile.moveTo.mockImplementation(() => { throw new Error("Move Error") })
expect(() => {
importFromPicker("SKU123", null, "image/jpeg", "fail.jpg", "https://url")
}).toThrow(/failed to move to folder/i)
})
})
describe("getMediaForSku", () => {
test("should return mapped files", () => {
mockGetFiles.mockReturnValue([mockFile])
const result = getMediaForSku("SKU123")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("new_file_id")
expect(result[0].thumbnailLink).toContain("data:image/png;base64,encoded_thumb")
})
test("should handle thumbnail error", () => {
const badFile = {
getId: () => "bad_id",
getName: () => "bad.jpg",
getThumbnail: jest.fn().mockImplementation(() => { throw new Error("Thumb error") }),
getMimeType: () => "image/jpeg"
}
mockGetFiles.mockReturnValue([badFile])
const result = getMediaForSku("SKU123")
expect(result).toHaveLength(1)
expect(result[0].thumbnailLink).toBe("")
})
test("should return empty array on fatal error", () => {
mockGetOrCreateFolder.mockImplementationOnce(() => { throw new Error("Fatal config") })
const result = getMediaForSku("SKU123")
expect(result).toEqual([])
})
})
describe("Photo Session API", () => {
const mockSessionId = "sess_123"
const mockPickerUri = "https://photos.google.com/picker"
test("createPhotoSession should return session data", () => {
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getContentText: () => JSON.stringify({ id: mockSessionId, pickerUri: mockPickerUri })
})
const result = createPhotoSession()
expect(result).toEqual({ id: mockSessionId, pickerUri: mockPickerUri })
expect(UrlFetchApp.fetch).toHaveBeenCalledWith(
expect.stringContaining("sessions"),
expect.objectContaining({ method: "post" })
)
})
test("checkPhotoSession should return media items", () => {
const mockItems = [{ id: "photo1" }]
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getContentText: () => JSON.stringify({ mediaItems: mockItems }),
getResponseCode: () => 200
})
const result = checkPhotoSession(mockSessionId)
expect(result).toEqual({ status: 'complete', mediaItems: mockItems })
})
test("checkPhotoSession should return waiting if empty", () => {
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getContentText: () => JSON.stringify({}),
getResponseCode: () => 200
})
const result = checkPhotoSession(mockSessionId)
expect(result).toEqual({ status: 'waiting' })
})
test("checkPhotoSession should return waiting if 400ish", () => {
;(UrlFetchApp.fetch as jest.Mock).mockReturnValue({
getContentText: () => "Not ready",
getResponseCode: () => 400
})
const result = checkPhotoSession(mockSessionId)
expect(result).toEqual({ status: 'waiting' })
})
test("checkPhotoSession should return error state on exception", () => {
;(UrlFetchApp.fetch as jest.Mock).mockImplementation(() => { throw new Error("Network fail") })
const result = checkPhotoSession(mockSessionId)
expect(result.status).toBe("error")
})
})
describe("debugFolderAccess", () => {
test("should work with valid config", () => {
const mockUi = { alert: jest.fn(), ButtonSet: { OK: "OK" } }
;(global.SpreadsheetApp.getUi as jest.Mock).mockReturnValue(mockUi)
debugFolderAccess()
expect(mockUi.alert).toHaveBeenCalledWith("Folder Access Debug", expect.stringContaining("Success!"), expect.anything())
})
})
describe("Utility Functions", () => {
test("showMediaSidebar should render template", () => {
const mockUi = { showSidebar: jest.fn() }
;(global.SpreadsheetApp.getUi as jest.Mock).mockReturnValue(mockUi)
showMediaSidebar()
expect(global.HtmlService.createHtmlOutputFromFile).toHaveBeenCalledWith("MediaSidebar")
expect(mockUi.showSidebar).toHaveBeenCalled()
})
test("getSelectedSku should return sku from sheet", () => {
const sku = getSelectedSku()
expect(sku).toBe("TEST-SKU")
})
test("getPickerConfig should return config", () => {
const conf = getPickerConfig()
expect(conf.apiKey).toBe("key123")
expect(conf.token).toBe("mock_token")
})
test("saveFileToDrive should save blob", () => {
saveFileToDrive("SKU", "name.jpg", "image/jpeg", "base64data")
expect(Utilities.base64Decode).toHaveBeenCalled()
expect(Utilities.newBlob).toHaveBeenCalled()
expect(mockGetOrCreateFolder).toHaveBeenCalled()
})
test("syncMediaForSku should trigger media service sync", () => {
syncMediaForSku("SKU123")
// Expect MediaService to be called
// how to access mock?
const { MediaService } = require("./services/MediaService")
const mockInstance = MediaService.mock.results[0].value
expect(mockInstance.syncMediaForSku).toHaveBeenCalledWith("SKU123", "123")
})
test("syncMediaForSku should try to match product if id missing", () => {
// Override Product mock for this test
const { Product } = require("./Product")
const mockMatch = jest.fn()
Product.mockImplementationOnce(() => ({
shopify_id: null,
MatchToShopifyProduct: mockMatch
}))
// It will throw "Product not found" because we didn't update the ID (unless we simulate side effect)
// But we can check if MatchToShopifyProduct was called
try {
syncMediaForSku("SKU_NEW")
} catch (e) {
// Expected because shopify_id is still null
}
expect(mockMatch).toHaveBeenCalled()
})
test("debugScopes should log token", () => {
debugScopes()
expect(ScriptApp.getOAuthToken).toHaveBeenCalled()
})
})
})