import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted (() => { const client = { delete: vi.fn (), get: vi.fn (), patch: vi.fn (), post: vi.fn (), put: vi.fn (), } return { client, isAxiosError: vi.fn (), } }) vi.mock ('axios', () => ({ default: { create: vi.fn (() => mocks.client), isAxiosError: mocks.isAxiosError, }, })) vi.mock ('@/config', () => ({ API_BASE_URL: '/api', })) describe ('api helpers', () => { beforeEach (() => { vi.resetModules () vi.clearAllMocks () localStorage.clear () mocks.client.get.mockReset ().mockResolvedValue ({ data: { token: 'csrf-token' } }) mocks.client.post.mockReset ().mockResolvedValue ({ data: { ok: true } }) mocks.client.patch.mockReset ().mockResolvedValue ({ data: { ok: true } }) mocks.client.delete.mockReset ().mockResolvedValue ({ data: '' }) mocks.isAxiosError.mockReturnValue (false) }) it ('uses Cookies without sending the recovery code and camelises responses', async () => { localStorage.setItem ('user_code', 'abc123') mocks.client.get.mockResolvedValueOnce ({ data: { post_id: 1, nested_value: { created_at: 'now' } }, }) const { apiGet } = await import ('@/lib/api') const data = await apiGet<{ postId: number; nestedValue: { createdAt: string } }> ( '/posts/1', { headers: { 'X-Extra': '1' }, params: { page: 2 } }, ) expect (mocks.client.get).toHaveBeenCalledWith ( '/posts/1', { headers: { 'X-Extra': '1' }, params: { page: 2 }, }, ) expect (data).toEqual ({ postId: 1, nestedValue: { createdAt: 'now' } }) const { default: axios } = await import ('axios') expect (axios.create).toHaveBeenCalledWith ({ baseURL: '/api', withCredentials: true }) }) it ('passes an empty body for post-like requests when body is omitted', async () => { mocks.client.patch.mockResolvedValueOnce ({ data: { ok_value: true } }) const { apiPatch } = await import ('@/lib/api') const data = await apiPatch<{ okValue: boolean }> ('/posts/1') expect (mocks.client.patch).toHaveBeenCalledWith ( '/posts/1', {}, { headers: { 'X-CSRF-Token': 'csrf-token' } }, ) expect (data.okValue).toBe (true) }) it ('does not camelize blob responses', async () => { const blob = new Blob (['csv']) mocks.client.get.mockResolvedValueOnce ({ data: blob }) const { apiGet } = await import ('@/lib/api') const data = await apiGet ('/exports', { responseType: 'blob' }) expect (data).toBe (blob) }) it ('delegates deletes and exposes axios error detection', async () => { const err = new Error ('bad') mocks.client.delete.mockResolvedValueOnce ({}) mocks.isAxiosError.mockReturnValueOnce (true) const { apiDelete, isApiError } = await import ('@/lib/api') await apiDelete ('/posts/1') expect (mocks.client.delete).toHaveBeenCalledWith ( '/posts/1', { headers: { 'X-CSRF-Token': 'csrf-token' } }, ) expect (isApiError (err)).toBe (true) expect (mocks.isAxiosError).toHaveBeenCalledWith (err) }) it ('shares initial CSRF acquisition across concurrent writes and reuses the token', async () => { let complete!: (value: { data: { token: string } }) => void mocks.client.get.mockReturnValueOnce (new Promise (resolve => { complete = resolve })) const { apiPost, apiPatch } = await import ('@/lib/api') const first = apiPost ('/posts', { title: 'one' }) const second = apiPatch ('/posts/1', { title: 'two' }) expect (mocks.client.get).toHaveBeenCalledTimes (1) expect (mocks.client.post).not.toHaveBeenCalled () complete ({ data: { token: 'shared-token' } }) await Promise.all ([first, second]) await apiPatch ('/posts/1') expect (mocks.client.get).toHaveBeenCalledTimes (1) expect (mocks.client.post).toHaveBeenCalledWith ('/posts', { title: 'one' }, { headers: { 'X-CSRF-Token': 'shared-token' } }) expect (mocks.client.patch).toHaveBeenLastCalledWith ('/posts/1', { }, { headers: { 'X-CSRF-Token': 'shared-token' } }) }) it.each (['/users', '/users/verify', '/users/code/renew']) ( 'refreshes CSRF after a session change through %s', async path => { mocks.client.get.mockResolvedValueOnce ({ data: { token: 'before' } }) mocks.client.get.mockResolvedValueOnce ({ data: { token: 'after' } }) mocks.client.post.mockResolvedValueOnce ({ data: { valid: true } }) const { apiPost, apiPatch } = await import ('@/lib/api') await apiPost (path) await apiPatch ('/posts/1') expect (mocks.client.get).toHaveBeenCalledTimes (2) expect (mocks.client.patch).toHaveBeenCalledWith ('/posts/1', { }, { headers: { 'X-CSRF-Token': 'after' } }) }) it ('clears the recovery code and CSRF token after session deletion', async () => { localStorage.setItem ('user_code', 'backup') const { apiDelete, apiPatch } = await import ('@/lib/api') await apiDelete ('/users/session') expect (localStorage.getItem ('user_code')).toBeNull () await apiPatch ('/posts/1') expect (mocks.client.get).toHaveBeenCalledTimes (2) }) it ('clears rejected CSRF tokens without replaying a write', async () => { const error = { response: { status: 403, headers: { 'x-csrf-invalid': '1' } } } mocks.isAxiosError.mockReturnValue (true) mocks.client.patch.mockRejectedValueOnce (error) const { apiPatch } = await import ('@/lib/api') await expect (apiPatch ('/posts/1')).rejects.toBe (error) expect (mocks.client.patch).toHaveBeenCalledTimes (1) await apiPatch ('/posts/1') expect (mocks.client.get).toHaveBeenCalledTimes (2) }) it ('allows CSRF acquisition to recover after a failed request', async () => { const error = new Error ('offline') mocks.client.get.mockRejectedValueOnce (error) const { apiPatch } = await import ('@/lib/api') await expect (apiPatch ('/posts/1')).rejects.toBe (error) expect (mocks.client.patch).not.toHaveBeenCalled () await apiPatch ('/posts/1') expect (mocks.client.get).toHaveBeenCalledTimes (2) }) })