evOo-local/packages/testkit/src/media-library.postgres.test.ts
Claude 044669edfc
Add a media library: owner folders that bots can search and import from
The deployment owner adds folders of photos and videos in the media settings. A per-folder
background job indexes them (relative path, kind, size, capture date, dimensions, duration),
re-indexes every six hours, and drops files that are gone. The owner's own runs get two tools:
search_media (words in folder and file names, kind, capture date range, newest first) and
import_media (copies one file into the conversation as an artifact). Files are never changed;
hidden entries and symlinks are skipped, and an import must resolve inside its folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs
2026-10-05 08:45:30 +00:00

205 lines
8.3 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ComposioEmulator,
importMediaLibraryAsset,
LocalArtifactStore,
MediaLibraryError,
searchMediaLibrary,
} from "@rakazo/adapters";
import type { MediaLibrarySource } from "@rakazo/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { createApp } from "../../../apps/api/src/app.ts";
import { sessionCookieHeader } from "./index.js";
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
type AppHandles = Awaited<ReturnType<typeof createApp>>;
process.env.WAKEUP_DRIVER = "memory";
process.env.SANDBOX_PROVIDER = "fake";
process.env.AGENT_RUNTIME = "scripted";
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
const describeWithDatabase = hasDb ? describe.sequential : describe.skip;
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
describeWithDatabase("media library (PostgreSQL)", () => {
let handles: AppHandles;
let app: App;
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-library-data-"));
const library = mkdtempSync(path.join(tmpdir(), "rakazo-library-"));
const outside = mkdtempSync(path.join(tmpdir(), "rakazo-library-outside-"));
beforeAll(async () => {
const { createApp } = await import("../../../apps/api/src/app.ts");
handles = await createApp({
databaseUrl: process.env.DATABASE_URL!,
dataDir,
sandboxProvider: "fake",
agentRuntime: "scripted",
wakeupDriver: "memory",
signupsEnabled: "true",
composio: new ComposioEmulator(),
});
app = handles.app;
mkdirSync(path.join(library, "Italy 2024"));
mkdirSync(path.join(library, ".thumbnails"));
writeFileSync(path.join(library, "Italy 2024", "beach.png"), PNG);
writeFileSync(path.join(library, "Italy 2024", "notes.txt"), "not media");
writeFileSync(path.join(library, "birthday.png"), PNG);
writeFileSync(path.join(library, ".thumbnails", "hidden.png"), PNG);
writeFileSync(path.join(outside, "secret.png"), PNG);
symlinkSync(path.join(outside, "secret.png"), path.join(library, "linked.png"));
utimesSync(
path.join(library, "Italy 2024", "beach.png"),
new Date("2024-07-01"),
new Date("2024-07-01"),
);
utimesSync(path.join(library, "birthday.png"), new Date("2025-03-01"), new Date("2025-03-01"));
});
afterAll(async () => {
await handles?.stop();
for (const dir of [dataDir, library, outside]) rmSync(dir, { recursive: true, force: true });
});
async function user(label: string) {
const cookie = await signup(app, `${label}-${stamp}@rakazo.test`, label);
const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me");
return { cookie, me };
}
async function waitForSync(cookie: string, sourceId: string) {
const start = Date.now();
while (Date.now() - start < 20_000) {
const sources = await rpc<MediaLibrarySource[]>(app, cookie, "media/library/list");
const source = sources.find((entry) => entry.id === sourceId);
if (source && source.status !== "syncing") return source;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`library source ${sourceId} never finished syncing`);
}
it("indexes, searches and imports only the owner's own library files", async () => {
const owner = await user("library-owner");
const other = await user("library-other");
await handles.prisma.deploymentSettings.upsert({
where: { id: "default" },
create: { id: "default", ownerUserId: owner.me.userId },
update: { ownerUserId: owner.me.userId },
});
expect((await raw(app, other.cookie, "media/library/list")).status).toBe(403);
expect((await raw(app, other.cookie, "media/library/add", { path: library })).status).toBe(403);
expect(
(await raw(app, owner.cookie, "media/library/add", { path: "relative/folder" })).status,
).toBe(400);
expect(
(await raw(app, owner.cookie, "media/library/add", { path: path.join(library, "nope") }))
.status,
).toBe(400);
const added = await rpc<MediaLibrarySource>(app, owner.cookie, "media/library/add", {
path: library,
});
expect(added).toMatchObject({ path: library, status: "syncing" });
// Adding the same folder again returns it instead of a duplicate.
expect(
(await rpc<MediaLibrarySource>(app, owner.cookie, "media/library/add", { path: library })).id,
).toBe(added.id);
const synced = await waitForSync(owner.cookie, added.id);
expect(synced).toMatchObject({ status: "idle", error: null, assetCount: 2 });
expect(synced.lastSyncedAt).not.toBeNull();
const all = await searchMediaLibrary(handles.prisma, owner.me.userId, {});
expect(all.total).toBe(2);
expect(all.items.map((item) => item.path)).toEqual(["birthday.png", "Italy 2024/beach.png"]);
const italy = await searchMediaLibrary(handles.prisma, owner.me.userId, {
query: "italy BEACH",
});
expect(italy.items.map((item) => item.path)).toEqual(["Italy 2024/beach.png"]);
const before = await searchMediaLibrary(handles.prisma, owner.me.userId, {
taken_before: "2025-01-01T00:00:00Z",
});
expect(before.items.map((item) => item.path)).toEqual(["Italy 2024/beach.png"]);
expect((await searchMediaLibrary(handles.prisma, other.me.userId, {})).total).toBe(0);
const artifacts = new LocalArtifactStore(dataDir);
const assetId = italy.items[0]!.asset_id;
const imported = await importMediaLibraryAsset(
{ prisma: handles.prisma, artifacts },
{
spaceId: owner.me.spaceId,
userId: owner.me.userId,
botId: null,
groupId: null,
runId: null,
},
assetId,
);
expect(imported).toMatchObject({ name: "beach.png", mimeType: "image/png", size: PNG.length });
await expect(
importMediaLibraryAsset(
{ prisma: handles.prisma, artifacts },
{
spaceId: other.me.spaceId,
userId: other.me.userId,
botId: null,
groupId: null,
runId: null,
},
assetId,
),
).rejects.toBeInstanceOf(MediaLibraryError);
// Another user can neither resync nor remove the owner's folder.
expect(
(await raw(app, other.cookie, "media/library/sync", { sourceId: added.id })).status,
).toBe(403);
// A deleted file leaves the index on the next sync.
rmSync(path.join(library, "birthday.png"));
await rpc(app, owner.cookie, "media/library/sync", { sourceId: added.id });
expect(await waitForSync(owner.cookie, added.id)).toMatchObject({ assetCount: 1 });
await rpc(app, owner.cookie, "media/library/remove", { sourceId: added.id });
expect(await rpc(app, owner.cookie, "media/library/list")).toEqual([]);
expect(await handles.prisma.mediaLibraryAsset.count({ where: { sourceId: added.id } })).toBe(0);
expect(
(await raw(app, owner.cookie, "media/library/sync", { sourceId: added.id })).status,
).toBe(404);
});
});
async function signup(app: App, email: string, name: string) {
const res = await app.request("/api/auth/sign-up/email", {
method: "POST",
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
body: JSON.stringify({ email, password: "password12", name }),
});
if (res.status >= 400) throw new Error(`signup failed ${res.status}: ${await res.text()}`);
return sessionCookieHeader(res);
}
function raw(app: App, cookie: string, proc: string, body: unknown = {}) {
return app.request(`/rpc/${proc}`, {
method: "POST",
headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173" },
body: JSON.stringify({ json: body }),
});
}
async function rpc<T>(app: App, cookie: string, proc: string, body: unknown = {}): Promise<T> {
const res = await raw(app, cookie, proc, body);
const text = await res.text();
const parsed = JSON.parse(text) as { json?: T; error?: { message?: string } };
if (res.status >= 400 || parsed.error) {
throw new Error(`${proc} ${res.status}: ${parsed.error?.message ?? text}`);
}
return parsed.json as T;
}