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:
Your Name 2026-11-05 12:53:56 +07:00
parent 2e951f99f4
commit a02e983ba8
34 changed files with 2396 additions and 62 deletions

View 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 กดลิงก์ใหม่");
});
});

View 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);
});
});

76
tests/helpers/db-mock.ts Normal file
View file

@ -0,0 +1,76 @@
// shared mock ของ @/db สำหรับ route/lib tests ที่ต้องจำลอง drizzle chain
// (select().from().where().limit() / insert().values() / update().set().where())
// — ใช้ร่วมโดย vi.mock("@/db", async () => ({ db: createDbMock(state) }))
// โดย state เก็บบน globalThis ของ test file (pattern เดียวกับ lib/staged-docs.ts)
export type DbMockRow = {
id: string;
title: string;
pdfUrl: string;
externalId: string;
typeId: string | null;
};
export type DbMockState = {
/** ค่า default ของ select ทุกครั้ง */
rows: DbMockRow[];
/** override รายครั้งของ select (index = ลำดับการเรียน — undefined = ใช้ rows) */
rowsBySelect: (DbMockRow[] | undefined)[];
selectCalls: number;
insertAttempts: Array<Record<string, unknown>>;
updates: Array<{ cond: unknown; patch: Record<string, unknown> }>;
/** โค้ด error ของ postgres ที่ insert จะโยน (เช่น "23505") — null = สำเร็จ */
insertErrCode: string | null;
};
export function createDbMockState(): DbMockState {
return {
rows: [],
rowsBySelect: [],
selectCalls: 0,
insertAttempts: [],
updates: [],
insertErrCode: null,
};
}
export function createDbMock(state: DbMockState) {
return {
select: () => ({
from: () => ({
where: () => ({
limit: async (): Promise<DbMockRow[]> => {
const rows = state.rowsBySelect[state.selectCalls] ?? state.rows;
state.selectCalls += 1;
return rows.map((r) => ({ ...r }));
},
}),
}),
}),
insert: () => ({
values: async (v: Record<string, unknown>): Promise<void> => {
state.insertAttempts.push(v);
if (state.insertErrCode !== null) {
throw Object.assign(new Error("db error (mock)"), {
code: state.insertErrCode,
});
}
},
}),
update: () => ({
set: (patch: Record<string, unknown>) => ({
where: async (cond: unknown): Promise<void> => {
state.updates.push({ cond, patch });
},
}),
}),
};
}
/** คืน state ที่ vi.mock factory สร้างไว้บน globalThis (factory รันก่อน import ของ test) */
export function dbMockStateFromGlobal(): DbMockState {
const holder = globalThis as typeof globalThis & { __dbMockState?: DbMockState };
if (holder.__dbMockState === undefined) {
throw new Error("db mock ยังไม่ถูกสร้าง — vi.mock ต้องรันก่อน");
}
return holder.__dbMockState;
}

View 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);
});
});

View 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();
});
});

View 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");
});
});

View 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);
});
});

View file

@ -48,6 +48,29 @@ describe("imageContentTypeFromUrl", () => {
});
});
describe("sentinel staged: ของ N11 (PLAN-STAGED.md D4)", () => {
const uid = "0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133";
// ล็อกสมมติฐาน "ext ใน sentinel เข้ากับ extOf() ทุกตัว" กัน helper เปลี่ยนวิธี
// parse ภายหลัง — sentinel คือ pdf_url จริงใน DB ตั้งแต่ N11
it("ครบ 4 ext: fileKindFromUrl แยก pdf/image ตาม extension ของ sentinel", () => {
expect(fileKindFromUrl(`staged:${uid}.pdf`)).toBe("pdf");
expect(fileKindFromUrl(`staged:${uid}.jpg`)).toBe("image");
expect(fileKindFromUrl(`staged:${uid}.png`)).toBe("image");
expect(fileKindFromUrl(`staged:${uid}.tif`)).toBe("image");
});
it("imageFormatFromUrl / imageContentTypeFromUrl ทำงานกับ sentinel (pdf ไม่ผ่านตัวรูป — สถานะเดิมคืน octet-stream)", () => {
expect(imageFormatFromUrl(`staged:${uid}.pdf`)).toBeNull();
expect(imageFormatFromUrl(`staged:${uid}.jpg`)).toBe("jpeg");
expect(imageFormatFromUrl(`staged:${uid}.png`)).toBe("png");
expect(imageFormatFromUrl(`staged:${uid}.tif`)).toBe("tiff");
expect(imageContentTypeFromUrl(`staged:${uid}.jpg`)).toBe("image/jpeg");
expect(imageContentTypeFromUrl(`staged:${uid}.png`)).toBe("image/png");
expect(imageContentTypeFromUrl(`staged:${uid}.tif`)).toBe("image/tiff");
expect(imageContentTypeFromUrl(`staged:${uid}.pdf`)).toBe("application/octet-stream");
});
});
describe("SAMPLE_FILE_EXTENSIONS", () => {
it("ครบทุกนามสกุลที่ requirement 5.3.4 ระบุ", () => {
for (const ext of [".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff"]) {