eCourt-note/tests/lib/document-versioning.test.ts
Suphonchai Phoonsawat b8ff622999
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 5s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m28s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
fix(docver): drop fields value limit + treat history 404 as empty in add-in
Two fixes from first real-Word testing of the unmocked versioning flow:

- fields value length: remove the limit entirely (was 10,000, briefly
  100,000) — real court documents run 100-200 pages with content
  controls several hundred thousand chars long; callers are Bearer-only
  and the column is Postgres jsonb (up to ~255MB), so no storage
  bottleneck. Structural guards stay: ≤1,000 keys (was 100), key ≤200
  chars, value must be string.
- getVersions in versioningService now maps the history 404 to an empty
  list: files stamped SavedByAddIn back in the mock era enter the
  tracking phase before any real upload exists, so the pane shows
  "ยังไม่มี version ในระบบ" and Save & Version creates v1 (implicit
  document creation) instead of surfacing a 404 error banner. Other
  errors still propagate.

Tests: 288/288 (new cases — 1M-char value passes, key cap 1,000/1,001,
history 404 -> []). E2E against MinIO: 50k and 1M-char Thai field values
upload 200 and roundtrip byte-exact through jsonb. Docs synced:
PLAN-DOCVER §1, CLAUDE.md.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 13:14:04 +07:00

