627 lines
22 KiB
TypeScript
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 = ``;
|
|
expect(replaceArchivedImageUrls(markdown, { [raw]: permanent })).toBe(
|
|
``,
|
|
);
|
|
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);
|
|
});
|