ไฟล์ร่าง (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>
328 lines
14 KiB
TypeScript
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();
|
|
}
|
|
);
|
|
});
|