eCourt-Doc-Edit/addin/src/services/versionFlow.test.ts
Suphonchai Phoonsawat b3c1c667f7 Phase 12: draft generation (FR-16)
ไฟล์ร่าง (draft) — generate docx จาก template แบบ stateless ไม่สร้าง
Document/version; stamp เฉพาะ custom property TemplateId เพื่อให้ add-in
เห็นเป็น untracked; Start Versioning ส่ง form field templateId ให้ backend
ผูก template ตอน auto-create — เอกสารเริ่มที่ v1 (ไม่มี v0) + field sync เต็มระบบ

- Backend: POST /api/templates/{id}/generate-draft (200 คืนไฟล์) +
  DocxGenerationService.GenerateDraft + แยก TemplateFieldValidation ใช้ร่วมกับ
  generate + UploadVersion รับ templateId (non-GUID/unknown → 400,
  deactivated → ผูกได้, document เดิม → เฉย) + CORS expose Content-Disposition
- Add-in: readTrackedProperties อ่าน TemplateId + ส่ง templateId เฉพาะ
  startVersioning + hint ไฟล์ร่างใน task pane
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม + downloadFile รับ init (POST → blob)
- Docs: spec v8 (FR-16, AC-19) + Phase 12 + CONTEXT.md + ADR 0002 +
  acceptance-review AC-19
- Tests: backend 82 / add-in 66 / web 59 + build ครบ; QA reviewer PASS
  (2 rounds); ค้าง manual บน Word จริง

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 10:51:13 +07:00

328 lines
14 KiB
TypeScript

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<string, unknown>).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();
}
);
});