Refactor Media Manager sync logic and fix duplication bugs

This major refactor addresses improper image matching and duplication:

- Implemented strict ID-based matching in 'MediaService', removing the greedy filename matching fallback.

- Redesigned synchronization pipeline to treat Google Drive as the Source of Truth, supporting orphan adoption (Shopify -> Drive) and secure uploads.

- Implemented 'gallery_order' using Drive file properties (supporting both v2 and v3 APIs) for stable, drag-and-drop global ordering.

- Added conditional file renaming using timestamps to enforce '_' naming convention without unnecessary renames.

- Fixed runtime errors in 'MediaService' loops and updated 'ShopifyMediaService' GraphQL mutations to match correctly schema.

- Rewrote 'MediaService.test.ts' with robust test cases for strict matching, adoption, sorting, and reordering.
This commit is contained in:
Ben Miller
2025-12-28 12:25:13 -07:00
parent 6e1222cec9
commit 7c35817313
14 changed files with 1299 additions and 505 deletions

View File

@ -7,19 +7,22 @@ import { Config } from "../config"
class MockNetworkService implements INetworkService {
lastUrl: string = ""
lastPayload: any = {}
fetch(url: string, params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions): GoogleAppsScript.URL_Fetch.HTTPResponse {
fetch(url: string, params: any): GoogleAppsScript.URL_Fetch.HTTPResponse {
this.lastUrl = url
this.lastPayload = params.payload
let blobName = "mock_blob"
return {
getResponseCode: () => 200,
getBlob: () => ({ getBytes: () => [], getContentType: () => "image/jpeg", setName: () => {} })
getBlob: () => ({
getBytes: () => [],
getContentType: () => "image/jpeg",
getName: () => blobName,
setName: (n) => { blobName = n }
} as any)
} as unknown as GoogleAppsScript.URL_Fetch.HTTPResponse
}
}
describe("MediaService", () => {
describe("MediaService Robust Sync", () => {
let mediaService: MediaService
let driveService: MockDriveService
let shopifyService: MockShopifyMediaService
@ -34,85 +37,130 @@ describe("MediaService", () => {
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
// 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
global.Drive = { Files: { get: () => ({ appProperties: {} }) } } as any
global.UrlFetchApp = networkService as unknown as GoogleAppsScript.URL_Fetch.UrlFetchApp
})
test("getUnifiedMediaState should match files", () => {
test("Strict Matching: Only matches via property, ignores filename", () => {
const folder = driveService.getOrCreateFolder("SKU123", "root")
const blob1 = { getName: () => "01.jpg", getMimeType: () => "image/jpeg", getBytes: () => [], getThumbnail: () => ({ getBytes: () => [] }) } as unknown as GoogleAppsScript.Base.Blob
driveService.saveFile(blob1, folder.getId())
// 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(state).toHaveLength(1)
expect(state[0].filename).toBe("01.jpg")
// 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("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())
test("Sorting: Respects gallery_order then filename", () => {
const folder = driveService.getOrCreateFolder("SKU123", "root")
// Update Shopify Mock to return this media
shopifyService.getProductMedia = jest.fn().mockReturnValue([{
id: "gid://shopify/Media/media_1",
alt: "delete_me.jpg"
}])
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())
// Update global Drive to return synced ID
global.Drive = { Files: { get: () => ({ appProperties: { shopify_media_id: "gid://shopify/Media/media_1" } }) } } as any
// Order: C (0), A (1), B (No Order -> 9999)
driveService.updateFileProperties(fC.getId(), { gallery_order: "0" })
driveService.updateFileProperties(fA.getId(), { gallery_order: "1" })
const finalState = []
const state = mediaService.getUnifiedMediaState("SKU123", "pid")
const deleteSpy = jest.spyOn(shopifyService, 'productDeleteMedia')
const trashSpy = jest.spyOn(driveService, 'trashFile')
mediaService.processMediaChanges("SKU123", finalState, "pid")
expect(deleteSpy).toHaveBeenCalled()
expect(trashSpy).toHaveBeenCalled()
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("processMediaChanges should handle backfills (Shopify -> Drive)", () => {
// Current state: Empty Drive, 1 Shopify Media
test("Adoption: Orphan in finalState is downloaded and linked", () => {
shopifyService.getProductMedia = jest.fn().mockReturnValue([{
id: "gid://shopify/Media/media_2",
alt: "backfill.jpg",
preview: { image: { originalSrc: "http://shopify.com/img.jpg" } }
id: "gid://shopify/Media/orphan",
preview: { image: { originalSrc: "http://img.com/orphan.jpg" } }
}])
// Final state: 1 item (the backfilled one)
// Final state keeps the orphan (triggering adoption)
const finalState = [{
id: "gid://shopify/Media/media_2",
filename: "backfill.jpg",
status: "synced"
id: "gid://shopify/Media/orphan",
shopifyId: "gid://shopify/Media/orphan",
source: "shopify_only",
filename: "orphan",
thumbnail: "http://img.com/orphan.jpg"
}]
// Mock network fetch for download
jest.spyOn(networkService, 'fetch')
mediaService.processMediaChanges("SKU123", finalState, "pid")
// Should create file in Drive
// Verify file created
const folder = driveService.getOrCreateFolder("SKU123", "root")
const files = driveService.getFiles(folder.getId())
expect(files).toHaveLength(1)
expect(files[0].getName()).toBe("backfill.jpg")
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())
})
})