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:
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user