- Update \MediaService.ts\ to populate \ humbnail\ and \contentUrl\ from Shopify media when a Drive file is synced. - Enable \synced\ videos to use the Shopify video URL for playback/hover. - Update \MediaManager.html\ to allow \synced\ items to render as \<video>\ tags if they have a valid \contentUrl\. - Add regression tests in \MediaService.test.ts\ for thumbnail and video sync behavior.
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
|
|
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")
|
|
})
|
|
})
|