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
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,
|
|
});
|
|
});
|
|
});
|
|
|