feat(addin): unmock versioning — real upload/download against docver API
Replace the in-memory versioningService with real calls to the docver
group (/api/documents/*) via apiClient with the ticket-issued Bearer;
keep the mock only for ?addindemo (authService.isDemoMode).
- uploadVersion: multipart POST /api/documents/versions (file, optional
pdf when non-empty, clientVersionId, fields JSON, trimmed message,
fileName) — identity fields are never sent (userId/userName/templateId
in the form = 400 per the 2026-10-22 contract; docId comes from the
fid claim)
- getVersions: GET → DocumentVersionDTO mapped to VersionRecord (adds
optional createdByUserName; history panel shows it with userId
fallback)
- getDownloadLink: GET presigned URL (+ ?format=pdf)
- apiClient.toUrl: empty NEXT_PUBLIC_ADDIN_API_BASE_URL = same-origin
through withBasePath (/docedit/api/...); the env is now only for a
split deployment (must include basePath, HTTPS)
- documentId/templateId params stay in signatures but are not sent
- tests: versioningService.test.ts covers both modes (real sign-in via
signInWithTicket, FormData contract assertions, DTO mapping, format
query, 401 -> AuthRequiredError, demo mock without network)
- docs sync: WORDADDIN-DEV (new "การรันกับ backend จริง" section),
addin-ui-flow status note, PLAN-DOCVER §8, PLAN-ADDIN-AUTH §12, CLAUDE.md
Verified: tsc clean, 286/286 tests, full E2E against MinIO (ticket ->
token -> upload docx / docx+pdf -> history -> presigned download of both
formats).
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 12:52:29 +07:00
|
|
|
// @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<void> {
|
|
|
|
|
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" });
|
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
it("404 จาก history (เอกสารยังไม่เคยมี version เช่น stamp ค้างสมัย mock) = list ว่าง ไม่ใช่ error", async () => {
|
|
|
|
|
await signInReal();
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
"fetch",
|
|
|
|
|
vi.fn(
|
|
|
|
|
async (_url: string, _init?: RequestInit) =>
|
|
|
|
|
new Response(JSON.stringify({ error: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)" }), { status: 404 }),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await expect(getVersions("d")).resolves.toEqual([]);
|
|
|
|
|
});
|
feat(addin): unmock versioning — real upload/download against docver API
Replace the in-memory versioningService with real calls to the docver
group (/api/documents/*) via apiClient with the ticket-issued Bearer;
keep the mock only for ?addindemo (authService.isDemoMode).
- uploadVersion: multipart POST /api/documents/versions (file, optional
pdf when non-empty, clientVersionId, fields JSON, trimmed message,
fileName) — identity fields are never sent (userId/userName/templateId
in the form = 400 per the 2026-10-22 contract; docId comes from the
fid claim)
- getVersions: GET → DocumentVersionDTO mapped to VersionRecord (adds
optional createdByUserName; history panel shows it with userId
fallback)
- getDownloadLink: GET presigned URL (+ ?format=pdf)
- apiClient.toUrl: empty NEXT_PUBLIC_ADDIN_API_BASE_URL = same-origin
through withBasePath (/docedit/api/...); the env is now only for a
split deployment (must include basePath, HTTPS)
- documentId/templateId params stay in signatures but are not sent
- tests: versioningService.test.ts covers both modes (real sign-in via
signInWithTicket, FormData contract assertions, DTO mapping, format
query, 401 -> AuthRequiredError, demo mock without network)
- docs sync: WORDADDIN-DEV (new "การรันกับ backend จริง" section),
addin-ui-flow status note, PLAN-DOCVER §8, PLAN-ADDIN-AUTH §12, CLAUDE.md
Verified: tsc clean, 286/286 tests, full E2E against MinIO (ticket ->
token -> upload docx / docx+pdf -> history -> presigned download of both
formats).
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 12:52:29 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// โหมด 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();
|
|
|
|
|
});
|