diff --git a/src/interfaces/IDriveService.ts b/src/interfaces/IDriveService.ts index 8796a93..d62fe81 100644 --- a/src/interfaces/IDriveService.ts +++ b/src/interfaces/IDriveService.ts @@ -3,4 +3,8 @@ export interface IDriveService { saveFile(blob: GoogleAppsScript.Base.Blob, folderId: string): GoogleAppsScript.Drive.File getFiles(folderId: string): GoogleAppsScript.Drive.File[] getFileById(id: string): GoogleAppsScript.Drive.File + renameFile(fileId: string, newName: string): void + trashFile(fileId: string): void + updateFileProperties(fileId: string, properties: {[key: string]: string}): void + createFile(blob: GoogleAppsScript.Base.Blob): GoogleAppsScript.Drive.File } diff --git a/src/interfaces/IShopifyMediaService.ts b/src/interfaces/IShopifyMediaService.ts index 648c426..46973ef 100644 --- a/src/interfaces/IShopifyMediaService.ts +++ b/src/interfaces/IShopifyMediaService.ts @@ -1,4 +1,7 @@ export interface IShopifyMediaService { stagedUploadsCreate(input: any[]): any productCreateMedia(productId: string, media: any[]): any + getProductMedia(productId: string): any[] + productDeleteMedia(productId: string, mediaId: string): any + productReorderMedia(productId: string, moves: any[]): any } diff --git a/src/mediaHandlers.test.ts b/src/mediaHandlers.test.ts index 9bdbe46..b9011da 100644 --- a/src/mediaHandlers.test.ts +++ b/src/mediaHandlers.test.ts @@ -1,5 +1,5 @@ -import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedSku, getPickerConfig, saveFileToDrive, syncMediaForSku, debugScopes } from "./mediaHandlers" +import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedSku, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges } from "./mediaHandlers" import { Config } from "./config" import { GASDriveService } from "./services/GASDriveService" import { GASSpreadsheetService } from "./services/GASSpreadsheetService" @@ -23,7 +23,7 @@ jest.mock("./config", () => { 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("./services/MediaService", () => ({ MediaService: jest.fn().mockReturnValue({ getUnifiedMediaState: jest.fn(), processMediaChanges: jest.fn() }) })) jest.mock("./Product", () => ({ Product: jest.fn().mockImplementation(() => ({ shopify_id: "123", MatchToShopifyProduct: jest.fn() })) })) @@ -220,35 +220,41 @@ describe("mediaHandlers", () => { }) 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 delegate to MediaService.getUnifiedMediaState", () => { + const { MediaService } = require("./services/MediaService") + // We need to ensure new instance is used + const mockState = [{ id: "1", filename: "foo.jpg" }] - 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]) + // Execute + getMediaForSku("SKU123") - const result = getMediaForSku("SKU123") - expect(result).toHaveLength(1) - expect(result[0].thumbnailLink).toBe("") - }) + // Get the instance that was created + const mockInstance = MediaService.mock.instances[MediaService.mock.instances.length - 1] - test("should return empty array on fatal error", () => { - mockGetOrCreateFolder.mockImplementationOnce(() => { throw new Error("Fatal config") }) - const result = getMediaForSku("SKU123") - expect(result).toEqual([]) + // Checking delegation + expect(mockInstance.getUnifiedMediaState).toHaveBeenCalledWith("SKU123", "123") }) }) + describe("saveMediaChanges", () => { + test("should delegate to MediaService.processMediaChanges", () => { + const { MediaService } = require("./services/MediaService") + const finalState = [{ id: "1" }] + + saveMediaChanges("SKU123", finalState) + + const mockInstance = MediaService.mock.instances[MediaService.mock.instances.length - 1] + expect(mockInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, "123") + }) + + test("should throw if product not synced", () => { + const { Product } = require("./Product") + Product.mockImplementationOnce(() => ({ shopify_id: null })) + + expect(() => saveMediaChanges("SKU123", [])).toThrow("Product must be synced") + }) + }) + describe("Photo Session API", () => { const mockSessionId = "sess_123" const mockPickerUri = "https://photos.google.com/picker" @@ -352,34 +358,6 @@ describe("mediaHandlers", () => { 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() diff --git a/src/mediaHandlers.ts b/src/mediaHandlers.ts index 93396e0..9883d33 100644 --- a/src/mediaHandlers.ts +++ b/src/mediaHandlers.ts @@ -38,45 +38,45 @@ export function getPickerConfig() { } export function getMediaForSku(sku: string): any[] { + const config = new Config() const driveService = new GASDriveService() + const shop = new Shop() + const shopifyMediaService = new ShopifyMediaService(shop) + const networkService = new GASNetworkService() + const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config) - try { - const config = new Config() // Moved inside try block to catch init errors - const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId) - const files = driveService.getFiles(folder.getId()) + // Resolve Product ID (Best Effort) + const product = new Product(sku) + const shopifyId = product.shopify_id || "" - return files.map(f => { - let thumb = "" - try { - const bytes = f.getThumbnail().getBytes() - thumb = "data:image/png;base64," + Utilities.base64Encode(bytes) - } catch (e) { - console.log(`Failed to get thumbnail for ${f.getName()}`) - // Fallback or empty - } - return { - id: f.getId(), - name: f.getName(), - thumbnailLink: thumb - } - }) - } catch (e) { - console.error(e) - return [] + return mediaService.getUnifiedMediaState(sku, shopifyId) +} + +export function saveMediaChanges(sku: string, finalState: any[]) { + const config = new Config() + const driveService = new GASDriveService() + const shop = new Shop() + const shopifyMediaService = new ShopifyMediaService(shop) + const networkService = new GASNetworkService() + const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config) + + const product = new Product(sku) + if (!product.shopify_id) { + // Allow saving Drive-only changes? No, we need Shopify context for "Staging" usually. + // But if we just rename drive files, we could? + // For now, fail safe. + throw new Error("Product must be synced to Shopify before saving media changes.") } + + mediaService.processMediaChanges(sku, finalState, product.shopify_id) } export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) { const config = new Config() const driveService = new GASDriveService() - const folder = driveService.getOrCreateFolder(sku, config.productPhotosFolderId) const blob = Utilities.newBlob(Utilities.base64Decode(base64Data), mimeType, filename) - driveService.saveFile(blob, folder.getId()) - - // Auto-sync after upload? - // syncMediaForSku(sku) // Optional: auto-sync } // Picker Callback specific handler if needed, or we just rely on frontend passing back file ID @@ -205,31 +205,7 @@ export function importFromPicker(sku: string, fileId: string, mimeType: string, } -export function syncMediaForSku(sku: string) { - const config = new Config() - const driveService = new GASDriveService() - const shop = new Shop() - const shopifyMediaService = new ShopifyMediaService(shop) - const networkService = new GASNetworkService() - const mediaService = new MediaService(driveService, shopifyMediaService, networkService, config) - - // Need Shopify Product ID - // We can get it from the Product class or Sheet - const product = new Product(sku) - if (!product.shopify_id) { - product.MatchToShopifyProduct(shop) - } - - if (!product.shopify_id) { - throw new Error("Product not found on Shopify. Please sync product first.") - } - - mediaService.syncMediaForSku(sku, product.shopify_id) - - // Update thumbnail in sheet - // TODO: Implement thumbnail update in sheet if desired -} export function debugScopes() { const token = ScriptApp.getOAuthToken(); diff --git a/src/mediaManager.integration.test.ts b/src/mediaManager.integration.test.ts new file mode 100644 index 0000000..82c3096 --- /dev/null +++ b/src/mediaManager.integration.test.ts @@ -0,0 +1,231 @@ + +import { MediaService } from "./services/MediaService" +// Unmock MediaService so we test the real class logic +jest.unmock("./services/MediaService") + +// Mock dependencies +const mockDrive = { + getOrCreateFolder: jest.fn(), + getFiles: jest.fn(), + createFile: jest.fn(), + renameFile: jest.fn(), + trashFile: jest.fn(), + updateFileProperties: jest.fn(), + getFileById: jest.fn() +} +const mockShopify = { + getProductMedia: jest.fn(), + productCreateMedia: jest.fn(), + productDeleteMedia: jest.fn(), + productReorderMedia: jest.fn(), + stagedUploadsCreate: jest.fn() +} +const mockNetwork = { fetch: jest.fn() } +const mockConfig = { productPhotosFolderId: "root_folder" } + +// Mock Utilities +global.Utilities = { + base64Encode: jest.fn().mockReturnValue("base64encoded"), + newBlob: jest.fn() +} as any + +// Mock Advanced Drive Service +global.Drive = { + Files: { + get: jest.fn().mockImplementation((id) => { + if (id === "drive_1") return { appProperties: { shopify_media_id: "gid://shopify/Media/100" } } + return { appProperties: {} } + }) + } +} as any + +describe("MediaService V2 Integration Logic", () => { + let service: MediaService + const dummyPid = "gid://shopify/Product/123" + + beforeEach(() => { + jest.clearAllMocks() + // Instantiate the REAL service with MOCKED delegates + service = new MediaService(mockDrive as any, mockShopify as any, mockNetwork as any, mockConfig as any) + + // Setup Network mock for Blob download + // MediaService calls networkService.fetch(...).getBlob() + // so fetch matches MUST return an object with getBlob + mockNetwork.fetch.mockReturnValue({ + getBlob: jest.fn().mockReturnValue({ + getDataAsString: () => "fake_blob_data", + getContentType: () => "image/jpeg", + getBytes: () => [] + }) + }) + + // Setup default File mock behaviors + mockDrive.getFileById.mockImplementation((id: string) => ({ + setName: jest.fn(), + getName: () => "file_name.jpg", + moveTo: jest.fn(), + getMimeType: () => "image/jpeg", + getBlob: () => ({}) + })) + + mockDrive.createFile.mockReturnValue({ + getId: () => "new_created_file_id" + }) + }) + + describe("getUnifiedMediaState (Phase A)", () => { + test("should match Drive and Shopify items by ID (Strong Link)", () => { + // Setup Drive + const driveFile = { + getId: () => "drive_1", + getName: () => "IMG_001.jpg", + getAppProperty: (k: string) => k === 'shopify_media_id' ? "gid://shopify/Media/100" : null, + getThumbnail: () => ({ getBytes: () => [] }) + } + mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1" }) + mockDrive.getFiles.mockReturnValue([driveFile]) + + // Setup Shopify + const shopMedia = { + id: "gid://shopify/Media/100", + mediaContentType: "IMAGE", + preview: { image: { originalSrc: "http://shopify.com/img.jpg" } } + } + mockShopify.getProductMedia.mockReturnValue([shopMedia]) + + // Act + const result = service.getUnifiedMediaState("SKU-123", dummyPid) + + // Expect + expect(result).toHaveLength(1) + expect(result[0].driveId).toBe("drive_1") + expect(result[0].shopifyId).toBe("gid://shopify/Media/100") + expect(result[0].source).toBe("synced") + }) + + test("should identify Drive-Only items (New Uploads)", () => { + const driveFile = { + getId: () => "drive_new", + getName: () => "new.jpg", + getAppProperty: () => null, + getThumbnail: () => ({ getBytes: () => [] }) + } + mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1" }) + mockDrive.getFiles.mockReturnValue([driveFile]) + mockShopify.getProductMedia.mockReturnValue([]) + + const result = service.getUnifiedMediaState("SKU-123", dummyPid) + + expect(result).toHaveLength(1) + expect(result[0].source).toBe("drive_only") + }) + + test("should identify Shopify-Only items", () => { + mockDrive.getOrCreateFolder.mockReturnValue({ getId: () => "folder_1" }) + mockDrive.getFiles.mockReturnValue([]) + + const shopMedia = { + id: "gid://shopify/Media/555", + mediaContentType: "IMAGE", + preview: { image: { originalSrc: "url" } } + } + mockShopify.getProductMedia.mockReturnValue([shopMedia]) + + const result = service.getUnifiedMediaState("SKU-123", dummyPid) + + expect(result).toHaveLength(1) + expect(result[0].source).toBe("shopify_only") + }) + }) + + describe("processMediaChanges (Phase B)", () => { + test("should rename Drive files sequentially", () => { + const finalState = [ + { id: "1", driveId: "d1", shopifyId: "s1", source: "synced", filename: "foo.jpg" }, + { id: "2", driveId: "d2", shopifyId: "s2", source: "synced", filename: "bar.jpg" } + ] + + // Mock getUnifiedMediaState to return empty to skip delete logic interference? + // Or return something consistent. + jest.spyOn(service, "getUnifiedMediaState").mockReturnValue([]) + + // Act + service.processMediaChanges("SKU-123", finalState, dummyPid) + + // Assert + expect(mockDrive.renameFile).toHaveBeenCalledWith("d1", "SKU-123_0001.jpg") + expect(mockDrive.renameFile).toHaveBeenCalledWith("d2", "SKU-123_0002.jpg") + }) + + test("should call Shopify Reorder Mutation", () => { + const finalState = [ + { id: "1", shopifyId: "s10", sortOrder: 0 }, + { id: "2", shopifyId: "s20", sortOrder: 1 } + ] + jest.spyOn(service, "getUnifiedMediaState").mockReturnValue([]) + + service.processMediaChanges("SKU-123", finalState, dummyPid) + + expect(mockShopify.productReorderMedia).toHaveBeenCalledWith(dummyPid, [ + { id: "s10", newPosition: "0" }, + { id: "s20", newPosition: "1" } + ]) + }) + + test("should backfill Shopify-Only items to Drive", () => { + const finalState = [ + { id: "3", driveId: null, shopifyId: "s99", source: "shopify_only", thumbnail: "http://url.jpg", filename: "backfill.jpg" } + ] + jest.spyOn(service, "getUnifiedMediaState").mockReturnValue([]) + + // Mock file creation + // We set default mockDrive.createFile above but we can specialize if needed + // Default returns "new_created_file_id" + + // Act + service.processMediaChanges("SKU-123", finalState, dummyPid) + + expect(mockDrive.createFile).toHaveBeenCalled() + expect(mockDrive.updateFileProperties).toHaveBeenCalledWith("new_created_file_id", { shopify_media_id: "s99" }) + }) + + test("should delete removed items", () => { + // Mock current state has items + const current = [ + { id: "del_1", driveId: "d_del", shopifyId: "s_del", filename: "delete_me.jpg" } + ] + jest.spyOn(service, "getUnifiedMediaState").mockReturnValue(current) + + // Final state empty + const finalState: any[] = [] + + service.processMediaChanges("SKU-123", finalState, dummyPid) + + expect(mockShopify.productDeleteMedia).toHaveBeenCalledWith(dummyPid, "s_del") + expect(mockDrive.trashFile).toHaveBeenCalledWith("d_del") + }) + + test("should upload Drive-Only items", () => { + const finalState = [ + { id: "new_1", driveId: "d_new", shopifyId: null, source: "drive_only", filename: "new.jpg" } + ] + jest.spyOn(service, "getUnifiedMediaState").mockReturnValue([]) + + // Mock staged uploads flow + mockShopify.stagedUploadsCreate.mockReturnValue({ + stagedTargets: [{ url: "http://upload", resourceUrl: "http://resource", parameters: [] }] + }) + // Mock Create Media returning ID + mockShopify.productCreateMedia.mockReturnValue({ + media: [{ id: "new_shopify_id", status: "READY" }] + }) + + service.processMediaChanges("SKU-123", finalState, dummyPid) + + expect(mockShopify.stagedUploadsCreate).toHaveBeenCalled() + expect(mockShopify.productCreateMedia).toHaveBeenCalled() + // Check property update + expect(mockDrive.updateFileProperties).toHaveBeenCalledWith("d_new", { shopify_media_id: "new_shopify_id" }) + }) + }) +}) diff --git a/src/services/GASDriveService.ts b/src/services/GASDriveService.ts index 9f9e1eb..a3f08a2 100644 --- a/src/services/GASDriveService.ts +++ b/src/services/GASDriveService.ts @@ -29,4 +29,53 @@ export class GASDriveService implements IDriveService { getFileById(id: string): GoogleAppsScript.Drive.File { return DriveApp.getFileById(id) } + renameFile(fileId: string, newName: string): void { + const file = DriveApp.getFileById(fileId) + file.setName(newName) + } + + trashFile(fileId: string): void { + const file = DriveApp.getFileById(fileId) + file.setTrashed(true) + } + + updateFileProperties(fileId: string, properties: {[key: string]: string}): void { + // Requires Advanced Drive Service (Drive API v2 or v3) + // We assume v2 is default or v3. Let's try v2 style 'properties' or v3 'appProperties'. + // Plan said 'appProperties'. v3 uses 'appProperties'. + // If we are uncertain, we can try to detect or just assume v2/v3 enabled. + // Standard DriveApp doesn't support this. + try { + if (typeof Drive === 'undefined') { + throw new Error("Advanced Drive Service not enabled") + } + // Using 'any' cast to bypass TS strict check if 'Drive' global isn't typed + const drive = Drive as any + + // Drive v2 uses 'properties' list. v3 uses 'appProperties' map. + // Let's assume v2 for GAS usually? Or check? + // Most modern scripts use v2 default but v3 is option. + // Let's check `mediaHandlers.ts` importFromPicker logic: it checked for `Drive.Files.create` (v3) vs `insert` (v2). + // Let's do the same check. + if (drive.Files.update) { + // v3? v2 has update too. + // v2: update(resource, fileId). v3: update(resource, fileId). + // Properties format differs. + // v2: { properties: [{key:.., value:..}] } + // v3: { appProperties: { key: value } } + + // We'll try v3 format first, it's cleaner. + drive.Files.update({ appProperties: properties }, fileId) + } else { + console.warn("Drive Global found but no update method?") + } + } catch (e) { + console.error("Failed to update file properties", e) + // Fallback: Description hacking? No, let's fail or log. + } + } + + createFile(blob: GoogleAppsScript.Base.Blob): GoogleAppsScript.Drive.File { + return DriveApp.createFile(blob) + } } diff --git a/src/services/MediaService.test.ts b/src/services/MediaService.test.ts index 038b872..dc33489 100644 --- a/src/services/MediaService.test.ts +++ b/src/services/MediaService.test.ts @@ -1,3 +1,4 @@ + import { MediaService } from "./MediaService" import { MockDriveService } from "./MockDriveService" import { MockShopifyMediaService } from "./MockShopifyMediaService" @@ -12,8 +13,9 @@ class MockNetworkService implements INetworkService { this.lastUrl = url this.lastPayload = params.payload return { - getResponseCode: () => 200 - } as GoogleAppsScript.URL_Fetch.HTTPResponse + getResponseCode: () => 200, + getBlob: () => ({ getBytes: () => [], getContentType: () => "image/jpeg", setName: () => {} }) + } as unknown as GoogleAppsScript.URL_Fetch.HTTPResponse } } @@ -28,28 +30,89 @@ describe("MediaService", () => { driveService = new MockDriveService() shopifyService = new MockShopifyMediaService() networkService = new MockNetworkService() - config = { productPhotosFolderId: "root" } as Config // Mock config + config = { productPhotosFolderId: "root" } as Config mediaService = new MediaService(driveService, shopifyService, networkService, config) + + // Global Mocks + global.Utilities = { + base64Encode: (b) => "base64", + newBlob: (b, m, n) => ({ + getBytes: () => b, + getContentType: () => m, + getName: () => n, + setName: () => {} + }) + } as any + global.Drive = { Files: { get: () => ({ appProperties: {} }) } } as any + global.UrlFetchApp = networkService as unknown as GoogleAppsScript.URL_Fetch.UrlFetchApp }) - test("syncMediaForSku uploads files from Drive to Shopify", () => { - // Setup Drive State + test("getUnifiedMediaState should match files", () => { const folder = driveService.getOrCreateFolder("SKU123", "root") - const blob1 = { getName: () => "01.jpg", getMimeType: () => "image/jpeg", getBytes: () => [] } as unknown as GoogleAppsScript.Base.Blob + const blob1 = { getName: () => "01.jpg", getMimeType: () => "image/jpeg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as unknown as GoogleAppsScript.Base.Blob driveService.saveFile(blob1, folder.getId()) - // Run Sync - mediaService.syncMediaForSku("SKU123", "shopify_prod_id") - - // Verify Network Call (Upload) - expect(networkService.lastUrl).toBe("https://mock-upload.shopify.com") - // Verify payload contained file - expect(networkService.lastPayload).toHaveProperty("file") + const state = mediaService.getUnifiedMediaState("SKU123", "pid") + expect(state).toHaveLength(1) + expect(state[0].filename).toBe("01.jpg") }) - test("syncMediaForSku does nothing if no files", () => { - mediaService.syncMediaForSku("SKU_EMPTY", "pid") - expect(networkService.lastUrl).toBe("") + test("processMediaChanges should handle deletions", () => { + const folder = driveService.getOrCreateFolder("SKU123", "root") + const blob1 = { + getName: () => "delete_me.jpg", + getId: () => "file_id_1", + getMimeType: () => "image/jpeg", + getBytes: () => [], + getThumbnail: () => ({ getBytes: () => [] }) + } as unknown as GoogleAppsScript.Base.Blob + driveService.saveFile(blob1, folder.getId()) + + // Update Shopify Mock to return this media + shopifyService.getProductMedia = jest.fn().mockReturnValue([{ + id: "gid://shopify/Media/media_1", + alt: "delete_me.jpg" + }]) + + // Update global Drive to return synced ID + global.Drive = { Files: { get: () => ({ appProperties: { shopify_media_id: "gid://shopify/Media/media_1" } }) } } as any + + const finalState = [] + + const deleteSpy = jest.spyOn(shopifyService, 'productDeleteMedia') + const trashSpy = jest.spyOn(driveService, 'trashFile') + + mediaService.processMediaChanges("SKU123", finalState, "pid") + + expect(deleteSpy).toHaveBeenCalled() + expect(trashSpy).toHaveBeenCalled() + }) + + test("processMediaChanges should handle backfills (Shopify -> Drive)", () => { + // Current state: Empty Drive, 1 Shopify Media + shopifyService.getProductMedia = jest.fn().mockReturnValue([{ + id: "gid://shopify/Media/media_2", + alt: "backfill.jpg", + preview: { image: { originalSrc: "http://shopify.com/img.jpg" } } + }]) + + // Final state: 1 item (the backfilled one) + const finalState = [{ + id: "gid://shopify/Media/media_2", + filename: "backfill.jpg", + status: "synced" + }] + + // Mock network fetch for download + jest.spyOn(networkService, 'fetch') + + mediaService.processMediaChanges("SKU123", finalState, "pid") + + // Should create file in Drive + const folder = driveService.getOrCreateFolder("SKU123", "root") + const files = driveService.getFiles(folder.getId()) + expect(files).toHaveLength(1) + expect(files[0].getName()).toBe("backfill.jpg") }) }) diff --git a/src/services/MediaService.ts b/src/services/MediaService.ts index ad5b2ec..47d40ac 100644 --- a/src/services/MediaService.ts +++ b/src/services/MediaService.ts @@ -21,83 +21,230 @@ export class MediaService { this.config = config } - syncMediaForSku(sku: string, shopifyProductId: string) { - console.log(`MediaService: Syncing media for SKU ${sku}`) - // 1. Get files from Drive + + getUnifiedMediaState(sku: string, shopifyProductId: string): any[] { + console.log(`MediaService: Getting unified state for SKU ${sku}`) + + // 1. Get Drive Files const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId) - const files = this.driveService.getFiles(folder.getId()) + const driveFiles = this.driveService.getFiles(folder.getId()) - if (files.length === 0) { - console.log("No files found in Drive.") - return - } - console.log(`Found ${files.length} files in Drive folder ${folder.getId()}`) - - // Sort files by name to ensure consistent order (01.jpg, 02.jpg) - files.sort((a, b) => a.getName().localeCompare(b.getName())) - - // TODO: optimization - check if file already exists on Shopify by filename/size/hash - // For now, we will just upload everything that is new, or we rely on Shopify to dedupe? - // Shopify does NOT dedupe automatically if we create new media entries. - // We should probably list current media on the product and compare filenames. - // But filenames in Shopify are sanitized. - // Pro trick: Use 'alt' text to store the original filename/Drive ID. - - // 2. Prepare Staged Uploads - // collecting files needing upload - const filesToUpload = files; // uploading all for MVP simplicity, assume clean state or overwrite logic later - - if (filesToUpload.length === 0) return - - const stagedUploadInput = filesToUpload.map(f => ({ - filename: f.getName(), - mimeType: f.getMimeType(), - resource: "IMAGE", // or VIDEO - httpMethod: "POST" - })) - - const response = this.shopifyMediaService.stagedUploadsCreate(stagedUploadInput) - - if (response.userErrors && response.userErrors.length > 0) { - console.error("Staged upload errors:", response.userErrors) - throw new Error("Staged upload failed") + // 2. Get Shopify Media + let shopifyMedia: any[] = [] + if (shopifyProductId) { + shopifyMedia = this.shopifyMediaService.getProductMedia(shopifyProductId) } - const stagedTargets = response.stagedTargets + // 3. Match + const unifiedState: any[] = [] + const matchedShopifyIds = new Set() - if (!stagedTargets || stagedTargets.length !== filesToUpload.length) { - throw new Error("Failed to create staged upload targets") + // Map of Drive Files + const driveMap = new Map() + + driveFiles.forEach(f => { + let shopifyId = null + try { + // Expensive lookup for properties: + if (typeof Drive !== 'undefined') { + const advFile = (Drive as any).Files.get(f.getId(), { fields: 'appProperties' }) + if (advFile.appProperties && advFile.appProperties['shopify_media_id']) { + shopifyId = advFile.appProperties['shopify_media_id'] + } + } + } catch (e) { + console.warn(`Failed to get properties for ${f.getName()}`) + } + + driveMap.set(f.getId(), { file: f, shopifyId }) + }) + + // Match Logic + driveFiles.forEach(f => { + const d = driveMap.get(f.getId()) + if (!d) return + + let match = null + + // 1. ID Match + if (d.shopifyId) { + match = shopifyMedia.find(m => m.id === d.shopifyId) + if (match) matchedShopifyIds.add(match.id) + } + + // 2. Filename Match (if no ID match) + if (!match) { + match = shopifyMedia.find(m => + !matchedShopifyIds.has(m.id) && + (m.filename === f.getName() || (m.preview && m.preview.image && m.preview.image.originalSrc && m.preview.image.originalSrc.includes(f.getName()))) + ) + if (match) matchedShopifyIds.add(match.id) + } + + unifiedState.push({ + id: f.getId(), // Use Drive ID as primary key for "Synced" or "Drive" items + driveId: f.getId(), + shopifyId: match ? match.id : null, + filename: f.getName(), + source: match ? 'synced' : 'drive_only', + thumbnail: `data:image/jpeg;base64,${Utilities.base64Encode(f.getThumbnail().getBytes())}`, // Expensive? + status: 'active' + }) + }) + + // Find Shopify Orphans + shopifyMedia.forEach(m => { + if (!matchedShopifyIds.has(m.id)) { + unifiedState.push({ + id: m.id, // Use Shopify ID keys for orphans + driveId: null, + shopifyId: m.id, + filename: "Shopify Media", // TODO: extract real name + source: 'shopify_only', + thumbnail: m.preview?.image?.originalSrc || "", + status: 'active' + }) + } + }) + + return unifiedState + } + + processMediaChanges(sku: string, finalState: any[], shopifyProductId: string) { + console.log(`MediaService: Processing changes for SKU ${sku}`) + + // 1. Get Current State (for diffing deletions) + const currentState = this.getUnifiedMediaState(sku, shopifyProductId) + const finalIds = new Set(finalState.map(f => f.id)) + + // 2. Process Deletions + const toDelete = currentState.filter(c => !finalIds.has(c.id)) + toDelete.forEach(item => { + console.log(`Deleting item: ${item.filename}`) + if (item.shopifyId) { + this.shopifyMediaService.productDeleteMedia(shopifyProductId, item.shopifyId) + } + if (item.driveId) { + this.driveService.trashFile(item.driveId) + } + }) + + // 3. Process Backfills (Shopify Only -> Drive) + finalState.forEach(item => { + if (item.source === 'shopify_only' && item.shopifyId) { + console.log(`Backfilling item: ${item.filename}`) + // Download using global UrlFetchApp for blob access if generic interface is limited? + // Actually implementation of INetworkService returns HTTPResponse which has getBlob(). + // But item.thumbnail usually is a URL. + const resp = this.networkService.fetch(item.thumbnail, { method: 'get' }) + const blob = resp.getBlob() + const file = this.driveService.createFile(blob) + + // Move to correct folder + const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId) + const driveFile = this.driveService.getFileById(file.getId()) + // GASDriveService must handle move? Standard File has moveTo? + // "moveTo" is standard GAS. + // But we used interface `IDriveService` which returns `GoogleAppsScript.Drive.File`. + // So we can assume `driveFile.moveTo(folder)` works if it's a real GAS object. + // TypeScript might complain if `IDriveService` returns a wrapper? + // Interface says `GoogleAppsScript.Drive.File`. That is the native type. + // Native type has `moveTo(destination: Folder)`. + driveFile.moveTo(folder) + + this.driveService.updateFileProperties(file.getId(), { shopify_media_id: item.shopifyId }) + + // Update item refs for subsequent steps + item.driveId = file.getId() + item.source = 'synced' + } + }) + + // 4. Process Uploads (Drive Only -> Shopify) + const toUpload = finalState.filter(item => item.source === 'drive_only' && item.driveId) + if (toUpload.length > 0) { + console.log(`Uploading ${toUpload.length} new items from Drive`) + + const uploads = toUpload.map(item => { + const f = this.driveService.getFileById(item.driveId) + return { + filename: f.getName(), + mimeType: f.getMimeType(), + resource: "IMAGE", + httpMethod: "POST", + file: f + } + }) + + const stagedInput = uploads.map(u => ({ + filename: u.filename, + mimeType: u.mimeType, + resource: u.resource, + httpMethod: u.httpMethod + })) + const stagedResp = this.shopifyMediaService.stagedUploadsCreate(stagedInput) + const targets = stagedResp.stagedTargets + + const mediaToCreate = [] + uploads.forEach((u, i) => { + const target = targets[i] + const payload = {} + target.parameters.forEach((p: any) => payload[p.name] = p.value) + payload['file'] = u.file.getBlob() + + this.networkService.fetch(target.url, { method: "post", payload: payload }) + + mediaToCreate.push({ + originalSource: target.resourceUrl, + alt: u.filename, + mediaContentType: "IMAGE" + }) + }) + + // Create Media (Updated to return IDs) + const createdMedia = this.shopifyMediaService.productCreateMedia(shopifyProductId, mediaToCreate) + if (createdMedia && createdMedia.media) { + createdMedia.media.forEach((m: any, i: number) => { + const originalItem = toUpload[i] + if (m.status === 'FAILED') { + console.error("Media create failed", m) + return + } + if (m.id) { + this.driveService.updateFileProperties(originalItem.driveId, { shopify_media_id: m.id }) + originalItem.shopifyId = m.id + originalItem.source = 'synced' + } + }) + } } - const mediaToCreate = [] - - // 3. Upload files to Targets - for (let i = 0; i < filesToUpload.length; i++) { - const file = filesToUpload[i] - const target = stagedTargets[i] - - console.log(`Uploading ${file.getName()} to ${target.url}`) - - const payload = {} - target.parameters.forEach(p => payload[p.name] = p.value) - payload['file'] = file.getBlob() - - this.networkService.fetch(target.url, { - method: "post", - payload: payload - }) - - mediaToCreate.push({ - originalSource: target.resourceUrl, - alt: file.getName(), // Storing filename in Alt for basic deduping later - mediaContentType: "IMAGE" // TODO: Detect video - }) + // 5. Process Reordering + const moves: any[] = [] + finalState.forEach((item, index) => { + if (item.shopifyId) { + moves.push({ id: item.shopifyId, newPosition: index.toString() }) + } + }) + if (moves.length > 0) { + this.shopifyMediaService.productReorderMedia(shopifyProductId, moves) } - // 4. Create Media on Shopify - console.log("Creating media on Shopify...") - const result = this.shopifyMediaService.productCreateMedia(shopifyProductId, mediaToCreate) - console.log("Media created successfully") + // 6. Rename Drive Files + finalState.forEach((item, index) => { + if (item.driveId) { + const paddedIndex = (index + 1).toString().padStart(4, '0') + const ext = item.filename.includes('.') ? item.filename.split('.').pop() : 'jpg' + const newName = `${sku}_${paddedIndex}.${ext}` + + // Avoid unnecessary rename validation calls if possible, but renameFile is fast + const startName = this.driveService.getFileById(item.driveId).getName() + if (startName !== newName) { + this.driveService.renameFile(item.driveId, newName) + } + } + }) } } + diff --git a/src/services/MockDriveService.ts b/src/services/MockDriveService.ts index 0606abc..f13a001 100644 --- a/src/services/MockDriveService.ts +++ b/src/services/MockDriveService.ts @@ -29,7 +29,8 @@ export class MockDriveService implements IDriveService { getName: () => blob.getName(), getBlob: () => blob, getUrl: () => `https://mock.drive/files/${blob.getName()}`, - getLastUpdated: () => new Date() + getLastUpdated: () => new Date(), + getThumbnail: () => ({ getBytes: () => [] }) } as unknown as GoogleAppsScript.Drive.File if (!this.files.has(folderId)) { @@ -52,4 +53,26 @@ export class MockDriveService implements IDriveService { } throw new Error("File not found in mock") } + + renameFile(fileId: string, newName: string): void { + const file = this.getFileById(fileId) + // Mock setName + // We can't easily mutate the mock object created in saveFile without refactoring + // But for type satisfaction it's void. + console.log(`[MockDrive] Renaming ${fileId} to ${newName}`) + // Assuming we can mutate if we kept ref? + } + + trashFile(fileId: string): void { + console.log(`[MockDrive] Trashing ${fileId}`) + } + + updateFileProperties(fileId: string, properties: any): void { + console.log(`[MockDrive] Updating properties for ${fileId}`, properties) + } + + createFile(blob: GoogleAppsScript.Base.Blob): GoogleAppsScript.Drive.File { + // Create in "root" or similar + return this.saveFile(blob, "root") + } } diff --git a/src/services/MockShopifyMediaService.ts b/src/services/MockShopifyMediaService.ts index f2ad638..6a58cd3 100644 --- a/src/services/MockShopifyMediaService.ts +++ b/src/services/MockShopifyMediaService.ts @@ -15,6 +15,7 @@ export class MockShopifyMediaService implements IShopifyMediaService { productCreateMedia(productId: string, media: any[]): any { return { media: media.map(m => ({ + id: `gid://shopify/Media/${Math.random()}`, alt: m.alt, mediaContentType: m.mediaContentType, status: "PROCESSING" @@ -26,4 +27,27 @@ export class MockShopifyMediaService implements IShopifyMediaService { } } } + + getProductMedia(productId: string): any[] { + // Return empty or mock list + return [] + } + + productDeleteMedia(productId: string, mediaId: string): any { + return { + productDeleteMedia: { + deletedMediaId: mediaId, + userErrors: [] + } + } + } + + productReorderMedia(productId: string, moves: any[]): any { + return { + productReorderMedia: { + job: { id: "job_123" }, + userErrors: [] + } + } + } } diff --git a/src/services/ShopifyMediaService.ts b/src/services/ShopifyMediaService.ts index 88795b8..fe00670 100644 --- a/src/services/ShopifyMediaService.ts +++ b/src/services/ShopifyMediaService.ts @@ -42,6 +42,7 @@ export class ShopifyMediaService implements IShopifyMediaService { mutation productCreateMedia($media: [CreateMediaInput!]!, $productId: ID!) { productCreateMedia(media: $media, productId: $productId) { media { + id alt mediaContentType status @@ -68,4 +69,79 @@ export class ShopifyMediaService implements IShopifyMediaService { const response = this.shop.shopifyGraphQLAPI(payload) return response.content.data.productCreateMedia } + getProductMedia(productId: string): any[] { + const query = /* GraphQL */ ` + query getProductMedia($productId: ID!) { + product(id: $productId) { + media(first: 250) { + edges { + node { + id + alt + mediaContentType + preview { + image { + originalSrc + } + } + } + } + } + } + } + ` + const variables = { productId } + const payload = { + query: formatGqlForJSON(query), + variables: variables + } + const response = this.shop.shopifyGraphQLAPI(payload) + if (!response.content.data.product) return [] + return response.content.data.product.media.edges.map((edge: any) => edge.node) + } + + productDeleteMedia(productId: string, mediaId: string): any { + const query = /* GraphQL */ ` + mutation productDeleteMedia($mediaId: ID!, $productId: ID!) { + productDeleteMedia(mediaId: $mediaId, productId: $productId) { + deletedMediaId + userErrors { + field + message + } + } + } + ` + const variables = { productId, mediaId } + const payload = { + query: formatGqlForJSON(query), + variables: variables + } + const response = this.shop.shopifyGraphQLAPI(payload) + return response.content.data.productDeleteMedia + } + + productReorderMedia(productId: string, moves: any[]): any { + const query = /* GraphQL */ ` + mutation productReorderMedia($id: ID!, $moves: [MoveInput!]!) { + productReorderMedia(id: $id, moves: $moves) { + job { + id + done + } + userErrors { + field + message + } + } + } + ` + const variables = { id: productId, moves } + const payload = { + query: formatGqlForJSON(query), + variables: variables + } + const response = this.shop.shopifyGraphQLAPI(payload) + return response.content.data.productReorderMedia + } }