Files
summercms/admin/tests/app/client.test.ts
Jakub Zych 1c2a66df45 test(10-05): bring every SPA module, composable and component under Vitest
- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
  covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
  left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
2026-09-27 17:53:57 +02:00

162 lines
6.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api, onRefreshed, onUnauthorized, refreshSession, REQUESTED_WITH } from '../../src/api/client'
import { API, json, pathOf } from '../helpers'
const unauthenticated = { error: { code: 'unauthenticated', message: 'Unauthenticated', details: {} } }
const refreshed = (expiresIn: unknown) => json(200, { data: { token_type: 'cookie', expires_in: expiresIn }, meta: {} })
const navigation = { data: [], meta: {} }
/** fetch mock driven by a handler; every request is recorded. */
function serve(handler: (request: Request) => Response | Promise<Response>): Request[] {
const seen: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
const request = input as Request
seen.push(request)
return handler(request)
})
return seen
}
const count = (seen: Request[], path: string) => seen.filter((request) => pathOf(request) === `${API}${path}`).length
beforeEach(() => {
onUnauthorized(null)
onRefreshed(null)
})
afterEach(() => {
onUnauthorized(null)
onRefreshed(null)
})
describe('transport', () => {
it('sends X-Requested-With and same-origin credentials on every request to the runtime API base', async () => {
const seen = serve(() => json(200, navigation))
await api.GET('/navigation')
await api.POST('/auth/login', { body: { login: 'dev', password: 'x' } })
expect(REQUESTED_WITH).toBe('XMLHttpRequest')
for (const request of seen) {
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(request.credentials).toBe('same-origin')
expect(pathOf(request).startsWith(API)).toBe(true)
}
expect(seen.map(pathOf)).toEqual([`${API}/navigation`, `${API}/auth/login`])
})
it('passes a 401 from login or refresh through without refreshing', async () => {
const seen = serve(() => json(401, unauthenticated))
const handler = vi.fn()
onUnauthorized(handler)
const login = await api.POST('/auth/login', { body: { login: 'dev', password: 'bad' } })
const refresh = await api.POST('/auth/refresh')
expect(login.response.status).toBe(401)
expect(refresh.response.status).toBe(401)
expect(count(seen, '/auth/refresh')).toBe(1)
expect(handler).not.toHaveBeenCalled()
})
it('leaves other statuses alone', async () => {
const seen = serve(() => json(403, { error: { code: 'forbidden', message: 'no', details: {} } }))
const handler = vi.fn()
onUnauthorized(handler)
const result = await api.GET('/navigation')
expect(result.response.status).toBe(403)
expect(seen).toHaveLength(1)
expect(handler).not.toHaveBeenCalled()
})
it('single-flights one refresh for concurrent 401s and replays each request once with its body', async () => {
let session = false
const seen = serve(async (request) => {
if (pathOf(request) === `${API}/auth/refresh`) {
await new Promise((resolve) => setTimeout(resolve, 5))
session = true
return refreshed(900)
}
if (!session) {
return json(401, unauthenticated)
}
if (request.method === 'PUT') {
return json(200, { data: await request.json(), meta: { labels: {} } })
}
return json(200, navigation)
})
const lifetimes: Array<number | null> = []
onRefreshed((seconds) => lifetimes.push(seconds))
const handler = vi.fn()
onUnauthorized(handler)
const path = { vendor: 'acme', plugin: 'demo', controller: 'widgets', id: 1 }
const [a, b, put] = await Promise.all([
api.GET('/navigation'),
api.GET('/navigation'),
api.PUT('/{vendor}/{plugin}/{controller}/{id}', { params: { path }, body: { name: 'Replayed' } }),
])
expect(a.response.status).toBe(200)
expect(b.response.status).toBe(200)
expect(put.data?.data).toEqual({ name: 'Replayed' })
expect(count(seen, '/auth/refresh')).toBe(1)
expect(count(seen, '/navigation')).toBe(4)
expect(count(seen, '/acme/demo/widgets/1')).toBe(2)
expect(lifetimes).toEqual([900])
expect(handler).not.toHaveBeenCalled()
})
it('reports the session gone when the refresh fails, without replaying', async () => {
const seen = serve((request) =>
pathOf(request) === `${API}/auth/refresh` ? json(401, unauthenticated) : json(401, unauthenticated),
)
const handler = vi.fn()
onUnauthorized(handler)
const result = await api.GET('/navigation')
expect(result.response.status).toBe(401)
expect(count(seen, '/navigation')).toBe(1)
expect(handler).toHaveBeenCalledTimes(1)
})
it('reports the session gone when the replay is still 401, and replays only once', async () => {
const seen = serve((request) => (pathOf(request) === `${API}/auth/refresh` ? refreshed(60) : json(401, unauthenticated)))
const handler = vi.fn()
onUnauthorized(handler)
const result = await api.GET('/navigation')
expect(result.response.status).toBe(401)
expect(count(seen, '/navigation')).toBe(2)
expect(count(seen, '/auth/refresh')).toBe(1)
expect(handler).toHaveBeenCalledTimes(1)
})
})
describe('refreshSession', () => {
it('shares one in-flight request between concurrent callers and starts a new one afterwards', async () => {
const seen = serve(async () => {
await new Promise((resolve) => setTimeout(resolve, 5))
return refreshed(120)
})
const first = refreshSession()
const second = refreshSession()
expect(second).toBe(first)
expect(await first).toBe(true)
expect(count(seen, '/auth/refresh')).toBe(1)
expect(await refreshSession()).toBe(true)
expect(count(seen, '/auth/refresh')).toBe(2)
})
it('reports a missing lifetime as null', async () => {
serve(() => refreshed('soon'))
const lifetimes: Array<number | null> = []
onRefreshed((seconds) => lifetimes.push(seconds))
expect(await refreshSession()).toBe(true)
expect(lifetimes).toEqual([null])
})
it('answers false on an error status or a network failure', async () => {
serve(() => json(500, { error: { code: 'server', message: 'x', details: {} } }))
expect(await refreshSession()).toBe(false)
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network'))
expect(await refreshSession()).toBe(false)
})
})