eCourt-Doc-Edit/web/test/useApi.spec.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

117 lines
4.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError, useApi } from '../app/composables/useApi'
/**
* useApi — fetch wrapper ของ web portal: ไฟล์ร่าง (FR-16) ใช้ downloadFile แบบ
* POST + JSON body ที่คืนไฟล์ (ชื่อไทยอ่านจาก Content-Disposition filename* ที่ CORS expose ไว้)
*/
const getAccessToken = vi.fn()
const fetchMock = vi.fn()
/** Response ปลอม — ไม่พึ่ง global Response ของ environment (happy-dom) */
function fakeResponse(options: {
ok?: boolean
status?: number
headers?: Record<string, string>
body?: string
}): Response {
const lower = Object.fromEntries(
Object.entries(options.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]),
)
return {
ok: options.ok ?? true,
status: options.status ?? 200,
headers: { get: (name: string) => lower[name.toLowerCase()] ?? null },
blob: async () => new Blob([options.body ?? 'docx-bytes']),
text: async () => options.body ?? '',
} as unknown as Response
}
let clicked: HTMLAnchorElement | null = null
const originalCreateObjectURL = URL.createObjectURL
const originalRevokeObjectURL = URL.revokeObjectURL
beforeEach(() => {
vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBaseUrl: 'https://api.test/' } }))
vi.stubGlobal('useAuth', () => ({ getAccessToken }))
vi.stubGlobal('fetch', fetchMock)
getAccessToken.mockReset().mockResolvedValue('token-1')
fetchMock.mockReset()
clicked = null
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function click(this: HTMLAnchorElement) {
clicked = this
})
URL.createObjectURL = vi.fn(() => 'blob:mock')
URL.revokeObjectURL = vi.fn()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
URL.createObjectURL = originalCreateObjectURL
URL.revokeObjectURL = originalRevokeObjectURL
})
describe('downloadFile (FR-16 ไฟล์ร่าง — POST + JSON body คืนไฟล์)', () => {
it('forwards method/body to fetch with Bearer and saves the Thai filename from Content-Disposition', async () => {
fetchMock.mockResolvedValue(
fakeResponse({
headers: {
// .NET ใส่ทั้ง filename และ filename* (RFC 5987) สำหรับชื่อไทย
'Content-Disposition':
"attachment; filename=\"draft.docx\"; filename*=UTF-8''%E0%B9%84%E0%B8%9F%E0%B8%A5%E0%B9%8C%E0%B8%A3%E0%B9%88%E0%B8%B2%E0%B8%87.docx",
},
}),
)
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }),
})
expect(fetchMock).toHaveBeenCalledWith('https://api.test/api/templates/abc/generate-draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token-1' },
body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }),
})
expect(clicked?.download).toBe('ไฟล์ร่าง.docx')
})
it('falls back to the given name when Content-Disposition is missing (CORS ไม่ expose)', async () => {
fetchMock.mockResolvedValue(fakeResponse({}))
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' })
expect(clicked?.download).toBe('fallback.docx')
})
it('retries once with a refreshed token on 401 before saving', async () => {
getAccessToken.mockImplementation(async (forceRefresh?: boolean) => (forceRefresh ? 'token-2' : 'token-1'))
fetchMock
.mockResolvedValueOnce(fakeResponse({ ok: false, status: 401 }))
.mockResolvedValueOnce(fakeResponse({ headers: { 'Content-Disposition': 'attachment; filename="draft.docx"' } }))
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer token-2')
expect(clicked?.download).toBe('draft.docx')
})
it('throws ApiError with the backend message when the response is not ok', async () => {
fetchMock.mockResolvedValue(
fakeResponse({ ok: false, status: 400, body: 'กรอก required fields ให้ครบ: employee_name' }),
)
await expect(
useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' }),
).rejects.toMatchObject({
constructor: ApiError,
status: 400,
message: 'กรอก required fields ให้ครบ: employee_name',
})
expect(clicked).toBeNull() // ไม่มีการดาวน์โหลดเกิดขึ้น
})
})