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(); const items = new Map(); 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 { 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, }); }); });