import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' import { AuthenticationRequiredError, useApi } from '../../composables/useApi' import { MintApiError } from '../../apiErrors' import { useAuthStore } from '../../stores/auth' function readAuthorizationHeader(headers: InternalAxiosRequestConfig['headers']): unknown { const bag = headers as Record & { get?: (header: string) => unknown } return (typeof bag.get === 'function' ? bag.get('Authorization') : undefined) ?? bag.Authorization ?? bag.authorization } function unauthorizedAdapter(config: AxiosRequestConfig): Promise { const error = new AxiosError( 'Unauthorized', AxiosError.ERR_BAD_REQUEST, config as InternalAxiosRequestConfig, undefined, { data: { detail: 'Authentication required' }, status: 401, statusText: 'Unauthorized', headers: {}, config: config as InternalAxiosRequestConfig, }, ) return Promise.reject(error) } describe('useApi URL helpers', () => { beforeEach(() => { setActivePinia(createPinia()) useAuthStore().clearToken() }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllEnvs() }) it('joins explicit API base URLs without duplicate slashes', () => { const api = useApi({ baseUrl: '/api/plugin/' }) expect(api.buildUrl('/rows')).toBe('/api/plugin/rows') expect(api.buildUrl('rows')).toBe('/api/plugin/rows') expect(api.buildUrl('')).toBe('/api/plugin/') }) it('keeps absolute URLs outside the configured API base', () => { const api = useApi({ baseUrl: '/api/plugin' }) expect(api.buildUrl('https://files.example.test/result.csv')).toBe( 'https://files.example.test/result.csv', ) }) it('joins configured WebSocket base URLs without duplicate slashes', () => { vi.stubEnv('VITE_API_PREFIX', '/api/') const api = useApi() expect(api.buildWsUrl('/events')).toMatch(/\/api\/events$/) expect(api.buildWsUrl('/events')).not.toContain('/api//events') }) it('normalizes paths that already include the configured API prefix', async () => { const api = useApi({ baseUrl: '/api' }) const getSpy = vi.spyOn(api.client, 'get').mockResolvedValue({ data: { ok: true } }) expect(api.buildUrl('/api/projects')).toBe('/api/projects') expect(api.buildUrl('/api')).toBe('/api') expect(api.buildUrl('/api?active=true')).toBe('/api?active=true') await expect(api.get<{ ok: boolean }>('/api/projects')).resolves.toEqual({ ok: true }) expect(getSpy).toHaveBeenCalledWith('/projects', expect.objectContaining({ baseURL: '/api' })) }) it('honors withAuth=false even when the SDK auth store has a token', async () => { const authStore = useAuthStore() authStore.setToken('secret-token', 3600) const api = useApi({ baseUrl: '/api', withAuth: false }) let authorization: unknown const result = await api.get<{ ok: boolean }>('/public', { adapter: async (config) => { authorization = readAuthorizationHeader(config.headers) return { data: { ok: true }, status: 200, statusText: 'OK', headers: {}, config, } }, }) expect(result).toEqual({ ok: true }) expect(authorization).toBeUndefined() }) it.each([false, true])('normalizes authenticated 401 responses and clears the rejected token (typedErrors=%s)', async (typedErrors) => { const authStore = useAuthStore() authStore.setToken('expired-token', 3600) const api = useApi({ baseUrl: '/api', typedErrors }) const adapter = vi.fn(unauthorizedAdapter) const error = await api.get('/protected', { adapter }).catch((reason: unknown) => reason) expect({ attempts: adapter.mock.calls.length, errorType: error?.constructor, typed: error instanceof MintApiError, status: (error as MintApiError).status, code: (error as MintApiError).code, token: authStore.token, }).toEqual({ attempts: 1, errorType: AuthenticationRequiredError, typed: true, status: 401, code: 'AUTHENTICATION_REQUIRED', token: null, }) }) it.each([false, true])('does not clear auth state for public requests that return 401 (typedErrors=%s)', async (typedErrors) => { const authStore = useAuthStore() authStore.setToken('still-valid-token', 3600) const api = useApi({ baseUrl: '/api', typedErrors, withAuth: false }) const error = await api.get('/public', { adapter: unauthorizedAdapter }).catch((reason: unknown) => reason) expect({ normalized: error instanceof AuthenticationRequiredError, token: authStore.token }).toEqual({ normalized: false, token: 'still-valid-token', }) }) it.each([false, true])('does not clear a replacement token after an older request returns 401 (typedErrors=%s)', async (typedErrors) => { const authStore = useAuthStore() authStore.setToken('rejected-token', 3600) const api = useApi({ baseUrl: '/api', typedErrors }) await api.get('/protected', { adapter: async (config) => { authStore.setToken('replacement-token', 3600) return unauthorizedAdapter(config) }, }).catch(() => undefined) expect(authStore.token).toBe('replacement-token') }) it.each([false, true])('does not clear a token created while an unauthenticated request is in flight (typedErrors=%s)', async (typedErrors) => { const authStore = useAuthStore() const api = useApi({ baseUrl: '/api', typedErrors }) expect(authStore.token).toBeNull() await api.get('/protected', { adapter: async (config) => { authStore.setToken('new-login-token', 3600) return unauthorizedAdapter(config) }, }).catch(() => undefined) expect(authStore.token).toBe('new-login-token') }) it('normalizes a typed response when typed errors are enabled', async () => { const api = useApi({ baseUrl: '/api', typedErrors: true }) const adapter = vi.fn(async (config: AxiosRequestConfig) => Promise.reject(new AxiosError( 'Not Found', AxiosError.ERR_BAD_REQUEST, config as InternalAxiosRequestConfig, undefined, { data: { code: 'leaf.ARTIFACT_NOT_FOUND', message: 'Artifact is not available', status: 404, request_id: 'req-generated', details: { artifact_id: 67 }, detail: 'Artifact is not available', }, status: 404, statusText: 'Not Found', headers: { 'x-request-id': 'req-generated' }, config: config as InternalAxiosRequestConfig, }, ))) const error = await api.get('/artifacts/67', { adapter }).catch((reason: unknown) => reason) const typedError = error as MintApiError expect({ attempts: adapter.mock.calls.length, type: typedError.constructor, code: typedError.code }).toEqual({ attempts: 1, type: MintApiError, code: 'leaf.ARTIFACT_NOT_FOUND', }) }) it.each([true, false])('normalizes Blob download errors (JSON=%s)', async (json) => { const api = useApi({ typedErrors: true }) const body = { code: 'artifact.unavailable', message: 'No artifact', details: { id: 67 } } const blob = new Blob([json ? JSON.stringify(body) : 'Bad gateway'], { type: json ? 'application/json' : 'text/plain', }) let cause: AxiosError | undefined const error = await api.get('/download', { responseType: 'blob', adapter: async (config) => { cause = new AxiosError('Bad gateway', AxiosError.ERR_BAD_RESPONSE, config, undefined, { data: blob, status: 502, statusText: 'Bad gateway', headers: { 'x-request-id': 'blob-request' }, config, }) throw cause }, }).catch((reason: unknown) => reason) expect(error).toBeInstanceOf(MintApiError) expect(error).toMatchObject({ code: json ? body.code : 'unknown', message: json ? body.message : 'Bad gateway', status: 502, requestId: 'blob-request', cause, }) expect((error as MintApiError).body).toEqual(json ? body : blob) }) it('leaves cancellations and network failures unchanged in typed mode', async () => { const api = useApi({ typedErrors: true }) const cause = new AxiosError('Offline', AxiosError.ERR_NETWORK) await expect(api.get('/offline', { adapter: async () => { throw cause } })).rejects.toBe(cause) const controller = new AbortController() controller.abort() await expect(api.get('/cancelled', { signal: controller.signal })).rejects.toMatchObject({ code: 'ERR_CANCELED' }) }) })