Files
product_inventory/src/services/MediaService.test.ts
Ben Miller d9fe81f282 feat: Use Shopify thumbnail and playback URL for synced media
- 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.
2025-12-29 01:26:18 -07:00

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")
})
})