ai-chatbot/tests/routes/image-archive-flow.test.ts

627 lines
22 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import vm from "node:vm";
import * as crypto from "node:crypto";
import ts from "typescript";
import {
collectImageArchiveSources,
replaceArchivedImageUrls,
} from "@/lib/storage/image-archive-urls";
import { normalizeCanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
import type { ImageNodeData } from "@/types/canvas";
import { useCanvasStore } from "@/store/canvas";
const root = path.resolve(__dirname, "../..");
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=",
"base64",
);
const raw = "https://provider.example/result.png?signature=keep-me";
const permanent = "https://bucket.example/result.png";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
// Execute the actual server modules with isolated DB/storage adapters: no provider
// requests, production DB connections, or paid generations are made by these tests.
function serverLoader(mocks: Record<string, unknown>, env: Record<string, string> = {}) {
const modules = new Map<string, { exports: any }>();
const runtimeGlobal = {};
const load = (relative: string): any => {
const filename = path.resolve(root, relative);
if (modules.has(filename)) return modules.get(filename)!.exports;
const module = { exports: {} };
modules.set(filename, module);
const source = ts.transpileModule(readFileSync(filename, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
esModuleInterop: true,
},
}).outputText;
const nativeRequire = createRequire(filename);
const requireMock = (name: string) => {
if (name === "server-only") return {};
if (Object.prototype.hasOwnProperty.call(mocks, name)) return mocks[name];
if (name.startsWith("@/")) return load(`${name.slice(2)}.ts`);
if (name.startsWith("."))
return load(path.relative(root, path.resolve(path.dirname(filename), `${name}.ts`)));
return nativeRequire(name);
};
const context = vm.createContext({
module,
exports: module.exports,
require: requireMock,
globalThis: runtimeGlobal,
process: { env },
Buffer,
URL,
Response,
crypto,
setTimeout,
clearTimeout,
setImmediate,
AbortSignal,
console: { log() {}, warn() {}, error() {}, info() {} },
fetch:
mocks.fetch ||
(() => {
throw new Error("Unexpected network call");
}),
});
new vm.Script(source, { filename }).runInContext(context);
return module.exports;
};
return load;
}
const loggerMock = { createLogger: () => ({ info() {}, warn() {}, error() {} }) };
test("160 requests from the same user and model start without admission waiting", async () => {
const module = serverLoader({})("lib/canvas/image-concurrency.ts");
let queued = 0;
const slots = await Promise.all(
Array.from({ length: 160 }, () =>
module.acquireCanvasImageModelSlot({
modelKey: "same-model",
ownerKey: "same-user",
onQueued: () => {
queued += 1;
},
}),
),
);
expect(module.getCanvasImageRunningCountForModel("same-model")).toBe(160);
expect(queued).toBe(0);
expect(slots.every((slot: any) => slot.waitMs === 0 && slot.maxConcurrent === 0)).toBe(true);
await Promise.all(slots.map((slot: any) => slot.release()));
await slots[0].release();
expect(module.getCanvasImageRunningCountForModel("same-model")).toBe(0);
expect(
(await module.getCanvasImageModelQueueStatus({ modelKey: "same-model" })).estimatedWaitMs,
).toBe(0);
expect(
normalizeCanvasImageTaskQueueConfig(
{ concurrency: 3, batchDispatchIntervalMs: 1000 },
{ envConcurrency: "2" },
),
).toEqual({ concurrency: 0, batchDispatchIntervalMs: 0 });
});
test("provider preview resolves while a bucket upload is still blocked", async () => {
const bucket = deferred<any>();
let uploadCalls = 0;
let downloadCalls = 0;
let workerCalls = 0;
const registered: string[] = [];
const module = serverLoader(
{
"@/lib/observability/logger": loggerMock,
"@/lib/storage/image-archive-db": {
enqueueImageArchiveTasks: async ({ urls }: { urls: string[] }) => {
registered.push(...urls);
return [];
},
getImageArchiveDisplayHoldMs: () => 0,
imageArchiveSourceKey: (url: string) => url,
},
"./image-archive-worker": {
scheduleImageArchiveWorker: () => {
workerCalls += 1;
},
},
"@/lib/storage/service": {
uploadFile: async () => {
uploadCalls += 1;
return bucket.promise;
},
},
fetch: async () => {
downloadCalls += 1;
return new Response(png, { headers: { "content-type": "image/png" } });
},
},
{ AI_IMAGE_IMMEDIATE_ARCHIVE: "true", AI_IMAGE_DEFERRED_ARCHIVE: "false" },
)("lib/storage/image-archive.ts");
const archive = module.archiveTemporaryImage(raw);
await expect.poll(() => uploadCalls).toBe(1);
const preview = await module.createDeferredUploadedImage(raw);
expect(preview).toEqual({ original: raw, thumbnail: raw });
expect(uploadCalls).toBe(1);
expect(downloadCalls).toBe(1);
expect(registered).toEqual([raw]);
expect(workerCalls).toBe(1);
bucket.resolve({ url: permanent, pathname: "result.png", contentType: "image/png" });
expect((await archive).url).toBe(permanent);
});
test("inline outputs have short, stable preview identities and durable queue payloads", async () => {
let inserted: any;
const db = {
insert: () => ({
values: (value: unknown) => {
inserted = value;
return { onConflictDoUpdate: () => ({ returning: async () => [inserted] }) };
},
}),
};
const module = serverLoader(
{
"@/lib/db": { db },
"@/lib/db/schema": { imageArchiveTask: { sourceUrl: "sourceUrl" } },
},
{ NEXT_PUBLIC_APP_URL: "https://app.example/subpath", IMAGE_ARCHIVE_DISPLAY_HOLD_HOURS: "1" },
)("lib/storage/image-archive-db.ts");
const data = `data:image/png;base64,${png.toString("base64")}`;
await module.enqueueImageArchiveTask({
sourceUrl: data,
metadata: { providerType: "gemini-image" },
});
expect(inserted.sourceUrl).toMatch(
/^https:\/\/app\.example\/api\/generated-image-preview\/[a-f0-9]{64}$/,
);
expect(inserted.metadata.inlineSource).toBe(data);
expect(module.imageArchiveSourceKey(data)).toBe(inserted.sourceUrl);
expect(module.getImageArchiveDisplayHoldMs()).toBe(0);
});
test("background storage starts after delivery and coalesces repeated wakeups", async () => {
const callbacks: Array<() => Promise<void>> = [];
let runs = 0;
const module = serverLoader({
"next/server": {
after: (fn: () => Promise<void>) => {
callbacks.push(fn);
},
},
"@/lib/jobs/image-archive-job": {
runImageArchiveJob: async () => {
runs += 1;
return {
store: { total: runs === 1 ? 1 : 0 },
replace: { total: 0 },
stats: { pendingStore: 0, pendingReplace: 0 },
};
},
},
})("lib/storage/image-archive-worker.ts");
module.scheduleImageArchiveWorker();
module.scheduleImageArchiveWorker();
expect(callbacks).toHaveLength(1);
expect(runs).toBe(0);
await callbacks[0]();
expect(runs).toBe(2);
});
test("bucket failure retains the provider URL and retry task", async () => {
let failed = 0;
let stored = 0;
let replaced = 0;
const module = serverLoader({
"@/lib/storage/image-archive": {
archiveTemporaryImage: async () => {
throw new Error("Bucket unavailable");
},
},
"@/lib/storage/image-archive-db": {
recoverStaleImageArchiveTasks: async () => {},
getImageArchiveTasksToStore: async () => [{ id: "task", sourceUrl: raw, metadata: null }],
markImageArchiveTaskArchiving: async () => true,
markImageArchiveTaskStored: async () => {
stored += 1;
},
markImageArchiveTaskFailed: async () => {
failed += 1;
},
getImageArchiveTasksToReplace: async () => [],
getImageArchiveTaskStats: async () => ({ pendingStore: 1, pendingReplace: 0 }),
},
"@/lib/storage/image-url-replacement": {
replaceImageUrlInDatabase: async () => {
replaced += 1;
},
},
})("lib/jobs/image-archive-job.ts");
const result = await module.runImageArchiveJob();
expect(result.store.failed).toBe(1);
expect(failed).toBe(1);
expect(stored).toBe(0);
expect(replaced).toBe(0);
});
test("URL replacement preserves canvas geometry, selection, dates and undo position", () => {
const time = new Date("2026-10-11T01:00:00Z");
const node = {
id: "node",
type: "image",
position: { x: 300, y: 40 },
size: { width: 300, height: 500 },
data: { src: raw, originalUrl: raw },
createdAt: time,
};
const unaffected = { id: "text", type: "text", data: { text: "unchanged" } };
const nodes = [node, unaffected];
useCanvasStore.setState({
projectId: "project",
nodes: nodes as any,
history: [{ nodes } as any],
historyIndex: 0,
selectedNodeIds: ["node"],
});
useCanvasStore.getState().replaceArchivedImageUrls({ [raw]: permanent });
const state = useCanvasStore.getState();
expect((state.nodes[0].data as ImageNodeData).src).toBe(permanent);
expect(state.nodes[0].position).toEqual(node.position);
expect(state.nodes[0].size).toEqual(node.size);
expect(state.nodes[0].createdAt).toBe(time);
expect(state.nodes[1]).toBe(unaffected);
expect((state.history[0].nodes[0].data as ImageNodeData).src).toBe(permanent);
expect(state.historyIndex).toBe(0);
expect(state.selectedNodeIds).toEqual(["node"]);
const markdown = `![thumb|||${raw}|||2K](${raw})`;
expect(replaceArchivedImageUrls(markdown, { [raw]: permanent })).toBe(
`![thumb|||${permanent}|||2K](${permanent})`,
);
expect(collectImageArchiveSources({ nodes, markdown })).toEqual([raw]);
});
test("durable canvas queue starts all same-user jobs and claims each only once", async () => {
const tasks = Array.from({ length: 20 }, (_, index) => ({
id: `task-${index}`,
userId: "one-user",
status: "pending",
attempts: 0,
request: { model: "one-model", prompt: "test" },
createdAt: new Date(),
startedAt: null,
})) as any[];
const gate = deferred<any>();
let started = 0;
const projection = (rows: any[], shape?: Record<string, string>) =>
shape
? rows.map((row) =>
Object.fromEntries(Object.entries(shape).map(([key, field]) => [key, row[field]])),
)
: rows;
const query = (shape?: Record<string, string>, updates?: any) => {
let predicate = (_row: any) => true;
let limit = Infinity;
const execute = () => {
const rows = tasks.filter(predicate).slice(0, limit);
if (updates)
for (const row of rows)
Object.assign(row, updates, {
attempts: updates.status === "running" ? row.attempts + 1 : row.attempts,
});
return rows;
};
const builder: any = {
from: () => builder,
where: (condition: any) => {
predicate = condition;
return builder;
},
orderBy: () => builder,
limit: (count: number) => {
limit = count;
return builder;
},
returning: async (returnShape: any) => projection(execute(), returnShape),
then: (resolve: any, reject: any) =>
Promise.resolve(projection(execute(), shape)).then(resolve, reject),
};
return builder;
};
const sqlMock = (_parts: TemplateStringsArray, ..._args: unknown[]) => ({});
const taskTable = Object.fromEntries(
[
"id",
"request",
"userId",
"status",
"attempts",
"createdAt",
"startedAt",
"completedAt",
"result",
"error",
"updatedAt",
].map((field) => [field, field]),
);
const module = serverLoader({
"@/lib/db": {
db: {
select: (shape: any) => query(shape),
update: () => ({ set: (updates: any) => query(undefined, updates) }),
},
},
"@/lib/db/schema": { canvasImageTask: taskTable },
"drizzle-orm": {
eq: (field: string, value: unknown) => (row: any) => row[field] === value,
and:
(...predicates: any[]) =>
(row: any) =>
predicates.every((fn) => fn(row)),
lt: (field: string, value: any) => (row: any) => row[field] != null && row[field] < value,
asc: (field: unknown) => field,
notInArray: (field: string, values: unknown[]) => (row: any) => !values.includes(row[field]),
sql: sqlMock,
},
"./generate-image": {
generateCanvasImage: async () => {
started += 1;
return gate.promise;
},
},
"./scale-adjust-model-relight": {
applyScaleAdjustModelRelight: async ({ initialResult }: any) => initialResult,
},
"@/lib/style-pro/task": {
getStyleProTaskPrimaryModelKey: () => "style",
executeStyleProTask: async () => {
throw new Error("Unexpected style task");
},
},
"@/lib/canvas/layout-replace-task": { isCanvasLayoutReplaceTaskRequest: () => false },
"@/lib/canvas/layout-replace-batch": {},
"@/lib/agents/agent-project-result-persistence": {},
"@/lib/storage/image-archive-db": { resolveArchivedImageUrls: async (value: unknown) => value },
})("lib/canvas/image-task-queue.ts");
await module.enqueueCanvasImageTask(tasks[0].id);
await expect.poll(() => started).toBe(20);
await module.enqueueCanvasImageTask(tasks[0].id);
expect(started).toBe(20);
expect(tasks.every((task) => task.status === "running" && task.attempts === 1)).toBe(true);
gate.resolve({
images: [{ originalUrl: raw, thumbnailUrl: raw }],
model: "one-model",
prompt: "test",
});
await expect.poll(() => tasks.filter((task) => task.status === "succeeded").length).toBe(20);
});
test("successful background archive replaces saved references immediately", async () => {
let stored = false;
let claimed = false;
let completed = false;
const calls: string[][] = [];
const module = serverLoader({
"@/lib/storage/image-archive": {
archiveTemporaryImage: async () => ({ url: permanent, bytes: png.length }),
},
"@/lib/storage/image-archive-db": {
recoverStaleImageArchiveTasks: async () => {},
getImageArchiveTasksToStore: async () => [{ id: "task", sourceUrl: raw, metadata: null }],
markImageArchiveTaskArchiving: async () => {
if (claimed) return false;
claimed = true;
return true;
},
markImageArchiveTaskStored: async () => {
stored = true;
},
markImageArchiveTaskFailed: async () => {
throw new Error("Unexpected storage failure");
},
getImageArchiveTasksToReplace: async () =>
stored && !completed ? [{ id: "task", sourceUrl: raw, targetUrl: permanent }] : [],
markImageArchiveTaskReplacing: async () => true,
markImageArchiveTaskCompleted: async () => {
completed = true;
},
getImageArchiveTaskStats: async () => ({ pendingStore: 0, pendingReplace: 0 }),
},
"@/lib/storage/image-url-replacement": {
replaceImageUrlInDatabase: async (...urls: string[]) => {
calls.push(urls);
return { changed: 2 };
},
},
})("lib/jobs/image-archive-job.ts");
const result = await module.runImageArchiveJob();
expect(result.store.succeeded).toBe(1);
expect(result.replace.succeeded).toBe(1);
expect(calls).toEqual([[raw, permanent]]);
await module.runImageArchiveJob();
expect(calls).toHaveLength(1);
});
test("inline preview serves exact image bytes then redirects to the stored object", async () => {
const key = "a".repeat(64);
let archived = false;
const rows = () => [
{
targetUrl: archived ? permanent : null,
metadata: { inlineSource: `data:image/png;base64,${png.toString("base64")}` },
},
];
const module = serverLoader(
{
"@/lib/db": {
db: { select: () => ({ from: () => ({ where: () => ({ limit: async () => rows() }) }) }) },
},
"@/lib/db/schema": { imageArchiveTask: { sourceUrl: "sourceUrl" } },
"next/server": {
NextResponse: class extends Response {
static redirect(url: string, status: number) {
return new Response(null, { status, headers: { Location: url } });
}
},
},
},
{ NEXT_PUBLIC_APP_URL: "https://app.example" },
)("app/api/generated-image-preview/[key]/route.ts");
const response = await module.GET({}, { params: Promise.resolve({ key }) });
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(Buffer.from(await response.arrayBuffer())).toEqual(png);
archived = true;
const redirect = await module.GET({}, { params: Promise.resolve({ key }) });
expect(redirect.status).toBe(307);
expect(redirect.headers.get("Location")).toBe(permanent);
const invalid = await module.GET({}, { params: Promise.resolve({ key: "guess" }) });
expect(invalid.status).toBe(404);
});
test("archive lookup requires login and an owned canvas before reading image mappings", async () => {
let signedIn = false;
let reads = 0;
const module = serverLoader({
"@/app/(auth)/auth": { auth: async () => (signedIn ? { user: { id: "owner" } } : null) },
"@/lib/db": {
db: {
select: () => {
reads += 1;
return { from: () => ({ where: () => ({ limit: async () => [] }) }) };
},
},
},
"@/lib/db/schema": {
canvasProject: { id: "id", userId: "userId" },
imageArchiveTask: {},
generatedImage: {},
canvasImageTask: {},
},
"@/lib/storage/image-archive-db": { imageArchiveSourceKey: (url: string) => url },
"@/lib/storage/image-archive-worker": {
scheduleImageArchiveWorker: () => {
throw new Error("Unowned request must not start archiving");
},
},
"next/server": {
NextResponse: class extends Response {
static json(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), init);
}
},
},
})("app/api/canvas/[id]/image-archives/route.ts");
const request = { json: async () => ({ urls: [raw] }) };
expect(
(await module.POST(request, { params: Promise.resolve({ id: "other-project" }) })).status,
).toBe(401);
expect(reads).toBe(0);
signedIn = true;
expect(
(await module.POST(request, { params: Promise.resolve({ id: "other-project" }) })).status,
).toBe(404);
expect(reads).toBe(1);
});
test("replacement retries reconcile late writes once, without reclaiming completed jobs forever", async () => {
const past = new Date(Date.now() - 1000);
const future = new Date(Date.now() + 60_000);
const rows = [
{ id: "stored", status: "stored", replaceAfterAt: past },
{ id: "settle", status: "completed", replaceAfterAt: past, metadata: { replacementPasses: 1 } },
{
id: "too-soon",
status: "completed",
replaceAfterAt: future,
metadata: { replacementPasses: 1 },
},
{ id: "done", status: "completed", replaceAfterAt: past, metadata: { replacementPasses: 2 } },
{ id: "legacy", status: "completed", replaceAfterAt: past },
];
const table = Object.fromEntries(
["id", "status", "replaceAfterAt", "metadata", "createdAt", "updatedAt"].map((key) => [
key,
key,
]),
);
const query = (updates?: any) => {
let predicate = (_row: any) => true;
const execute = () => {
const found = rows.filter(predicate);
if (updates) found.forEach((row) => Object.assign(row, updates));
return found;
};
const builder: any = {
from: () => builder,
orderBy: () => builder,
limit: () => builder,
where: (value: any) => {
predicate = value;
return builder;
},
returning: async () => execute(),
then: (resolve: any) => Promise.resolve(execute()).then(resolve),
};
return builder;
};
const module = serverLoader({
"@/lib/db": {
db: { select: () => query(), update: () => ({ set: (updates: any) => query(updates) }) },
},
"@/lib/db/schema": { imageArchiveTask: table },
"drizzle-orm": {
and:
(...conditions: any[]) =>
(row: any) =>
conditions.every((fn) => fn(row)),
or:
(...conditions: any[]) =>
(row: any) =>
conditions.some((fn) => fn(row)),
eq: (field: string, value: unknown) => (row: any) => row[field] === value,
lte: (field: string, value: any) => (row: any) => row[field] <= value,
asc: (field: unknown) => field,
sql: (parts: TemplateStringsArray) => {
if (!parts.join("").includes("replacementPasses")) throw new Error("Unexpected predicate");
return (row: any) => row.metadata?.replacementPasses === 1;
},
},
})("lib/storage/image-archive-db.ts");
expect(
(await module.getImageArchiveTasksToReplace({ limit: 50 })).map((row: any) => row.id),
).toEqual(["stored", "settle"]);
expect(await module.markImageArchiveTaskReplacing("settle")).toBe(true);
expect(await module.markImageArchiveTaskReplacing("settle")).toBe(false);
expect(await module.markImageArchiveTaskReplacing("done")).toBe(false);
});
test("detached generation still archives when its original request has already closed", async () => {
let runs = 0;
const module = serverLoader({
// A closed request may accept a callback that is never invoked.
"next/server": { after: () => {} },
"@/lib/jobs/image-archive-job": {
runImageArchiveJob: async () => {
runs += 1;
return {
store: { total: 0 },
replace: { total: 0 },
stats: { pendingStore: 0, pendingReplace: 0 },
};
},
},
})("lib/storage/image-archive-worker.ts");
module.scheduleImageArchiveWorker();
expect(runs).toBe(0);
await expect.poll(() => runs, { timeout: 5_000 }).toBe(1);
});