Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16)
แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
b3c1c667f7
commit
70f71f4c5c
23 changed files with 408 additions and 241 deletions
|
|
@ -1,117 +0,0 @@
|
|||
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() // ไม่มีการดาวน์โหลดเกิดขึ้น
|
||||
})
|
||||
})
|
||||
|
|
@ -97,4 +97,14 @@ describe('version open lifecycle', () => {
|
|||
app.unmount()
|
||||
expect(remove).toHaveBeenCalledWith('blur', expect.any(Function))
|
||||
})
|
||||
it('openDocxUrl opens a URL the caller already holds (ไฟล์ร่าง FR-16) without extra request', async () => {
|
||||
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||
const state = mount()
|
||||
state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb')
|
||||
expect(apiJson).not.toHaveBeenCalled()
|
||||
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb')
|
||||
expect(state.fallbackUrl.value).toBe('https://files.example/draft.docx?sig=a%2Fb')
|
||||
vi.advanceTimersByTime(1500)
|
||||
expect(state.openNotice.value).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue