94 lines
5.1 KiB
TypeScript
94 lines
5.1 KiB
TypeScript
// Run with a disposable migrated database, POSTGRES_MAX_CONNECTIONS=1 and
|
|
// node --conditions=react-server --import tsx --test <this file>.
|
|
import assert from "node:assert/strict";
|
|
import { randomUUID } from "node:crypto";
|
|
import { after, before, test } from "node:test";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { db } from "@/lib/db";
|
|
import { syncCanvasNodesForProject } from "@/lib/db/queries";
|
|
import { canvasNode, canvasProject, imageArchiveTask, mediaAsset, mediaAssetRelation, user } from "@/lib/db/schema";
|
|
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
|
|
|
const database = new URL(process.env.POSTGRES_URL || "http://missing");
|
|
if (!["127.0.0.1", "localhost", "::1"].includes(database.hostname)) throw new Error("Use a disposable local database");
|
|
if (!/_test$/.test(database.pathname)) throw new Error("Use a disposable test database");
|
|
if (process.env.POSTGRES_MAX_CONNECTIONS !== "1") throw new Error("A single connection makes accidental pool re-entry observable");
|
|
|
|
const userId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const nodeId = randomUUID();
|
|
const assetId = randomUUID();
|
|
const sourceUrl = `https://provider.example/${randomUUID()}.png`;
|
|
const targetUrl = `https://storage.example/${randomUUID()}.png`;
|
|
const canvas = (x: number) => ({ nodes: [{ id: nodeId, type: "image", position: { x, y: 0 }, data: { src: sourceUrl } }], edges: [] });
|
|
|
|
before(async () => {
|
|
await db.insert(user).values({ id: userId, email: `${userId}@transaction-test.invalid` });
|
|
await db.insert(canvasProject).values({ id: projectId, userId, title: "Transaction regression", canvasData: canvas(0) });
|
|
await db.insert(mediaAsset).values({ id: assetId, userId, projectId, mediaType: "image", sourceType: "generated", originalUrl: targetUrl, normalizedUrl: targetUrl });
|
|
await db.insert(imageArchiveTask).values({ sourceUrl, targetUrl, status: "stored", archiveAfterAt: new Date(), replaceAfterAt: new Date() });
|
|
});
|
|
|
|
after(async () => {
|
|
await db.delete(imageArchiveTask).where(eq(imageArchiveTask.sourceUrl, sourceUrl));
|
|
await db.delete(user).where(eq(user.id, userId));
|
|
await db.$client.end({ timeout: 2 });
|
|
});
|
|
|
|
test("archive URL lookup stays on the existing connection", { timeout: 8000 }, async () => {
|
|
const result = await db.transaction(async (tx) => {
|
|
await tx.execute(sql`set local idle_in_transaction_session_timeout = '2s'`);
|
|
await tx.select().from(canvasProject).where(eq(canvasProject.id, projectId)).for("update");
|
|
return resolveArchivedImageUrls(canvas(1), tx);
|
|
});
|
|
assert.equal(result.nodes[0].data.src, targetUrl);
|
|
});
|
|
|
|
async function save(x: number) {
|
|
return db.transaction(async (tx) => {
|
|
await tx.execute(sql`set local idle_in_transaction_session_timeout = '2s'`);
|
|
await tx.select().from(canvasProject).where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))).for("update");
|
|
const next = await syncCanvasNodesForProject({ projectId, userId, canvasData: canvas(x) }, tx);
|
|
await tx.update(canvasProject).set({ canvasData: next }).where(eq(canvasProject.id, projectId));
|
|
return next;
|
|
});
|
|
}
|
|
|
|
test("canvas saves reuse the transaction for URL lookup, asset lookup and node relations", { timeout: 8000 }, async () => {
|
|
const result = await save(2);
|
|
assert.equal(result.nodes[0].data.src, targetUrl);
|
|
assert.equal(result.nodes[0].data.assetId, assetId);
|
|
const nodes = await db.select().from(canvasNode).where(eq(canvasNode.projectId, projectId));
|
|
assert.equal(nodes.length, 1);
|
|
assert.equal((nodes[0].data as { src: string }).src, targetUrl);
|
|
const relations = await db.select().from(mediaAssetRelation).where(eq(mediaAssetRelation.projectId, projectId));
|
|
assert.equal(relations.length, 1);
|
|
assert.equal(relations[0].fromAssetId, assetId);
|
|
});
|
|
|
|
test("ten overlapping saves complete with a pool of one connection", { timeout: 8000 }, async () => {
|
|
const results = await Promise.all(Array.from({ length: 10 }, (_, x) => save(x + 10)));
|
|
assert.equal(results.length, 10);
|
|
for (const result of results) assert.equal(result.nodes[0].data.src, targetUrl);
|
|
const relations = await db.select().from(mediaAssetRelation).where(eq(mediaAssetRelation.projectId, projectId));
|
|
assert.equal(relations.length, 1);
|
|
});
|
|
|
|
test("a failed save rolls back node changes and asset relations together", { timeout: 8000 }, async () => {
|
|
await save(30);
|
|
await assert.rejects(db.transaction(async (tx) => {
|
|
await tx.execute(sql`set local idle_in_transaction_session_timeout = '2s'`);
|
|
await tx.select().from(canvasProject).where(eq(canvasProject.id, projectId)).for("update");
|
|
await syncCanvasNodesForProject({ projectId, userId, canvasData: canvas(99) }, tx);
|
|
throw new Error("intentional rollback");
|
|
}), /intentional rollback/);
|
|
const [node] = await db.select().from(canvasNode).where(eq(canvasNode.id, nodeId));
|
|
assert.equal((node.position as { x: number }).x, 30);
|
|
const relations = await db.select().from(mediaAssetRelation).where(eq(mediaAssetRelation.projectId, projectId));
|
|
assert.equal(relations.length, 1);
|
|
});
|
|
|
|
test("standalone archive normalization remains compatible", async () => {
|
|
assert.equal(await resolveArchivedImageUrls(sourceUrl), targetUrl);
|
|
});
|