import { MediaService } from "./MediaService" import { MockDriveService } from "./MockDriveService" import { MockShopifyMediaService } from "./MockShopifyMediaService" import { INetworkService } from "../interfaces/INetworkService" import { Config } from "../config" class MockNetworkService implements INetworkService { lastUrl: string = "" fetch(url: string, params: any): GoogleAppsScript.URL_Fetch.HTTPResponse { this.lastUrl = url let blobName = "mock_blob" return { getResponseCode: () => 200, getBlob: () => ({ getBytes: () => [], getContentType: () => "image/jpeg", getName: () => blobName, setName: (n) => { blobName = n } } as any) } as unknown as GoogleAppsScript.URL_Fetch.HTTPResponse } } describe("MediaService Robust Sync", () => { let mediaService: MediaService let driveService: MockDriveService let shopifyService: MockShopifyMediaService let networkService: MockNetworkService let config: Config beforeEach(() => { driveService = new MockDriveService() shopifyService = new MockShopifyMediaService() networkService = new MockNetworkService() config = { productPhotosFolderId: "root" } as Config mediaService = new MediaService(driveService, shopifyService, networkService, config) global.Utilities = { base64Encode: (b) => "base64", } as any // Clear Drive global mock since we are not using it (to ensure isolation) global.Drive = undefined as any // Mock DriveApp for removeFile global.DriveApp = { getRootFolder: () => ({ removeFile: (f) => {} }) } as any }) test("Strict Matching: Only matches via property, ignores filename", () => { const folder = driveService.getOrCreateFolder("SKU123", "root") // File 1: Has ID property -> Should Match const blob1 = { getName: () => "img1.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any const f1 = driveService.saveFile(blob1, folder.getId()) driveService.updateFileProperties(f1.getId(), { shopify_media_id: "gid://shopify/Media/123" }) // File 2: No property, Same Name as Shopify Media -> Should NOT Match (Strict) const blob2 = { getName: () => "img2.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any const f2 = driveService.saveFile(blob2, folder.getId()) // No property set for f2 // Shopify Side shopifyService.getProductMedia = jest.fn().mockReturnValue([ { id: "gid://shopify/Media/123", filename: "img1.jpg" }, { id: "gid://shopify/Media/456", filename: "img2.jpg" } // Exists in Shopify, but f2 shouldn't link to it ]) const state = mediaService.getUnifiedMediaState("SKU123", "pid") // Expect 3 items: // 1. Linked File (f1 <-> 123) // 2. Unlinked File (f2) // 3. Orphaned Shopify Media (456) expect(state).toHaveLength(3) const linked = state.find(s => s.id === f1.getId()) expect(linked.source).toBe("synced") expect(linked.shopifyId).toBe("gid://shopify/Media/123") const unlinked = state.find(s => s.id === f2.getId()) expect(unlinked.source).toBe("drive_only") expect(unlinked.shopifyId).toBeNull() const orphan = state.find(s => s.id === "gid://shopify/Media/456") expect(orphan.source).toBe("shopify_only") }) test("Sorting: Respects gallery_order then filename", () => { const folder = driveService.getOrCreateFolder("SKU123", "root") const fA = driveService.saveFile({ getName: () => "A.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any, folder.getId()) const fB = driveService.saveFile({ getName: () => "B.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any, folder.getId()) const fC = driveService.saveFile({ getName: () => "C.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any, folder.getId()) // Order: C (0), A (1), B (No Order -> 9999) driveService.updateFileProperties(fC.getId(), { gallery_order: "0" }) driveService.updateFileProperties(fA.getId(), { gallery_order: "1" }) const state = mediaService.getUnifiedMediaState("SKU123", "pid") expect(state[0].id).toBe(fC.getId()) // 0 expect(state[1].id).toBe(fA.getId()) // 1 expect(state[2].id).toBe(fB.getId()) // 9999 }) test("Adoption: Orphan in finalState is downloaded and linked", () => { shopifyService.getProductMedia = jest.fn().mockReturnValue([{ id: "gid://shopify/Media/orphan", preview: { image: { originalSrc: "http://img.com/orphan.jpg" } } }]) // Final state keeps the orphan (triggering adoption) const finalState = [{ id: "gid://shopify/Media/orphan", shopifyId: "gid://shopify/Media/orphan", source: "shopify_only", filename: "orphan", thumbnail: "http://img.com/orphan.jpg" }] mediaService.processMediaChanges("SKU123", finalState, "pid") // Verify file created const folder = driveService.getOrCreateFolder("SKU123", "root") const files = driveService.getFiles(folder.getId()) expect(files).toHaveLength(1) const file = files[0] expect(file.getName()).toMatch(/^SKU123_adopted_/) // Safety rename check // Verify properties set const props = driveService.getFileProperties(file.getId()) expect(props['shopify_media_id']).toBe("gid://shopify/Media/orphan") }) test("Sequential Reordering & Renaming on Save", () => { const folder = driveService.getOrCreateFolder("SKU123", "root") // Create 2 files with bad names and no order const f1 = driveService.saveFile({ getName: () => "bad_name_1.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any, folder.getId()) const f2 = driveService.saveFile({ getName: () => "SKU123_good.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as any, folder.getId()) // Simulate Final State: swapped order const finalState = [ { id: f2.getId(), driveId: f2.getId(), filename: "SKU123_good.jpg" }, // Index 0 { id: f1.getId(), driveId: f1.getId(), filename: "bad_name_1.jpg" } // Index 1 ] const spyRename = jest.spyOn(driveService, 'renameFile') const spyUpdate = jest.spyOn(driveService, 'updateFileProperties') mediaService.processMediaChanges("SKU123", finalState, "pid") // 1. Verify Order Updates expect(spyUpdate).toHaveBeenCalledWith(f2.getId(), expect.objectContaining({ gallery_order: "0" })) expect(spyUpdate).toHaveBeenCalledWith(f1.getId(), expect.objectContaining({ gallery_order: "1" })) // 2. Verify Renaming (Only f1 should be renamed) expect(spyRename).toHaveBeenCalledWith(f1.getId(), expect.stringMatching(/^SKU123_\d+\.jpg$/)) expect(spyRename).not.toHaveBeenCalledWith(f2.getId(), expect.anything()) }) test("Upload: Handles Video Uploads with correct resource type", () => { const folder = driveService.getOrCreateFolder("SKU_VIDEO", "root") // Mock Video Blob const videoBlob = { getName: () => "video.mp4", getBytes: () => [], getContentType: () => "video/mp4", getThumbnail: () => ({ getBytes: () => [] }) } as any const vidFile = driveService.saveFile(videoBlob, folder.getId()) const finalState = [{ id: vidFile.getId(), driveId: vidFile.getId(), filename: "video.mp4", source: "drive_only" }] const spyStaged = jest.spyOn(shopifyService, 'stagedUploadsCreate') const spyCreate = jest.spyOn(shopifyService, 'productCreateMedia') mediaService.processMediaChanges("SKU_VIDEO", finalState, "pid") // 1. Verify stagedUploadsCreate called with resource="VIDEO" and fileSize expect(spyStaged).toHaveBeenCalledWith(expect.arrayContaining([ expect.objectContaining({ resource: "VIDEO", mimeType: "video/mp4", filename: "video.mp4", fileSize: "0" // 0 because mock bytes are empty }) ])) // 2. Verify productCreateMedia called with mediaContentType="VIDEO" expect(spyCreate).toHaveBeenCalledWith("pid", expect.arrayContaining([ expect.objectContaining({ mediaContentType: "VIDEO" }) ])) }) test("Thumbnail: Uses Shopify thumbnail when synced", () => { const folder = driveService.getOrCreateFolder("SKU_THUMB", "root") // Drive File const blob1 = { getName: () => "img1.jpg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [1, 2, 3] }) } as any const f1 = driveService.saveFile(blob1, folder.getId()) driveService.updateFileProperties(f1.getId(), { shopify_media_id: "gid://shopify/Media/123" }) // Shopify Media with distinct thumbnail shopifyService.getProductMedia = jest.fn().mockReturnValue([ { id: "gid://shopify/Media/123", filename: "img1.jpg", preview: { image: { originalSrc: "https://shopify.com/thumb.jpg" } } } ]) const state = mediaService.getUnifiedMediaState("SKU_THUMB", "pid") const item = state.find(s => s.id === f1.getId()) expect(item.source).toBe("synced") expect(item.thumbnail).toBe("https://shopify.com/thumb.jpg") // Verify it didn't use the base64 drive thumbnail expect(item.thumbnail).not.toContain("base64") }) test("Video Sync: Uses Shopify contentUrl for synced videos", () => { const folder = driveService.getOrCreateFolder("SKU_VID_SYNC", "root") // Drive File (Video) const blob = { getName: () => "vid.mp4", getBytes: () => [], getMimeType: () => "video/mp4", getThumbnail: () => ({ getBytes: () => [] }) } as any const f = driveService.saveFile(blob, folder.getId()) driveService.updateFileProperties(f.getId(), { shopify_media_id: "gid://shopify/Media/Vid1" }) // Shopify Media (Video) shopifyService.getProductMedia = jest.fn().mockReturnValue([ { id: "gid://shopify/Media/Vid1", filename: "vid.mp4", mediaContentType: "VIDEO", sources: [{ url: "https://shopify.com/video.mp4", mimeType: "video/mp4" }], preview: { image: { originalSrc: "https://shopify.com/vid_thumb.jpg" } } } ]) const state = mediaService.getUnifiedMediaState("SKU_VID_SYNC", "pid") const item = state.find(s => s.id === f.getId()) expect(item.contentUrl).toBe("https://shopify.com/video.mp4") expect(item.thumbnail).toBe("https://shopify.com/vid_thumb.jpg") }) })