feat(n11): add base64 document staging from e-Court
เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage (x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging (TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม: - lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc - lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid - route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 + concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง - registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก - ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์) - session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี - Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย) - MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง) Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้ fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) — ผ่าน code review รอบ 7 แก้ครบทุก finding Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
2e951f99f4
commit
a02e983ba8
34 changed files with 2396 additions and 62 deletions
103
tests/api/note-auth-session-staged.test.ts
Normal file
103
tests/api/note-auth-session-staged.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createDbMock, createDbMockState, dbMockStateFromGlobal, type DbMockRow } 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 ถูกแทนก่อน
|
||||
import { POST } from "@/app/api/note/auth/session/route";
|
||||
import { issueDocverToken } from "@/lib/docver-token";
|
||||
import { ecourtExternalId } from "@/lib/ecourt-fid";
|
||||
import { __clearStagedDocsForTest, putStagedDoc } from "@/lib/staged-docs";
|
||||
|
||||
// PLAN-STAGED.md D5 — ลิงก์ fid-only (body มีแค่ t=): 4 branch ของการเช็ค staging
|
||||
// ตั้งแต่ตอนแลก token · ใช้ hasStagedDoc จริงของ lib/staged-docs (ไม่ยืด TTL)
|
||||
|
||||
const URL_ = "http://localhost/docedit/api/note/auth/session";
|
||||
const FID = "F-STAGED-1";
|
||||
const FILE_ID = "0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133";
|
||||
const PDF_BYTES = Buffer.from("%PDF-1.7 เอกสาร staging");
|
||||
|
||||
function stagedRow(pdfUrl: string): DbMockRow {
|
||||
return { id: FILE_ID, title: "เอกสารทดสอบ", pdfUrl, externalId: ecourtExternalId(FID), typeId: null };
|
||||
}
|
||||
|
||||
async function postSession(): Promise<Response> {
|
||||
const token = issueDocverToken(
|
||||
{ userid: "u1", username: "ผู้ทดสอบ", fid: FID, template: null },
|
||||
"test-secret",
|
||||
300,
|
||||
"login",
|
||||
);
|
||||
return POST(
|
||||
new Request(URL_, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ t: token }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__clearStagedDocsForTest();
|
||||
const state = dbMockStateFromGlobal();
|
||||
state.rows = [];
|
||||
state.rowsBySelect = [];
|
||||
state.selectCalls = 0;
|
||||
state.insertAttempts = [];
|
||||
state.updates = [];
|
||||
state.insertErrCode = null;
|
||||
vi.stubEnv("DOCVER_TOKEN_SECRET", "test-secret");
|
||||
vi.stubEnv("FID_HASH_PEPPER", "test-pepper");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("D5 — ลิงก์ fid-only (ไม่มี fileUrl)", () => {
|
||||
it("เจอ row staged + hasStagedDoc = true → ได้ fileId ไม่มี fileError", async () => {
|
||||
dbMockStateFromGlobal().rows = [stagedRow(`staged:${FILE_ID}.pdf`)];
|
||||
putStagedDoc(FILE_ID, PDF_BYTES, "pdf");
|
||||
const res = await postSession();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { access_token?: string; fileId?: string; fileError?: string };
|
||||
expect(body.fileId).toBe(FILE_ID);
|
||||
expect(body.fileError).toBeUndefined();
|
||||
expect(typeof body.access_token).toBe("string");
|
||||
});
|
||||
|
||||
it("เจอ row staged + hasStagedDoc = false (restart/TTL/evict) → fileError พ้นช่วงจัดเตรียม", async () => {
|
||||
dbMockStateFromGlobal().rows = [stagedRow(`staged:${FILE_ID}.pdf`)];
|
||||
// ไม่ put — staging ว่าง
|
||||
const res = await postSession();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { access_token?: string; fileId?: string; fileError?: string };
|
||||
expect(body.fileId).toBeUndefined();
|
||||
expect(body.fileError).toBe("เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่");
|
||||
// ผู้ใช้ยังได้ token ตามปกติ
|
||||
expect(typeof body.access_token).toBe("string");
|
||||
});
|
||||
|
||||
it("เจอ row ที่ pdf_url เป็น URL ปกติ (เอกสาร N8 แบบเดิม) → ได้ fileId", async () => {
|
||||
dbMockStateFromGlobal().rows = [stagedRow("https://admincourt.go.th/doc.pdf")];
|
||||
const res = await postSession();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { fileId?: string; fileError?: string };
|
||||
expect(body.fileId).toBe(FILE_ID);
|
||||
expect(body.fileError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ไม่เจอ row → fileError ยังไม่ถูกส่งเข้าระบบ (token ออกตามปกติ)", async () => {
|
||||
dbMockStateFromGlobal().rows = [];
|
||||
const res = await postSession();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { fileId?: string; fileError?: string };
|
||||
expect(body.fileId).toBeUndefined();
|
||||
expect(body.fileError).toBe("เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่");
|
||||
});
|
||||
});
|
||||
287
tests/api/note-files-stage.test.ts
Normal file
287
tests/api/note-files-stage.test.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
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<string, string> = {}): 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<Uint8Array>,
|
||||
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<Uint8Array>; controller: ReadableStreamDefaultController<Uint8Array> | null } {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
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<Uint8Array> | null = null;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
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<Uint8Array>({
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue