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, deleteExportTaskForUser, getExportTaskForUser, listExportTasksByUser, markExportTaskFailed, markExportTaskExpired, 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, }); }); test("markExportTaskExpired updates status and message", async () => { const task = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" }); await markExportTaskRunning(task.id); await markExportTaskSucceeded(task.id, { outputDir: "/tmp/export-expired", outputName: "export-expired.zip", totalBytes: 100, expiresAt: new Date("2026-04-24T00:00:00.000Z"), }); const expired = await markExportTaskExpired(task.id, "导出文件已丢失,请重新导出"); expect(expired.status).toBe("expired"); expect(expired.errorCode).toBe("EXPORT_EXPIRED"); expect(expired.errorMessage).toBe("导出文件已丢失,请重新导出"); }); test("deleteExportTaskForUser removes own non-running task", async () => { const task = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" }); await deleteExportTaskForUser(task.id, USER_1.id); const deleted = await getExportTaskForUser(task.id, USER_1.id); expect(deleted).toBeNull(); }); test("deleteExportTaskForUser rejects running task", async () => { const task = await createExportTask({ userId: USER_1.id, maskPolicy: "masked" }); await markExportTaskRunning(task.id); await expect(deleteExportTaskForUser(task.id, USER_1.id)).rejects.toMatchObject({ statusCode: 409, }); }); });