evOo-local/apps/mobile/lib/media-index.test.ts
Claude 4c30af9ea3
feat(media): mobile library index, resumable upload, and project variants
Index photos and videos from the device library into a client-pushed
ios_library source, upload originals in resumable chunks, and review,
approve, and share project variants with expo-video and the native share
sheet. Pure logic for chunking, resume, indexing, and variants is unit
tested.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:20:42 +00:00

216 lines
8.2 KiB
TypeScript

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> = {}): 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();
});
});