236 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from "vitest";
import {
DOCVER_MESSAGE_MAX,
contentDispositionValue,
docverError,
isDocxBytes,
isPdfBytes,
parseDownloadFormat,
parseFieldsJson,
parseVersionIdParam,
parseVersionUploadForm,
sanitizeDisplayFileName,
sanitizeFilePartName,
} from "@/lib/document-versioning";
// docver (PLAN-DOCVER.md) — validation ล้วนของ /api/documents/*: ไฟล์นี้ตั้งใจไม่แตะ
// module ใดที่ลาก @/db หรือ minio มาด้วย (pattern เดียวกับ external-api-auth.test.ts
// — vitest ไม่โหลด .env จึงต้องรันได้โดยไม่ตั้ง DATABASE_URL/MINIO_*)
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
expect(result.ok).toBe(true);
}
describe("parseVersionIdParam", () => {
it("รับ UUID ทั้งพิมพ์ใหญ่/เล็ก (normalize ลงตัวพิมพ์เล็ก) + trim", () => {
const result = parseVersionIdParam(" 6F9619FF-8B86-D011-B42D-00C04FC964FF ");
ok(result);
expect(result.versionId).toBe("6f9619ff-8b86-d011-b42d-00c04fc964ff");
});
it("ตกเมื่อไม่ใช่รูป UUID", () => {
for (const raw of ["not-a-uuid", "123", "{6f9619ff-8b86-d011-b42d-00c04fc964ff}", ""]) {
expect(parseVersionIdParam(raw).ok).toBe(false);
}
});
});
describe("parseDownloadFormat", () => {
it("ไม่ส่ง/ว่าง/ข้างในมี whitespace = docx (default ตาม contract)", () => {
for (const raw of [null, "", " ", " docx "]) {
const result = parseDownloadFormat(raw);
ok(result);
expect(result.format).toBe("docx");
}
});
it("รับ pdf ตรง ๆ และตกเมื่อค่าอื่น (รวมพิมพ์ใหญ่)", () => {
const pdf = parseDownloadFormat("pdf");
ok(pdf);
expect(pdf.format).toBe("pdf");
for (const raw of ["exe", "PDF", "docxx"]) {
expect(parseDownloadFormat(raw).ok).toBe(false);
}
});
});
describe("isDocxBytes / isPdfBytes", () => {
it("ผ่านเมื่อ magic bytes ตรง (แม้ buffer สั้นกว่าที่อ่าน)", () => {
expect(isDocxBytes(Buffer.from("PK\x03\x04rest-of-zip"))).toBe(true);
expect(isPdfBytes(Buffer.from("%PDF-1.7\n..."))).toBe(true);
});
it("ตกเมื่อ signature ผิดหรือ buffer ว่าง/สั้นกว่า signature", () => {
expect(isDocxBytes(Buffer.from("%PDF-1.7"))).toBe(false);
expect(isDocxBytes(Buffer.from("PK"))).toBe(false);
expect(isDocxBytes(Buffer.alloc(0))).toBe(false);
expect(isPdfBytes(Buffer.from("PK\x03\x04"))).toBe(false);
expect(isPdfBytes(Buffer.from("%PD"))).toBe(false);
expect(isPdfBytes(Buffer.alloc(0))).toBe(false);
});
});
describe("parseFieldsJson", () => {
it("แยก JSON object ล้วนได้ และ `{}` = null (ถือว่าไม่ส่ง)", () => {
const result = parseFieldsJson('{"blackno":"คดีหมายเลขดำที่"}');
ok(result);
expect(result.fields).toEqual({ blackno: "คดีหมายเลขดำที่" });
const empty = parseFieldsJson("{}");
ok(empty);
expect(empty.fields).toBeNull();
});
it("ตกเมื่อ JSON เพี้ยน / ไม่ใช่ object / value ไม่ใช่ string", () => {
for (const raw of ["ไม่ใช่ JSON", "[1,2]", '"string"', "null", '{"a":1}']) {
expect(parseFieldsJson(raw).ok).toBe(false);
}
});
});
describe("parseVersionUploadForm", () => {
// identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก
// Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts)
function baseForm(): FormData {
const form = new FormData();
form.append("file", new File([Buffer.from("PK\x03\x04")], "draft.docx"));
return form;
}
it("happy path: แยกทุก field + trim ค่าว่างเป็น null + fileName ตัด path ที่ฝังมา", () => {
const form = baseForm();
form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 ");
form.append("message", " ");
form.append("fields", '{"blackno":"1"}');
form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx");
const result = parseVersionUploadForm(form);
ok(result);
expect(result.form).toEqual({
clientVersionId: "11111111-1111-4111-8111-111111111111",
message: null,
fileName: "คำสั่งเรียก.docx",
fields: { blackno: "1" },
});
});
it("ตกเมื่อมี field ที่ไม่รู้จัก (กัน typo เงียบ ๆ)", () => {
const form = baseForm();
form.append("oops", "1");
const result = parseVersionUploadForm(form);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("oops");
});
it("ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field (identity มาจาก token)", () => {
for (const legacy of ["userId", "userName", "templateId"]) {
const form = baseForm();
form.append(legacy, "x");
const result = parseVersionUploadForm(form);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain(legacy);
}
});
it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
const form = baseForm();
form.append("message", new File(["x"], "m.txt"));
const result = parseVersionUploadForm(form);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("message");
});
it(`message ยาว ${DOCVER_MESSAGE_MAX} ผ่าน แต่เกิน = ตก`, () => {
const edge = baseForm();
edge.append("message", "a".repeat(DOCVER_MESSAGE_MAX));
ok(parseVersionUploadForm(edge));
const over = baseForm();
over.append("message", "a".repeat(DOCVER_MESSAGE_MAX + 1));
expect(parseVersionUploadForm(over).ok).toBe(false);
});
it("fields ใน form ผิดกติกาส่งต่อ error ของ parseFieldsJson", () => {
const form = baseForm();
form.append("fields", '{"a":1}');
expect(parseVersionUploadForm(form).ok).toBe(false);
});
it("ค่าของ fields ไม่จำกัดความยาว (เอกสาร 100–200 หน้า — 2026-10-27) · key รับได้ถึง 1,000", () => {
const big = baseForm();
big.append("fields", JSON.stringify({ detail1: "พ".repeat(1_000_000) }));
expect(parseVersionUploadForm(big).ok).toBe(true);
const manyKeys = baseForm();
manyKeys.append(
"fields",
JSON.stringify(Object.fromEntries(Array.from({ length: 1_000 }, (_, i) => [`tag${i}`, "x"]))),
);
expect(parseVersionUploadForm(manyKeys).ok).toBe(true);
const over = baseForm();
over.append(
"fields",
JSON.stringify(Object.fromEntries(Array.from({ length: 1_001 }, (_, i) => [`tag${i}`, "x"]))),
);
expect(parseVersionUploadForm(over).ok).toBe(false);
});
});
describe("sanitizeDisplayFileName / sanitizeFilePartName", () => {
it("ตัด path ฝังมา (ทั้ง / และ \\) + trim ว่าง = null + cap 200", () => {
expect(sanitizeDisplayFileName("C:\\Users\\judge\\a.docx")).toBe("a.docx");
expect(sanitizeDisplayFileName("/tmp/b.pdf")).toBe("b.pdf");
expect(sanitizeDisplayFileName(" ")).toBeNull();
expect(sanitizeDisplayFileName(null)).toBeNull();
expect(sanitizeDisplayFileName(`${"a".repeat(250)}.docx`)).toHaveLength(200);
});
it("File.name ที่ undici ถอดเป็น latin1 (ชื่อไทยเพี้ยนแบบ N4) กู้คืนเป็น UTF-8 ได้", () => {
const original = "คำสั่งเรียกเอกสาร.docx";
const mangled = Buffer.from(original, "utf8").toString("latin1");
expect(mangled).not.toBe(original); // ต้องเพี้ยนจริงก่อน ไม่ใช่ทดสอบตัวเอง
expect(sanitizeFilePartName(mangled)).toBe(original);
// ชื่อ ASCII ไม่มีตัว U+0080–U+00FF จึงไม่ถูกแตะ
expect(sanitizeFilePartName("plain.docx")).toBe("plain.docx");
});
});
describe("docverError", () => {
it("สร้าง Response JSON ไทยพร้อม status ที่ขอ", async () => {
const res = docverError("ไม่พบ version นี้ของเอกสาร", 404);
expect(res.status).toBe(404);
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
expect(await res.json()).toEqual({ error: "ไม่พบ version นี้ของเอกสาร" });
});
});
describe("contentDispositionValue", () => {
it("ไม่มีชื่อ = ชื่อ default อย่างเดียว", () => {
expect(contentDispositionValue(null, "docx")).toBe('attachment; filename="document.docx"');
expect(contentDispositionValue(" ", "pdf")).toBe('attachment; filename="document.pdf"');
});
it("ชื่อ ASCII: เติม extension ที่ขาด / คงชื่อที่มีอยู่แล้ว / ตัด quote-backslash", () => {
expect(contentDispositionValue("Report Final", "docx")).toBe(
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final',
);
expect(contentDispositionValue("Report Final.docx", "docx")).toBe(
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final.docx',
);
expect(contentDispositionValue('bad"name\\', "pdf")).toBe(
'attachment; filename="badname.pdf"; filename*=UTF-8\'\'bad%22name%5C',
);
});
it("ชื่อไทยล้วน = ASCII fallback เป็นชื่อ default แต่ filename* ยังชื่อเต็ม", () => {
expect(contentDispositionValue("คำสั่งเรียก", "docx")).toBe(
'attachment; filename="document.docx"; filename*=UTF-8\'\''
+ encodeURIComponent("คำสั่งเรียก"),
);
});
it("ชื่อผสมไทย+เลข = ส่วน ASCII ที่เหลือใช้เป็น fallback ได้", () => {
expect(contentDispositionValue("รายงาน 2026", "pdf")).toBe(
'attachment; filename="2026.pdf"; filename*=UTF-8\'\''
+ encodeURIComponent("รายงาน 2026"),
);
});
});