feat(admin-v2): add ProTable search, scenarios/quick_commands form, tests, remove quota_reset_interval

- 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>
This commit is contained in:
iven
2026-03-31 11:13:16 +08:00
parent f79560a911
commit ee51d5abcd
20 changed files with 1528 additions and 31 deletions

View File

@@ -0,0 +1,179 @@
// ============================================================
// 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()
}
})
})

43
admin-v2/tests/setup.ts Normal file
View File

@@ -0,0 +1,43 @@
// ============================================================
// Test setup: globals, jsdom polyfills, localStorage mock
// ============================================================
import { beforeAll, beforeEach, vi } from 'vitest'
import '@testing-library/jest-dom/vitest'
// ── localStorage mock (jsdom provides one but we ensure clean state) ──────
beforeEach(() => {
localStorage.clear()
})
// ── Ant Design / rc-util requires matchMedia ──────────────────────────────
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// Ant Design's scrollTo polyfill
window.scrollTo = vi.fn()
// React 19 + jsdom: ensure getComputedStyle returns something useful
const originalGetComputedStyle = window.getComputedStyle
window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => {
try {
return originalGetComputedStyle(elt, pseudoElt)
} catch {
return {} as CSSStyleDeclaration
}
}
})

View File

@@ -0,0 +1,115 @@
// ============================================================
// authStore 测试
// ============================================================
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAuthStore } from '@/stores/authStore'
import type { AccountPublic } from '@/types'
// Mock fetch for logout
const mockFetch = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', mockFetch)
const mockAccount: AccountPublic = {
id: 'test-id',
username: 'testuser',
display_name: 'Test User',
email: 'test@example.com',
role: 'admin',
status: 'active',
totp_enabled: false,
llm_routing: 'relay',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
const superAdminAccount: AccountPublic = {
...mockAccount,
id: 'super-id',
username: 'superadmin',
role: 'super_admin',
}
describe('authStore', () => {
beforeEach(() => {
localStorage.clear()
mockFetch.mockClear()
// Reset store state
useAuthStore.setState({
token: null,
refreshToken: null,
account: null,
permissions: [],
})
})
it('login sets token, refreshToken, account and permissions', () => {
const store = useAuthStore.getState()
store.login('jwt-token', 'refresh-token', mockAccount)
const state = useAuthStore.getState()
expect(state.token).toBe('jwt-token')
expect(state.refreshToken).toBe('refresh-token')
expect(state.account).toEqual(mockAccount)
expect(state.permissions).toContain('provider:manage')
})
it('super_admin gets admin:full + all permissions', () => {
const store = useAuthStore.getState()
store.login('jwt', 'refresh', superAdminAccount)
const state = useAuthStore.getState()
expect(state.permissions).toContain('admin:full')
expect(state.permissions).toContain('account:admin')
expect(state.permissions).toContain('prompt:admin')
})
it('user role gets only basic permissions', () => {
const userAccount: AccountPublic = { ...mockAccount, role: 'user' }
const store = useAuthStore.getState()
store.login('jwt', 'refresh', userAccount)
const state = useAuthStore.getState()
expect(state.permissions).toContain('model:read')
expect(state.permissions).toContain('relay:use')
expect(state.permissions).not.toContain('provider:manage')
})
it('logout clears all state', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
useAuthStore.getState().logout()
const state = useAuthStore.getState()
expect(state.token).toBeNull()
expect(state.refreshToken).toBeNull()
expect(state.account).toBeNull()
expect(state.permissions).toEqual([])
expect(localStorage.getItem('zclaw_admin_account')).toBeNull()
})
it('hasPermission returns true for matching permission', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
expect(useAuthStore.getState().hasPermission('provider:manage')).toBe(true)
expect(useAuthStore.getState().hasPermission('config:write')).toBe(true)
})
it('hasPermission returns false for non-matching permission', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
expect(useAuthStore.getState().hasPermission('admin:full')).toBe(false)
})
it('admin:full grants all permissions via wildcard', () => {
useAuthStore.getState().login('jwt', 'refresh', superAdminAccount)
expect(useAuthStore.getState().hasPermission('anything:here')).toBe(true)
expect(useAuthStore.getState().hasPermission('made:up')).toBe(true)
})
it('persists account to localStorage on login', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
const stored = localStorage.getItem('zclaw_admin_account')
expect(stored).not.toBeNull()
expect(JSON.parse(stored!).username).toBe('testuser')
})
})