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
205 lines
8.3 KiB
TypeScript
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;
|
|
}
|