- Enable ProTable search on Accounts (username/email), Models (model_id/alias), Providers (display_name/name) with hideInSearch for non-searchable columns - Add scenarios (Select tags) and quick_commands (Form.List) to AgentTemplates create form, plus service type updates - Remove unused quota_reset_interval from ProviderKey model, key_pool SQL, handlers, and frontend types; add migration + bump schema to v11 - Add Vitest config, test setup, request interceptor tests (7 cases), authStore tests (8 cases) — all 15 passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
180 lines
5.1 KiB
TypeScript
180 lines
5.1 KiB
TypeScript
// ============================================================
|
|
// request.ts 拦截器测试
|
|
// ============================================================
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { http, HttpResponse } from 'msw'
|
|
import { setupServer } from 'msw/node'
|
|
|
|
// ── Hoisted: mock functions + store (accessible in vi.mock factory) ──
|
|
const { mockSetToken, mockSetRefreshToken, mockLogout, _store } = vi.hoisted(() => {
|
|
const mockSetToken = vi.fn()
|
|
const mockSetRefreshToken = vi.fn()
|
|
const mockLogout = vi.fn()
|
|
const _store = {
|
|
token: null as string | null,
|
|
refreshToken: null as string | null,
|
|
setToken: mockSetToken,
|
|
setRefreshToken: mockSetRefreshToken,
|
|
logout: mockLogout,
|
|
}
|
|
return { mockSetToken, mockSetRefreshToken, mockLogout, _store }
|
|
})
|
|
|
|
vi.mock('@/stores/authStore', () => ({
|
|
useAuthStore: {
|
|
getState: () => _store,
|
|
},
|
|
}))
|
|
|
|
import request, { ApiRequestError } from '@/services/request'
|
|
|
|
function setStoreState(overrides: Partial<typeof _store>) {
|
|
Object.assign(_store, overrides)
|
|
}
|
|
|
|
// ── MSW server ──────────────────────────────────────────────
|
|
const server = setupServer()
|
|
|
|
beforeEach(() => {
|
|
server.listen({ onUnhandledRequest: 'bypass' })
|
|
mockSetToken.mockClear()
|
|
mockSetRefreshToken.mockClear()
|
|
mockLogout.mockClear()
|
|
_store.token = null
|
|
_store.refreshToken = null
|
|
})
|
|
|
|
afterEach(() => {
|
|
server.close()
|
|
})
|
|
|
|
describe('request interceptor', () => {
|
|
it('attaches Authorization header when token exists', async () => {
|
|
let capturedAuth: string | null = null
|
|
server.use(
|
|
http.get('*/api/v1/test', ({ request }) => {
|
|
capturedAuth = request.headers.get('Authorization')
|
|
return HttpResponse.json({ ok: true })
|
|
}),
|
|
)
|
|
|
|
setStoreState({ token: 'test-jwt-token' })
|
|
await request.get('/test')
|
|
|
|
expect(capturedAuth).toBe('Bearer test-jwt-token')
|
|
})
|
|
|
|
it('does not attach Authorization header when no token', async () => {
|
|
let capturedAuth: string | null = null
|
|
server.use(
|
|
http.get('*/api/v1/test', ({ request }) => {
|
|
capturedAuth = request.headers.get('Authorization')
|
|
return HttpResponse.json({ ok: true })
|
|
}),
|
|
)
|
|
|
|
setStoreState({ token: null })
|
|
await request.get('/test')
|
|
|
|
expect(capturedAuth).toBeNull()
|
|
})
|
|
|
|
it('wraps non-401 errors as ApiRequestError', async () => {
|
|
server.use(
|
|
http.get('*/api/v1/test', () => {
|
|
return HttpResponse.json(
|
|
{ error: 'not_found', message: 'Resource not found' },
|
|
{ status: 404 },
|
|
)
|
|
}),
|
|
)
|
|
|
|
try {
|
|
await request.get('/test')
|
|
expect.fail('Should have thrown')
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ApiRequestError)
|
|
expect((err as ApiRequestError).status).toBe(404)
|
|
expect((err as ApiRequestError).body.message).toBe('Resource not found')
|
|
}
|
|
})
|
|
|
|
it('wraps network errors as ApiRequestError with status 0', async () => {
|
|
server.use(
|
|
http.get('*/api/v1/test', () => {
|
|
return HttpResponse.error()
|
|
}),
|
|
)
|
|
|
|
try {
|
|
await request.get('/test')
|
|
expect.fail('Should have thrown')
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ApiRequestError)
|
|
expect((err as ApiRequestError).status).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('handles 401 with refresh token success', async () => {
|
|
let callCount = 0
|
|
|
|
server.use(
|
|
http.get('*/api/v1/protected', () => {
|
|
callCount++
|
|
if (callCount === 1) {
|
|
return HttpResponse.json({ error: 'unauthorized' }, { status: 401 })
|
|
}
|
|
return HttpResponse.json({ data: 'success' })
|
|
}),
|
|
http.post('*/api/v1/auth/refresh', () => {
|
|
return HttpResponse.json({ token: 'new-jwt', refresh_token: 'new-refresh' })
|
|
}),
|
|
)
|
|
|
|
setStoreState({ token: 'old-jwt', refreshToken: 'old-refresh' })
|
|
const res = await request.get('/protected')
|
|
|
|
expect(res.data).toEqual({ data: 'success' })
|
|
expect(mockSetToken).toHaveBeenCalledWith('new-jwt')
|
|
expect(mockSetRefreshToken).toHaveBeenCalledWith('new-refresh')
|
|
})
|
|
|
|
it('handles 401 with no refresh token — calls logout immediately', async () => {
|
|
server.use(
|
|
http.get('*/api/v1/norefresh', () => {
|
|
return HttpResponse.json({ error: 'unauthorized' }, { status: 401 })
|
|
}),
|
|
)
|
|
|
|
setStoreState({ token: 'old-jwt', refreshToken: null })
|
|
|
|
try {
|
|
await request.get('/norefresh')
|
|
expect.fail('Should have thrown')
|
|
} catch {
|
|
expect(mockLogout).toHaveBeenCalled()
|
|
}
|
|
})
|
|
|
|
it('handles 401 with refresh failure — calls logout', async () => {
|
|
server.use(
|
|
http.get('*/api/v1/refreshfail', () => {
|
|
return HttpResponse.json({ error: 'unauthorized' }, { status: 401 })
|
|
}),
|
|
http.post('*/api/v1/auth/refresh', () => {
|
|
return HttpResponse.json({ error: 'invalid' }, { status: 401 })
|
|
}),
|
|
)
|
|
|
|
setStoreState({ token: 'old-jwt', refreshToken: 'old-refresh' })
|
|
|
|
try {
|
|
await request.get('/refreshfail')
|
|
expect.fail('Should have thrown')
|
|
} catch {
|
|
expect(mockLogout).toHaveBeenCalled()
|
|
}
|
|
})
|
|
})
|