evOo-local/packages/adapters/src/media-folder-source.test.ts
Claude 270ea8068a
feat(media): inference emulator, sidecar client, folder source and factory
Add the Media Studio adapters behind the frozen provider contracts: a
deterministic offline inference emulator driven by file-name markers that
renders real redactions with ffmpeg, a synthetic emulator library, the
bearer-auth HTTP client for the local media sidecar with validated
responses, typed errors, busy retries and timeouts, a lazily paginated
folder source that never follows symlinks, and the configuration factory.
A shared conformance suite runs against the emulator and, in the Docker
integration job, against the sidecar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:52:17 +00:00

178 lines
6.9 KiB
TypeScript

import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { MediaSourceItem } from "@rakazo/adapter-kit";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ffmpegAvailable } from "./media-emulator-clips.js";
import { FolderMediaSourceProvider } from "./media-folder-source.js";
import { parseIso6709, runMediaTool } from "./media-folder-source-probe.js";
let root: string;
const provider = new FolderMediaSourceProvider();
async function touch(relative: string, content = "") {
const file = path.join(root, relative);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, content);
}
async function listAll(limit: number, cursor: string | null = null) {
const pages: MediaSourceItem[][] = [];
let next = cursor;
do {
const page = await provider.list({ config: { path: root }, cursor: next, limit });
expect(page.items.length).toBeLessThanOrEqual(limit);
pages.push(page.items);
next = page.nextCursor;
} while (next);
return pages.flat().map((item) => item.externalId);
}
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), "media-folder-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
describe("FolderMediaSourceProvider", () => {
it("lists media in stable code-unit order across pages", async () => {
for (const name of ["b/c.mp4", "b-d.jpg", "a.MOV", "b/a/z.webm", "c.png", "notes.txt"])
await touch(name);
const ids = await listAll(2);
expect(ids).toEqual(["a.MOV", "b-d.jpg", "b/a/z.webm", "b/c.mp4", "c.png"]);
});
it("keeps the cursor stable when files are added mid-sync", async () => {
for (const name of ["a.mp4", "c.mp4", "e.mp4", "g.mp4"]) await touch(name);
const first = await provider.list({ config: { path: root }, cursor: null, limit: 2 });
expect(first.items.map((item) => item.externalId)).toEqual(["a.mp4", "c.mp4"]);
await touch("b.mp4"); // before the cursor: picked up by the next full sync
await touch("d.mp4"); // after the cursor: part of this sync
const rest = await listAll(2, first.nextCursor);
expect(rest).toEqual(["d.mp4", "e.mp4", "g.mp4"]);
expect(await listAll(10)).toEqual(["a.mp4", "b.mp4", "c.mp4", "d.mp4", "e.mp4", "g.mp4"]);
});
it("skips hidden entries and symlinks without following them", async () => {
await touch("keep/clip.mp4");
await touch(".hidden.mp4");
await touch(".cache/inside.mp4");
await touch("keep/.DS_Store.jpg");
const outside = await mkdtemp(path.join(tmpdir(), "media-folder-outside-"));
try {
await writeFile(path.join(outside, "secret.mp4"), "");
await symlink(path.join(outside, "secret.mp4"), path.join(root, "link.mp4"));
await symlink(outside, path.join(root, "linked-dir"));
await symlink(path.join(root, "keep"), path.join(root, "keep-alias"));
expect(await listAll(10)).toEqual(["keep/clip.mp4"]);
await expect(provider.resolve({ path: root }, "link.mp4")).rejects.toThrow();
await expect(provider.resolve({ path: root }, "linked-dir/secret.mp4")).rejects.toThrow();
await expect(provider.resolve({ path: root }, "keep-alias/clip.mp4")).rejects.toThrow();
} finally {
await rm(outside, { recursive: true, force: true });
}
});
it("resolves inside the root and rejects traversal", async () => {
await touch("keep/clip.mp4");
await touch(".hidden.mp4");
await expect(provider.resolve({ path: root }, "keep/clip.mp4")).resolves.toBe(
path.join(root, "keep/clip.mp4"),
);
for (const bad of [
"../x",
"../x.mp4",
"keep/../../x.mp4",
"/etc/passwd",
".hidden.mp4",
"keep",
])
await expect(provider.resolve({ path: root }, bad), bad).rejects.toThrow();
});
it("rejects relative roots and forged cursors", async () => {
await expect(
provider.list({ config: { path: "media" }, cursor: null, limit: 5 }),
).rejects.toThrow();
await expect(
provider.list({ config: { path: root }, cursor: "not base64url!", limit: 5 }),
).rejects.toThrow();
});
it("pages a 1,000-file folder without duplicates or gaps", async () => {
const expected: string[] = [];
for (let dir = 0; dir < 10; dir++) {
for (let file = 0; file < 100; file++) {
const relative = `d${dir}/f${String(file).padStart(3, "0")}.${file % 3 === 0 ? "jpg" : "mp4"}`;
// Unreadable media must not abort a page; most files are empty and skip probing.
await touch(relative, file % 50 === 7 ? "garbage, not a video" : "");
expected.push(relative);
}
}
await touch("d3/ignore.txt");
const pages: MediaSourceItem[][] = [];
let cursor: string | null = null;
do {
const page = await provider.list({ config: { path: root }, cursor, limit: 137 });
pages.push(page.items);
cursor = page.nextCursor;
} while (cursor);
const items = pages.flat();
expect(pages.length).toBe(Math.ceil(1000 / 137));
expect(items.map((item) => item.externalId)).toEqual(expected.sort());
expect(new Set(items.map((item) => item.externalId)).size).toBe(1000);
const garbage = items.find((item) => item.externalId === "d0/f007.mp4");
expect(garbage).toMatchObject({ durationMs: null, width: null, sizeBytes: 20, kind: "video" });
expect(garbage?.capturedAt).toBeInstanceOf(Date);
expect(garbage?.revision).toMatch(/^\d+-20$/);
expect(items.find((item) => item.externalId === "d0/f000.jpg")?.kind).toBe("photo");
});
it.skipIf(!ffmpegAvailable())(
"reads duration, size, capture time and GPS with ffprobe",
async () => {
await mkdir(path.join(root, "trip"), { recursive: true });
await runMediaTool("ffmpeg", [
"-v",
"error",
"-f",
"lavfi",
"-i",
"testsrc2=s=320x180:r=25:d=1",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-metadata",
"creation_time=2026-05-02T08:00:00Z",
"-metadata",
"location=+52.5200+013.4050/",
path.join(root, "trip/clip.mp4"),
]);
const [item] = (await provider.list({ config: { path: root }, cursor: null, limit: 5 }))
.items;
expect(item).toMatchObject({
externalId: "trip/clip.mp4",
kind: "video",
durationMs: 1000,
width: 320,
height: 180,
fps: 25,
gpsLat: 52.52,
gpsLon: 13.405,
albumNames: ["trip"],
});
expect(item?.capturedAt?.toISOString()).toBe("2026-05-02T08:00:00.000Z");
},
);
it("parses ISO 6709 locations", () => {
expect(parseIso6709("+52.5200+013.4050+034.000/")).toEqual({ lat: 52.52, lon: 13.405 });
expect(parseIso6709("-33.8688+151.2093/")).toEqual({ lat: -33.8688, lon: 151.2093 });
expect(parseIso6709("+95.0+013.0/")).toBeNull();
expect(parseIso6709("nowhere")).toBeNull();
});
});