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
57
tests/lib/ecourt-fid.test.ts
Normal file
57
tests/lib/ecourt-fid.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ECOURT_ID_PREFIX,
|
||||
ecourtExternalId,
|
||||
isEcourtExternalId,
|
||||
} from "@/lib/ecourt-fid";
|
||||
|
||||
// hash fid ของ e-Court ก่อนเก็บลง DB (ตามคำขอผู้ใช้ 2026-11-04) — pure module
|
||||
// รันได้ไม่ต้องตั้ง DATABASE_URL · env FID_HASH_PEPPER เป็นค่าคงที่ตลอดชีพ
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("ecourtExternalId", () => {
|
||||
it("deterministic + รูป ecourt: + HMAC-SHA256 hex 64 ตัว + ไม่มี fid ดิบในค่า", () => {
|
||||
vi.stubEnv("FID_HASH_PEPPER", "test-pepper");
|
||||
const a = ecourtExternalId("1");
|
||||
expect(a).toBe(ecourtExternalId("1"));
|
||||
expect(a).toMatch(/^ecourt:[0-9a-f]{64}$/);
|
||||
expect(a.startsWith(ECOURT_ID_PREFIX)).toBe(true);
|
||||
// fid ดิบ (ที่มีเอกลักษณ์พอ) ต้องไม่หลุดไปอยู่ในค่า hash
|
||||
const b = ecourtExternalId("SECRET-FID-XYZ-987654");
|
||||
expect(b).not.toContain("SECRET-FID-XYZ");
|
||||
});
|
||||
|
||||
it("fid ต่างค่า → hash ต่าง · pepper ต่างค่า → hash ต่าง (คาดเดากลับไม่ได้)", () => {
|
||||
vi.stubEnv("FID_HASH_PEPPER", "test-pepper");
|
||||
expect(ecourtExternalId("1")).not.toBe(ecourtExternalId("2"));
|
||||
const withA = ecourtExternalId("F123");
|
||||
vi.stubEnv("FID_HASH_PEPPER", "other-pepper");
|
||||
expect(ecourtExternalId("F123")).not.toBe(withA);
|
||||
});
|
||||
|
||||
it("trim fid ก่อน hash — มี whitespace รอบได้ค่าเดียวกัน (จุด normalize เดียว)", () => {
|
||||
vi.stubEnv("FID_HASH_PEPPER", "test-pepper");
|
||||
expect(ecourtExternalId(" F123 ")).toBe(ecourtExternalId("F123"));
|
||||
expect(ecourtExternalId("\tF123\n")).toBe(ecourtExternalId("F123"));
|
||||
});
|
||||
|
||||
it("ไม่ตั้ง/ว่าง pepper = throw (fail-closed กันสถานะครึ่ง ๆ สอง key)", () => {
|
||||
delete process.env.FID_HASH_PEPPER;
|
||||
expect(() => ecourtExternalId("1")).toThrow(/FID_HASH_PEPPER/);
|
||||
vi.stubEnv("FID_HASH_PEPPER", " ");
|
||||
expect(() => ecourtExternalId("1")).toThrow(/FID_HASH_PEPPER/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEcourtExternalId", () => {
|
||||
it("แยกแถว ecourt: ออกจากแถว url: ของ N8", () => {
|
||||
vi.stubEnv("FID_HASH_PEPPER", "test-pepper");
|
||||
expect(isEcourtExternalId(ecourtExternalId("1"))).toBe(true);
|
||||
expect(isEcourtExternalId("url:https://x.go.th/a.pdf")).toBe(false);
|
||||
expect(isEcourtExternalId("url:/samples/a.pdf")).toBe(false);
|
||||
});
|
||||
});
|
||||
165
tests/lib/external-import-staged.test.ts
Normal file
165
tests/lib/external-import-staged.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { __clearStagedDocsForTest, getStagedDoc } from "@/lib/staged-docs";
|
||||
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 ถูกแทนก่อน (vi hoist จัดการให้อยู่แล้ว)
|
||||
import { registerStagedFile } from "@/lib/external-import";
|
||||
import { ecourtExternalId } from "@/lib/ecourt-fid";
|
||||
import { ECOURT_DEFAULT_TITLE } from "@/utils/external-file";
|
||||
|
||||
// PLAN-STAGED.md D3 — registerStagedFile: stage ใต้ id ผู้ชนะเสมอ ไม่มี row ชี้
|
||||
// sentinel ที่ไม่มี bytes · ผลข้างเคียง staging ตรวจผ่าน store จริง (lib/staged-docs)
|
||||
// · external_id เก็บ hash ของ fid (lib/ecourt-fid 2026-11-04 — expected คำนวณ
|
||||
// ด้วย helper ตัวเดียวกับ production, stub FID_HASH_PEPPER)
|
||||
|
||||
const WINNER = "22222222-3333-4444-8555-666666666666";
|
||||
const PDF_BYTES = Buffer.from("%PDF-1.4 เอกสาร staging ทดสอบ");
|
||||
|
||||
function row(overrides: Partial<DbMockRow> = {}): DbMockRow {
|
||||
return {
|
||||
id: WINNER,
|
||||
title: ECOURT_DEFAULT_TITLE,
|
||||
pdfUrl: `staged:${WINNER}.pdf`,
|
||||
externalId: ecourtExternalId("F1"),
|
||||
typeId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("FID_HASH_PEPPER", "test-pepper");
|
||||
__clearStagedDocsForTest();
|
||||
const state = dbMockStateFromGlobal();
|
||||
state.rows = [];
|
||||
state.rowsBySelect = [];
|
||||
state.selectCalls = 0;
|
||||
state.insertAttempts = [];
|
||||
state.updates = [];
|
||||
state.insertErrCode = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("เจอ row เดิม — replace staging + patch", () => {
|
||||
it("pdf_url เป็น URL (เอกสาร N8) + ส่ง typeId + title ยังเป็นค่าจริง → patch เฉพาะที่ต้องเปลี่ยน", async () => {
|
||||
const state = dbMockStateFromGlobal();
|
||||
state.rows = [
|
||||
row({ title: "คำสั่งศาลเดิม", pdfUrl: "https://admincourt.go.th/x.pdf" }),
|
||||
];
|
||||
const result = await registerStagedFile("F1", PDF_BYTES, "pdf", {
|
||||
title: "ชื่อใหม่",
|
||||
typeId: "G9",
|
||||
});
|
||||
expect(result.fileId).toBe(WINNER);
|
||||
// staging อยู่ใต้ id ของ row เดิม
|
||||
expect(getStagedDoc(`staged:${WINNER}.pdf`)).toEqual(PDF_BYTES);
|
||||
// insert ไม่เกิด / patch เดียว: pdfUrl เปลี่ยน (URL → sentinel) + typeId ย้ายกลุ่ม
|
||||
// แต่ title ไม่แตะ (ค่าปัจจุบันไม่ใช่ค่า default)
|
||||
expect(state.insertAttempts).toHaveLength(0);
|
||||
expect(state.updates).toHaveLength(1);
|
||||
expect(state.updates[0].patch).toEqual({
|
||||
pdfUrl: `staged:${WINNER}.pdf`,
|
||||
typeId: "G9",
|
||||
});
|
||||
});
|
||||
|
||||
it("title ปัจจุบันยังเป็นค่า default → title ที่ส่งมาชนะ (ครั้งแรกล็อก)", async () => {
|
||||
const state = dbMockStateFromGlobal();
|
||||
state.rows = [row({ pdfUrl: `staged:${WINNER}.jpg`, typeId: "G1" })];
|
||||
await registerStagedFile("F1", PDF_BYTES, "pdf", { title: "ชื่อจริงครั้งแรก" });
|
||||
// ext ต่าง → sentinel เปลี่ยนด้วย; typeId ไม่ส่ง = คงเดิมไม่เคลียร์
|
||||
expect(state.updates[0].patch).toEqual({
|
||||
pdfUrl: `staged:${WINNER}.pdf`,
|
||||
title: "ชื่อจริงครั้งแรก",
|
||||
});
|
||||
});
|
||||
|
||||
it("row เดิมเป็น sentinel เดียวกันทุกค่า → ไม่มี update (แค่ replace bytes)", async () => {
|
||||
const state = dbMockStateFromGlobal();
|
||||
state.rows = [row()];
|
||||
await registerStagedFile("F1", PDF_BYTES, "pdf");
|
||||
expect(state.updates).toHaveLength(0);
|
||||
expect(getStagedDoc(`staged:${WINNER}.pdf`)).toEqual(PDF_BYTES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ไม่เจอ row — insert ด้วย candidateId", () => {
|
||||
it("สร้าง row ใหม่ sentinel ชี้ candidateId + staging ใต้ id เดียวกัน + external_id เป็น hash ของ fid", async () => {
|
||||
const state = dbMockStateFromGlobal();
|
||||
state.rows = [];
|
||||
const result = await registerStagedFile("F2", PDF_BYTES, "jpg", { title: "ภาพถ่าย", typeId: "G2" });
|
||||
expect(state.insertAttempts).toHaveLength(1);
|
||||
expect(state.updates).toHaveLength(0);
|
||||
const inserted = state.insertAttempts[0];
|
||||
expect(inserted.id).toBe(result.fileId);
|
||||
expect(inserted.pdfUrl).toBe(`staged:${result.fileId}.jpg`);
|
||||
expect(inserted.title).toBe("ภาพถ่าย");
|
||||
// hash ของ fid ไม่ใช่ fid ดิบ — fid ดิบต้องไม่ปรากฏในแถวเลย
|
||||
expect(inserted.externalId).toBe(ecourtExternalId("F2"));
|
||||
expect(JSON.stringify(inserted)).not.toContain("F2");
|
||||
expect(inserted.typeId).toBe("G2");
|
||||
expect(getStagedDoc(`staged:${result.fileId}.jpg`)).toEqual(PDF_BYTES);
|
||||
});
|
||||
|
||||
it("ไม่ส่ง title = ค่า default กลาง (ไม่ฝัง fid) · typeId ไม่ส่ง = null", async () => {
|
||||
const state = dbMockStateFromGlobal();
|
||||
const result = await registerStagedFile("F3", PDF_BYTES, "png");
|
||||
expect(state.insertAttempts[0].title).toBe(ECOURT_DEFAULT_TITLE);
|
||||
expect(state.insertAttempts[0].typeId).toBeNull();
|
||||
expect(result.fileId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insert โดน 23505 — คู่ขนาน fid เดียวกัน", () => {
|
||||
it("re-stage ใต้ winnerId + ลบ candidateId + คืน winnerId (bytes ของเรา = ล่าสุด)", async () => {
|
||||
const state = dbMockStateFromGlobal();
|
||||
const winnerRow = row({
|
||||
pdfUrl: "https://admincourt.go.th/old.pdf",
|
||||
externalId: ecourtExternalId("F4"),
|
||||
});
|
||||
// select รอบแรก (ก่อน insert) ไม่เจอ — รอบสอง (หลังชน 23505) เจอผู้ชนะ
|
||||
state.rowsBySelect = [[], [winnerRow]];
|
||||
state.insertErrCode = "23505";
|
||||
|
||||
const result = await registerStagedFile("F4", PDF_BYTES, "pdf");
|
||||
expect(result.fileId).toBe(WINNER);
|
||||
expect(state.selectCalls).toBe(2);
|
||||
// staging ทับใต้ id ผู้ชนะด้วย bytes ของเรา (last-write-wins)
|
||||
expect(getStagedDoc(`staged:${WINNER}.pdf`)).toEqual(PDF_BYTES);
|
||||
// candidate ของผู้แพ้ถูกลบคืน RAM — เหลือ entry เดียวใน store
|
||||
const candidateId = state.insertAttempts[0].id as string;
|
||||
expect(candidateId).not.toBe(WINNER);
|
||||
expect(getStagedDoc(`staged:${candidateId}.pdf`)).toBeNull();
|
||||
// patch ผู้ชนะ: URL เดิม → sentinel
|
||||
expect(state.updates).toHaveLength(1);
|
||||
expect(state.updates[0].patch).toEqual({ pdfUrl: `staged:${WINNER}.pdf` });
|
||||
});
|
||||
|
||||
it("error อื่น (ไม่ใช่ 23505) โยนต่อ — entry กำพร้าใน RAM ให้ TTL กินเอง", async () => {
|
||||
const state = dbMockStateFromGlobal();
|
||||
state.rows = [];
|
||||
state.insertErrCode = "42P01";
|
||||
await expect(registerStagedFile("F5", PDF_BYTES, "pdf")).rejects.toThrow("db error (mock)");
|
||||
});
|
||||
|
||||
it("ชน 23505 แต่ select รอบสองไม่เจอ row ผู้ชนะ — โยนต่อพร้อมคืน RAM ผู้แพ้ทันที (review ข้อ 3)", async () => {
|
||||
const state = dbMockStateFromGlobal();
|
||||
state.rowsBySelect = [[], []]; // ทั้งสองรอบไม่เจอ
|
||||
state.insertErrCode = "23505";
|
||||
await expect(registerStagedFile("F6", PDF_BYTES, "pdf")).rejects.toThrow();
|
||||
const candidateId = state.insertAttempts[0].id as string;
|
||||
expect(getStagedDoc(`staged:${candidateId}.pdf`)).toBeNull();
|
||||
});
|
||||
});
|
||||
21
tests/lib/server-pdf.test.ts
Normal file
21
tests/lib/server-pdf.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf";
|
||||
|
||||
// PLAN-STAGED.md D4 — guard ปฏิเสธ sentinel staged: ชัด ๆ (defense in depth)
|
||||
// ก่อนหน้านี้ "staged:x.pdf" ผ่าน resolve ตกใน publicDir แล้วตายด้วย ENOENT เป็น 500 ดิบ
|
||||
describe("resolvePublicPdfPath — sentinel staged: (N11)", () => {
|
||||
it("โยน PdfPathError ไม่ใช่ ENOENT ทะลุเป็น 500", () => {
|
||||
expect(() =>
|
||||
resolvePublicPdfPath("staged:0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133.pdf"),
|
||||
).toThrow(PdfPathError);
|
||||
expect(() =>
|
||||
resolvePublicPdfPath("staged:0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133.tif"),
|
||||
).toThrow(PdfPathError);
|
||||
});
|
||||
|
||||
it("path ปกติ (สัมพัทธ์ public/note) ยัง resolve ได้ตามเดิม", () => {
|
||||
const p = resolvePublicPdfPath("/samples/demo.pdf");
|
||||
expect(p.replace(/\\/g, "/")).toContain("public/note/samples/demo.pdf");
|
||||
});
|
||||
});
|
||||
153
tests/lib/staged-docs.test.ts
Normal file
153
tests/lib/staged-docs.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
__clearStagedDocsForTest,
|
||||
detectStagedExt,
|
||||
getStagedDoc,
|
||||
hasStagedDoc,
|
||||
parseStagedSource,
|
||||
putStagedDoc,
|
||||
stagedSourceUrl,
|
||||
StagedCapError,
|
||||
} from "@/lib/staged-docs";
|
||||
|
||||
// PLAN-STAGED.md D2 — RAM staging (pure module ไม่ต้องตั้ง DATABASE_URL)
|
||||
// ใช้ fake timers คุม Date.now ของ TTL sliding + env stub ปรับ cap ต่อเคส
|
||||
// (store เก็บบน globalThis จึงต้อง __clearStagedDocsForTest ทุกเคส)
|
||||
|
||||
const ID_A = "0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133";
|
||||
const ID_B = "11111111-2222-4333-8444-555555555555";
|
||||
const ID_C = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
|
||||
beforeEach(() => {
|
||||
__clearStagedDocsForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("sentinel: stagedSourceUrl/parseStagedSource", () => {
|
||||
it("สร้าง + แกะกลับได้ครบทุก ext", () => {
|
||||
for (const ext of ["pdf", "jpg", "png", "tif"] as const) {
|
||||
const url = stagedSourceUrl(ID_A, ext);
|
||||
expect(url).toBe(`staged:${ID_A}.${ext}`);
|
||||
expect(parseStagedSource(url)).toEqual({ fileId: ID_A, ext });
|
||||
}
|
||||
});
|
||||
|
||||
it("ปฏิเสธ uuid/ext เพี้ยนและค่าที่ไม่ใช่ sentinel", () => {
|
||||
expect(parseStagedSource(`staged:not-a-uuid.pdf`)).toBeNull();
|
||||
expect(parseStagedSource(`staged:${ID_A}.exe`)).toBeNull();
|
||||
expect(parseStagedSource(`staged:${ID_A}`)).toBeNull();
|
||||
expect(parseStagedSource(`staged:.pdf`)).toBeNull();
|
||||
expect(parseStagedSource(`/samples/${ID_A}.pdf`)).toBeNull();
|
||||
expect(parseStagedSource("https://x.go.th/a.pdf")).toBeNull();
|
||||
expect(parseStagedSource("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectStagedExt — magic bytes", () => {
|
||||
it("แยก pdf/jpg/png/tif (ทั้งสอง endian) ถูก", () => {
|
||||
expect(detectStagedExt(Buffer.from("%PDF-1.7\n..."))).toBe("pdf");
|
||||
expect(detectStagedExt(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]))).toBe("jpg");
|
||||
expect(detectStagedExt(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d]))).toBe("png");
|
||||
expect(detectStagedExt(Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x00]))).toBe("tif");
|
||||
expect(detectStagedExt(Buffer.from([0x4d, 0x4d, 0x00, 0x2a, 0x00]))).toBe("tif");
|
||||
});
|
||||
|
||||
it("ไม่รู้จัก = null (รวมไฟล์ว่าง/GIF/ตัด magic สั้นเกิน)", () => {
|
||||
expect(detectStagedExt(Buffer.alloc(0))).toBeNull();
|
||||
expect(detectStagedExt(Buffer.from("GIF89a"))).toBeNull();
|
||||
expect(detectStagedExt(Buffer.from([0x89, 0x50]))).toBeNull();
|
||||
expect(detectStagedExt(Buffer.from("%PD"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("put/get ตัวตน", () => {
|
||||
it("put แล้ว get ได้ bytes เดิม (อ่านซ้ำก็ได้)", () => {
|
||||
const bytes = Buffer.from("%PDF-1.4 เอกสารทดสอบ");
|
||||
putStagedDoc(ID_A, bytes, "pdf");
|
||||
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes);
|
||||
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes);
|
||||
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true);
|
||||
});
|
||||
|
||||
it("get ด้วย ext ที่ไม่ตรง entry = miss แต่ไม่ทำลาย entry เดิม", () => {
|
||||
const bytes = Buffer.from("%PDF-1.4");
|
||||
putStagedDoc(ID_A, bytes, "pdf");
|
||||
expect(getStagedDoc(`staged:${ID_A}.jpg`)).toBeNull();
|
||||
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("replace ช่องเดิมต่อ fileId — ไม่งอก entry ใหม่ต่อครั้ง", () => {
|
||||
vi.stubEnv("STAGED_MAX_TOTAL_BYTES", "1000");
|
||||
putStagedDoc(ID_B, Buffer.alloc(500, 1), "pdf");
|
||||
putStagedDoc(ID_B, Buffer.alloc(300, 2), "pdf");
|
||||
// ถ้างอกเป็น 2 entry รวม 800 + ID_C 400 = 1200 > 1000 จะโดน evict — แทนที่แล้ว
|
||||
// รวม 300 + 400 = 700 ทุกตัวรอด
|
||||
putStagedDoc(ID_C, Buffer.alloc(400, 3), "pdf");
|
||||
expect(getStagedDoc(`staged:${ID_B}.pdf`)).toEqual(Buffer.alloc(300, 2));
|
||||
expect(getStagedDoc(`staged:${ID_C}.pdf`)).toEqual(Buffer.alloc(400, 3));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TTL sliding", () => {
|
||||
it("get ที่สำเร็จ = touch — ใช้งานต่อเนื่องไม่หมดอายุ / ปล่อยเลยหมดแล้วถูกเก็บกวาด", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("STAGED_DOC_TTL_SECONDS", "10");
|
||||
const bytes = Buffer.from("%PDF-1.4");
|
||||
putStagedDoc(ID_A, bytes, "pdf");
|
||||
|
||||
vi.advanceTimersByTime(9_000);
|
||||
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes); // touch ที่ 9 วิ
|
||||
|
||||
vi.advanceTimersByTime(9_000); // 18 วินับจาก put แต่ 9 วินับจาก touch ล่าสุด
|
||||
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes);
|
||||
|
||||
vi.advanceTimersByTime(11_000); // ไม่มีใคร touch 11 วิ = พ้น TTL
|
||||
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toBeNull();
|
||||
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(false);
|
||||
// หมดอายุแล้ว get เก็บกวาดทิ้ง — put ใหม่ได้ตามปกติ
|
||||
putStagedDoc(ID_A, bytes, "pdf");
|
||||
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true);
|
||||
});
|
||||
|
||||
it("hasStagedDoc ไม่ touch TTL (lastAccessAt ไม่ขยับ)", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("STAGED_DOC_TTL_SECONDS", "10");
|
||||
const bytes = Buffer.from("%PDF-1.4");
|
||||
putStagedDoc(ID_A, bytes, "pdf");
|
||||
|
||||
vi.advanceTimersByTime(8_000);
|
||||
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true);
|
||||
// อีก 5 วิ: รวม 13 วิจาก put — ถ้า has() แอบ touch ตัวนี้จะยังไม่หมดอายุ (5 < 10)
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(false);
|
||||
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cap + LRU", () => {
|
||||
it("เกิน total cap = evict รายการ lastAccessAt เก่าสุด (ผู้เพิ่งใช้รอด)", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("STAGED_MAX_TOTAL_BYTES", "1000");
|
||||
putStagedDoc(ID_A, Buffer.alloc(400), "pdf"); // t=0
|
||||
vi.advanceTimersByTime(1_000);
|
||||
putStagedDoc(ID_B, Buffer.alloc(400), "pdf"); // t=1s
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(Buffer.alloc(400)); // A ถูก touch ล่าสุด
|
||||
|
||||
putStagedDoc(ID_C, Buffer.alloc(400), "pdf"); // รวม 1200 > 1000 → evict B (เก่าสุด)
|
||||
expect(hasStagedDoc(`staged:${ID_B}.pdf`)).toBe(false);
|
||||
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true);
|
||||
expect(hasStagedDoc(`staged:${ID_C}.pdf`)).toBe(true);
|
||||
});
|
||||
|
||||
it("ไฟล์เดี่ยวใหญ่กว่า total cap = put ปฏิเสธ (StagedCapError)", () => {
|
||||
vi.stubEnv("STAGED_MAX_TOTAL_BYTES", "1000");
|
||||
expect(() => putStagedDoc(ID_A, Buffer.alloc(1001), "pdf")).toThrow(StagedCapError);
|
||||
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue