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 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() // ไม่มีการดาวน์โหลดเกิดขึ้น }) })