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
216 lines
8.2 KiB
TypeScript
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();
|
|
});
|
|
});
|