Stand-alone API group at /api/documents/{docId}/versions — one document
per docId (unique), each version stores .docx + optional .pdf as MinIO
objects with uploader userId/userName/createdAt (no auth, like
api/template; callers identify themselves per docs/PLAN-DOCVER.md).
- POST versions (multipart): validates fields/magic bytes/100MB cap,
uploads to MinIO first, cleans up objects if the DB insert fails;
returns {hasPdf, versionId, versionNumber} with server-authoritative
numbering (tx: upsert + SELECT FOR UPDATE, safe under parallel calls)
- GET versions: DocumentVersionDTO[] newest-first (404 for unknown docId)
- GET versions/{versionId}/download-link?format=docx|pdf: presigned URL
(docId+versionId matched together; pdf-missing = 404)
- tables documents/document_versions + migration drizzle/0005
- docker-compose.yml: judes-note-db + judes-note-minio (quay.io image,
host ports 19000/19001, POSTGRES_DB ecourt-note, pinned volume names)
- lib/openapi.ts: documents tag + paths for Swagger UI
- middleware: exempt api/documents from login redirect
- unit tests tests/lib/document-versioning.test.ts (23 cases)
wordaddin untouched — swap the mock later via NEXT_PUBLIC_ADDIN_API_BASE_URL.
Co-Authored-By: Claude Code <noreply@anthropic.com>
244 lines
10 KiB
TypeScript
244 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
|
||
import {
|
||
DOCVER_MESSAGE_MAX,
|
||
contentDispositionValue,
|
||
docverError,
|
||
isDocxBytes,
|
||
isPdfBytes,
|
||
parseDocId,
|
||
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("parseDocId", () => {
|
||
it("รับ GUID / รหัส e-Court / charset ที่อนุญาตทั้งหมด และ trim รอบ ๆ", () => {
|
||
for (const raw of [
|
||
"123e4567-e89b-12d3-a456-426614174000",
|
||
"ecourt:123",
|
||
"DOC_2026.10",
|
||
" doc1 ",
|
||
"a".repeat(200),
|
||
]) {
|
||
const result = parseDocId(raw);
|
||
ok(result);
|
||
expect(result.docId).toBe(raw.trim());
|
||
}
|
||
});
|
||
|
||
it("ตกเมื่อว่าง / เกิน 200 / มีช่องว่างกลาง / ไทย / slash / '..'", () => {
|
||
for (const raw of ["", " ", "a".repeat(201), "doc 1", "เอกสาร1", "a/b", "..", "a\\b"]) {
|
||
expect(parseDocId(raw).ok).toBe(false);
|
||
}
|
||
});
|
||
});
|
||
|
||
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", () => {
|
||
function baseForm(): FormData {
|
||
const form = new FormData();
|
||
form.append("file", new File([Buffer.from("PK\x03\x04")], "draft.docx"));
|
||
form.append("userId", "u0001");
|
||
form.append("userName", "สมชาย ใจดี");
|
||
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("templateId", "tmpl-main");
|
||
form.append("fields", '{"blackno":"1"}');
|
||
form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx");
|
||
const result = parseVersionUploadForm(form);
|
||
ok(result);
|
||
expect(result.form).toEqual({
|
||
userId: "u0001",
|
||
userName: "สมชาย ใจดี",
|
||
clientVersionId: "11111111-1111-4111-8111-111111111111",
|
||
message: null,
|
||
templateId: "tmpl-main",
|
||
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 (stand-alone บอกผู้อัปโหลดเอง)", () => {
|
||
const noUser = baseForm();
|
||
noUser.delete("userId");
|
||
expect(parseVersionUploadForm(noUser).ok).toBe(false);
|
||
const noName = baseForm();
|
||
noName.delete("userName");
|
||
expect(parseVersionUploadForm(noName).ok).toBe(false);
|
||
});
|
||
|
||
it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
|
||
const form = baseForm();
|
||
form.delete("userId");
|
||
form.append("userId", new File(["x"], "u.txt"));
|
||
const result = parseVersionUploadForm(form);
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) expect(result.error).toContain("userId");
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
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"),
|
||
);
|
||
});
|
||
});
|