import { mediaRpcShapes } from "@rakazo/contracts"; import { describe, expect, it, vi } from "vitest"; import type { DeviceAsset, DeviceIndexIo, PushInput } from "./media-index"; import { chunked, parseSyncToken, roundCoordinate, syncDeviceIndex, toClientMediaItem, } from "./media-index"; function asset(id: string, overrides: Partial = {}): DeviceAsset { return { id, filename: `${id}.mov`, mediaType: "video", width: 1920, height: 1080, duration: 12_345, creationTime: 1_700_000_000_000, modificationTime: 1_700_000_000_000, isFavorite: false, ...overrides, }; } /** Device library plus a server that keeps what was pushed, like `sources.pushItems`. */ function harness(device: DeviceAsset[], serverIds: string[] = []) { const server = new Set(serverIds); const pushes: PushInput[] = []; const io = { listDevice: vi.fn(async (offset: number, limit: number) => device.slice(offset, offset + limit), ), locate: vi.fn(async () => ({ latitude: 52.520_08, longitude: 13.404_95 })), push: vi.fn(async (input: PushInput) => { // Every push must satisfy the RPC contract. mediaRpcShapes.sources.pushItems.input.parse(input); pushes.push(input); for (const item of input.items) server.add(item.externalId); for (const id of input.deletedExternalIds) server.delete(id); return { created: input.items.length, updated: 0, unchanged: 0, deleted: 0 }; }), serverAssetCount: vi.fn(async () => server.size), serverExternalIds: vi.fn(async () => [...server]), } satisfies DeviceIndexIo; return { io, server, pushes }; } describe("toClientMediaItem", () => { it("maps videos with duration and rounded location", () => { const item = toClientMediaItem(asset("v1"), { latitude: 52.520_08, longitude: 13.404_95 }); expect(item).toEqual({ externalId: "v1", kind: "video", capturedAt: "2023-11-14T22:13:20.000Z", durationMs: 12_345, width: 1920, height: 1080, fps: null, gpsLat: 52.52, gpsLon: 13.4, deviceModel: null, albumNames: [], isFavorite: false, fileName: "v1.mov", }); }); it("maps photos without a duration", () => { const item = toClientMediaItem( asset("p1", { mediaType: "image", duration: 0, filename: "p1.heic", isFavorite: true }), null, ); expect(item).toMatchObject({ kind: "photo", durationMs: null, gpsLat: null, isFavorite: true }); }); it("skips audio and unknown entries", () => { expect(toClientMediaItem(asset("a1", { mediaType: "audio" }), null)).toBeNull(); expect(toClientMediaItem(asset("u1", { mediaType: "unknown" }), null)).toBeNull(); }); it("drops missing or invalid metadata instead of sending it", () => { const item = toClientMediaItem( asset("v2", { width: null, height: 0, creationTime: null, filename: null }), { latitude: 0, longitude: 0 }, ); expect(item).toMatchObject({ width: null, height: null, capturedAt: null, fileName: null, gpsLat: null, gpsLon: null, }); expect(toClientMediaItem(asset("v3"), { latitude: 120, longitude: 10 })?.gpsLat).toBeNull(); }); it("rounds coordinates to about a kilometer", () => { expect(roundCoordinate(48.137_15)).toBe(48.14); expect(roundCoordinate(-33.868_82)).toBe(-33.87); }); }); describe("helpers", () => { it("parses only numeric sync tokens", () => { expect(parseSyncToken(null)).toBeNull(); expect(parseSyncToken("abc")).toBeNull(); expect(parseSyncToken("1700000000000")).toBe(1_700_000_000_000); }); it("chunks lists", () => { expect(chunked([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); expect(chunked([], 2)).toEqual([]); }); }); describe("syncDeviceIndex", () => { it("indexes photos and videos on the first sync and completes", async () => { const device = [ asset("v1"), asset("p1", { mediaType: "image", modificationTime: 1_700_000_500_000 }), asset("a1", { mediaType: "audio" }), ]; const { io, server, pushes } = harness(device); const result = await syncDeviceIndex({ sourceId: "src1", syncToken: null, io }); expect([...server].sort()).toEqual(["p1", "v1"]); expect(result).toEqual({ scanned: 2, pushed: 2, deleted: 0, syncToken: "1700000500000" }); const last = pushes.at(-1); expect(last).toMatchObject({ complete: true, syncToken: "1700000500000", items: [] }); expect(pushes.slice(0, -1).every((push) => !push.complete && push.syncToken === null)).toBe( true, ); }); it("pages through large libraries within the push limit", async () => { const device = Array.from({ length: 1_203 }, (_, index) => asset(`v${index}`, { modificationTime: 1_700_000_000_000 + index }), ); const { io, server, pushes } = harness(device); await syncDeviceIndex({ sourceId: "src1", syncToken: null, io }); expect(server.size).toBe(1_203); expect(io.listDevice).toHaveBeenCalledTimes(3); expect(pushes.every((push) => push.items.length <= 500)).toBe(true); }); it("pushes only items changed since the token when nothing else differs", async () => { const device = [ asset("old", { modificationTime: 1_000, creationTime: 1_000 }), asset("new", { modificationTime: 3_000, creationTime: 3_000 }), ]; const { io, pushes } = harness(device, ["old"]); const result = await syncDeviceIndex({ sourceId: "src1", syncToken: "2000", io }); expect(pushes.flatMap((push) => push.items.map((item) => item.externalId))).toEqual(["new"]); expect(io.serverExternalIds).not.toHaveBeenCalled(); expect(result.syncToken).toBe("3000"); }); it("sends no items when nothing changed and keeps the token", async () => { const device = [asset("v1", { modificationTime: 1_000, creationTime: 1_000 })]; const { io, pushes } = harness(device, ["v1"]); const result = await syncDeviceIndex({ sourceId: "src1", syncToken: "1000", io }); expect(pushes).toHaveLength(1); expect(pushes[0]).toMatchObject({ items: [], deletedExternalIds: [], complete: true }); expect(result.syncToken).toBe("1000"); expect(io.locate).not.toHaveBeenCalled(); }); it("reports items deleted on the device", async () => { const device = [asset("keep", { modificationTime: 1_000, creationTime: 1_000 })]; const { io, server } = harness(device, ["keep", "gone1", "gone2"]); const result = await syncDeviceIndex({ sourceId: "src1", syncToken: "1000", io }); expect([...server]).toEqual(["keep"]); expect(result.deleted).toBe(2); }); it("adds old items newly shared through limited access", async () => { const device = [ asset("known", { modificationTime: 1_000, creationTime: 1_000 }), asset("shared", { modificationTime: 500, creationTime: 500 }), ]; const { io, server } = harness(device, ["known"]); await syncDeviceIndex({ sourceId: "src1", syncToken: "1000", io }); expect([...server].sort()).toEqual(["known", "shared"]); }); it("splits large deletions and completes only on the last push", async () => { const serverIds = Array.from({ length: 6_000 }, (_, index) => `gone${index}`); const { io, pushes, server } = harness([], serverIds); const result = await syncDeviceIndex({ sourceId: "src1", syncToken: null, io }); expect(server.size).toBe(0); expect(result.deleted).toBe(6_000); expect(pushes.map((push) => [push.deletedExternalIds.length, push.complete])).toEqual([ [5_000, false], [1_000, true], ]); }); it("indexes without location when it is not permitted", async () => { const { io, pushes } = harness([asset("v1")]); io.locate.mockRejectedValue(new Error("ACCESS_MEDIA_LOCATION")); await syncDeviceIndex({ sourceId: "src1", syncToken: null, io }); expect(pushes[0]?.items[0]).toMatchObject({ gpsLat: null, gpsLon: null }); }); it("stops when aborted", async () => { const { io } = harness([asset("v1")]); const controller = new AbortController(); controller.abort(new Error("stopped")); await expect( syncDeviceIndex({ sourceId: "src1", syncToken: null, io, signal: controller.signal }), ).rejects.toThrow("stopped"); expect(io.push).not.toHaveBeenCalled(); }); });