import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createDbMock, createDbMockState, dbMockStateFromGlobal } from "../helpers/db-mock"; vi.mock("@/db", async () => { const { createDbMock, createDbMockState } = await import("../helpers/db-mock"); const state = createDbMockState(); (globalThis as unknown as { __dbMockState: unknown }).__dbMockState = state; return { db: createDbMock(state) }; }); // import หลัง vi.mock — @/db ถูกแทนก่อน (lib/external-import จะได้ mock ผ่าน chain) import { POST } from "@/app/api/note/files/stage/route"; import { ecourtExternalId } from "@/lib/ecourt-fid"; import { __clearStagedDocsForTest, getStagedDoc } from "@/lib/staged-docs"; import { ECOURT_DEFAULT_TITLE } from "@/utils/external-file"; // PLAN-STAGED.md D1 — POST handler เรียกตรง + db/staged จริง (ปรับ cap ผ่าน env) // ครอบ: 401/400/413 (มีและไม่มี Content-Length + ไฟล์เดี่ยวเกิน total cap)/429/ // 408 (idle + total ด้วย fake timers)/stream โยน/req.signal abort — ทุกทางออกต้อง // คืน slot ของ concurrency counter const URL_ = "http://localhost/docedit/api/note/files/stage"; const PDF_BYTES = Buffer.from("%PDF-1.7\n%เอกสารทดสอบ staging"); const PDF_B64 = PDF_BYTES.toString("base64"); function makeReq(body: unknown, headers: Record = {}): Request { return new Request(URL_, { method: "POST", body: typeof body === "string" ? body : JSON.stringify(body), headers: { "x-api-key": "test-key", ...headers }, }); } /** Request จาก stream body — Node 24 (undici) บังคับ duplex:"half" ตอน constructor * กับ stream แต่ lib.dom ยังไม่มี field นี้ใน type จึงต้อง cast (runtime รับค่า) */ function streamReq( stream: ReadableStream, opts: { signal?: AbortSignal } = {}, ): Request { return new Request(URL_, { method: "POST", body: stream, headers: { "x-api-key": "test-key" }, signal: opts.signal, duplex: "half", } as RequestInit); } /** stream ที่ enqueue chunks แล้วคงเปิดไว้ (ไม่ close อัตโนมัติ) — เคส timeout/ * concurrent ต้องการ stream ที่ค้างระหว่างทาง, controller คืนให้ปิดเองตอนจบ */ function streamOf( chunks: string[], ): { stream: ReadableStream; controller: ReadableStreamDefaultController | null } { let controller: ReadableStreamDefaultController | null = null; const stream = new ReadableStream({ start(c) { controller = c; for (const chunk of chunks) c.enqueue(new TextEncoder().encode(chunk)); }, }); return { stream, controller }; } beforeEach(() => { __clearStagedDocsForTest(); const state = dbMockStateFromGlobal(); state.rows = []; state.rowsBySelect = []; state.selectCalls = 0; state.insertAttempts = []; state.updates = []; state.insertErrCode = null; vi.stubEnv("DOCVER_API_KEY", "test-key"); vi.stubEnv("FID_HASH_PEPPER", "test-pepper"); }); afterEach(() => { vi.unstubAllEnvs(); vi.useRealTimers(); }); describe("auth ก่อนแตะอะไรทั้งสิ้น", () => { it("env ว่าง = 401 fail closed · ไม่ใส่ key กับ key ผิด = 401 ข้อความเดียวกัน (กัน enumerate)", async () => { vi.stubEnv("DOCVER_API_KEY", ""); const res = await POST(makeReq({ fid: "F", data: PDF_B64 })); expect(res.status).toBe(401); vi.stubEnv("DOCVER_API_KEY", "test-key"); const res2 = await POST(makeReq({ fid: "F", data: PDF_B64 }, { "x-api-key": "wrong" })); const res3 = await POST(makeReq({ fid: "F", data: PDF_B64 }, { "x-api-key": "" })); expect(res2.status).toBe(401); expect(res3.status).toBe(401); const e2 = (await res2.json()) as { error: string }; const e3 = (await res3.json()) as { error: string }; expect(e2.error).toBe(e3.error); }); }); describe("400 — body/base64/fid/magic", () => { it.each([ ["base64 มี whitespace", { fid: "F", data: "JVBE Ri0" }], ["base64 มี line break", { fid: "F", data: "JVBE\nRi0" }], ["base64url ไม่รับ", { fid: "F", data: "JVBE-Ri0=" }], ["ความยาวไม่ลงตัวส่วน 4", { fid: "F", data: "JVBERi" }], ["magic ไม่รู้จัก", { fid: "F", data: Buffer.from("GIF89aเอกสาร").toString("base64") }], ["fid มี &", { fid: "a&b", data: PDF_B64 }], ["field ที่ไม่รู้จัก", { fid: "F", data: PDF_B64, fileName: "x.pdf" }], ["ขาด data", { fid: "F" }], ])("%s = 400", async (_label, body) => { const res = await POST(makeReq(body)); expect(res.status).toBe(400); const parsed = (await res.json()) as { error: string }; expect(parsed.error).not.toContain("undefined"); }); it("body ไม่ใช่ JSON = 400", async () => { const res = await POST(makeReq("not json")); expect(res.status).toBe(400); }); }); describe("413 — เกิน cap", () => { it("มี Content-Length เกิน cap = ตัดทันทีก่อนอ่าน (413)", async () => { vi.stubEnv("STAGED_MAX_FILE_BYTES", "1000"); // body cap ≈ 4*334+8192 ≈ 9.5KB const big = "x".repeat(20_000); const res = await POST(makeReq(big)); expect(res.status).toBe(413); }); it("ไม่มี Content-Length (chunked stream) = นับไบต์จนเกินแล้วตัดกลางทาง (413)", async () => { vi.stubEnv("STAGED_MAX_FILE_BYTES", "1000"); const { stream } = streamOf(["x".repeat(20_000)]); const res = await POST( streamReq(stream), ); expect(res.status).toBe(413); }); it("ไฟล์เดี่ยวใหญ่กว่า STAGED_MAX_TOTAL_BYTES = 413 ที่ตัว stage (ไม่สร้าง row)", async () => { vi.stubEnv("STAGED_MAX_TOTAL_BYTES", "10"); const res = await POST(makeReq({ fid: "F1", data: PDF_B64 })); expect(res.status).toBe(413); expect(dbMockStateFromGlobal().insertAttempts).toHaveLength(0); }); }); describe("429 — concurrent", () => { it("กำลัง stage อยู่ = 429 + Retry-After แล้ว request ถัดไป (หลังจบ) ผ่าน", async () => { const { stream, controller } = streamOf([]); const first = POST( streamReq(stream), ); // POST แรกวิ่งถึง await แรกแล้ว (sync จนถึง reader.read) — counter ถูกจับไว้ const busy = await POST(makeReq({ fid: "F1", data: PDF_B64 })); expect(busy.status).toBe(429); expect(busy.headers.get("retry-after")).toBe("5"); controller!.enqueue(new TextEncoder().encode(JSON.stringify({ fid: "F1", data: PDF_B64 }))); controller!.close(); const done = await first; expect(done.status).toBe(200); // slot คืนแล้ว — request ถัดไปไม่โดน 429 const next = await POST(makeReq({ fid: "F2", data: PDF_B64 })); expect(next.status).toBe(200); }); }); describe("408 — timeout ของ body (fake timers)", () => { it("stream หยุดนิ่งครบ idle 60 วิ = 408 + คืน slot", async () => { vi.useFakeTimers(); const { stream } = streamOf([]); // ไม่ enqueue อะไรเลยและไม่ close const pending = POST( streamReq(stream), ); vi.advanceTimersByTime(60_000); const res = await pending; expect(res.status).toBe(408); expect(((await res.json()) as { error: string }).error).toContain("ลองส่งใหม่"); // slot คืนใน finally — request ถัดไปไม่โดน 429 const next = await POST(makeReq({ fid: "F1", data: PDF_B64 })); expect(next.status).toBe(200); }); it("stream ไหลต่อเนื่องแต่รวมเกิน 10 นาที = 408 (total timeout แม้ไม่ idle)", async () => { vi.useFakeTimers(); // ปล่อย chunk เล็กทุก 50 วิ (ต่ำกว่า idle 60 วิ) — รวม 10 นาทีต้องโดนตัด let controller: ReadableStreamDefaultController | null = null; const stream = new ReadableStream({ start(c) { controller = c; c.enqueue(new TextEncoder().encode('{"fid":"F1","data":"')); }, }); const interval = setInterval(() => { controller?.enqueue(new TextEncoder().encode("JVBERi0=")); // chunk base64 ถูกกฎ 8 ตัว }, 50_000); const pending = POST( streamReq(stream), ); vi.advanceTimersByTime(600_000); const res = await pending; clearInterval(interval); expect(res.status).toBe(408); }); }); describe("ทางออกผิดปกติของ stream — คืน slot ใน finally เสมอ", () => { it("stream โยน error = 500 + request ถัดไปไม่โดน 429", async () => { const stream = new ReadableStream({ start(c) { c.error(new Error("boom")); }, }); const res = await POST( streamReq(stream), ); expect(res.status).toBe(500); const next = await POST(makeReq({ fid: "F1", data: PDF_B64 })); expect(next.status).toBe(200); }); it("client ตัดการเชื่อมต่อ (req.signal abort) = 408 + คืน slot", async () => { const ac = new AbortController(); const { stream } = streamOf([]); const pending = POST(streamReq(stream, { signal: ac.signal })); ac.abort(); const res = await pending; expect(res.status).toBe(408); const next = await POST(makeReq({ fid: "F1", data: PDF_B64 })); expect(next.status).toBe(200); }); }); describe("happy path", () => { it("payload ใหญ่ (8MiB เนื้อไฟล์) ผ่านทั้งชุด — stream นับไบต์ + base64 decode + magic (กัน regex/backtracking regression 2026-11-04; ขนาด ~140MB เคยพิสูจน์บน server จริงแล้ว — test เล็กพอที่จะไม่กด heap ของ vitest worker)", async () => { const chunk = Buffer.from( "%PDF-1.7 เอกสารทดสอบขนาดใหญ่ padding padding padding padding padding\n", "utf8", ); const parts: Buffer[] = []; let size = 0; while (size < 8 * 1024 * 1024) { parts.push(chunk); size += chunk.length; } const bytes = Buffer.concat(parts); const res = await POST(makeReq({ fid: "F-BIG", data: bytes.toString("base64") })); expect(res.status).toBe(200); const body = (await res.json()) as { fileId: string }; expect(getStagedDoc(`staged:${body.fileId}.pdf`)).toEqual(bytes); }, 60_000); it("ได้ {fileId} + staging มี bytes ใต้ id นั้น + row ถูก insert ครบ", async () => { const res = await POST( makeReq({ fid: "F100", data: PDF_B64, title: "คำสั่งศาล", typeId: "G1" }), ); expect(res.status).toBe(200); const body = (await res.json()) as { fileId: string }; expect(getStagedDoc(`staged:${body.fileId}.pdf`)).toEqual(PDF_BYTES); const state = dbMockStateFromGlobal(); expect(state.insertAttempts).toHaveLength(1); expect(state.insertAttempts[0].externalId).toBe(ecourtExternalId("F100")); expect(state.insertAttempts[0].title).toBe("คำสั่งศาล"); expect(state.insertAttempts[0].typeId).toBe("G1"); }); it("re-POST fid เดิม (มี row แล้ว) = replace ไม่เกิด row ใหม่", async () => { const first = await POST(makeReq({ fid: "F100", data: PDF_B64 })); const { fileId } = (await first.json()) as { fileId: string }; const state = dbMockStateFromGlobal(); state.rows = [ { id: fileId, title: ECOURT_DEFAULT_TITLE, pdfUrl: `staged:${fileId}.pdf`, externalId: ecourtExternalId("F100"), typeId: null, }, ]; const jpgBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); const second = await POST(makeReq({ fid: "F100", data: jpgBytes.toString("base64") })); expect(second.status).toBe(200); expect(((await second.json()) as { fileId: string }).fileId).toBe(fileId); expect(state.insertAttempts).toHaveLength(1); // ไม่มี insert รอบสอง expect(getStagedDoc(`staged:${fileId}.jpg`)).toEqual(jpgBytes); }); });