// @vitest-environment jsdom // versioningService หลังปลด mock (2026-10-27) — คุมทั้งสองโหมด: // - ของจริง: sign-in ผ่าน signInWithTicket ให้ได้ Bearer ก่อน แล้วตรวจ FormData ของ // uploadVersion ตรง contract POST /api/documents/versions (ห้ามส่ง userId/userName/ // templateId — identity มาจาก Bearer หมด), getVersions แปลง DTO, getDownloadLink ใส่ ?format= // - โหมด ?addindemo: ใช้ mock ในเครื่อง (ไม่ยิง network เลย) import { createHmac, webcrypto } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as authService from "@/wordaddin/services/authService"; import * as ticketService from "@/wordaddin/services/ticketService"; import { getDownloadLink, getVersions, uploadVersion, } from "@/wordaddin/services/versioningService"; const TOKEN = "header.payload.sig"; // ค่าหยง ๆ ก็ได้ — apiClient แนบตรง ๆ ไม่ decode /** sign-in จริงผ่าน signInWithTicket (fetch ตอบ token 1 ครั้ง) — real mode + มี Bearer ใน session */ async function signInReal(): Promise { vi.stubGlobal("crypto", webcrypto); // jsdom ไม่มี crypto.subtle const ticket = { exp: Math.floor(Date.now() / 1000) + 300, fid: "REAL_FID_001", uid: "oom", uname: "สรวิชญ์", }; const ticketData = JSON.stringify(ticket); const ticketHmac = createHmac("sha256", "super_secret").update(ticketData, "utf8").digest("hex"); vi.stubGlobal( "fetch", vi.fn( async () => new Response( JSON.stringify({ access_token: TOKEN, token_type: "Bearer", expires_in: 3600 }), { status: 200, headers: { "content-type": "application/json" } }, ), ), ); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); expect(result.ok, JSON.stringify(result)).toBe(true); vi.unstubAllGlobals(); } describe("versioningService (real mode)", () => { it("uploadVersion สร้าง multipart ตรง contract + แนบ Bearer ผ่าน apiClient + parse ผล", async () => { await signInReal(); const fetchMock = vi.fn( async (_url: string, _init?: RequestInit) => new Response( JSON.stringify({ hasPdf: true, versionId: "v-1", versionNumber: 3 }), { status: 200, headers: { "content-type": "application/json" } }, ), ); vi.stubGlobal("fetch", fetchMock); const docx = new Blob(["PK\x03\x04 docx"], { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", }); const pdf = new Blob(["%PDF-"], { type: "application/pdf" }); const result = await uploadVersion( "doc-guid-ไม่ส่งถึง-server", docx, "client-vid-1", { blackno: "ด1" }, "คำฟ้อง.docx", " แก้ไขย่อหน้า 3 ", pdf, "T12-ไม่ส่งถึง-server", ); expect(result).toEqual({ hasPdf: true, versionId: "v-1", versionNumber: 3 }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; // same-origin ผ่าน withBasePath (ใน vitest env NEXT_PUBLIC_BASE_PATH ว่าง — Next inline ตอน build // เท่านั้น จึง assert เฉพาะ path ที่อยู่หลัง basePath) expect(url.endsWith("/api/documents/versions")).toBe(true); expect(init.method).toBe("POST"); expect((init.headers as Headers).get("authorization")).toBe(`Bearer ${TOKEN}`); const form = init.body as FormData; expect(form.get("file")).toBeInstanceOf(File); expect((form.get("file") as File).name).toBe("คำฟ้อง.docx"); expect((form.get("pdf") as File).name).toBe("document.pdf"); expect(form.get("clientVersionId")).toBe("client-vid-1"); expect(JSON.parse(form.get("fields") as string)).toEqual({ blackno: "ด1" }); expect(form.get("message")).toBe("แก้ไขย่อหน้า 3"); // trim แล้ว // contract 2026-10-22: ห้ามมี field identity/template เหลืออยู่เลย expect(form.has("userId")).toBe(false); expect(form.has("userName")).toBe(false); expect(form.has("templateId")).toBe(false); }); it("pdf ว่าง/ไม่แนบ = ไม่ append field pdf (ไฟล์เปล่า = version ไม่มี PDF)", async () => { await signInReal(); const fetchMock = vi.fn( async (_url: string, _init?: RequestInit) => new Response(JSON.stringify({ hasPdf: false, versionId: "v-2", versionNumber: 1 }), { status: 200 }), ); vi.stubGlobal("fetch", fetchMock); await uploadVersion("d", new Blob(["PK"]), "cv", undefined, undefined, undefined, new Blob([])); const form = (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit; expect((form.body as FormData).has("pdf")).toBe(false); }); it("getVersions แปลง DocumentVersionDTO → VersionRecord ครบทุก field", async () => { await signInReal(); const fetchMock = vi.fn( async (_url: string, _init?: RequestInit) => new Response( JSON.stringify([ { versionId: "v-9", versionNumber: 9, createdAt: "2026-10-27T05:00:00Z", createdByUserId: "oom", createdByUserName: "สรวิชญ์", contentHash: "ab", fields: { blackno: "ด1" }, message: null, hasPdf: true, }, ]), { status: 200 }, ), ); vi.stubGlobal("fetch", fetchMock); const rows = await getVersions("anything"); expect(rows).toEqual([ { versionId: "v-9", versionNumber: 9, createdAt: "2026-10-27T05:00:00Z", createdByUserId: "oom", createdByUserName: "สรวิชญ์", contentHash: "ab", fields: { blackno: "ด1" }, message: null, hasPdf: true, }, ]); }); it("getDownloadLink ใส่ ?format=pdf · format docx/ไม่ส่ง = ไม่มี query", async () => { await signInReal(); const fetchMock = vi.fn( async (_url: string, _init?: RequestInit) => new Response(JSON.stringify({ url: "http://minio/x", expiresAt: "2026-10-27T06:00:00Z" }), { status: 200 }), ); vi.stubGlobal("fetch", fetchMock); await getDownloadLink("d", "v-9", "pdf"); expect(String(fetchMock.mock.calls[0][0]).endsWith("/api/documents/versions/v-9/download-link?format=pdf")).toBe(true); await getDownloadLink("d", "v-9"); expect(String(fetchMock.mock.calls[1][0]).endsWith("/api/documents/versions/v-9/download-link")).toBe(true); await getDownloadLink("d", "v-9", "docx"); expect(String(fetchMock.mock.calls[2][0]).endsWith("/api/documents/versions/v-9/download-link")).toBe(true); }); it("โดน 401 แล้ว renew ไม่สำเร็จ = AuthRequiredError (ไม่ใช่ error อื่น)", async () => { await signInReal(); // fetch ตอบ 401 ทุกอัน (ทั้ง request หลักและการ renew token จาก ticket) vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 401 }))); await expect(getVersions("d")).rejects.toMatchObject({ name: "AuthRequiredError" }); }); }); // โหมด demo ไว้ท้ายสุด — signInForDemo เปิด flag ค้างใน module จนกว่าจะ sign-in จริง describe("versioningService (demo mode = mock)", () => { it("signInForDemo แล้วใช้ mock ในเครื่อง — ไม่ยิง network และ persist ตาม documentId", async () => { authService.signInForDemo(); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const first = await uploadVersion("demo-doc", new Blob(["PK"]), "cv1", undefined, undefined, "vแรก"); const second = await uploadVersion("demo-doc", new Blob(["PK2"]), "cv2"); expect(first.versionNumber).toBe(1); expect(second.versionNumber).toBe(2); const rows = await getVersions("demo-doc"); expect(rows.map((row) => row.versionNumber)).toEqual([2, 1]); // ใหม่ → เก่า expect(rows[0].message).toBeNull(); expect(rows[1].message).toBe("vแรก"); const link = await getDownloadLink("demo-doc", rows[0].versionId, "pdf"); expect(link.url).toBe("about:blank"); expect(fetchMock).not.toHaveBeenCalled(); expect(window.localStorage.getItem("wordaddin-mock-versions")).toBeTruthy(); }); }); afterEach(() => { window.localStorage.clear(); ticketService.clearSession(); authService.signInForDemo(); // รีเซ็ต authService ให้มี username เสมอ (เคสถัดไป sign-in ใหม่เอง) vi.restoreAllMocks(); vi.unstubAllGlobals(); });