eCourt-Doc-Edit/addin/src/services/versionFlow.test.ts

269 lines
11 KiB
TypeScript
Raw Normal View History

2026-08-19 23:06:57 +07:00
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): void {
const slice = new Uint8Array([1, 2, 3, 4]);
(globalThis as unknown as Record<string, unknown>).Office = {
FileType: { Compressed: "compressed", Text: "text" },
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
context: {
document: {
url,
getFileAsync: (
_fileType: unknown,
_options: unknown,
callback: (result: unknown) => void
) => {
callback({
status: "succeeded",
value: {
sliceCount: 1,
getSliceAsync: (index: number, cb: (result: unknown) => void) =>
cb({ status: "succeeded", value: { index, data: slice.buffer } }),
closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }),
},
});
},
},
},
};
}
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("เริ่มติดตามฉบับแรก");
});
2026-08-19 23:06:57 +07:00
});
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 ล่าสุด
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" });
});
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();
});
2026-08-19 23:06:57 +07:00
});