You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

153 lines
5.7 KiB

import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
import { eq, inArray } from "drizzle-orm";
process.env.DATABASE_URL ??= "file:./packages/drizzle-pkg/db.sqlite";
const { dbGlobal } = await import("drizzle-pkg/database/sqlite/db-bun");
mock.module("drizzle-pkg/lib/db", () => ({ dbGlobal }));
const { users } = await import("drizzle-pkg/lib/schema/auth");
const { userExportTasks } = await import("drizzle-pkg/lib/schema/export");
const {
claimNextQueuedTask,
createExportTask,
getExportTaskForUser,
listExportTasksByUser,
markExportTaskFailed,
markExportTaskRunning,
markExportTaskSucceeded,
} = await import("./jobs");
const USER_1 = { id: 910001, username: "export_jobs_u1", password: "pw1" };
const USER_2 = { id: 910002, username: "export_jobs_u2", password: "pw2" };
const TEST_USERS: Array<{ id: number; username: string; password: string }> = [USER_1, USER_2];
async function resetRows() {
const ids = [USER_1.id, USER_2.id];
await dbGlobal.delete(userExportTasks).where(inArray(userExportTasks.userId, ids));
await dbGlobal.delete(users).where(inArray(users.id, ids));
}
describe("export jobs service", () => {
beforeAll(async () => {
await resetRows();
});
beforeEach(async () => {
await resetRows();
await dbGlobal.insert(users).values(TEST_USERS);
});
test("createExportTask creates queued task with given policy", async () => {
const task = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" });
expect(task).toBeTruthy();
expect(task.userId).toBe(USER_1.id);
expect(task.status).toBe("queued");
expect(task.maskPolicy).toBe("masked");
expect(task.exportCutoffAt).toBeNull();
});
test("markExportTaskRunning writes exportCutoffAt timestamp", async () => {
const task = await createExportTask({ userId: USER_1.id, maskPolicy: "raw" });
const running = await markExportTaskRunning(task.id);
expect(running).toBeTruthy();
expect(running?.status).toBe("running");
expect(running?.exportCutoffAt).toBeInstanceOf(Date);
const [fromDb] = await dbGlobal
.select()
.from(userExportTasks)
.where(eq(userExportTasks.id, task.id))
.limit(1);
expect(fromDb?.exportCutoffAt).toBeInstanceOf(Date);
});
test("listExportTasksByUser only returns the requested user tasks", async () => {
const firstU1 = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" });
await markExportTaskRunning(firstU1.id);
await markExportTaskSucceeded(firstU1.id, {
outputDir: "/tmp/export-u1-1",
outputName: "export-u1-1.zip",
totalBytes: 128,
expiresAt: new Date("2026-04-24T00:00:00.000Z"),
});
await createExportTask({ userId: USER_2.id, maskPolicy: "raw" });
await createExportTask({ userId: USER_1.id, maskPolicy: "raw" });
const rows = await listExportTasksByUser(USER_1.id);
expect(rows.length).toBe(2);
expect(rows.every((row) => row.userId === USER_1.id)).toBe(true);
expect(rows[0]!.id).toBeGreaterThan(rows[1]!.id);
});
test("markExportTaskSucceeded and markExportTaskFailed persist payload fields", async () => {
const task1 = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" });
await markExportTaskRunning(task1.id);
await markExportTaskSucceeded(task1.id, {
outputDir: "/tmp/export-1",
outputName: "export-1.zip",
totalBytes: 1234,
expiresAt: new Date("2026-04-24T00:00:00.000Z"),
});
const task2 = await createExportTask({ userId: USER_1.id, maskPolicy: "raw" });
await markExportTaskRunning(task2.id);
await markExportTaskFailed(task2.id, {
errorCode: "EXPORT_IO",
errorMessage: "disk full",
});
const row1 = await getExportTaskForUser(task1.id, USER_1.id);
const row2 = await getExportTaskForUser(task2.id, USER_1.id);
expect(row1?.status).toBe("succeeded");
expect(row1?.outputName).toBe("export-1.zip");
expect(row2?.status).toBe("failed");
expect(row2?.errorCode).toBe("EXPORT_IO");
});
test("invalid status transition is rejected", async () => {
const task = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" });
await expect(
markExportTaskSucceeded(task.id, {
outputDir: "/tmp/export-invalid",
outputName: "export-invalid.zip",
totalBytes: 1,
expiresAt: new Date("2026-04-24T00:00:00.000Z"),
}),
).rejects.toThrow("invalid export task transition");
});
test("getExportTaskForUser enforces ownership", async () => {
const task = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" });
const own = await getExportTaskForUser(task.id, USER_1.id);
const other = await getExportTaskForUser(task.id, USER_2.id);
expect(own?.id).toBe(task.id);
expect(other).toBeNull();
});
test("claimNextQueuedTask returns null when no queued task", async () => {
const task = await claimNextQueuedTask();
expect(task).toBeNull();
});
test("claimNextQueuedTask claims the earliest queued task once", async () => {
const first = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" });
await createExportTask({ userId: USER_2.id, maskPolicy: "raw" });
const claimed = await claimNextQueuedTask();
const nextClaimed = await claimNextQueuedTask();
expect(claimed?.id).toBe(first.id);
expect(claimed?.status).toBe("running");
expect(nextClaimed?.id).not.toBe(first.id);
});
test("createExportTask rejects when user already has active task", async () => {
await createExportTask({ userId: USER_1.id, maskPolicy: "masked" });
await expect(createExportTask({ userId: USER_1.id, maskPolicy: "raw" })).rejects.toMatchObject({
statusCode: 409,
});
});
});