import * as authService from "./authService"; import { ADDIN_SIGNATURE_GUID, buildVersionHistoryXml } from "./metadataService"; import { saveAndVersion, startVersioning } from "./versionFlow"; import { installWordMock } from "../test/wordMock"; import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock"; /** mock Office.context.document (url สำหรับชื่อไฟล์ + getFileAsync สำหรับ fileService) */ function installOfficeDocumentMock( url: string | undefined, options: { failPdf?: boolean; emptyPdf?: boolean } = {} ): string[] { const requested: string[] = []; const slice = new Uint8Array([1, 2, 3, 4]); (globalThis as unknown as Record).Office = { FileType: { Compressed: "compressed", Pdf: "pdf", Text: "text" }, AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" }, context: { requirements: { isSetSupported: () => true }, document: { url, getFileAsync: ( _fileType: unknown, _options: unknown, callback: (result: unknown) => void ) => { requested.push(String(_fileType)); if (_fileType === "pdf" && options.failPdf) { callback({ status: "failed", error: { message: "PDF unavailable" } }); return; } callback({ status: "succeeded", value: { sliceCount: _fileType === "pdf" && options.emptyPdf ? 0 : 1, getSliceAsync: (index: number, cb: (result: unknown) => void) => cb({ status: "succeeded", value: { index, data: slice.buffer } }), closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }), }, }); }, }, }, }; return requested; } function seedSession(): void { authService.setSessionForTest( makeFakeJwt(Date.now() + 600_000, "user-sub", { preferred_username: "tester" }), "refresh-1", Date.now() + 600_000 ); } function parseUploadCall(call: [string, { body?: FormData }]): { url: URL; form: FormData } { return { url: new URL(call[0]), form: call[1].body as FormData }; } beforeEach(() => { seedSession(); }); afterEach(() => { authService.signOut(); }); describe("startVersioning (FR-1 / AC-5)", () => { it("stamp รอบแรกครบ → save → upload v1 พร้อม fields + ชื่อไฟล์จาก URL → เขียน XML part v1", async () => { const state = installWordMock({ contentControls: [ { tag: "employee_name", text: "สมชาย ใจดี" }, { tag: "salary", text: "50000" }, ], }); installOfficeDocumentMock("C:\\Users\\tester\\Documents\\สัญญาจ้าง.docx"); const fetchMock = installFetchMock(); fetchMock.mockImplementationOnce(async () => mockResponse({ status: 201, body: { versionId: "backend-version-1", versionNumber: 1 } }) ); const result = await startVersioning(); // ผลลัพธ์ผูกกันทั้งชุด expect(result).toEqual({ documentId: result.documentId, versionId: "backend-version-1", versionNumber: 1, }); expect(result.documentId).toMatch(/^[0-9a-f-]{36}$/i); // ครบทั้ง 4 properties + SavedByAddIn = GUID ของ add-in expect(state.properties.SavedByAddIn).toBe(ADDIN_SIGNATURE_GUID); expect(state.properties.DocumentTrackingId).toBe(result.documentId); expect(state.properties.LastVersionId).toMatch(/^[0-9a-f-]{36}$/i); expect(state.properties.LastVersionTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); // เรียก native save หนึ่งครั้ง (FR-2 ขั้นตอนที่ 4) expect(state.saveCalls).toBe(1); // upload ไปที่ documentId ที่ generate + แนบ fields + clientVersionId + ชื่อไฟล์จาก URL const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); expect(`${url.origin}${url.pathname}`).toBe( `https://localhost:7232/api/documents/${result.documentId}/versions` ); expect(String(form.get("clientVersionId"))).toBe(state.properties.LastVersionId); expect(JSON.parse(String(form.get("fields")))).toEqual({ employee_name: "สมชาย ใจดี", salary: "50000", }); expect(String((form.get("file") as File).name)).toBe("สัญญาจ้าง.docx"); // XML part มี entry v1 ผูกกับ clientVersionId เดียวกับ LastVersionId expect(state.xmlParts).toHaveLength(1); expect(state.xmlParts[0].xml).toContain('id="'); expect(state.xmlParts[0].xml).toContain('number="1"'); expect(state.xmlParts[0].xml).toContain('user="tester"'); }); it("URL ไม่มี (Word Online) → ชื่อไฟล์ fallback", async () => { installWordMock({}); installOfficeDocumentMock(undefined); const fetchMock = installFetchMock(); fetchMock.mockImplementationOnce(async () => mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1 } }) ); await startVersioning(); const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); expect(String((form.get("file") as File).name)).toMatch(/\.docx$/); }); it("FR-13 — ส่ง message แนบไปกับ multipart form ของ v1", async () => { installWordMock({}); installOfficeDocumentMock(undefined); const fetchMock = installFetchMock(); fetchMock.mockImplementationOnce(async () => mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1 } }) ); await startVersioning("เริ่มติดตามฉบับแรก"); const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); expect(String(form.get("message"))).toBe("เริ่มติดตามฉบับแรก"); // ไม่ใช่ไฟล์ร่าง → ไม่แนบ templateId expect(form.get("templateId")).toBeNull(); }); it("FR-16 — ไฟล์ร่าง: แนบ templateId ไปกับ form ของ v1", async () => { installWordMock({}); installOfficeDocumentMock(undefined); const fetchMock = installFetchMock(); fetchMock.mockImplementationOnce(async () => mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1 } }) ); await startVersioning("เริ่มติดตามฉบับแรก", "44444444-4444-4444-4444-444444444444"); const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); expect(String(form.get("templateId"))).toBe("44444444-4444-4444-4444-444444444444"); }); }); describe("saveAndVersion (FR-2 / AC-7, 13)", () => { it("stamp → append XML entry (เดาเลขถัดไป) → save → upload พร้อม fields ล่าสุด", async () => { const state = installWordMock({ properties: { SavedByAddIn: ADDIN_SIGNATURE_GUID, DocumentTrackingId: "11111111-1111-1111-1111-111111111111", LastVersionId: "old-client-version-id", }, xmlParts: [ buildVersionHistoryXml([ { id: "old-client-version-id", number: 1, timestamp: "2026-08-19T09:00:00Z", user: "tester", }, ]), ], contentControls: [{ tag: "salary", text: "60000" }], }); installOfficeDocumentMock(undefined); const fetchMock = installFetchMock(); fetchMock.mockImplementationOnce(async () => mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } }) ); const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1); expect(result).toEqual({ versionId: "backend-version-2", versionNumber: 2 }); // properties: tracking id คงเดิม, LastVersionId เปลี่ยนเป็น clientVersionId ใหม่ expect(state.properties.DocumentTrackingId).toBe("11111111-1111-1111-1111-111111111111"); const newLastVersionId = state.properties.LastVersionId; expect(newLastVersionId).not.toBe("old-client-version-id"); expect(state.saveCalls).toBe(1); // XML part: 2 entries เรียง v2 ก่อน v1 expect(state.xmlParts).toHaveLength(1); const xml = state.xmlParts[0].xml; expect(xml).toContain('number="2"'); expect(xml).toContain('number="1"'); expect(xml.indexOf('number="2"')).toBeLessThan(xml.indexOf('number="1"')); // upload ไปยัง document เดิม + fields ล่าสุด (ไม่ส่ง templateId — FR-16 ใช้เฉพาะ Start Versioning) const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); expect(url.pathname).toBe("/api/documents/11111111-1111-1111-1111-111111111111/versions"); expect(String(form.get("clientVersionId"))).toBe(newLastVersionId); expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" }); expect(form.get("templateId")).toBeNull(); }); it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => { const state = installWordMock({ properties: { SavedByAddIn: ADDIN_SIGNATURE_GUID, DocumentTrackingId: "11111111-1111-1111-1111-111111111111", LastVersionId: "old", }, xmlParts: [buildVersionHistoryXml([{ id: "old", number: 1, timestamp: "t", user: "u" }])], }); installOfficeDocumentMock(undefined); const fetchMock = installFetchMock(); fetchMock.mockImplementationOnce(async () => mockResponse({ status: 201, body: { versionId: "backend-version-5", versionNumber: 5 } }) ); const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1); expect(result.versionNumber).toBe(5); // entry ใหม่ถูกแก้จาก 2 (ที่เดา) เป็น 5 (ของจริง) const xml = state.xmlParts[0].xml; expect(xml).toContain('number="5"'); expect(xml).not.toContain('number="2"'); expect(xml).toContain('number="1"'); }); it("XML part เขียนไม่ได้ (known bug บน Mac) → flow ยังสำเร็จต่อ (AC-10)", async () => { installWordMock({ properties: { SavedByAddIn: ADDIN_SIGNATURE_GUID, DocumentTrackingId: "11111111-1111-1111-1111-111111111111", }, failXmlAdd: true, }); installOfficeDocumentMock(undefined); const fetchMock = installFetchMock(); fetchMock.mockImplementationOnce(async () => mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } }) ); await expect(saveAndVersion("11111111-1111-1111-1111-111111111111", 1)).resolves.toEqual({ versionId: "backend-version-2", versionNumber: 2, }); }); it("FR-13 — ส่ง message แนบไปกับ multipart form", async () => { installWordMock({ properties: { SavedByAddIn: ADDIN_SIGNATURE_GUID, DocumentTrackingId: "11111111-1111-1111-1111-111111111111", }, }); installOfficeDocumentMock(undefined); const fetchMock = installFetchMock(); fetchMock.mockImplementationOnce(async () => mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } }) ); await saveAndVersion("11111111-1111-1111-1111-111111111111", 1, "แก้ไขสัญญาข้อ 3"); const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); expect(String(form.get("message"))).toBe("แก้ไขสัญญาข้อ 3"); }); it("FR-13 — message ว่าง/whitespace ล้วน → ไม่แนบฟิลด์ message เลย", async () => { installWordMock({ properties: { SavedByAddIn: ADDIN_SIGNATURE_GUID, DocumentTrackingId: "11111111-1111-1111-1111-111111111111", }, }); installOfficeDocumentMock(undefined); const fetchMock = installFetchMock(); fetchMock.mockImplementationOnce(async () => mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } }) ); await saveAndVersion("11111111-1111-1111-1111-111111111111", 1, " "); const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); expect(form.get("message")).toBeNull(); }); }); describe.each(["start", "save"])("FR-15 %s", (flow) => { it.each(["success", "failure", "empty", "storage-failure"])( "PDF %s preserves docx upload", async (mode) => { installWordMock({ properties: { DocumentTrackingId: "doc" } }); const requested = installOfficeDocumentMock(undefined, { failPdf: mode === "failure", emptyPdf: mode === "empty", }); const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); const fetchMock = installFetchMock(); fetchMock.mockImplementationOnce(async () => mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1, hasPdf: mode === "success" }, }) ); const result = flow === "start" ? await startVersioning() : await saveAndVersion("doc", 0); const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); expect(requested).toEqual(["compressed", "pdf"]); expect(form.get("file")).toBeInstanceOf(Blob); if (mode === "success" || mode === "storage-failure") { expect((form.get("pdf") as File).name).toMatch(/\.pdf$/); expect((form.get("pdf") as File).type).toBe("application/pdf"); } else expect(form.get("pdf")).toBeNull(); expect(result.hasPdf).toBe(mode === "success"); expect(fetchMock).toHaveBeenCalledTimes(1); warn.mockRestore(); } ); });