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:
378
src/mediaHandlers.test.ts
Normal file
378
src/mediaHandlers.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user