Phase 11: version PDF (FR-15)

- เลข version ใหม่: เว็บ-generate = v0 ฉบับตั้งต้น (ไม่มี PDF), add-in save (Start Versioning + Save & Version) = v1+ มี PDF เสมอ
- PDF ผลิตจาก Word จริงผ่าน task pane เท่านั้น (getFileAsync Pdf + runtime guard, manifest ไม่เพิ่ม requirement); capture/upload เป็น best-effort — docx ผ่านแม้ PDF ล้ม + MessageBar เตือนตาม hasPdf
- Backend: DocumentVersion.PdfObjectKey (nullable, migration AddVersionPdf) + sibling .pdf ใน bucket docx-versions · multipart part pdf (ผิด type → 400) · /download + /download-link รับ ?format=pdf (ไม่มี → 404) · presigned ฝังชื่อไฟล์ไทย + _vN (RFC 5987)
- Web: เปิดไฟล์แทน blob-download — PDF แท็บใหม่, docx ผ่าน ms-word:ofe|u| พร้อม heuristic fallback ลิงก์สำรอง (useVersionOpen, versionDownload) · ปุ่ม "เปิดใน Word" ที่หน้า success ของฟอร์มสร้างเอกสาร
- Spec v7 + CONTEXT.md + ADR 0001 + แผน docs/plans/version-pdf.md
- Automated QA: backend 68 / add-in 63 / web 55 ผ่าน · manual acceptance ผ่านบน Word for Mac (2026-09-08) รวม fidelity + track changes — ค้างเฉพาะ Windows / Word on the web

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-08 14:01:13 +07:00
parent 618328ffa7
commit 39d610a781
39 changed files with 1387 additions and 212 deletions

View file

@ -5,12 +5,17 @@ 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 {
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", Text: "text" },
FileType: { Compressed: "compressed", Pdf: "pdf", Text: "text" },
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
context: {
requirements: { isSetSupported: () => true },
document: {
url,
getFileAsync: (
@ -18,10 +23,15 @@ function installOfficeDocumentMock(url: string | undefined): void {
_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: 1,
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" }),
@ -31,6 +41,7 @@ function installOfficeDocumentMock(url: string | undefined): void {
},
},
};
return requested;
}
function seedSession(): void {
@ -266,3 +277,35 @@ describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
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();
}
);
});