| import Fastify from "fastify"; |
| import fastifyMultipart from "@fastify/multipart"; |
| import JSZip from "jszip"; |
| import { mkdtempSync, rmSync } from "node:fs"; |
| import { tmpdir } from "node:os"; |
| import { join } from "node:path"; |
| import { randomUUID } from "node:crypto"; |
| import { afterEach, describe, expect, it } from "vitest"; |
|
|
| import { registerAnalysisRoutes } from "../routes/analysis.js"; |
| import type { DetectedMode, ItemRow, JobRow, Store } from "../queue/store.js"; |
|
|
| function now() { |
| return Date.now(); |
| } |
|
|
| function createFakeStore(): Store { |
| const jobs = new Map<string, JobRow>(); |
| const items = new Map<string, ItemRow>(); |
|
|
| return { |
| close() {}, |
| createJob(optionsSnapshot: object) { |
| const ts = now(); |
| const job: JobRow = { |
| id: randomUUID(), |
| status: "pending", |
| options_snapshot: JSON.stringify(optionsSnapshot), |
| created_at: ts, |
| updated_at: ts, |
| }; |
| jobs.set(job.id, job); |
| return job; |
| }, |
| getJob(id: string) { |
| return jobs.get(id) ?? null; |
| }, |
| setJobStatus(id, status) { |
| const job = jobs.get(id); |
| if (job) jobs.set(id, { ...job, status, updated_at: now() }); |
| }, |
| updateJobOptions(id, optionsSnapshot) { |
| const job = jobs.get(id); |
| if (job) { |
| jobs.set(id, { |
| ...job, |
| options_snapshot: JSON.stringify(optionsSnapshot), |
| updated_at: now(), |
| }); |
| } |
| }, |
| listResumableJobs() { |
| return []; |
| }, |
| recoverInterruptedJob() {}, |
| createItem(input: { |
| jobId: string; |
| ord: number; |
| filename: string; |
| uploadPath: string; |
| detectedMode: DetectedMode; |
| }) { |
| const ts = now(); |
| const item: ItemRow = { |
| id: randomUUID(), |
| job_id: input.jobId, |
| ord: input.ord, |
| filename: input.filename, |
| upload_path: input.uploadPath, |
| detected_mode: input.detectedMode, |
| status: "queued", |
| evidence_path: null, |
| task_package_path: null, |
| judge_result_path: null, |
| export_zip_path: null, |
| error_preview: null, |
| error_details_path: null, |
| attempt_count: 0, |
| last_stage: null, |
| created_at: ts, |
| updated_at: ts, |
| }; |
| items.set(item.id, item); |
| return item; |
| }, |
| getItem(id) { |
| return items.get(id) ?? null; |
| }, |
| listItemsByJob(jobId) { |
| return [...items.values()] |
| .filter((item) => item.job_id === jobId) |
| .sort((a, b) => a.ord - b.ord); |
| }, |
| setItemStatus(id, status, errorPreview, meta) { |
| const item = items.get(id); |
| if (item) { |
| items.set(id, { |
| ...item, |
| status, |
| error_preview: errorPreview ?? item.error_preview, |
| last_stage: meta?.lastStage ?? item.last_stage, |
| error_details_path: meta?.errorDetailsPath ?? item.error_details_path, |
| updated_at: now(), |
| }); |
| } |
| }, |
| setItemPaths(id, paths) { |
| const item = items.get(id); |
| if (item) { |
| items.set(id, { |
| ...item, |
| evidence_path: paths.evidencePath ?? item.evidence_path, |
| task_package_path: paths.taskPackagePath ?? item.task_package_path, |
| judge_result_path: paths.judgeResultPath ?? item.judge_result_path, |
| export_zip_path: paths.exportZipPath ?? item.export_zip_path, |
| updated_at: now(), |
| }); |
| } |
| }, |
| takeNextQueuedItem() { |
| return [...items.values()].find((item) => item.status === "queued") ?? null; |
| }, |
| resetItemForRetry(id) { |
| const item = items.get(id); |
| if (item) items.set(id, { ...item, status: "queued", updated_at: now() }); |
| }, |
| }; |
| } |
|
|
| async function buildApiLookingZip(): Promise<Buffer> { |
| const zip = new JSZip(); |
| zip.file("network.json", JSON.stringify([{ url: "https://example.test/api" }])); |
| return zip.generateAsync({ type: "nodebuffer" }); |
| } |
|
|
| function multipartFileThenRequestedMode(file: Buffer) { |
| const boundary = "----rl-auto-test-" + randomUUID(); |
| const payload = Buffer.concat([ |
| Buffer.from( |
| `--${boundary}\r\n` + |
| `Content-Disposition: form-data; name="files"; filename="api-looking.zip"\r\n` + |
| "Content-Type: application/zip\r\n\r\n", |
| ), |
| file, |
| Buffer.from( |
| `\r\n--${boundary}\r\n` + |
| 'Content-Disposition: form-data; name="requestedMode"\r\n\r\n' + |
| `mcp\r\n` + |
| `--${boundary}--\r\n`, |
| ), |
| ]); |
| return { |
| payload, |
| contentType: `multipart/form-data; boundary=${boundary}`, |
| }; |
| } |
|
|
| describe("analysis upload requestedMode", () => { |
| let tmpRoot: string | null = null; |
|
|
| afterEach(() => { |
| if (tmpRoot) rmSync(tmpRoot, { recursive: true, force: true }); |
| tmpRoot = null; |
| }); |
|
|
| it("uses requestedMode for every uploaded file even when the field arrives after files", async () => { |
| tmpRoot = mkdtempSync(join(tmpdir(), "rl-auto-upload-mode-")); |
| const app = Fastify(); |
| await app.register(fastifyMultipart, { |
| limits: { fileSize: 10 * 1024 * 1024, files: 10 }, |
| }); |
| await registerAnalysisRoutes(app, { |
| store: createFakeStore(), |
| config: { |
| uploadDir: tmpRoot, |
| maxBatchFiles: 10, |
| maxUploadMB: 10, |
| } as any, |
| runner: {} as any, |
| }); |
|
|
| const { payload, contentType } = multipartFileThenRequestedMode( |
| await buildApiLookingZip(), |
| ); |
| const res = await app.inject({ |
| method: "POST", |
| url: "/api/analysis/jobs", |
| headers: { "content-type": contentType }, |
| payload, |
| }); |
|
|
| await app.close(); |
|
|
| expect(res.statusCode).toBe(200); |
| expect(res.json()).toMatchObject({ |
| code: 0, |
| data: { |
| items: [expect.objectContaining({ detectedMode: "mcp" })], |
| }, |
| }); |
| }); |
|
|
| it("clamps oversized numeric options when starting a job", async () => { |
| tmpRoot = mkdtempSync(join(tmpdir(), "rl-auto-options-")); |
| const store = createFakeStore(); |
| const job = await store.createJob({}); |
| const scheduledJobs: string[] = []; |
| const app = Fastify(); |
| await registerAnalysisRoutes(app, { |
| store, |
| config: { |
| uploadDir: tmpRoot, |
| maxBatchFiles: 10, |
| maxUploadMB: 10, |
| } as any, |
| runner: { |
| scheduleJob(id: string) { |
| scheduledJobs.push(id); |
| }, |
| cancelJob() {}, |
| } as any, |
| }); |
|
|
| const res = await app.inject({ |
| method: "POST", |
| url: `/api/analysis/jobs/${job.id}/start`, |
| headers: { "content-type": "application/json" }, |
| payload: { |
| optionsSnapshot: { |
| requestedMode: "api", |
| concurrency: 99, |
| temperature: 9, |
| topP: 9, |
| maxTokens: 999999, |
| }, |
| }, |
| }); |
|
|
| await app.close(); |
|
|
| expect(res.statusCode).toBe(200); |
| expect(scheduledJobs).toEqual([job.id]); |
| expect(JSON.parse((await store.getJob(job.id))?.options_snapshot || "{}")).toMatchObject({ |
| concurrency: 8, |
| temperature: 2, |
| topP: 1, |
| maxTokens: 131072, |
| }); |
| }); |
| }); |
|
|