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
This commit is contained in:
161
admin/tests/app/client.test.ts
Normal file
161
admin/tests/app/client.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
39
admin/tests/app/controllerRoutes.test.ts
Normal file
39
admin/tests/app/controllerRoutes.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
controllerIdFromParams,
|
||||
controllerIdFromPath,
|
||||
controllerPath,
|
||||
parseControllerId,
|
||||
pluginKey,
|
||||
} from '../../src/app/controllerRoutes'
|
||||
|
||||
describe('controller routes (D-10)', () => {
|
||||
it('parses a three-segment controller id', () => {
|
||||
expect(parseControllerId('acme.demo.widgets')).toEqual({ vendor: 'acme', plugin: 'demo', controller: 'widgets' })
|
||||
expect(parseControllerId('a_b.c-d.E9')).toEqual({ vendor: 'a_b', plugin: 'c-d', controller: 'E9' })
|
||||
})
|
||||
|
||||
it.each(['', 'acme.demo', 'acme.demo.widgets.extra', 'acme..widgets', 'acme.demo.wid gets', 'acme/demo/widgets', '../x.y.z'])(
|
||||
'rejects %j',
|
||||
(id) => {
|
||||
expect(parseControllerId(id)).toBeNull()
|
||||
expect(controllerPath(id)).toBeNull()
|
||||
expect(pluginKey(id)).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('maps ids and paths both ways', () => {
|
||||
expect(controllerPath('acme.demo.widgets')).toBe('/acme/demo/widgets')
|
||||
expect(controllerIdFromParams({ vendor: 'acme', plugin: 'demo', controller: 'widgets' })).toBe('acme.demo.widgets')
|
||||
expect(controllerIdFromPath('/acme/demo/widgets')).toBe('acme.demo.widgets')
|
||||
expect(controllerIdFromPath('/acme/demo/widgets/12')).toBe('acme.demo.widgets')
|
||||
expect(controllerIdFromPath('acme/demo/widgets/')).toBe('acme.demo.widgets')
|
||||
expect(controllerIdFromPath('/settings')).toBeNull()
|
||||
expect(controllerIdFromPath('/a/b')).toBeNull()
|
||||
expect(controllerIdFromPath('/a/b c/d')).toBeNull()
|
||||
})
|
||||
|
||||
it('names the owning plugin of a controller', () => {
|
||||
expect(pluginKey('acme.demo.widgets')).toBe('acme.demo')
|
||||
})
|
||||
})
|
||||
137
admin/tests/app/i18n.test.ts
Normal file
137
admin/tests/app/i18n.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
currentLocale,
|
||||
interpolate,
|
||||
loadStrings,
|
||||
message,
|
||||
pluralCategory,
|
||||
setBundle,
|
||||
t,
|
||||
tc,
|
||||
} from '../../src/app/i18n'
|
||||
import { API, mockApi, requestsTo } from '../helpers'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
|
||||
beforeEach(() => {
|
||||
setBundle(langFixture.data, 'pl')
|
||||
})
|
||||
|
||||
describe('interpolate (parity with phrasebook.interpolate)', () => {
|
||||
it('replaces :name, :Name and :NAME with the plain, first-upper and upper value', () => {
|
||||
expect(interpolate(':name / :Name / :NAME', { name: 'ada' })).toBe('ada / Ada / ADA')
|
||||
})
|
||||
|
||||
it('replaces the longer placeholder first so :names never loses to :name', () => {
|
||||
expect(interpolate(':names and :name', { name: 'one', names: 'many' })).toBe('many and one')
|
||||
expect(interpolate(':name and :names', { names: 'many', name: 'one' })).toBe('one and many')
|
||||
})
|
||||
|
||||
it('accepts a leading colon on the parameter name and skips an empty name', () => {
|
||||
expect(interpolate('Hello :who', { ':who': 'world' })).toBe('Hello world')
|
||||
expect(interpolate('Hello :', { ':': 'x', '': 'y' })).toBe('Hello :')
|
||||
})
|
||||
|
||||
it('upper-cases the first code point, not the first UTF-16 unit', () => {
|
||||
expect(interpolate(':Name', { name: 'łódź' })).toBe('Łódź')
|
||||
expect(interpolate(':Name', { name: '𝒶bc' })).toBe('𝒶bc')
|
||||
})
|
||||
|
||||
it('stringifies numbers and leaves text without placeholders or params alone', () => {
|
||||
expect(interpolate(':count items', { count: 3 })).toBe('3 items')
|
||||
expect(interpolate('plain', { name: 'x' })).toBe('plain')
|
||||
expect(interpolate(':name')).toBe(':name')
|
||||
expect(interpolate('', { name: 'x' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plural selection', () => {
|
||||
it.each([
|
||||
[1, 'one'],
|
||||
[2, 'few'],
|
||||
[5, 'many'],
|
||||
[22, 'few'],
|
||||
[25, 'many'],
|
||||
])('pl %i is %s', (count, category) => {
|
||||
expect(pluralCategory(count, 'pl')).toBe(category)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, 'one'],
|
||||
[2, 'other'],
|
||||
])('en %i is %s', (count, category) => {
|
||||
expect(pluralCategory(count, 'en')).toBe(category)
|
||||
})
|
||||
|
||||
it('answers other for a locale Intl cannot parse', () => {
|
||||
expect(pluralCategory(1, '!!invalid!!')).toBe('other')
|
||||
})
|
||||
|
||||
it('uses the bundle locale by default', () => {
|
||||
setBundle(langFixture.data, 'en')
|
||||
expect(pluralCategory(2)).toBe('other')
|
||||
setBundle(langFixture.data, 'pl')
|
||||
expect(pluralCategory(2)).toBe('few')
|
||||
})
|
||||
|
||||
it('picks the form for the count and falls back to other when that form is missing', () => {
|
||||
const forms = { one: ':count plik', other: ':count pliku' }
|
||||
expect(message(forms, 1)).toBe('1 plik')
|
||||
expect(message(forms, 5)).toBe('5 pliku')
|
||||
expect(message(forms, 2, { count: 'dwa' })).toBe('dwa pliku')
|
||||
})
|
||||
|
||||
it('renders other without a count and nothing without forms', () => {
|
||||
expect(message({ other: 'Hi :name', one: 'x' }, undefined, { name: 'Ada' })).toBe('Hi Ada')
|
||||
expect(message(null)).toBe('')
|
||||
expect(message(undefined, 3)).toBe('')
|
||||
expect(message({ one: 'only one' }, 5)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bundle lookups', () => {
|
||||
it('t returns the key itself when the key is not in the bundle', () => {
|
||||
expect(t('backend::lang.missing.key')).toBe('backend::lang.missing.key')
|
||||
expect(tc('backend::lang.missing.key', 3)).toBe('backend::lang.missing.key')
|
||||
})
|
||||
|
||||
it('t interpolates the other form and tc the plural form', () => {
|
||||
expect(t('backend::lang.form.remove_item', { name: 'Ada' })).toBe('Usuń: Ada')
|
||||
expect(tc('backend::lang.list.results', 1)).toBe('1 wynik')
|
||||
expect(tc('backend::lang.list.results', 3)).toBe('3 wyniki')
|
||||
expect(tc('backend::lang.list.results', 7)).toBe('7 wyników')
|
||||
})
|
||||
|
||||
it('setBundle replaces the strings and sets the document language', () => {
|
||||
setBundle({ 'backend::lang.auth.title': { other: 'Welcome back' } }, 'en')
|
||||
expect(t('backend::lang.auth.title')).toBe('Welcome back')
|
||||
expect(t('backend::lang.auth.submit')).toBe('backend::lang.auth.submit')
|
||||
expect(document.documentElement.lang).toBe('en')
|
||||
expect(currentLocale.value).toBe('en')
|
||||
})
|
||||
|
||||
it('setBundle without a locale keeps the current locale', () => {
|
||||
setBundle({}, 'pl')
|
||||
setBundle({ a: { other: 'b' } })
|
||||
expect(currentLocale.value).toBe('pl')
|
||||
expect(t('a')).toBe('b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadStrings', () => {
|
||||
it('loads GET /lang and adopts meta.locale', async () => {
|
||||
const calls = mockApi({
|
||||
[`GET ${API}/lang`]: { body: { data: { 'backend::lang.auth.title': { other: 'Hello' } }, meta: { locale: 'en' } } },
|
||||
})
|
||||
await loadStrings()
|
||||
expect(requestsTo(calls, 'GET', `${API}/lang`)).toHaveLength(1)
|
||||
expect(t('backend::lang.auth.title')).toBe('Hello')
|
||||
expect(currentLocale.value).toBe('en')
|
||||
})
|
||||
|
||||
it('keeps the current strings when the bundle cannot be loaded', async () => {
|
||||
mockApi({ [`GET ${API}/lang`]: { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } } })
|
||||
await loadStrings()
|
||||
expect(t('backend::lang.auth.title')).toBe('Witaj ponownie')
|
||||
expect(currentLocale.value).toBe('pl')
|
||||
})
|
||||
})
|
||||
34
admin/tests/app/icons.test.ts
Normal file
34
admin/tests/app/icons.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Archive, Disc3, Settings, Square, Tags } from '@lucide/vue'
|
||||
import { fallbackIcon, iconFor, icons, winterIcons } from '../../src/app/icons'
|
||||
|
||||
describe('iconFor', () => {
|
||||
it('resolves lucide names', () => {
|
||||
expect(iconFor('disc-3')).toBe(Disc3)
|
||||
expect(iconFor('tags')).toBe(Tags)
|
||||
expect(iconFor(' tags ')).toBe(Tags)
|
||||
})
|
||||
|
||||
it('resolves Winter icon aliases', () => {
|
||||
expect(iconFor('icon-archive')).toBe(Archive)
|
||||
expect(iconFor('icon-cog')).toBe(Settings)
|
||||
})
|
||||
|
||||
it('falls back to the neutral square for unknown, empty or missing names', () => {
|
||||
expect(fallbackIcon).toBe(Square)
|
||||
expect(iconFor('unknown-icon-name')).toBe(Square)
|
||||
expect(iconFor('icon-unknown')).toBe(Square)
|
||||
expect(iconFor('')).toBe(Square)
|
||||
expect(iconFor(null)).toBe(Square)
|
||||
expect(iconFor(undefined)).toBe(Square)
|
||||
expect(iconFor('constructor')).toBe(Square)
|
||||
expect(iconFor('toString')).toBe(Square)
|
||||
expect(iconFor('__proto__')).toBe(Square)
|
||||
})
|
||||
|
||||
it('maps every Winter alias onto a registered lucide icon', () => {
|
||||
for (const [alias, name] of Object.entries(winterIcons)) {
|
||||
expect(icons[name], alias).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
74
admin/tests/app/listQuery.test.ts
Normal file
74
admin/tests/app/listQuery.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { listQueryKey, parseListQuery, toListQuery } from '../../src/app/listQuery'
|
||||
|
||||
describe('parseListQuery', () => {
|
||||
it('reads search, sort, dir, page, per_page and filter[<name>]', () => {
|
||||
expect(
|
||||
parseListQuery({
|
||||
search: ' blue ',
|
||||
sort: 'name',
|
||||
dir: 'desc',
|
||||
page: '3',
|
||||
per_page: '50',
|
||||
'filter[active]': 'true',
|
||||
'filter[created]': '2026-01-01..2026-02-01',
|
||||
}),
|
||||
).toEqual({
|
||||
search: 'blue',
|
||||
sort: 'name',
|
||||
dir: 'desc',
|
||||
page: 3,
|
||||
per_page: 50,
|
||||
filter: { active: 'true', created: '2026-01-01..2026-02-01' },
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['0', '-1', '1.5', 'abc', '', '01', ' 2'])('drops the invalid page and per_page %j', (value) => {
|
||||
const out = parseListQuery({ page: value, per_page: value })
|
||||
expect(out.page).toBeUndefined()
|
||||
expect(out.per_page).toBeUndefined()
|
||||
})
|
||||
|
||||
it('defaults the direction to asc with a sort and ignores a direction without one', () => {
|
||||
expect(parseListQuery({ sort: 'name', dir: 'sideways' })).toEqual({ sort: 'name', dir: 'asc' })
|
||||
expect(parseListQuery({ dir: 'desc' })).toEqual({})
|
||||
expect(parseListQuery({ sort: ' ' })).toEqual({})
|
||||
})
|
||||
|
||||
it('takes the last value of a repeated key and drops empty filters and non-filter keys', () => {
|
||||
expect(parseListQuery({ search: ['a', 'b'], 'filter[x]': '', 'filter[]': 'y', filters: 'z', other: '1' })).toEqual({
|
||||
search: 'b',
|
||||
})
|
||||
expect(parseListQuery({ page: ['2', null] })).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toListQuery', () => {
|
||||
it('writes only meaningful values and omits page 1', () => {
|
||||
expect(toListQuery({})).toEqual({})
|
||||
expect(toListQuery({ page: 1, search: '' })).toEqual({})
|
||||
expect(toListQuery({ page: 2, per_page: 10 })).toEqual({ page: '2', per_page: '10' })
|
||||
expect(toListQuery({ sort: 'code' })).toEqual({ sort: 'code', dir: 'asc' })
|
||||
expect(toListQuery({ filter: { b: '2', a: '1', c: '' } })).toEqual({ 'filter[a]': '1', 'filter[b]': '2' })
|
||||
})
|
||||
|
||||
it('round-trips through parseListQuery', () => {
|
||||
const input = {
|
||||
search: 'blue',
|
||||
sort: 'name',
|
||||
dir: 'desc' as const,
|
||||
page: 4,
|
||||
per_page: 25,
|
||||
filter: { active: 'false', maker: '2' },
|
||||
}
|
||||
const raw = toListQuery(input)
|
||||
const back = parseListQuery(raw as Record<string, string>)
|
||||
expect(back).toEqual(input)
|
||||
})
|
||||
|
||||
it('keys equal queries alike regardless of filter order', () => {
|
||||
expect(listQueryKey({ filter: { a: '1', b: '2' } })).toBe(listQueryKey({ filter: { b: '2', a: '1' } }))
|
||||
expect(listQueryKey({ page: 1 })).toBe(listQueryKey({}))
|
||||
expect(listQueryKey({ page: 2 })).not.toBe(listQueryKey({}))
|
||||
})
|
||||
})
|
||||
95
admin/tests/app/router.test.ts
Normal file
95
admin/tests/app/router.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import { createAdminRouter, safeRedirect } from '../../src/app/router'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { navigationFixture } from '../fixtures/typed'
|
||||
import { resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('safeRedirect', () => {
|
||||
it('accepts in-app paths with exactly one leading slash', () => {
|
||||
expect(safeRedirect('/acme/demo/widgets')).toBe('/acme/demo/widgets')
|
||||
expect(safeRedirect('/acme/demo/widgets/1?tab=2#x')).toBe('/acme/demo/widgets/1?tab=2#x')
|
||||
expect(safeRedirect('/')).toBe('/')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['protocol-relative', '//evil.example/x'],
|
||||
['backslash protocol-relative', '/\\evil.example'],
|
||||
['absolute http', 'http://evil.example/'],
|
||||
['absolute https', 'https://evil.example/'],
|
||||
['javascript', 'javascript:alert(1)'],
|
||||
['relative', 'acme/demo'],
|
||||
['empty', ''],
|
||||
])('rejects a %s value', (_label, value) => {
|
||||
expect(safeRedirect(value)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects non-string values', () => {
|
||||
expect(safeRedirect(undefined)).toBeNull()
|
||||
expect(safeRedirect(null)).toBeNull()
|
||||
expect(safeRedirect(['/a'])).toBeNull()
|
||||
expect(safeRedirect(1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('route guard', () => {
|
||||
it('sends a visitor without a session to login with the requested path', async () => {
|
||||
const router = await routerAt('/acme/demo/widgets/3?x=1')
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets/3?x=1')
|
||||
})
|
||||
|
||||
it('lets a visitor open the login screen', async () => {
|
||||
const router = await routerAt('/login')
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('moves a signed-in admin away from login to a safe redirect', async () => {
|
||||
await signIn()
|
||||
const router = await routerAt('/login?redirect=/acme/demo/gadgets')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/gadgets')
|
||||
})
|
||||
|
||||
it.each(['//evil.example/x', 'https://evil.example/', '/\\evil'])(
|
||||
'ignores the unsafe redirect %j and goes home',
|
||||
async (redirect) => {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push({ path: '/login', query: { redirect } })
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
},
|
||||
)
|
||||
|
||||
it('lands home on the first permitted controller, or on the empty page without navigation', async () => {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
expect((await routerAt('/')).currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
setNavigation([])
|
||||
const empty = await routerAt('/')
|
||||
expect(empty.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('names the list, create, record, settings and not-found routes', async () => {
|
||||
await signIn()
|
||||
const cases: Array<[string, string]> = [
|
||||
['/acme/demo/widgets', 'list'],
|
||||
['/acme/demo/widgets/create', 'create'],
|
||||
['/acme/demo/widgets/12', 'record'],
|
||||
['/acme/demo/widgets/abc', 'not-found'],
|
||||
['/settings', 'settings'],
|
||||
['/settings/mail', 'settings-form'],
|
||||
['/nowhere', 'not-found'],
|
||||
]
|
||||
for (const [path, name] of cases) {
|
||||
const router = await routerAt(path)
|
||||
expect(router.currentRoute.value.name, path).toBe(name)
|
||||
expect(router.currentRoute.value.meta.shell, path).toBe(true)
|
||||
}
|
||||
expect((await routerAt('/acme/demo/widgets/12')).currentRoute.value.params.id).toBe('12')
|
||||
})
|
||||
})
|
||||
41
admin/tests/app/runtime.test.ts
Normal file
41
admin/tests/app/runtime.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_BASE, readBase, runtime } from '../../src/app/runtime'
|
||||
|
||||
function docWith(content: string | null): Document {
|
||||
const doc = document.implementation.createHTMLDocument('test')
|
||||
if (content !== null) {
|
||||
const meta = doc.createElement('meta')
|
||||
meta.setAttribute('name', 'summer-admin-base')
|
||||
meta.setAttribute('content', content)
|
||||
doc.head.appendChild(meta)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
describe('runtime base', () => {
|
||||
it('derives the router base and the API base from the served meta', () => {
|
||||
expect(runtime.base).toBe('/admin-test')
|
||||
expect(runtime.api).toBe('/admin-test/api/v1')
|
||||
})
|
||||
|
||||
it('reads a custom prefix and trims trailing slashes and whitespace', () => {
|
||||
expect(readBase(docWith('/acp'))).toBe('/acp')
|
||||
expect(readBase(docWith(' /acp/panel// '))).toBe('/acp/panel')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing meta', null],
|
||||
['an empty value', ''],
|
||||
['the unreplaced build token', '__SUMMER_ADMIN_BASE__'],
|
||||
['a relative path', 'backend'],
|
||||
['an absolute URL', 'https://evil.example/backend'],
|
||||
['the root alone', '/'],
|
||||
['slashes only', '///'],
|
||||
])('falls back to the default for %s', (_label, content) => {
|
||||
expect(readBase(docWith(content))).toBe(DEFAULT_BASE)
|
||||
})
|
||||
|
||||
it('uses /backend as the default', () => {
|
||||
expect(DEFAULT_BASE).toBe('/backend')
|
||||
})
|
||||
})
|
||||
34
admin/tests/app/theme.test.ts
Normal file
34
admin/tests/app/theme.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { applyColorScheme, DARK_QUERY } from '../../src/app/theme'
|
||||
import { fakeMedia } from '../helpers'
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove('dark')
|
||||
})
|
||||
|
||||
describe('applyColorScheme (A6: system preference only)', () => {
|
||||
it('sets .dark from prefers-color-scheme and follows changes until unsubscribed', () => {
|
||||
const media = fakeMedia({ [DARK_QUERY]: true })
|
||||
const stop = applyColorScheme()
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
expect(media.listeners(DARK_QUERY)).toBe(1)
|
||||
|
||||
media.change(DARK_QUERY, false)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
media.change(DARK_QUERY, true)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
|
||||
stop()
|
||||
expect(media.listeners(DARK_QUERY)).toBe(0)
|
||||
media.change(DARK_QUERY, false)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
})
|
||||
|
||||
it('stays light without matchMedia', () => {
|
||||
document.documentElement.classList.add('dark')
|
||||
const win = { document } as unknown as Window
|
||||
const stop = applyColorScheme(win)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
expect(stop()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
53
admin/tests/app/winterUrl.test.ts
Normal file
53
admin/tests/app/winterUrl.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapWinterUrl } from '../../src/app/winterUrl'
|
||||
|
||||
const ID = 'acme.demo.widgets'
|
||||
const LIST = '/acme/demo/widgets'
|
||||
|
||||
describe('mapWinterUrl', () => {
|
||||
it('maps an empty, null or missing URL to the list', () => {
|
||||
expect(mapWinterUrl('', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl(null, ID)).toBe(LIST)
|
||||
expect(mapWinterUrl(undefined, ID)).toBe(LIST)
|
||||
expect(mapWinterUrl(' ', ID)).toBe(LIST)
|
||||
})
|
||||
|
||||
it('maps the controller root and create', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('/acme/demo/widgets/', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/create', ID)).toBe(`${LIST}/create`)
|
||||
})
|
||||
|
||||
it('substitutes :id in update/:id and accepts a literal numeric id', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, 42)).toBe(`${LIST}/42`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, '7')).toBe(`${LIST}/7`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/9', ID)).toBe(`${LIST}/9`)
|
||||
})
|
||||
|
||||
it('falls back to the list when update has no usable id', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, null)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, 'abc')).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/1e3', ID)).toBe(LIST)
|
||||
})
|
||||
|
||||
it('never leaves the current controller for a foreign or absolute URL (T-10-20)', () => {
|
||||
expect(mapWinterUrl('acme/demo/gadgets/update/:id', ID, 1)).toBe(LIST)
|
||||
expect(mapWinterUrl('https://evil.example/acme/demo/widgets/create', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('//evil.example/x', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('javascript:alert(1)', ID)).toBe(LIST)
|
||||
})
|
||||
|
||||
it('ignores the query and hash, matches the owner case-insensitively and rejects unknown actions', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/create?x=1#top', ID)).toBe(`${LIST}/create`)
|
||||
expect(mapWinterUrl('Acme/Demo/Widgets/update/:id', ID, 3)).toBe(`${LIST}/3`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/3', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/3/extra', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/CREATE', ID)).toBe(LIST)
|
||||
})
|
||||
|
||||
it('answers the root for a malformed controller id', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets', 'acme.demo')).toBe('/')
|
||||
expect(mapWinterUrl('acme/demo/widgets', 'acme.demo.wid gets')).toBe('/')
|
||||
})
|
||||
})
|
||||
74
admin/tests/fixtures/typed.ts
vendored
Normal file
74
admin/tests/fixtures/typed.ts
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
// Typed views of the JSON fixtures. Each fixture is assigned to the generated
|
||||
// OpenAPI schema type of the response it stands for, so a change to the
|
||||
// admin document that the fixtures no longer match fails `npm run typecheck`
|
||||
// instead of letting the tests drift from the wire (D-15, D-16). Fixtures are
|
||||
// neutral acme.demo.* data; no application names appear in framework tests.
|
||||
import type { components } from '../../src/api/schema'
|
||||
import langJson from './lang.json'
|
||||
import navigationJson from './navigation.json'
|
||||
import settingsJson from './settings.json'
|
||||
import formSchemaJson from './widgets.form-schema.json'
|
||||
import listJson from './widgets.list.json'
|
||||
import listSchemaJson from './widgets.list-schema.json'
|
||||
import optionsJson from './widgets.options.json'
|
||||
import recordJson from './widgets.record.json'
|
||||
import candidatesJson from './widgets.relation-candidates.json'
|
||||
import linkedJson from './widgets.relation-linked.json'
|
||||
import relationSchemaJson from './widgets.relation-schema.json'
|
||||
|
||||
type S = components['schemas']
|
||||
type Rows = S['cabana.ListEnvelope-array_cabana_AdminRecord']
|
||||
|
||||
export const langFixture: S['cabana.Envelope-cabana_LangBundle'] = langJson
|
||||
export const navigationFixture: S['cabana.Envelope-array_cabana_NavigationEntry'] = navigationJson
|
||||
export const settingsFixture: {
|
||||
list: S['cabana.Envelope-array_cabana_SettingsEntry']
|
||||
schema: S['cabana.Envelope-cabana_FormView']
|
||||
values: S['cabana.Envelope-cabana_SettingsResult']
|
||||
} = settingsJson
|
||||
export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson
|
||||
export const listFixture: Rows = listJson
|
||||
export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
|
||||
export const optionsFixture: {
|
||||
maker: S['cabana.Envelope-array_cabana_FilterOption']
|
||||
makerField: S['cabana.ListEnvelope-array_cabana_RelationOption']
|
||||
makerFieldPage2: S['cabana.ListEnvelope-array_cabana_RelationOption']
|
||||
makerFieldSearch: S['cabana.ListEnvelope-array_cabana_RelationOption']
|
||||
tagsField: S['cabana.ListEnvelope-array_cabana_RelationOption']
|
||||
} = optionsJson
|
||||
export const recordFixture: S['cabana.RecordEnvelope'] = recordJson
|
||||
export const relationSchemaFixture: S['cabana.Envelope-cabana_RelationSchema'] = relationSchemaJson
|
||||
export const linkedFixture: { all: Rows; search: Rows; empty: Rows } = linkedJson
|
||||
export const candidatesFixture: { page1: Rows; page2: Rows; search: Rows; empty: Rows } = candidatesJson
|
||||
|
||||
/** A deep copy, so a test can reshape a fixture without touching the others. */
|
||||
export function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
|
||||
/** The form schema's field by name; throws when the fixture lost it. */
|
||||
export function formField(name: string): S['cabana.FormField'] {
|
||||
const field = formSchemaFixture.data.fields.find((item) => item.name === name)
|
||||
if (!field) {
|
||||
throw new Error(`fixture field ${name} missing`)
|
||||
}
|
||||
return clone(field)
|
||||
}
|
||||
|
||||
/** The list schema's column by key; throws when the fixture lost it. */
|
||||
export function listColumn(key: string): S['cabana.ListColumn'] {
|
||||
const column = listSchemaFixture.data.columns.find((item) => item.key === key)
|
||||
if (!column) {
|
||||
throw new Error(`fixture column ${key} missing`)
|
||||
}
|
||||
return clone(column)
|
||||
}
|
||||
|
||||
/** The list schema's filter by name; throws when the fixture lost it. */
|
||||
export function listFilter(name: string): S['cabana.ListFilter'] {
|
||||
const filter = listSchemaFixture.data.filters.find((item) => item.name === name)
|
||||
if (!filter) {
|
||||
throw new Error(`fixture filter ${name} missing`)
|
||||
}
|
||||
return clone(filter)
|
||||
}
|
||||
92
admin/tests/form/FormField.test.ts
Normal file
92
admin/tests/form/FormField.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import FieldRenderer from '../../src/components/form/FieldRenderer.vue'
|
||||
import FormErrorBanner from '../../src/components/form/FormErrorBanner.vue'
|
||||
import FormField from '../../src/components/form/FormField.vue'
|
||||
import { formField } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('form field', () => {
|
||||
it('links the label to the control and hides the required asterisk from assistive tech', () => {
|
||||
const wrapper = mount(FormField, { props: { field: formField('name'), modelValue: 'Blue', idPrefix: 'f' } })
|
||||
const label = wrapper.find('label')
|
||||
expect(label.attributes('for')).toBe('f-name')
|
||||
expect(label.text()).toBe('Name *')
|
||||
expect(label.find('[aria-hidden="true"]').text()).toBe('*')
|
||||
const input = wrapper.find('#f-name')
|
||||
expect(input.attributes('aria-required')).toBe('true')
|
||||
expect(input.attributes('aria-invalid')).toBeUndefined()
|
||||
expect(input.attributes('aria-describedby')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('describes the control by its comment and, when invalid, by its error line', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: { field: formField('code'), modelValue: '', errors: ['Taken', 'Too short'], idPrefix: 'f' },
|
||||
})
|
||||
const input = wrapper.find('#f-code')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(input.attributes('aria-describedby')).toBe('f-code-comment f-code-error')
|
||||
expect(wrapper.find('#f-code-comment').text()).toBe('Unique widget code')
|
||||
expect(wrapper.findAll('#f-code-error p').map((line) => line.text())).toEqual(['Taken', 'Too short'])
|
||||
})
|
||||
|
||||
it('uses the field name without a label and the default id prefix', () => {
|
||||
const field = { ...formField('code'), label: '', comment: undefined }
|
||||
const wrapper = mount(FormField, { props: { field, modelValue: '' } })
|
||||
expect(wrapper.find('label').text()).toBe('code')
|
||||
expect(wrapper.find('label').attributes('for')).toBe('field-code')
|
||||
})
|
||||
|
||||
it('lets a toggle card render its own label and still points at its comment', () => {
|
||||
const wrapper = mount(FormField, { props: { field: formField('active'), modelValue: false, errors: ['Bad'], idPrefix: 'f' } })
|
||||
expect(wrapper.findAll('label')).toHaveLength(1)
|
||||
expect(wrapper.find('label').attributes('for')).toBe('f-active')
|
||||
expect(wrapper.find('[role="switch"]').attributes('aria-describedby')).toBe('f-active-comment f-active-error')
|
||||
})
|
||||
|
||||
it('forwards value updates from the control', async () => {
|
||||
const wrapper = mount(FormField, { props: { field: formField('name'), modelValue: '', idPrefix: 'f' } })
|
||||
await wrapper.find('input').setValue('New')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['New']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('field renderer', () => {
|
||||
it('picks the control by type and forwards the shared props and updates', async () => {
|
||||
const wrapper = mount(FieldRenderer, {
|
||||
props: { field: formField('weight'), modelValue: 3, controlId: 'x', invalid: true, describedBy: 'x-error' },
|
||||
})
|
||||
const input = wrapper.find('input[type="number"]')
|
||||
expect(input.attributes('id')).toBe('x')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(input.attributes('aria-describedby')).toBe('x-error')
|
||||
await input.setValue('7')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[7]])
|
||||
})
|
||||
|
||||
it('renders the unsupported box for an unknown type', () => {
|
||||
const wrapper = mount(FieldRenderer, { props: { field: formField('color'), modelValue: '#fff', controlId: 'c' } })
|
||||
expect(wrapper.find('[data-unsupported-field]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('form error banner (D-09)', () => {
|
||||
it('is an alert with the plural field count that lists messages of non-field keys', () => {
|
||||
const wrapper = mount(FormErrorBanner, {
|
||||
props: { errors: { name: ['Required'], code: ['Taken'], _form: ['Record is locked'] }, fieldNames: ['name', 'code'] },
|
||||
})
|
||||
const banner = wrapper.find('[role="alert"]')
|
||||
expect(banner.text()).toContain('Nie udało się zapisać.')
|
||||
expect(banner.text()).toContain('Popraw 3 pola oznaczone poniżej.')
|
||||
expect(banner.findAll('p').at(-1)!.text()).toBe('Record is locked')
|
||||
})
|
||||
|
||||
it('renders nothing without errors', () => {
|
||||
const wrapper = mount(FormErrorBanner, { props: { errors: {}, fieldNames: ['name'] } })
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
52
admin/tests/form/FormGrid.test.ts
Normal file
52
admin/tests/form/FormGrid.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import FormGrid from '../../src/components/form/FormGrid.vue'
|
||||
import { formField } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('form grid', () => {
|
||||
it('places fields by span: left, right, full by default, auto and row in the next slot', () => {
|
||||
const fields = [
|
||||
formField('name'),
|
||||
formField('code'),
|
||||
formField('notes'),
|
||||
{ ...formField('weight'), span: 'auto' },
|
||||
{ ...formField('size'), span: 'row' },
|
||||
{ ...formField('serial'), span: undefined },
|
||||
formField('members'),
|
||||
]
|
||||
const wrapper = mount(FormGrid, { props: { fields, values: {}, errors: {}, source: null, idPrefix: 'grid' } })
|
||||
const classes = (name: string) => wrapper.find(`[data-field="${name}"]`).classes()
|
||||
expect(classes('name')).toContain('min-[600px]:col-start-1')
|
||||
expect(classes('code')).toContain('min-[600px]:col-start-2')
|
||||
expect(classes('notes')).toContain('col-span-full')
|
||||
expect(classes('weight')).not.toContain('col-span-full')
|
||||
expect(classes('size')).not.toContain('col-span-full')
|
||||
expect(classes('serial')).toContain('col-span-full')
|
||||
// The relation manager always takes the whole row.
|
||||
expect(classes('members')).toContain('col-span-full')
|
||||
})
|
||||
|
||||
it('passes values, errors and labels down and emits updates by field name', async () => {
|
||||
const wrapper = mount(FormGrid, {
|
||||
props: {
|
||||
fields: [formField('name'), formField('owner')],
|
||||
values: { name: 'Blue', owner: 5 },
|
||||
errors: { name: ['Required'] },
|
||||
labels: { owner: [{ value: 5, label: 'dana@example.test' }] },
|
||||
source: null,
|
||||
idPrefix: 'grid',
|
||||
},
|
||||
})
|
||||
const input = wrapper.find('#grid-name')
|
||||
expect((input.element as HTMLInputElement).value).toBe('Blue')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.find('#grid-owner').text()).toBe('dana@example.test')
|
||||
await input.setValue('Red')
|
||||
expect(wrapper.emitted('update')).toEqual([['name', 'Red']])
|
||||
})
|
||||
})
|
||||
61
admin/tests/form/FormTabs.test.ts
Normal file
61
admin/tests/form/FormTabs.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import FormTabs from '../../src/components/form/FormTabs.vue'
|
||||
import type { TabItem } from '../../src/components/form/formState'
|
||||
import { keydown, resetState } from '../helpers'
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ key: 'default', label: 'Podstawowe', errors: 0 },
|
||||
{ key: 'tab:Details', label: 'Details', errors: 2 },
|
||||
{ key: 'tab:Members', label: 'Members', errors: 1 },
|
||||
]
|
||||
|
||||
function mountTabs(modelValue = 'default') {
|
||||
return mount(FormTabs, { props: { tabs, modelValue, idPrefix: 'f' }, attachTo: document.body })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('form tabs', () => {
|
||||
it('is a tablist of tabs with roving tabindex, aria-selected and aria-controls', () => {
|
||||
const wrapper = mountTabs('tab:Details')
|
||||
expect(wrapper.find('[role="tablist"]').exists()).toBe(true)
|
||||
const items = wrapper.findAll('[role="tab"]')
|
||||
expect(items.map((tab) => tab.attributes('aria-selected'))).toEqual(['false', 'true', 'false'])
|
||||
expect(items.map((tab) => tab.attributes('tabindex'))).toEqual(['-1', '0', '-1'])
|
||||
expect(items.map((tab) => tab.attributes('id'))).toEqual(['f-tab-0', 'f-tab-1', 'f-tab-2'])
|
||||
expect(items.map((tab) => tab.attributes('aria-controls'))).toEqual(['f-panel-0', 'f-panel-1', 'f-panel-2'])
|
||||
})
|
||||
|
||||
it('badges tabs with invalid fields with a plural label', () => {
|
||||
const wrapper = mountTabs()
|
||||
const badges = wrapper.findAll('[data-tab-errors]')
|
||||
expect(badges.map((badge) => [badge.text(), badge.attributes('aria-label')])).toEqual([
|
||||
['2', '2 błędy'],
|
||||
['1', '1 błąd'],
|
||||
])
|
||||
})
|
||||
|
||||
it('selects on click and moves with the arrow keys, wrapping and moving focus', async () => {
|
||||
const wrapper = mountTabs()
|
||||
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['tab:Members'])
|
||||
|
||||
const first = wrapper.findAll('[role="tab"]')[0]!.element
|
||||
expect(keydown(first, 'ArrowLeft').defaultPrevented).toBe(true)
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['tab:Members'])
|
||||
expect(document.activeElement?.id).toBe('f-tab-2')
|
||||
keydown(first, 'ArrowRight')
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['tab:Details'])
|
||||
expect(document.activeElement?.id).toBe('f-tab-1')
|
||||
expect(keydown(first, 'Enter').defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
149
admin/tests/form/FormView.test.ts
Normal file
149
admin/tests/form/FormView.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import FormView from '../../src/views/FormView.vue'
|
||||
import { recordCrumb } from '../../src/state/useBreadcrumbs'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, formSchemaFixture, recordFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const RECORD = `${BASE}/1`
|
||||
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: formSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: recordFixture },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A form schema of plain, untabbed fields. */
|
||||
function flatSchema() {
|
||||
const envelope = clone(formSchemaFixture)
|
||||
envelope.data.fields = envelope.data.fields.filter((field) => ['name', 'code', 'weight'].includes(field.name))
|
||||
return { body: envelope }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('form view', () => {
|
||||
it('is the component of the record and create routes', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
||||
expect(wrapper.findComponent(FormView).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('titles a record by its first text field, with the update message as subtitle, and publishes the crumb', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
||||
expect(wrapper.find('h1').text()).toBe('Blue widget')
|
||||
expect(wrapper.find('header p').text()).toBe('Editing widget')
|
||||
expect(recordCrumb.value).toBe('Blue widget')
|
||||
wrapper.unmount()
|
||||
expect(recordCrumb.value).toBe('')
|
||||
})
|
||||
|
||||
it('titles a new record by the create message and offers no delete', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/create', routes())
|
||||
expect(wrapper.find('h1').text()).toBe('New widget')
|
||||
expect(wrapper.find('header p').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
|
||||
expect(recordCrumb.value).toBe('New widget')
|
||||
})
|
||||
|
||||
it('makes the form the tab panel of the active tab', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
||||
const form = wrapper.find('form')
|
||||
expect(form.attributes('role')).toBe('tabpanel')
|
||||
expect(form.attributes('id')).toBe('field-panel-0')
|
||||
expect(form.attributes('aria-labelledby')).toBe('field-tab-0')
|
||||
expect(wrapper.find('#field-tab-0').attributes('aria-controls')).toBe('field-panel-0')
|
||||
})
|
||||
|
||||
it('has no tabs and no tab panel role for an untabbed form', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema() }))
|
||||
expect(wrapper.find('[role="tablist"]').exists()).toBe(false)
|
||||
expect(wrapper.find('form').attributes('role')).toBeUndefined()
|
||||
expect(wrapper.findAll('[data-field]').map((field) => field.attributes('data-field'))).toEqual(['name', 'code', 'weight'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the record', { [`GET ${RECORD}`]: failure(404, 'Not found') }],
|
||||
['the schema', { [`GET ${BASE}/schema/form`]: failure(500, 'Boom') }],
|
||||
])('announces a failed load of %s and disables saving', async (_label, override) => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes(override))
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać formularza.')
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
if (_label === 'the schema') {
|
||||
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('toasts the generic error when the save cannot reach the server and re-enables saving', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
routes({
|
||||
[`PUT ${RECORD}`]: () => {
|
||||
throw new TypeError('offline')
|
||||
},
|
||||
}),
|
||||
)
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
|
||||
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.find('[data-action="save"]').text()).toBe('Zapisz')
|
||||
})
|
||||
|
||||
it('toasts the server message when a delete is refused and stays on the record', async () => {
|
||||
const { wrapper, router } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
routes({ [`DELETE ${RECORD}`]: failure(409, 'Widget is in use') }),
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-action="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Widget is in use', tone: 'danger' })
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1')
|
||||
})
|
||||
|
||||
it('keeps a create on the form when the save fails', async () => {
|
||||
const { wrapper, router } = await mountApp(
|
||||
'/acme/demo/widgets/create',
|
||||
routes({ [`POST ${BASE}`]: failure(500, 'Boom') }),
|
||||
)
|
||||
await wrapper.find('#field-name').setValue('New one')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/create')
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Boom', tone: 'danger' })
|
||||
})
|
||||
|
||||
it('follows the default redirect when the form declares no create redirect', async () => {
|
||||
const envelope = clone(formSchemaFixture)
|
||||
envelope.data.redirects.create.redirect = ''
|
||||
envelope.data.redirects.default = 'acme/demo/widgets/update/:id'
|
||||
const { wrapper, router } = await mountApp(
|
||||
'/acme/demo/widgets/create',
|
||||
routes({
|
||||
[`GET ${BASE}/schema/form`]: { body: envelope },
|
||||
[`POST ${BASE}`]: { status: 201, body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
|
||||
[`GET ${BASE}/5`]: { body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
|
||||
}),
|
||||
)
|
||||
await wrapper.find('#field-name').setValue('X')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/5')
|
||||
})
|
||||
})
|
||||
243
admin/tests/form/RelationField.test.ts
Normal file
243
admin/tests/form/RelationField.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import RelationField from '../../src/components/form/fields/RelationField.vue'
|
||||
import { formField, optionsFixture, recordFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
|
||||
|
||||
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
||||
const MAKER = `${API}/acme/demo/widgets/fields/maker/options`
|
||||
const TAGS = `${API}/acme/demo/widgets/fields/tags/options`
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${MAKER}`]: (request) => {
|
||||
const query = queryOf(request)
|
||||
if (query.get('search') === 'acme') {
|
||||
return { body: optionsFixture.makerFieldSearch }
|
||||
}
|
||||
return { body: query.get('page') === '2' ? optionsFixture.makerFieldPage2 : optionsFixture.makerField }
|
||||
},
|
||||
[`GET ${TAGS}`]: { body: optionsFixture.tagsField },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountField(name: string, modelValue: unknown, extra: Record<string, unknown> = {}) {
|
||||
return mount(RelationField, {
|
||||
props: {
|
||||
field: formField(name),
|
||||
modelValue,
|
||||
controlId: `r-${name}`,
|
||||
labels: recordFixture.meta.labels[name],
|
||||
source,
|
||||
...extra,
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
const combobox = (wrapper: VueWrapper) => wrapper.find('[role="combobox"]')
|
||||
const optionTexts = (wrapper: VueWrapper) => wrapper.findAll('[role="option"]').map((option) => option.text())
|
||||
|
||||
async function openList(wrapper: VueWrapper) {
|
||||
await combobox(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('read-only relation (D-26)', () => {
|
||||
it('shows only the label from meta.labels, with no control', () => {
|
||||
const wrapper = mountField('owner', 5, { describedBy: 'r-owner-comment' })
|
||||
const box = wrapper.find('[data-relation-readonly]')
|
||||
expect(box.text()).toBe('dana@example.test')
|
||||
expect(box.attributes('id')).toBe('r-owner')
|
||||
expect(box.attributes('aria-describedby')).toBe('r-owner-comment')
|
||||
expect(box.classes()).toContain('text-text')
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the emptyOption, then a muted dash', () => {
|
||||
const withEmpty = mountField('owner', null, { labels: [], field: { ...formField('owner'), emptyOption: 'Nobody' } })
|
||||
expect(withEmpty.find('[data-relation-readonly]').text()).toBe('Nobody')
|
||||
const bare = mountField('owner', null, { labels: undefined, field: { ...formField('owner'), emptyOption: undefined } })
|
||||
expect(bare.find('[data-relation-readonly]').text()).toBe('—')
|
||||
expect(bare.find('[data-relation-readonly]').classes()).toContain('text-muted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('single relation (D-17)', () => {
|
||||
it('is a collapsed combobox showing the current label', () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('maker', 2)
|
||||
const input = combobox(wrapper)
|
||||
expect(input.attributes('aria-expanded')).toBe('false')
|
||||
expect(input.attributes('aria-controls')).toBe('r-maker-listbox')
|
||||
expect(input.attributes('aria-autocomplete')).toBe('list')
|
||||
expect((input.element as HTMLInputElement).value).toBe('Bolt & Co')
|
||||
expect(wrapper.find('[role="listbox"]').attributes('id')).toBe('r-maker-listbox')
|
||||
})
|
||||
|
||||
it('loads the first page of 20 options on open, emptyOption first and muted', async () => {
|
||||
const calls = mockApi(routes())
|
||||
const wrapper = mountField('maker', 2)
|
||||
await openList(wrapper)
|
||||
const [request] = requestsTo(calls, 'GET', MAKER)
|
||||
expect(queryOf(request!).get('page')).toBe('1')
|
||||
expect(queryOf(request!).get('per_page')).toBe('20')
|
||||
expect(queryOf(request!).has('search')).toBe(false)
|
||||
expect(combobox(wrapper).attributes('aria-expanded')).toBe('true')
|
||||
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works', 'Bolt & Co'])
|
||||
const options = wrapper.findAll('[role="option"]')
|
||||
expect(options[0]!.attributes('data-value')).toBe('empty')
|
||||
expect(options[0]!.classes()).toContain('text-muted')
|
||||
expect(options.map((option) => option.attributes('aria-selected'))).toEqual(['false', 'false', 'true'])
|
||||
})
|
||||
|
||||
it('chooses an id or null and closes', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('maker', 2)
|
||||
await openList(wrapper)
|
||||
await wrapper.findAll('[role="option"]')[1]!.trigger('mousedown')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
|
||||
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
|
||||
await openList(wrapper)
|
||||
await wrapper.findAll('[role="option"]')[0]!.trigger('mousedown')
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('moves the active option with the arrows, exposes it as aria-activedescendant and picks it with Enter', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('maker', null)
|
||||
const input = combobox(wrapper).element
|
||||
expect(keydown(input, 'ArrowDown').defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(combobox(wrapper).attributes('aria-activedescendant')).toBeUndefined()
|
||||
keydown(input, 'ArrowDown')
|
||||
keydown(input, 'ArrowDown')
|
||||
keydown(input, 'ArrowDown')
|
||||
keydown(input, 'ArrowDown')
|
||||
await flushPromises()
|
||||
expect(combobox(wrapper).attributes('aria-activedescendant')).toBe('r-maker-listbox-2')
|
||||
keydown(input, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(combobox(wrapper).attributes('aria-activedescendant')).toBe('r-maker-listbox-1')
|
||||
expect(keydown(input, 'Enter').defaultPrevented).toBe(true)
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
|
||||
})
|
||||
|
||||
it('closes on Escape and blur, and ignores Enter and Escape while closed', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('maker', 2)
|
||||
const input = combobox(wrapper).element
|
||||
expect(keydown(input, 'Enter').defaultPrevented).toBe(false)
|
||||
expect(keydown(input, 'Escape').defaultPrevented).toBe(false)
|
||||
await openList(wrapper)
|
||||
expect(keydown(input, 'Escape').defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
|
||||
await openList(wrapper)
|
||||
await combobox(wrapper).trigger('blur')
|
||||
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('searches after a 300 ms debounce and loads more on demand', async () => {
|
||||
const calls = mockApi(routes())
|
||||
const wrapper = mountField('maker', null)
|
||||
await openList(wrapper)
|
||||
expect(wrapper.find('[data-action="more-options"]').exists()).toBe(true)
|
||||
await wrapper.find('[data-action="more-options"]').trigger('mousedown')
|
||||
await flushPromises()
|
||||
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works', 'Bolt & Co', 'Cog Ltd'])
|
||||
expect(queryOf(requestsTo(calls, 'GET', MAKER).at(-1)!).get('page')).toBe('2')
|
||||
expect(wrapper.find('[data-action="more-options"]').exists()).toBe(false)
|
||||
|
||||
await combobox(wrapper).setValue('acme')
|
||||
await wait(150)
|
||||
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(2)
|
||||
await wait(200)
|
||||
await flushPromises()
|
||||
expect(queryOf(requestsTo(calls, 'GET', MAKER).at(-1)!).get('search')).toBe('acme')
|
||||
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works'])
|
||||
})
|
||||
|
||||
it('loads more when the list is scrolled to its end', async () => {
|
||||
const calls = mockApi(routes())
|
||||
const wrapper = mountField('maker', null)
|
||||
await openList(wrapper)
|
||||
const list = wrapper.find('[role="listbox"]').element as HTMLElement
|
||||
Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 400 })
|
||||
Object.defineProperty(list, 'clientHeight', { configurable: true, value: 256 })
|
||||
list.scrollTop = 10
|
||||
list.dispatchEvent(new Event('scroll'))
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(1)
|
||||
list.scrollTop = 140
|
||||
list.dispatchEvent(new Event('scroll'))
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows "no options" for an empty or failed options load', async () => {
|
||||
mockApi(routes({ [`GET ${MAKER}`]: { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } } }))
|
||||
const wrapper = mountField('maker', null)
|
||||
await openList(wrapper)
|
||||
expect(optionTexts(wrapper)).toEqual(['— none —'])
|
||||
expect(wrapper.find('[role="listbox"]').text()).toContain('Brak wyników')
|
||||
})
|
||||
|
||||
it('never requests options without a source controller', async () => {
|
||||
const calls = mockApi(routes())
|
||||
const wrapper = mountField('maker', null, { source: null })
|
||||
await openList(wrapper)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple relation (D-18)', () => {
|
||||
it('shows ordered chips with initials and labelled remove buttons', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('tags', [3, 1])
|
||||
const chips = wrapper.findAll('[data-chip]')
|
||||
expect(chips.map((chip) => chip.find('.font-semibold').text())).toEqual(['Round', 'Small'])
|
||||
expect(chips[0]!.find('[aria-hidden="true"]').text()).toBe('R')
|
||||
expect(chips.map((chip) => chip.find('button').attributes('aria-label'))).toEqual(['Usuń: Round', 'Usuń: Small'])
|
||||
await chips[0]!.find('button').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[[1]]])
|
||||
})
|
||||
|
||||
it('offers only unselected options and appends the chosen id', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('tags', [3, 1])
|
||||
await openList(wrapper)
|
||||
expect(optionTexts(wrapper)).toEqual(['Large'])
|
||||
expect(wrapper.find('[role="option"]').attributes('aria-selected')).toBe('false')
|
||||
await wrapper.find('[role="option"]').trigger('mousedown')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[[3, 1, 4]]])
|
||||
})
|
||||
|
||||
it('removes the last chip on Backspace with an empty query', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('tags', [3, '1', 'x'])
|
||||
keydown(combobox(wrapper).element, 'Backspace')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[[3]]])
|
||||
const empty = mountField('tags', [])
|
||||
keydown(combobox(empty).element, 'Backspace')
|
||||
expect(empty.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('labels an unknown id with the id itself', () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('tags', [99], { labels: [] })
|
||||
expect(wrapper.find('[data-chip] .font-semibold').text()).toBe('99')
|
||||
})
|
||||
})
|
||||
111
admin/tests/form/Settings.test.ts
Normal file
111
admin/tests/form/Settings.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import SettingsFormView from '../../src/views/SettingsFormView.vue'
|
||||
import SettingsIndexView from '../../src/views/SettingsIndexView.vue'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { settingsFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const PAGE = `${API}/settings/search`
|
||||
const failure = (status: number, message: string, details: Record<string, unknown> = {}) => ({
|
||||
status,
|
||||
body: { error: { code: 'x', message, details } },
|
||||
})
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${API}/settings`]: { body: settingsFixture.list },
|
||||
[`GET ${PAGE}/schema`]: { body: settingsFixture.schema },
|
||||
[`GET ${PAGE}`]: { body: settingsFixture.values },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function openPage(overrides: Record<string, Route> = {}) {
|
||||
setSettings(settingsFixture.list.data)
|
||||
return mountApp('/settings/search', routes(overrides))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('settings index (D-21)', () => {
|
||||
it('says so when no settings page is permitted', async () => {
|
||||
const { wrapper } = await mountApp('/settings', routes())
|
||||
expect(wrapper.findComponent(SettingsIndexView).exists()).toBe(true)
|
||||
expect(wrapper.find('h1').text()).toBe('Ustawienia')
|
||||
expect(wrapper.find('section > p').text()).toBe('Brak dostępnych ustawień.')
|
||||
})
|
||||
|
||||
it('links every page by encoded code with its label and description', async () => {
|
||||
setSettings([{ ...settingsFixture.list.data[0]!, code: 'a b', category: '', description: '' }])
|
||||
const { wrapper } = await mountApp('/settings', routes())
|
||||
const link = wrapper.find('[data-settings="a b"]')
|
||||
expect(link.attributes('href')).toBe('/settings/a%20b')
|
||||
expect(link.text()).toBe('Search')
|
||||
expect(wrapper.find('[data-settings-group] h2').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings form (D-21)', () => {
|
||||
it('titles the page from the entry and links back to the index', async () => {
|
||||
const { wrapper } = await openPage()
|
||||
expect(wrapper.findComponent(SettingsFormView).exists()).toBe(true)
|
||||
expect(wrapper.find('h1').text()).toBe('Search')
|
||||
expect(wrapper.find('header p').text()).toBe('Search engine options')
|
||||
expect(wrapper.find('[data-action="back"]').attributes('href')).toBe('/settings')
|
||||
expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do ustawień')
|
||||
})
|
||||
|
||||
it('falls back to the schema name, then the code, without an entry', async () => {
|
||||
const { wrapper } = await mountApp('/settings/search', routes())
|
||||
expect(wrapper.find('h1').text()).toBe('Search')
|
||||
expect(wrapper.find('header p').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('announces a failed load and disables saving', async () => {
|
||||
const { wrapper } = await openPage({ [`GET ${PAGE}`]: failure(500, 'Boom') })
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać formularza.')
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts the server message for a refused save and the generic one for a network failure', async () => {
|
||||
const { wrapper } = await openPage({ [`PUT ${PAGE}`]: failure(403, 'Not allowed') })
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Not allowed', tone: 'danger' })
|
||||
|
||||
wrapper.unmount()
|
||||
const offline = await openPage({
|
||||
[`PUT ${PAGE}`]: () => {
|
||||
throw new TypeError('offline')
|
||||
},
|
||||
})
|
||||
await offline.wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
|
||||
expect(offline.wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps a 422 onto fields, lists unknown keys in the banner, focuses the field and clears it on change', async () => {
|
||||
const { wrapper, calls } = await openPage({
|
||||
[`PUT ${PAGE}`]: failure(422, 'Invalid', { engine_host: ['Unreachable'], other: ['Global problem'] }),
|
||||
})
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', PAGE)).toHaveLength(1)
|
||||
const input = wrapper.find('#setting-engine_host')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.find('[data-error-banner]').text()).toContain('Global problem')
|
||||
await input.setValue('ok.local')
|
||||
expect(wrapper.find('#setting-engine_host').attributes('aria-invalid')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
215
admin/tests/form/fields.test.ts
Normal file
215
admin/tests/form/fields.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import CheckboxField from '../../src/components/form/fields/CheckboxField.vue'
|
||||
import DropdownField from '../../src/components/form/fields/DropdownField.vue'
|
||||
import NumberField from '../../src/components/form/fields/NumberField.vue'
|
||||
import SwitchField from '../../src/components/form/fields/SwitchField.vue'
|
||||
import TextareaField from '../../src/components/form/fields/TextareaField.vue'
|
||||
import TextField from '../../src/components/form/fields/TextField.vue'
|
||||
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import { formField } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
const common = { controlId: 'c', describedBy: 'c-comment' }
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('text field', () => {
|
||||
it('is a required-aware text input that emits its value', async () => {
|
||||
const field = { ...formField('name'), attributes: { placeholder: 'Type', onclick: 'x' } }
|
||||
const wrapper = mount(TextField, { props: { field, modelValue: 'Blue', invalid: true, ...common } })
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes()).toMatchObject({
|
||||
id: 'c',
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
'aria-required': 'true',
|
||||
'aria-invalid': 'true',
|
||||
'aria-describedby': 'c-comment',
|
||||
placeholder: 'Type',
|
||||
})
|
||||
expect(input.attributes('required')).toBeDefined()
|
||||
expect(input.attributes('onclick')).toBeUndefined()
|
||||
expect(input.classes()).toContain('border-danger')
|
||||
await input.setValue('Red')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['Red']])
|
||||
})
|
||||
|
||||
it('shows an empty input for null and stringifies numbers', () => {
|
||||
const optional = formField('code')
|
||||
expect((mount(TextField, { props: { field: optional, modelValue: null, controlId: 'c' } }).find('input').element as HTMLInputElement).value).toBe('')
|
||||
const input = mount(TextField, { props: { field: optional, modelValue: 12, controlId: 'c' } }).find('input')
|
||||
expect((input.element as HTMLInputElement).value).toBe('12')
|
||||
expect(input.attributes('aria-required')).toBeUndefined()
|
||||
expect(input.attributes('required')).toBeUndefined()
|
||||
expect(input.attributes('aria-describedby')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('textarea field', () => {
|
||||
it.each([
|
||||
['tiny', '2'],
|
||||
['small', '4'],
|
||||
['large', '8'],
|
||||
['huge', '12'],
|
||||
['giant', '18'],
|
||||
[undefined, '5'],
|
||||
['odd', '5'],
|
||||
])('maps size %s to %s rows', (size, rows) => {
|
||||
const field: FormField = { ...formField('notes'), size }
|
||||
expect(mount(TextareaField, { props: { field, modelValue: 'x', controlId: 't' } }).find('textarea').attributes('rows')).toBe(rows)
|
||||
})
|
||||
|
||||
it('emits its text', async () => {
|
||||
const wrapper = mount(TextareaField, { props: { field: formField('notes'), modelValue: undefined, controlId: 't' } })
|
||||
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('')
|
||||
await wrapper.find('textarea').setValue('Long text')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['Long text']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('number field', () => {
|
||||
it('emits numbers and null for an empty input', async () => {
|
||||
const wrapper = mount(NumberField, { props: { field: formField('weight'), modelValue: 12, controlId: 'n' } })
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('type')).toBe('number')
|
||||
expect(input.attributes('inputmode')).toBe('decimal')
|
||||
expect((input.element as HTMLInputElement).value).toBe('12')
|
||||
await input.setValue('3.5')
|
||||
await input.setValue('')
|
||||
await input.setValue(' ')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[3.5], [null], [null]])
|
||||
})
|
||||
|
||||
it('emits null for a value that is not a finite number', () => {
|
||||
const wrapper = mount(NumberField, { props: { field: formField('weight'), modelValue: null, controlId: 'n' } })
|
||||
const input = wrapper.find('input').element as HTMLInputElement
|
||||
Object.defineProperty(input, 'value', { configurable: true, get: () => 'Infinity' })
|
||||
input.dispatchEvent(new Event('input'))
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropdown field', () => {
|
||||
const typed: FormField = {
|
||||
...formField('size'),
|
||||
options: [
|
||||
{ value: 's', label: 'Small' },
|
||||
{ value: 2, label: 'Two' },
|
||||
{ value: true, label: 'Yes' },
|
||||
],
|
||||
}
|
||||
|
||||
it('offers the emptyOption first and sends null for it', async () => {
|
||||
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: null, controlId: 'd' } })
|
||||
const select = wrapper.find('select')
|
||||
expect(select.findAll('option').map((option) => option.text())).toEqual(['— none —', 'Small', 'Two', 'Yes'])
|
||||
expect((select.element as HTMLSelectElement).value).toBe('empty')
|
||||
expect(select.classes()).toContain('text-muted')
|
||||
await select.setValue('empty')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
|
||||
})
|
||||
|
||||
it('keeps the JSON type of the chosen option value', async () => {
|
||||
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: 's', controlId: 'd' } })
|
||||
const select = wrapper.find('select')
|
||||
expect((select.element as HTMLSelectElement).value).toBe('0')
|
||||
expect(select.classes()).not.toContain('text-muted')
|
||||
await select.setValue('1')
|
||||
await select.setValue('2')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[2], [true]])
|
||||
})
|
||||
|
||||
it('matches a stored value by its string form', () => {
|
||||
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: '2', controlId: 'd' } })
|
||||
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('1')
|
||||
})
|
||||
|
||||
it('shows a disabled placeholder without an emptyOption or a matching value', () => {
|
||||
const field: FormField = { ...typed, emptyOption: undefined, required: true }
|
||||
const wrapper = mount(DropdownField, { props: { field, modelValue: 'unknown', controlId: 'd', invalid: true } })
|
||||
const placeholder = wrapper.find('option[value="placeholder"]')
|
||||
expect(placeholder.text()).toBe('Wybierz…')
|
||||
expect(placeholder.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('select').attributes('aria-required')).toBe('true')
|
||||
expect(wrapper.find('select').attributes('aria-invalid')).toBe('true')
|
||||
expect(mount(DropdownField, { props: { field, modelValue: 's', controlId: 'd' } }).find('option[value="placeholder"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the placeholder, not the empty option, for a stored value it does not know', () => {
|
||||
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: 'zzz', controlId: 'd' } })
|
||||
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('placeholder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggle cards', () => {
|
||||
it('renders a switch with role=switch, aria-checked, its own label and comment', async () => {
|
||||
const wrapper = mount(SwitchField, { props: { field: formField('active'), modelValue: true, controlId: 's', describedBy: 's-comment' } })
|
||||
expect(wrapper.find('[data-toggle-card]').exists()).toBe(true)
|
||||
expect(wrapper.find('label').attributes('for')).toBe('s')
|
||||
expect(wrapper.find('label').text()).toBe('Active')
|
||||
expect(wrapper.find('#s-comment').text()).toBe('Shown in the catalogue')
|
||||
const control = wrapper.find('[role="switch"]')
|
||||
expect(control.attributes('id')).toBe('s')
|
||||
expect(control.attributes('type')).toBe('button')
|
||||
expect(control.attributes('aria-checked')).toBe('true')
|
||||
expect(control.attributes('aria-describedby')).toBe('s-comment')
|
||||
await control.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('keeps a numeric switch value numeric and marks it invalid', async () => {
|
||||
const field = { ...formField('active'), required: true, label: '' }
|
||||
const wrapper = mount(SwitchField, { props: { field, modelValue: 0, controlId: 's', invalid: true } })
|
||||
expect(wrapper.find('[role="switch"]').attributes('aria-checked')).toBe('false')
|
||||
expect(wrapper.find('[role="switch"]').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.find('[data-toggle-card]').classes()).toContain('border-danger')
|
||||
expect(wrapper.find('label').text()).toBe('active *')
|
||||
await wrapper.find('[role="switch"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
|
||||
})
|
||||
|
||||
it('renders a checkbox card that emits its checked state', async () => {
|
||||
const wrapper = mount(CheckboxField, { props: { field: formField('featured'), modelValue: '1', controlId: 'k' } })
|
||||
const box = wrapper.find('input[type="checkbox"]')
|
||||
expect((box.element as HTMLInputElement).checked).toBe(true)
|
||||
expect(wrapper.find('label').attributes('for')).toBe('k')
|
||||
expect(wrapper.find('#k-comment').text()).toBe('Pinned to the top')
|
||||
await box.setValue(false)
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
|
||||
const numeric = mount(CheckboxField, { props: { field: formField('featured'), modelValue: 0, controlId: 'k', invalid: true } })
|
||||
expect(numeric.find('input').attributes('aria-invalid')).toBe('true')
|
||||
await numeric.find('input').setValue(true)
|
||||
expect(numeric.emitted('update:modelValue')).toEqual([[1]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsupported field (D-05)', () => {
|
||||
it('names the type as code inside the translated sentence', () => {
|
||||
const wrapper = mount(UnsupportedField, { props: { field: formField('color'), modelValue: '#fff', controlId: 'u', describedBy: 'u-error' } })
|
||||
const box = wrapper.find('[data-unsupported-field]')
|
||||
expect(box.attributes('id')).toBe('u')
|
||||
expect(box.attributes('aria-describedby')).toBe('u-error')
|
||||
expect(box.text()).toBe('Nieobsługiwany typ pola: colorpicker')
|
||||
expect(box.find('code').text()).toBe('colorpicker')
|
||||
})
|
||||
|
||||
it('appends the type when the sentence has no :type placeholder', () => {
|
||||
setBundle({ 'backend::lang.form.unsupported_field': { other: 'Unsupported field' } }, 'en')
|
||||
const wrapper = mount(UnsupportedField, { props: { field: formField('color'), modelValue: null, controlId: 'u' } })
|
||||
expect(wrapper.text()).toBe('Unsupported field colorpicker')
|
||||
expect(wrapper.find('code').text()).toBe('colorpicker')
|
||||
})
|
||||
|
||||
it('renders a hostile type name as text', () => {
|
||||
const field = { ...formField('color'), type: '<img src=x onerror=alert(1)>' }
|
||||
const wrapper = mount(UnsupportedField, { props: { field, modelValue: null, controlId: 'u' } })
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.find('code').text()).toBe('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
})
|
||||
92
admin/tests/form/formState.test.ts
Normal file
92
admin/tests/form/formState.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
contextAllows,
|
||||
DEFAULT_TAB,
|
||||
editablePayload,
|
||||
fieldErrors,
|
||||
focusField,
|
||||
initialValues,
|
||||
panelDomId,
|
||||
snapshot,
|
||||
tabDomId,
|
||||
tabOf,
|
||||
} from '../../src/components/form/formState'
|
||||
import { formField, formSchemaFixture } from '../fixtures/typed'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('form state helpers', () => {
|
||||
it('keys tabs by label with a default tab for untabbed fields', () => {
|
||||
expect(tabOf(formField('name'))).toBe(DEFAULT_TAB)
|
||||
expect(tabOf(formField('serial'))).toBe('tab:Details')
|
||||
expect(tabDomId('field', 2)).toBe('field-tab-2')
|
||||
expect(panelDomId('field', 2)).toBe('field-panel-2')
|
||||
})
|
||||
|
||||
it('applies Winter context semantics', () => {
|
||||
expect(contextAllows(formField('name'), 'create')).toBe(true)
|
||||
expect(contextAllows(formField('serial'), 'update')).toBe(true)
|
||||
expect(contextAllows(formField('serial'), 'create')).toBe(false)
|
||||
expect(contextAllows(formField('batch'), 'create')).toBe(true)
|
||||
expect(contextAllows(formField('batch'), 'update')).toBe(false)
|
||||
expect(contextAllows({ ...formField('name'), context: [] }, 'update')).toBe(true)
|
||||
expect(contextAllows({ ...formField('name'), context: null as unknown as undefined }, 'update')).toBe(true)
|
||||
})
|
||||
|
||||
it('sends only editable, registered fields that hold a value', () => {
|
||||
const fields = formSchemaFixture.data.fields
|
||||
const body = editablePayload(fields, {
|
||||
name: 'Blue',
|
||||
code: undefined,
|
||||
color: '#fff',
|
||||
owner: 5,
|
||||
members: [1],
|
||||
tags: [3],
|
||||
active: false,
|
||||
unknown_key: 'x',
|
||||
})
|
||||
expect(body).toEqual({ name: 'Blue', tags: [3], active: false })
|
||||
})
|
||||
|
||||
it('starts a new record from defaults, toggles off and empty multiple relations', () => {
|
||||
const fields = [
|
||||
{ ...formField('name'), default: 'Unnamed' },
|
||||
formField('active'),
|
||||
formField('featured'),
|
||||
formField('tags'),
|
||||
formField('maker'),
|
||||
{ ...formField('weight'), default: 0 },
|
||||
]
|
||||
expect(initialValues(fields)).toEqual({ name: 'Unnamed', active: false, featured: false, tags: [], weight: 0 })
|
||||
})
|
||||
|
||||
it('normalizes 422 details into string lists and drops empty entries', () => {
|
||||
expect(
|
||||
fieldErrors({
|
||||
name: ['Required', '', 3],
|
||||
code: 'Taken',
|
||||
empty: [],
|
||||
blank: '',
|
||||
nested: { a: 1 },
|
||||
}),
|
||||
).toEqual({ name: ['Required'], code: ['Taken'] })
|
||||
expect(fieldErrors(undefined)).toEqual({})
|
||||
})
|
||||
|
||||
it('snapshots records independently of key order', () => {
|
||||
expect(snapshot({ a: 1, b: [2] })).toBe(snapshot({ b: [2], a: 1 }))
|
||||
expect(snapshot({ a: 1 })).not.toBe(snapshot({ a: 2 }))
|
||||
})
|
||||
|
||||
it('focuses a control by its id, making a non-control focusable first', () => {
|
||||
document.body.innerHTML = '<input id="field-name"><div id="field-members"></div>'
|
||||
expect(focusField('field', 'name')).toBe(true)
|
||||
expect(document.activeElement?.id).toBe('field-name')
|
||||
expect(focusField('field', 'members')).toBe(true)
|
||||
expect(document.getElementById('field-members')?.getAttribute('tabindex')).toBe('-1')
|
||||
expect(document.activeElement?.id).toBe('field-members')
|
||||
expect(focusField('field', 'missing')).toBe(false)
|
||||
})
|
||||
})
|
||||
127
admin/tests/form/registry.test.ts
Normal file
127
admin/tests/form/registry.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import CheckboxField from '../../src/components/form/fields/CheckboxField.vue'
|
||||
import DropdownField from '../../src/components/form/fields/DropdownField.vue'
|
||||
import NumberField from '../../src/components/form/fields/NumberField.vue'
|
||||
import RelationField from '../../src/components/form/fields/RelationField.vue'
|
||||
import SwitchField from '../../src/components/form/fields/SwitchField.vue'
|
||||
import TextField from '../../src/components/form/fields/TextField.vue'
|
||||
import TextareaField from '../../src/components/form/fields/TextareaField.vue'
|
||||
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
|
||||
import RelationManager from '../../src/components/relation/RelationManager.vue'
|
||||
import {
|
||||
controlAttributes,
|
||||
controlClass,
|
||||
isRegistered,
|
||||
needsRecord,
|
||||
ownsLabel,
|
||||
RELATION_MANAGER,
|
||||
rendererFor,
|
||||
toggleOn,
|
||||
toggleValue,
|
||||
} from '../../src/components/form/registry'
|
||||
import * as control from '../../src/components/form/control'
|
||||
import { formField } from '../fixtures/typed'
|
||||
|
||||
describe('field renderer registry (D-05)', () => {
|
||||
it.each([
|
||||
['text', TextField],
|
||||
['textarea', TextareaField],
|
||||
['number', NumberField],
|
||||
['dropdown', DropdownField],
|
||||
['switch', SwitchField],
|
||||
['checkbox', CheckboxField],
|
||||
['relation', RelationField],
|
||||
[RELATION_MANAGER, RelationManager],
|
||||
])('renders %s with its control', (type, component) => {
|
||||
expect(rendererFor(type)).toBe(component)
|
||||
})
|
||||
|
||||
it.each(['colorpicker', 'fileupload', '', 'TEXT', 'constructor'])('falls back to UnsupportedField for %j', (type) => {
|
||||
expect(rendererFor(type)).toBe(UnsupportedField)
|
||||
expect(isRegistered(type)).toBe(false)
|
||||
})
|
||||
|
||||
it('edits values of every control type except the record-bound relation manager', () => {
|
||||
expect(RELATION_MANAGER).toBe('relation-manager')
|
||||
for (const type of ['text', 'textarea', 'number', 'dropdown', 'switch', 'checkbox', 'relation']) {
|
||||
expect(isRegistered(type), type).toBe(true)
|
||||
expect(needsRecord(type), type).toBe(false)
|
||||
}
|
||||
expect(isRegistered(RELATION_MANAGER)).toBe(false)
|
||||
expect(needsRecord(RELATION_MANAGER)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets toggle cards and the relation manager render their own label', () => {
|
||||
expect(ownsLabel('switch')).toBe(true)
|
||||
expect(ownsLabel('checkbox')).toBe(true)
|
||||
expect(ownsLabel(RELATION_MANAGER)).toBe(true)
|
||||
expect(ownsLabel('text')).toBe(false)
|
||||
expect(ownsLabel('relation')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('control attributes', () => {
|
||||
it('passes only allow-listed Winter attributes, lower-cased, and drops null and false', () => {
|
||||
const field = formField('name')
|
||||
field.attributes = {
|
||||
placeholder: 'Type…',
|
||||
MaxLength: 20,
|
||||
readonly: true,
|
||||
spellcheck: false,
|
||||
pattern: null,
|
||||
onclick: 'alert(1)',
|
||||
onfocus: 'x',
|
||||
style: 'color:red',
|
||||
class: 'evil',
|
||||
id: 'hijack',
|
||||
autofocus: true,
|
||||
}
|
||||
expect(controlAttributes(field)).toEqual({ placeholder: 'Type…', maxlength: 20, readonly: true })
|
||||
})
|
||||
|
||||
it('answers no attributes without a map', () => {
|
||||
expect(controlAttributes(formField('code'))).toEqual({})
|
||||
})
|
||||
|
||||
it('switches the border to danger when invalid', () => {
|
||||
expect(controlClass(true)).toContain('border-danger')
|
||||
expect(controlClass(false)).toContain('border-border-strong')
|
||||
expect(controlClass(undefined)).not.toContain('border-danger')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggle values', () => {
|
||||
it.each([
|
||||
[true, true],
|
||||
[1, true],
|
||||
['1', true],
|
||||
['true', true],
|
||||
['yes', true],
|
||||
[false, false],
|
||||
[0, false],
|
||||
['0', false],
|
||||
['', false],
|
||||
['False', false],
|
||||
[null, false],
|
||||
[undefined, false],
|
||||
])('%j is %s', (value, on) => {
|
||||
expect(toggleOn(value)).toBe(on)
|
||||
})
|
||||
|
||||
it('keeps a numeric stored value numeric and sends booleans otherwise', () => {
|
||||
expect(toggleValue(0, true)).toBe(1)
|
||||
expect(toggleValue(1, false)).toBe(0)
|
||||
expect(toggleValue(false, true)).toBe(true)
|
||||
expect(toggleValue('1', false)).toBe(false)
|
||||
expect(toggleValue(undefined, true)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('control module', () => {
|
||||
it('is what the registry re-exports, so field controls never import the registry', () => {
|
||||
expect(controlAttributes).toBe(control.controlAttributes)
|
||||
expect(controlClass).toBe(control.controlClass)
|
||||
expect(toggleOn).toBe(control.toggleOn)
|
||||
expect(toggleValue).toBe(control.toggleValue)
|
||||
})
|
||||
})
|
||||
@@ -6,9 +6,15 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import App from '../src/App.vue'
|
||||
import { createAdminRouter } from '../src/app/router'
|
||||
import { me } from '../src/state/useAuth'
|
||||
import { loadNavigation } from '../src/state/useNavigation'
|
||||
import { setBundle } from '../src/app/i18n'
|
||||
import { clearUser, me } from '../src/state/useAuth'
|
||||
import { clearRecordCrumb } from '../src/state/useBreadcrumbs'
|
||||
import { loadNavigation, setNavigation } from '../src/state/useNavigation'
|
||||
import { setSettings } from '../src/state/useSettings'
|
||||
import { watchViewport } from '../src/state/useSidebar'
|
||||
import { clearToasts } from '../src/state/useToasts'
|
||||
import navigation from './fixtures/navigation.json'
|
||||
import { langFixture } from './fixtures/typed'
|
||||
|
||||
export const API = '/admin-test/api/v1'
|
||||
|
||||
@@ -85,3 +91,81 @@ export async function mountApp(path: string, routes: Record<string, Route>, opti
|
||||
await flushPromises()
|
||||
return { wrapper, calls, router }
|
||||
}
|
||||
|
||||
/** Signs the profile in over the mocked API; extra routes join the mock. */
|
||||
export async function signIn(routes: Record<string, Route> = {}): Promise<Request[]> {
|
||||
const calls = mockApi({ [`GET ${API}/auth/me`]: { body: profile }, ...routes })
|
||||
await me()
|
||||
return calls
|
||||
}
|
||||
|
||||
/** The real admin router on memory history, already at path. */
|
||||
export async function routerAt(path: string) {
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
return router
|
||||
}
|
||||
|
||||
/** Dispatches a bubbling, cancelable keydown and returns the event. */
|
||||
export function keydown(target: Element, key: string): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
type MediaListener = (event: MediaQueryListEvent) => void
|
||||
|
||||
/** A matchMedia whose answers the test controls, with change events. */
|
||||
export function fakeMedia(initial: Record<string, boolean>) {
|
||||
const state = { ...initial }
|
||||
const listeners = new Map<string, Set<MediaListener>>()
|
||||
const of = (query: string): Set<MediaListener> => {
|
||||
let set = listeners.get(query)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
listeners.set(query, set)
|
||||
}
|
||||
return set
|
||||
}
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation(
|
||||
(query: string) =>
|
||||
({
|
||||
get matches() {
|
||||
return state[query] ?? false
|
||||
},
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: (_type: string, listener: MediaListener) => of(query).add(listener),
|
||||
removeEventListener: (_type: string, listener: MediaListener) => of(query).delete(listener),
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList,
|
||||
)
|
||||
return {
|
||||
change(query: string, matches: boolean): void {
|
||||
state[query] = matches
|
||||
for (const listener of of(query)) {
|
||||
listener({ matches, media: query } as MediaQueryListEvent)
|
||||
}
|
||||
},
|
||||
listeners: (query: string) => of(query).size,
|
||||
}
|
||||
}
|
||||
|
||||
/** Module state back to a signed-out, Polish-strings start. */
|
||||
export function resetState(): void {
|
||||
clearUser()
|
||||
setNavigation([])
|
||||
setSettings([])
|
||||
clearToasts()
|
||||
clearRecordCrumb()
|
||||
setBundle(langFixture.data, 'pl')
|
||||
window.localStorage.clear()
|
||||
// Re-reads the (cleared) preference and the desktop viewport of setup.ts,
|
||||
// so a narrow or collapsed sidebar never leaks into the next test.
|
||||
watchViewport()()
|
||||
}
|
||||
|
||||
77
admin/tests/list/CellValue.test.ts
Normal file
77
admin/tests/list/CellValue.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import CellValue from '../../src/components/list/CellValue.vue'
|
||||
import type { ListColumn } from '../../src/api/types'
|
||||
import { listColumn } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
function cell(column: ListColumn, value: unknown) {
|
||||
return mount({ components: { CellValue }, props: [], template: '<span data-cell><CellValue :column="column" :value="value" /></span>', data: () => ({ column, value }) })
|
||||
}
|
||||
|
||||
const text = listColumn('name')
|
||||
const datetime = listColumn('created_at')
|
||||
const toggle = listColumn('active')
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('cell value (D-12)', () => {
|
||||
it.each([
|
||||
['a string', 'Blue', 'Blue'],
|
||||
['a number', 12, '12'],
|
||||
['an array', ['small', 3, null, ''], 'small, 3'],
|
||||
['a boolean true', true, 'Tak'],
|
||||
['a boolean false', false, 'Nie'],
|
||||
])('renders %s as plain text', (_label, value, expected) => {
|
||||
const wrapper = cell(text, value)
|
||||
expect(wrapper.text()).toBe(expected)
|
||||
expect(wrapper.find('[data-empty]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['an empty string', ''],
|
||||
['an object', { nested: true }],
|
||||
['an empty array', []],
|
||||
])('renders %s as a muted dash', (_label, value) => {
|
||||
const wrapper = cell(text, value)
|
||||
expect(wrapper.find('[data-empty]').text()).toBe('—')
|
||||
})
|
||||
|
||||
it('never renders markup from a value', () => {
|
||||
const wrapper = cell(text, '<img src=x onerror=alert(1)>')
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.text()).toBe('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
|
||||
it('formats datetimes as YYYY-MM-DD HH:mm in tabular numbers', () => {
|
||||
const wrapper = cell(datetime, '2026-09-21T14:32:00')
|
||||
expect(wrapper.text()).toBe('2026-09-21 14:32')
|
||||
expect(wrapper.find('span.tabular-nums').exists()).toBe(true)
|
||||
expect(cell(datetime, 'not a date').text()).toBe('not a date')
|
||||
expect(cell(datetime, null).find('[data-empty]').exists()).toBe(true)
|
||||
expect(cell(datetime, true).find('[data-empty]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[true, 'true', 'Tak'],
|
||||
[1, 'true', 'Tak'],
|
||||
['yes', 'true', 'Tak'],
|
||||
[false, 'false', 'Nie'],
|
||||
[0, 'false', 'Nie'],
|
||||
['0', 'false', 'Nie'],
|
||||
['FALSE', 'false', 'Nie'],
|
||||
])('renders the switch value %j as the %s pill', (value, state, label) => {
|
||||
const wrapper = cell(toggle, value)
|
||||
expect(wrapper.find('[data-switch]').attributes('data-switch')).toBe(state)
|
||||
expect(wrapper.text()).toBe(label)
|
||||
})
|
||||
|
||||
it('renders an empty switch value as a dash', () => {
|
||||
expect(cell(toggle, null).find('[data-empty]').exists()).toBe(true)
|
||||
expect(cell(toggle, null).find('[data-switch]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
143
admin/tests/list/DataTable.test.ts
Normal file
143
admin/tests/list/DataTable.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import DataTable from '../../src/components/list/DataTable.vue'
|
||||
import type { AdminRecord, ListColumn } from '../../src/api/types'
|
||||
import type { RowId } from '../../src/app/listQuery'
|
||||
import { linkedFixture, listColumn, listFixture } from '../fixtures/typed'
|
||||
import { resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
const columns: ListColumn[] = [listColumn('name'), listColumn('code'), listColumn('tags')]
|
||||
|
||||
async function mountTable(props: Record<string, unknown>) {
|
||||
await signIn()
|
||||
const router = await routerAt('/acme/demo/widgets')
|
||||
const wrapper = mount(DataTable, {
|
||||
props: { columns, rows: listFixture.data, ...props },
|
||||
global: { plugins: [router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
const rowLink = (row: AdminRecord) => `/acme/demo/widgets/${String(row.id)}`
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('data table', () => {
|
||||
it('renders exactly the schema columns and each record through its column keys', async () => {
|
||||
const { wrapper } = await mountTable({})
|
||||
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags'])
|
||||
expect(wrapper.findAll('th').every((th) => th.attributes('scope') === 'col')).toBe(true)
|
||||
const cells = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text()))
|
||||
expect(cells).toEqual([
|
||||
['Blue widget', 'W-01', 'small, round'],
|
||||
['Green widget', 'W-02', '—'],
|
||||
])
|
||||
expect(wrapper.find('tbody tr').attributes('aria-selected')).toBeUndefined()
|
||||
expect(wrapper.find('th').attributes('aria-sort')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('marks sortable headers with aria-sort none, ascending or descending and emits the column', async () => {
|
||||
const { wrapper } = await mountTable({ sortable: true, sort: { column: 'code', dir: 'desc' } })
|
||||
const headers = wrapper.findAll('th')
|
||||
expect(headers.map((th) => th.attributes('aria-sort'))).toEqual(['none', 'descending', undefined])
|
||||
expect(headers[2]!.find('button').exists()).toBe(false)
|
||||
await wrapper.setProps({ sort: { column: 'name', dir: 'asc' } })
|
||||
expect(wrapper.findAll('th').map((th) => th.attributes('aria-sort'))).toEqual(['ascending', 'none', undefined])
|
||||
await wrapper.find('[data-sort="code"]').trigger('click')
|
||||
expect(wrapper.emitted('sort')).toEqual([['code']])
|
||||
})
|
||||
|
||||
it('selects rows through a tri-state header checkbox over the current page', async () => {
|
||||
const { wrapper } = await mountTable({ selectable: true, selected: [] as RowId[] })
|
||||
const header = () => wrapper.find('th[data-select] [role="checkbox"]')
|
||||
expect(header().attributes('aria-checked')).toBe('false')
|
||||
expect(header().attributes('aria-label')).toBe('Zaznacz wszystkie na stronie')
|
||||
expect(wrapper.findAll('tbody tr').map((tr) => tr.attributes('aria-selected'))).toEqual(['false', 'false'])
|
||||
|
||||
await header().trigger('click')
|
||||
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[1, 2]])
|
||||
|
||||
await wrapper.setProps({ selected: ['2'] })
|
||||
expect(header().attributes('aria-checked')).toBe('mixed')
|
||||
expect(wrapper.findAll('tbody tr').map((tr) => tr.attributes('aria-selected'))).toEqual(['false', 'true'])
|
||||
await header().trigger('click')
|
||||
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[1, 2]])
|
||||
|
||||
await wrapper.setProps({ selected: [1, 2] })
|
||||
expect(header().attributes('aria-checked')).toBe('true')
|
||||
await header().trigger('click')
|
||||
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[]])
|
||||
})
|
||||
|
||||
it('toggles single rows by id', async () => {
|
||||
const { wrapper } = await mountTable({ selectable: true, selected: [2] as RowId[] })
|
||||
const boxes = wrapper.findAll('tbody [role="checkbox"]')
|
||||
expect(boxes.map((box) => box.attributes('aria-checked'))).toEqual(['false', 'true'])
|
||||
expect(boxes[0]!.attributes('aria-label')).toBe('Zaznacz wiersz')
|
||||
await boxes[0]!.trigger('click')
|
||||
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[2, 1]])
|
||||
await boxes[1]!.trigger('click')
|
||||
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[]])
|
||||
})
|
||||
|
||||
it('opens the row link on a row click but not from its controls', async () => {
|
||||
const { wrapper, router } = await mountTable({ selectable: true, rowLink })
|
||||
expect(wrapper.find('tbody tr td:nth-child(2) a').attributes('href')).toBe('/acme/demo/widgets/1')
|
||||
await wrapper.find('tbody tr [role="checkbox"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
|
||||
await wrapper.findAll('tbody tr')[1]!.findAll('td')[2]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/2')
|
||||
})
|
||||
|
||||
it('does nothing on a row click without a row link and keeps rows without ids unselectable', async () => {
|
||||
const { wrapper, router } = await mountTable({
|
||||
selectable: true,
|
||||
rows: [{ name: 'No id', code: 'X', tags: [] }],
|
||||
})
|
||||
await wrapper.find('tbody td:nth-child(2)').trigger('click')
|
||||
await wrapper.find('tbody [role="checkbox"]').trigger('click')
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
|
||||
expect(wrapper.emitted('update:selected')).toBeUndefined()
|
||||
expect(wrapper.find('th [role="checkbox"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows eight skeleton rows with aria-busy while loading and the empty slot without rows', async () => {
|
||||
const { wrapper } = await mountTable({ loading: true, selectable: true })
|
||||
expect(wrapper.find('tbody').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(8)
|
||||
expect(wrapper.find('th [role="checkbox"]').attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.setProps({ loading: false, rows: [] })
|
||||
expect(wrapper.find('tbody').attributes('aria-busy')).toBeUndefined()
|
||||
expect(wrapper.find('tbody td').attributes('colspan')).toBe('4')
|
||||
expect(wrapper.find('tbody td').text()).toBe('Brak rekordów w tym widoku.')
|
||||
})
|
||||
|
||||
it('renders the relation variant with 56px rows, initials avatars and three skeleton rows', async () => {
|
||||
const relationColumns = [
|
||||
{ key: 'name', label: 'Name', searchable: true, sortable: true },
|
||||
{ key: 'email', label: 'E-mail', searchable: true, sortable: true },
|
||||
]
|
||||
const { wrapper } = await mountTable({ variant: 'relation', columns: relationColumns, rows: linkedFixture.all.data })
|
||||
const first = wrapper.find('tbody tr')
|
||||
expect(first.classes()).toContain('h-[56px]')
|
||||
expect(first.find('[data-avatar]').text()).toBe('AB')
|
||||
expect(first.find('[data-avatar]').attributes('aria-hidden')).toBe('true')
|
||||
expect(first.findAll('td')[1]!.classes()).toContain('text-muted')
|
||||
|
||||
await wrapper.setProps({ loading: true })
|
||||
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
121
admin/tests/list/FilterBar.test.ts
Normal file
121
admin/tests/list/FilterBar.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import FilterBar from '../../src/components/list/FilterBar.vue'
|
||||
import type { ListFilter } from '../../src/api/types'
|
||||
import { listFilter, optionsFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, queryOf, requestsTo, resetState } from '../helpers'
|
||||
|
||||
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
||||
const MAKER = `${API}/acme/demo/widgets/filters/maker/options`
|
||||
const filters: ListFilter[] = [listFilter('active'), listFilter('created'), listFilter('maker')]
|
||||
|
||||
async function mountBar(values: Record<string, string> = {}, extra: ListFilter[] = []) {
|
||||
const calls = mockApi({ [`GET ${MAKER}`]: { body: optionsFixture.maker } })
|
||||
const wrapper = mount(FilterBar, { props: { filters: [...filters, ...extra], values, source } })
|
||||
await flushPromises()
|
||||
return { wrapper, calls }
|
||||
}
|
||||
|
||||
function options(select: ReturnType<ReturnType<typeof mount>['find']>) {
|
||||
return select.findAll('option').map((option) => [option.attributes('value'), option.text()])
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('filter bar (D-22, D-27)', () => {
|
||||
it('is a labelled group with one control per filter shape', async () => {
|
||||
const { wrapper } = await mountBar()
|
||||
expect(wrapper.find('[role="group"]').attributes('aria-label')).toBe('Filtry')
|
||||
expect(wrapper.find('select[data-filter="active"]').exists()).toBe(true)
|
||||
expect(wrapper.find('fieldset legend').text()).toBe('Created')
|
||||
expect(wrapper.find('[data-filter="created-from"]').attributes('aria-label')).toBe('Od')
|
||||
expect(wrapper.find('[data-filter="created-to"]').attributes('aria-label')).toBe('Do')
|
||||
expect(wrapper.find('select[data-filter="maker"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('offers switch choices as JSON of trueValue and falseValue, or of the declared options', async () => {
|
||||
const withOptions: ListFilter = {
|
||||
name: 'size',
|
||||
type: 'switch',
|
||||
options: [
|
||||
{ value: 's', label: 'Small' },
|
||||
{ value: 2, label: 'Two' },
|
||||
],
|
||||
}
|
||||
const { wrapper } = await mountBar({}, [withOptions])
|
||||
expect(options(wrapper.find('select[data-filter="active"]'))).toEqual([
|
||||
['', 'Wszystkie'],
|
||||
['true', 'Tak'],
|
||||
['false', 'Nie'],
|
||||
])
|
||||
expect(options(wrapper.find('select[data-filter="size"]'))).toEqual([
|
||||
['', 'Wszystkie'],
|
||||
['"s"', 'Small'],
|
||||
['2', 'Two'],
|
||||
])
|
||||
expect(wrapper.find('select[data-filter="size"]').element.closest('label')?.textContent).toContain('size')
|
||||
|
||||
await wrapper.find('select[data-filter="active"]').setValue('false')
|
||||
await wrapper.find('select[data-filter="active"]').setValue('')
|
||||
expect(wrapper.emitted('update')).toEqual([
|
||||
['active', 'false'],
|
||||
['active', null],
|
||||
])
|
||||
})
|
||||
|
||||
it('loads scope choices once from the filter options endpoint and selects the URL value', async () => {
|
||||
const { wrapper, calls } = await mountBar({ maker: '2' })
|
||||
const requests = requestsTo(calls, 'GET', MAKER)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect([...queryOf(requests[0]!).keys()]).toEqual([])
|
||||
const select = wrapper.find('select[data-filter="maker"]')
|
||||
expect(options(select)).toEqual([
|
||||
['', 'Wszystkie'],
|
||||
['1', 'Acme Works'],
|
||||
['2', 'Bolt & Co'],
|
||||
])
|
||||
expect((select.element as HTMLSelectElement).value).toBe('2')
|
||||
await select.setValue('1')
|
||||
expect(wrapper.emitted('update')).toEqual([['maker', '1']])
|
||||
})
|
||||
|
||||
it('shows no scope choices when the options request fails', async () => {
|
||||
mockApi({ [`GET ${MAKER}`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } } })
|
||||
const wrapper = mount(FilterBar, { props: { filters, values: {}, source } })
|
||||
await flushPromises()
|
||||
expect(options(wrapper.find('select[data-filter="maker"]'))).toEqual([['', 'Wszystkie']])
|
||||
})
|
||||
|
||||
it('writes a date range only when both ends are set and removes it when either is cleared', async () => {
|
||||
const { wrapper } = await mountBar()
|
||||
const from = wrapper.find('[data-filter="created-from"]')
|
||||
const to = wrapper.find('[data-filter="created-to"]')
|
||||
await from.setValue('2026-01-01')
|
||||
expect(wrapper.emitted('update')).toBeUndefined()
|
||||
await to.setValue('2026-02-01')
|
||||
expect(wrapper.emitted('update')).toEqual([['created', '2026-01-01..2026-02-01']])
|
||||
|
||||
await wrapper.setProps({ values: { created: '2026-01-01..2026-02-01' } })
|
||||
await to.setValue('')
|
||||
expect(wrapper.emitted('update')!.at(-1)).toEqual(['created', null])
|
||||
await wrapper.setProps({ values: {} })
|
||||
// The half-filled pair stays on screen until both ends are cleared.
|
||||
expect((from.element as HTMLInputElement).value).toBe('2026-01-01')
|
||||
await from.setValue('')
|
||||
expect(wrapper.emitted('update')!.at(-1)).toEqual(['created', null])
|
||||
})
|
||||
|
||||
it('fills the range inputs from the URL value', async () => {
|
||||
const { wrapper } = await mountBar({ created: '2026-03-01..2026-03-31' })
|
||||
expect((wrapper.find('[data-filter="created-from"]').element as HTMLInputElement).value).toBe('2026-03-01')
|
||||
expect((wrapper.find('[data-filter="created-to"]').element as HTMLInputElement).value).toBe('2026-03-31')
|
||||
})
|
||||
})
|
||||
57
admin/tests/list/ListToolbar.test.ts
Normal file
57
admin/tests/list/ListToolbar.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ListToolbar from '../../src/components/list/ListToolbar.vue'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
const base = {
|
||||
showSearch: true,
|
||||
search: 'blue',
|
||||
searchPrompt: 'Search widgets…',
|
||||
buttons: ['delete'],
|
||||
selectedCount: 0,
|
||||
selectedLabel: 'Selected 0',
|
||||
deleteLabel: 'Delete selected',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('list toolbar (D-14)', () => {
|
||||
it('has a labelled search input that emits every change', async () => {
|
||||
const wrapper = mount(ListToolbar, { props: base })
|
||||
const input = wrapper.find('[data-list-search]')
|
||||
expect(input.attributes('type')).toBe('search')
|
||||
expect(input.attributes('placeholder')).toBe('Search widgets…')
|
||||
expect((input.element as HTMLInputElement).value).toBe('blue')
|
||||
expect(wrapper.find('label .sr-only').text()).toBe('Szukaj')
|
||||
await input.setValue('red')
|
||||
expect(wrapper.emitted('update:search')).toEqual([['red']])
|
||||
})
|
||||
|
||||
it('hides the search when the schema turns it off', () => {
|
||||
const wrapper = mount(ListToolbar, { props: { ...base, showSearch: false } })
|
||||
expect(wrapper.find('[data-list-search]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('disables delete without a selection and shows the pill and danger button with one', async () => {
|
||||
const wrapper = mount(ListToolbar, { props: base })
|
||||
const button = () => wrapper.find('[data-action="delete"]')
|
||||
expect(button().attributes('disabled')).toBeDefined()
|
||||
expect(button().classes()).toContain('border-border-strong')
|
||||
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
|
||||
|
||||
await wrapper.setProps({ selectedCount: 2, selectedLabel: 'Selected 2' })
|
||||
expect(button().attributes('disabled')).toBeUndefined()
|
||||
expect(button().classes()).toContain('text-danger')
|
||||
expect(button().text()).toBe('Delete selected')
|
||||
expect(wrapper.find('[data-selected-pill]').text()).toBe('Selected 2')
|
||||
await button().trigger('click')
|
||||
expect(wrapper.emitted('delete')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders only the declared toolbar buttons', () => {
|
||||
const wrapper = mount(ListToolbar, { props: { ...base, buttons: [] } })
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
132
admin/tests/list/ListView.test.ts
Normal file
132
admin/tests/list/ListView.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import ListView from '../../src/views/ListView.vue'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, listFixture, listSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, queryOf, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const serverError = { status: 500, body: { error: { code: 'server_error', message: 'Database down', details: {} } } }
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/list`]: { body: listSchemaFixture },
|
||||
[`GET ${BASE}`]: { body: listFixture },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function schemaWith(change: (schema: typeof listSchemaFixture.data) => void) {
|
||||
const envelope = clone(listSchemaFixture)
|
||||
change(envelope.data)
|
||||
return { body: envelope }
|
||||
}
|
||||
|
||||
async function selectAllAndDelete(wrapper: Awaited<ReturnType<typeof mountApp>>['wrapper']) {
|
||||
await wrapper.find('th [role="checkbox"]').trigger('click')
|
||||
await wrapper.find('[data-action="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('list view', () => {
|
||||
it('is the component of the list route', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
|
||||
expect(wrapper.findComponent(ListView).exists()).toBe(true)
|
||||
expect(wrapper.find('h1').text()).toBe('Widgets')
|
||||
expect(wrapper.find('[data-record-count]').text()).toBe('2 widgets')
|
||||
})
|
||||
|
||||
it('sends the URL query to the list endpoint', async () => {
|
||||
const { calls } = await mountApp('/acme/demo/widgets?search=blue&sort=code&dir=desc&page=2&filter[active]=true', routes())
|
||||
const [request] = requestsTo(calls, 'GET', BASE)
|
||||
const query = queryOf(request!)
|
||||
expect(query.get('search')).toBe('blue')
|
||||
expect(query.get('sort')).toBe('code')
|
||||
expect(query.get('dir')).toBe('desc')
|
||||
expect(query.get('page')).toBe('2')
|
||||
expect(query.get('filter[active]')).toBe('true')
|
||||
})
|
||||
|
||||
it('announces a failed list load as an alert and keeps the toolbar', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-list-search]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('announces a failed schema load and falls back to the navigation label', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/gadgets', {
|
||||
[`GET ${API}/acme/demo/gadgets/schema/list`]: serverError,
|
||||
[`GET ${API}/acme/demo/gadgets`]: { body: listFixture },
|
||||
})
|
||||
expect(wrapper.find('h1').text()).toBe('Gadgets')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-list-search]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the create button only when the toolbar declares it', async () => {
|
||||
const withCreate = await mountApp('/acme/demo/widgets', routes())
|
||||
expect(withCreate.wrapper.find('[data-action="create"]').attributes('href')).toBe('/acme/demo/widgets/create')
|
||||
withCreate.wrapper.unmount()
|
||||
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets',
|
||||
routes({ [`GET ${BASE}/schema/list`]: schemaWith((schema) => (schema.toolbarButtons = ['delete'])) }),
|
||||
)
|
||||
expect(wrapper.find('[data-action="create"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('links no rows without a recordUrl and shows no checkboxes or sorting when turned off', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets',
|
||||
routes({
|
||||
[`GET ${BASE}/schema/list`]: schemaWith((schema) => {
|
||||
delete schema.recordUrl
|
||||
schema.showCheckboxes = false
|
||||
schema.showSorting = false
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(wrapper.find('tbody a').exists()).toBe(false)
|
||||
expect(wrapper.find('[role="checkbox"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-sort]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts the server message when a bulk delete is refused', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets',
|
||||
routes({ [`POST ${BASE}/bulk-delete`]: { status: 403, body: { error: { code: 'forbidden', message: 'Not allowed', details: {} } } } }),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectAllAndDelete(wrapper)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Not allowed', tone: 'danger' })
|
||||
})
|
||||
|
||||
it('toasts the generic failure when the bulk delete cannot reach the server', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets',
|
||||
routes({
|
||||
[`POST ${BASE}/bulk-delete`]: () => {
|
||||
throw new TypeError('offline')
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectAllAndDelete(wrapper)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się usunąć zaznaczonych rekordów.', tone: 'danger' })
|
||||
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
84
admin/tests/list/Pagination.test.ts
Normal file
84
admin/tests/list/Pagination.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Pagination from '../../src/components/list/Pagination.vue'
|
||||
import type { ListMeta } from '../../src/api/types'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
function meta(page: number, lastPage: number, total = lastPage * 20, perPage = 20): ListMeta {
|
||||
return { page, last_page: lastPage, total, per_page: perPage }
|
||||
}
|
||||
|
||||
function pager(page: number, lastPage: number) {
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(page, lastPage), loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
return wrapper
|
||||
.findAll('nav > *')
|
||||
.filter((element) => !['prev', 'next'].includes(element.attributes('data-page') ?? ''))
|
||||
.map((element) => element.text())
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('pagination', () => {
|
||||
it('shows the range of the page and a labelled pager', () => {
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(2, 3, 45), loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
expect(wrapper.find('[data-range]').text()).toBe('21–40 z 45')
|
||||
expect(wrapper.find('nav').attributes('aria-label')).toBe('Stronicowanie')
|
||||
expect(wrapper.find('[data-page="2"]').attributes('aria-current')).toBe('page')
|
||||
expect(wrapper.find('[data-page="1"]').attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, 1, '1'],
|
||||
[1, 5, '1 2 3 … 5'],
|
||||
[3, 5, '1 2 3 4 5'],
|
||||
[5, 10, '1 … 4 5 6 … 10'],
|
||||
[10, 10, '1 … 8 9 10'],
|
||||
[2, 3, '1 2 3'],
|
||||
])('page %i of %i shows %s', (page, last, expected) => {
|
||||
expect(pager(page, last)).toBe(expected)
|
||||
})
|
||||
|
||||
it('disables prev on the first and next on the last page and emits pages', async () => {
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(1, 3), loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
expect(wrapper.find('[data-page="prev"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-page="next"]').attributes('disabled')).toBeUndefined()
|
||||
await wrapper.find('[data-page="next"]').trigger('click')
|
||||
await wrapper.find('[data-page="3"]').trigger('click')
|
||||
await wrapper.find('[data-page="1"]').trigger('click')
|
||||
expect(wrapper.emitted('page')).toEqual([[2], [3]])
|
||||
|
||||
await wrapper.setProps({ meta: meta(3, 3) })
|
||||
expect(wrapper.find('[data-page="next"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.find('[data-page="prev"]').trigger('click')
|
||||
expect(wrapper.emitted('page')!.at(-1)).toEqual([2])
|
||||
})
|
||||
|
||||
it('shows the plural zero-results message and no pager without rows', () => {
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(1, 0, 0), loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
expect(wrapper.find('[data-range]').text()).toBe('0 wyników')
|
||||
expect(wrapper.find('nav').exists()).toBe(false)
|
||||
const none = mount(Pagination, { props: { meta: null, loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
expect(none.find('[data-range]').text()).toBe('0 wyników')
|
||||
})
|
||||
|
||||
it('shows the loading text and hides the pager while loading', () => {
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(1, 2), loading: true, perPageOptions: [20], perPage: 20 } })
|
||||
expect(wrapper.find('[data-range]').text()).toBe('Wczytywanie…')
|
||||
expect(wrapper.find('nav').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('offers the per-page select only with more than one option and emits a valid choice', async () => {
|
||||
const one = mount(Pagination, { props: { meta: meta(1, 2), loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
expect(one.find('[data-per-page]').exists()).toBe(false)
|
||||
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(1, 2), loading: false, perPageOptions: [10, 20, 50], perPage: 20 } })
|
||||
const select = wrapper.find('[data-per-page]')
|
||||
expect(select.findAll('option').map((option) => option.text())).toEqual(['10', '20', '50'])
|
||||
expect((select.element as HTMLSelectElement).value).toBe('20')
|
||||
await select.setValue('50')
|
||||
expect(wrapper.emitted('perPage')).toEqual([[50]])
|
||||
})
|
||||
})
|
||||
152
admin/tests/relation/RelationManager.test.ts
Normal file
152
admin/tests/relation/RelationManager.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import RelationManager from '../../src/components/relation/RelationManager.vue'
|
||||
import { createAdminRouter } from '../../src/app/router'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, queryOf, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
||||
const SCHEMA = `${API}/acme/demo/widgets/schema/relation/members`
|
||||
const LINKED = `${API}/acme/demo/widgets/1/relations/members`
|
||||
const UNLINK = `${LINKED}/unlink`
|
||||
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${SCHEMA}`]: { body: relationSchemaFixture },
|
||||
[`GET ${LINKED}`]: { body: linkedFixture.all },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountManager(overrides: Record<string, Route> = {}, props: Record<string, unknown> = {}) {
|
||||
const calls = mockApi(routes(overrides))
|
||||
const wrapper = mount(RelationManager, {
|
||||
props: { field: formField('members'), modelValue: undefined, controlId: 'field-members', source, recordId: 1, ...props },
|
||||
// DataTable injects the router for its row links.
|
||||
global: { plugins: [createAdminRouter(createMemoryHistory())] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, calls }
|
||||
}
|
||||
|
||||
async function selectFirstAndUnlink(wrapper: VueWrapper) {
|
||||
await wrapper.find('tbody [role="checkbox"]').trigger('click')
|
||||
await wrapper.find('[data-action="unlink"]').trigger('click')
|
||||
await flushPromises()
|
||||
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('relation manager (D-05, design screen 5)', () => {
|
||||
it('renders nothing and requests nothing without a saved record or a source', async () => {
|
||||
for (const props of [{ recordId: null }, { source: null }]) {
|
||||
const { wrapper, calls } = await mountManager({}, props)
|
||||
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
|
||||
expect(calls).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('is a region labelled by its heading and described by its comment', async () => {
|
||||
const { wrapper } = await mountManager()
|
||||
const region = wrapper.find('[data-relation-manager]')
|
||||
expect(region.element.tagName).toBe('SECTION')
|
||||
expect(region.attributes('id')).toBe('field-members')
|
||||
expect(region.attributes('tabindex')).toBe('-1')
|
||||
expect(region.attributes('aria-labelledby')).toBe('field-members-heading')
|
||||
expect(wrapper.find('#field-members-heading').text()).toBe('Members')
|
||||
expect(region.attributes('aria-describedby')).toBe('field-members-comment')
|
||||
expect(wrapper.find('#field-members-comment').text()).toBe('People who may edit this widget')
|
||||
expect(wrapper.find('[data-action="link"]').attributes('aria-haspopup')).toBe('dialog')
|
||||
})
|
||||
|
||||
it('uses the relation key of the field, not its name, for the endpoints', async () => {
|
||||
const field = { ...formField('members'), name: 'team', relation: 'members' }
|
||||
const { wrapper, calls } = await mountManager({}, { field })
|
||||
expect(wrapper.find('[data-relation-manager]').attributes('data-relation-name')).toBe('members')
|
||||
expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('announces a failed schema or list load as an alert', async () => {
|
||||
const schemaDown = await mountManager({ [`GET ${SCHEMA}`]: failure(500, 'Boom') })
|
||||
expect(schemaDown.wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać powiązanych rekordów.')
|
||||
expect(requestsTo(schemaDown.calls, 'GET', LINKED)).toHaveLength(0)
|
||||
schemaDown.wrapper.unmount()
|
||||
|
||||
const listDown = await mountManager({ [`GET ${LINKED}`]: failure(403, 'Forbidden') })
|
||||
expect(listDown.wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(listDown.wrapper.find('table').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('cycles sorting asc, desc, none through the linked query and resets the page', async () => {
|
||||
const { wrapper, calls } = await mountManager()
|
||||
const header = () => wrapper.find('[data-sort="email"]')
|
||||
await header().trigger('click')
|
||||
await flushPromises()
|
||||
await header().trigger('click')
|
||||
await flushPromises()
|
||||
await header().trigger('click')
|
||||
await flushPromises()
|
||||
const queries = requestsTo(calls, 'GET', LINKED).map((request) => queryOf(request))
|
||||
expect(queries.map((query) => [query.get('sort'), query.get('dir'), query.get('page')])).toEqual([
|
||||
[null, null, '1'],
|
||||
['email', 'asc', '1'],
|
||||
['email', 'desc', '1'],
|
||||
[null, null, '1'],
|
||||
])
|
||||
expect(wrapper.find('th[aria-sort]').attributes('aria-sort')).toBe('none')
|
||||
})
|
||||
|
||||
it('pages the linked rows only when there is more than one page', async () => {
|
||||
expect((await mountManager()).wrapper.find('[data-page="next"]').exists()).toBe(false)
|
||||
|
||||
const paged = clone(linkedFixture.all)
|
||||
paged.meta = { page: 1, per_page: 3, total: 5, last_page: 2 }
|
||||
const { wrapper, calls } = await mountManager({ [`GET ${LINKED}`]: { body: paged } })
|
||||
await wrapper.find('[data-page="next"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(queryOf(requestsTo(calls, 'GET', LINKED).at(-1)!).get('page')).toBe('2')
|
||||
})
|
||||
|
||||
it('shows no unlink column or button when the view does not declare unlink', async () => {
|
||||
const schema = clone(relationSchemaFixture)
|
||||
schema.data.view.toolbarButtons = ['link']
|
||||
schema.data.view.showSearch = false
|
||||
const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: { body: schema } })
|
||||
expect(wrapper.find('[data-action="unlink"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[role="checkbox"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-relation-search]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts the server message when an unlink is refused and keeps the selection', async () => {
|
||||
const { wrapper } = await mountManager({ [`POST ${UNLINK}`]: failure(409, 'Owner cannot be removed') })
|
||||
await selectFirstAndUnlink(wrapper)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Owner cannot be removed', tone: 'danger' })
|
||||
expect(wrapper.find('tbody tr').attributes('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('toasts the generic error when the unlink cannot reach the server', async () => {
|
||||
const { wrapper } = await mountManager({
|
||||
[`POST ${UNLINK}`]: () => {
|
||||
throw new TypeError('offline')
|
||||
},
|
||||
})
|
||||
await selectFirstAndUnlink(wrapper)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
|
||||
expect(wrapper.find('[data-action="unlink"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
160
admin/tests/relation/RelationPickerModal.test.ts
Normal file
160
admin/tests/relation/RelationPickerModal.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import RelationPickerModal from '../../src/components/relation/RelationPickerModal.vue'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { candidatesFixture, relationSchemaFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
|
||||
|
||||
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
||||
const CANDIDATES = `${API}/acme/demo/widgets/1/relations/members/candidates`
|
||||
const LINK = `${API}/acme/demo/widgets/1/relations/members/link`
|
||||
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${CANDIDATES}`]: (request) => ({
|
||||
body: queryOf(request).get('page') === '2' ? candidatesFixture.page2 : candidatesFixture.page1,
|
||||
}),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function openModal(overrides: Record<string, Route> = {}) {
|
||||
const calls = mockApi(routes(overrides))
|
||||
const wrapper = mount(RelationPickerModal, {
|
||||
props: { open: true, source, recordId: 1, relation: 'members', schema: relationSchemaFixture.data },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
await flushPromises()
|
||||
return { wrapper, calls }
|
||||
}
|
||||
|
||||
const dialog = () => document.body.querySelector<HTMLElement>('[data-relation-picker]')
|
||||
const options = () => [...(dialog()?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])]
|
||||
const confirmButton = () => dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm-link"]')!
|
||||
|
||||
async function click(element: Element | null | undefined) {
|
||||
;(element as HTMLElement).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('relation picker modal', () => {
|
||||
it('is a titled, described modal dialog with a labelled multi-select listbox', async () => {
|
||||
await openModal()
|
||||
const element = dialog()!
|
||||
expect(element.getAttribute('role')).toBe('dialog')
|
||||
expect(element.getAttribute('aria-modal')).toBe('true')
|
||||
const title = document.getElementById(element.getAttribute('aria-labelledby')!)
|
||||
expect(title?.textContent).toBe('Add member')
|
||||
const description = document.getElementById(element.getAttribute('aria-describedby')!)
|
||||
expect(description?.textContent).toContain('The owner is not listed.')
|
||||
const listbox = element.querySelector('[role="listbox"]')!
|
||||
expect(listbox.getAttribute('aria-multiselectable')).toBe('true')
|
||||
expect(listbox.getAttribute('aria-label')).toBe('Add member')
|
||||
expect(element.querySelector('[data-candidate-search]')!.getAttribute('aria-controls')).toBe(listbox.id)
|
||||
expect(element.querySelector('[data-action="close"]')!.getAttribute('aria-label')).toBe('Zamknij')
|
||||
expect(options().every((option) => option.getAttribute('tabindex') === '0')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows initials and both columns for each candidate as served', async () => {
|
||||
await openModal()
|
||||
const first = options()[0]!
|
||||
expect(first.querySelector('[aria-hidden="true"]')!.textContent).toBe('AT')
|
||||
expect(first.textContent).toContain('alan.turing@example.test')
|
||||
})
|
||||
|
||||
it('disables prev on the first page and next on the last', async () => {
|
||||
await openModal()
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-page="prev"]')!.disabled).toBe(true)
|
||||
await click(dialog()!.querySelector('[data-page="next"]'))
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-page="next"]')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles with Enter and moves between options with the arrow keys', async () => {
|
||||
await openModal()
|
||||
const [first, second] = options()
|
||||
first!.focus()
|
||||
expect(keydown(first!, 'Enter').defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(options()[0]!.getAttribute('aria-selected')).toBe('true')
|
||||
keydown(first!, 'ArrowDown')
|
||||
expect(document.activeElement).toBe(second)
|
||||
keydown(second!, 'ArrowUp')
|
||||
expect(document.activeElement).toBe(first)
|
||||
keydown(first!, 'ArrowUp')
|
||||
expect(document.activeElement).toBe(first)
|
||||
keydown(first!, 'Enter')
|
||||
await flushPromises()
|
||||
expect(options()[0]!.getAttribute('aria-selected')).toBe('false')
|
||||
expect(keydown(first!, 'x').defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('says so when no candidate is left', async () => {
|
||||
await openModal({ [`GET ${CANDIDATES}`]: { body: candidatesFixture.empty } })
|
||||
expect(dialog()!.querySelector('[data-candidates-empty]')!.textContent?.trim()).toBe('Brak rekordów do dodania.')
|
||||
expect(dialog()!.querySelector('nav')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a failed candidates load as empty', async () => {
|
||||
await openModal({ [`GET ${CANDIDATES}`]: failure(500, 'Boom') })
|
||||
expect(options()).toHaveLength(0)
|
||||
expect(dialog()!.querySelector('[data-candidates-empty]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('toasts the server message when the link is refused and stays open', async () => {
|
||||
const { wrapper, calls } = await openModal({ [`POST ${LINK}`]: failure(422, 'Already a member') })
|
||||
await click(options()[0])
|
||||
await click(confirmButton())
|
||||
expect(requestsTo(calls, 'POST', LINK)).toHaveLength(1)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Already a member', tone: 'danger' })
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(wrapper.emitted('linked')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('toasts the generic error when the link cannot reach the server', async () => {
|
||||
await openModal({
|
||||
[`POST ${LINK}`]: () => {
|
||||
throw new TypeError('offline')
|
||||
},
|
||||
})
|
||||
await click(options()[1])
|
||||
await click(confirmButton())
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
|
||||
expect(confirmButton().disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('emits the linked count and closes after a successful link', async () => {
|
||||
const { wrapper } = await openModal({ [`POST ${LINK}`]: { body: { data: {}, meta: {} } } })
|
||||
await click(options()[0])
|
||||
await click(options()[2])
|
||||
await click(confirmButton())
|
||||
expect(wrapper.emitted('update:open')).toEqual([[false]])
|
||||
// Without a server count the selection size is reported.
|
||||
expect(wrapper.emitted('linked')).toEqual([[2]])
|
||||
})
|
||||
|
||||
it('clears the search, page and selection when it closes', async () => {
|
||||
const { wrapper, calls } = await openModal()
|
||||
await click(options()[0])
|
||||
await wrapper.setProps({ open: false })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
await flushPromises()
|
||||
expect(confirmButton().textContent?.trim()).toBe('Dodaj (0)')
|
||||
expect(queryOf(requestsTo(calls, 'GET', CANDIDATES).at(-1)!).get('page')).toBe('1')
|
||||
})
|
||||
})
|
||||
79
admin/tests/shell/AppShell.test.ts
Normal file
79
admin/tests/shell/AppShell.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import AppShell from '../../src/components/shell/AppShell.vue'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { NARROW_QUERY, setCollapsed } from '../../src/state/useSidebar'
|
||||
import { showToast } from '../../src/state/useToasts'
|
||||
import { navigationFixture } from '../fixtures/typed'
|
||||
import { fakeMedia, resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
async function mountShell(path: string) {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(AppShell, {
|
||||
slots: { default: '<p data-page>content</p>' },
|
||||
global: { plugins: [router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setCollapsed(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setCollapsed(false)
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('app shell', () => {
|
||||
it('renders rail, section panel, header landmarks and the page in main', async () => {
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
expect(wrapper.find('nav[aria-label="Wtyczki"]').exists()).toBe(true)
|
||||
expect(wrapper.find('aside[aria-label="Sekcje"]').exists()).toBe(true)
|
||||
expect(wrapper.find('header nav[aria-label="Ścieżka nawigacji"]').exists()).toBe(true)
|
||||
expect(wrapper.find('header [data-user-menu]').exists()).toBe(true)
|
||||
expect(wrapper.find('main [data-page]').text()).toBe('content')
|
||||
})
|
||||
|
||||
it('has no section panel outside a permitted plugin', async () => {
|
||||
const wrapper = await mountShell('/settings')
|
||||
expect(wrapper.find('aside').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the section panel when collapsed by the admin or by the viewport', async () => {
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
setCollapsed(true)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('aside').exists()).toBe(false)
|
||||
setCollapsed(false)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('aside').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('follows the viewport while mounted and stops listening on unmount', async () => {
|
||||
const media = fakeMedia({ [NARROW_QUERY]: false })
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
expect(media.listeners(NARROW_QUERY)).toBe(1)
|
||||
media.change(NARROW_QUERY, true)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('aside').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
expect(media.listeners(NARROW_QUERY)).toBe(0)
|
||||
})
|
||||
|
||||
it('mounts the toast queue with status and alert roles', async () => {
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
showToast('Saved')
|
||||
showToast('Broken', 'danger')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Saved')
|
||||
expect(wrapper.find('[role="alert"]').text()).toContain('Broken')
|
||||
})
|
||||
})
|
||||
79
admin/tests/shell/Breadcrumbs.test.ts
Normal file
79
admin/tests/shell/Breadcrumbs.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import Breadcrumbs from '../../src/components/shell/Breadcrumbs.vue'
|
||||
import { setRecordCrumb } from '../../src/state/useBreadcrumbs'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
||||
import { resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
async function crumbsAt(path: string) {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
setSettings(settingsFixture.list.data)
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(Breadcrumbs, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[data-crumb]')
|
||||
return {
|
||||
wrapper,
|
||||
labels: items.map((item) => item.text()),
|
||||
links: items.map((item) => (item.find('a').exists() ? item.find('a').attributes('href') : null)),
|
||||
current: items.map((item) => item.find('[aria-current="page"]').exists()),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('breadcrumbs', () => {
|
||||
it('is a labelled nav listing plugin and controller, the last one current', async () => {
|
||||
const { wrapper, labels, links, current } = await crumbsAt('/acme/demo/gadgets')
|
||||
expect(wrapper.find('nav').attributes('aria-label')).toBe('Ścieżka nawigacji')
|
||||
expect(wrapper.find('ol').exists()).toBe(true)
|
||||
expect(labels).toEqual(['Demo', 'Gadgets'])
|
||||
expect(links).toEqual(['/acme/demo/widgets', null])
|
||||
expect(current).toEqual([false, true])
|
||||
expect(wrapper.findAll('svg[aria-hidden="true"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('adds the record title on record and create routes only', async () => {
|
||||
setRecordCrumb('Blue widget')
|
||||
let crumbs = await crumbsAt('/acme/demo/widgets/1')
|
||||
expect(crumbs.labels).toEqual(['Demo', 'Widgets', 'Blue widget'])
|
||||
expect(crumbs.links).toEqual(['/acme/demo/widgets', '/acme/demo/widgets', null])
|
||||
expect(crumbs.current).toEqual([false, false, true])
|
||||
|
||||
setRecordCrumb('New widget')
|
||||
crumbs = await crumbsAt('/acme/demo/widgets/create')
|
||||
expect(crumbs.labels).toEqual(['Demo', 'Widgets', 'New widget'])
|
||||
|
||||
crumbs = await crumbsAt('/acme/demo/widgets')
|
||||
expect(crumbs.labels).toEqual(['Demo', 'Widgets'])
|
||||
})
|
||||
|
||||
it('leaves out an empty record title', async () => {
|
||||
const { labels } = await crumbsAt('/acme/demo/widgets/1')
|
||||
expect(labels).toEqual(['Demo', 'Widgets'])
|
||||
})
|
||||
|
||||
it('shows Ustawienia and the page label on settings routes', async () => {
|
||||
expect((await crumbsAt('/settings')).labels).toEqual(['Ustawienia'])
|
||||
const page = await crumbsAt('/settings/mail')
|
||||
expect(page.labels).toEqual(['Ustawienia', 'Mail'])
|
||||
expect(page.links).toEqual(['/settings', null])
|
||||
expect((await crumbsAt('/settings/unknown')).labels).toEqual(['Ustawienia'])
|
||||
})
|
||||
|
||||
it('is empty outside any permitted plugin', async () => {
|
||||
expect((await crumbsAt('/acme/secret/things')).labels).toEqual([])
|
||||
expect((await crumbsAt('/acme/demo/unlisted')).labels).toEqual(['Demo'])
|
||||
})
|
||||
})
|
||||
136
admin/tests/shell/PluginRail.test.ts
Normal file
136
admin/tests/shell/PluginRail.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import PluginRail from '../../src/components/shell/PluginRail.vue'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { NARROW_QUERY, SIDEBAR_KEY, setCollapsed, watchViewport } from '../../src/state/useSidebar'
|
||||
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
||||
import { fakeMedia, keydown, resetState, routerAt, signIn, wait } from '../helpers'
|
||||
|
||||
async function mountRail(path: string) {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(PluginRail, { global: { plugins: [router] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
let stopViewport: (() => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setCollapsed(false)
|
||||
stopViewport = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stopViewport?.()
|
||||
setCollapsed(false)
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('plugin rail', () => {
|
||||
it('is a labelled navigation of the permitted plugins in order, the active one aria-current', async () => {
|
||||
const { wrapper } = await mountRail('/acme/demo/gadgets')
|
||||
const nav = wrapper.find('nav')
|
||||
expect(nav.attributes('aria-label')).toBe('Wtyczki')
|
||||
const items = wrapper.findAll('[data-rail-item]')
|
||||
expect(items.map((item) => item.attributes('data-rail-item'))).toEqual(['demo', 'tools'])
|
||||
expect(items.map((item) => item.text())).toEqual(['Demo', 'Tools'])
|
||||
expect(items[0]!.attributes('aria-current')).toBe('page')
|
||||
expect(items[1]!.attributes('aria-current')).toBeUndefined()
|
||||
expect(items[0]!.attributes('href')).toBe('/acme/demo/widgets')
|
||||
expect(items[1]!.attributes('href')).toBe('/acme/tools/hammers')
|
||||
// Expanded: no popup semantics on the items.
|
||||
expect(items[0]!.attributes('aria-haspopup')).toBeUndefined()
|
||||
expect(items[0]!.attributes('aria-expanded')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows Ustawienia only with a permitted settings page, current on settings routes', async () => {
|
||||
const { wrapper } = await mountRail('/settings/mail')
|
||||
expect(wrapper.find('[data-rail-settings]').exists()).toBe(false)
|
||||
setSettings(settingsFixture.list.data)
|
||||
await flushPromises()
|
||||
const link = wrapper.find('[data-rail-settings]')
|
||||
expect(link.text()).toBe('Ustawienia')
|
||||
expect(link.attributes('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('offers menu semantics and the expand button after a manual collapse', async () => {
|
||||
const { wrapper } = await mountRail('/acme/demo/widgets')
|
||||
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
|
||||
setCollapsed(true)
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-rail-item="demo"]')
|
||||
expect(item.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(item.attributes('aria-expanded')).toBe('false')
|
||||
const expand = wrapper.find('[data-sidebar-expand]')
|
||||
expect(expand.attributes('aria-label')).toBe('Rozwiń menu')
|
||||
|
||||
await expand.trigger('click')
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
|
||||
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('has no expand button when the viewport forces the collapse', async () => {
|
||||
fakeMedia({ [NARROW_QUERY]: true })
|
||||
stopViewport = watchViewport()
|
||||
const { wrapper } = await mountRail('/acme/demo/widgets')
|
||||
expect(wrapper.find('[data-rail-item="demo"]').attributes('aria-haspopup')).toBe('menu')
|
||||
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the flyout on hover, links it by aria-controls and closes it on expand', async () => {
|
||||
const { wrapper } = await mountRail('/acme/demo/widgets')
|
||||
setCollapsed(true)
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-rail-item="tools"]')
|
||||
await item.trigger('mouseenter')
|
||||
const flyout = document.querySelector('[data-section-flyout]')
|
||||
expect(flyout?.getAttribute('aria-label')).toBe('Tools')
|
||||
expect(item.attributes('aria-expanded')).toBe('true')
|
||||
expect(item.attributes('aria-controls')).toBe(flyout?.id)
|
||||
|
||||
setCollapsed(false)
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-section-flyout]')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores keys while expanded and closes an open flyout on Escape', async () => {
|
||||
const { wrapper } = await mountRail('/acme/demo/widgets')
|
||||
const item = wrapper.find('[data-rail-item="demo"]').element
|
||||
expect(keydown(item, 'Enter').defaultPrevented).toBe(false)
|
||||
|
||||
setCollapsed(true)
|
||||
await flushPromises()
|
||||
expect(keydown(item, 'ArrowDown').defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
|
||||
expect(keydown(item, 'Escape').defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-section-flyout]')).toBeNull()
|
||||
expect(keydown(item, 'Escape').defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the flyout open while focus stays on its rail item', async () => {
|
||||
const { wrapper } = await mountRail('/acme/demo/widgets')
|
||||
setCollapsed(true)
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-rail-item="demo"]')
|
||||
;(item.element as HTMLElement).focus()
|
||||
await item.trigger('focus')
|
||||
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
|
||||
await item.trigger('mouseleave')
|
||||
await wait(250)
|
||||
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
|
||||
;(item.element as HTMLElement).blur()
|
||||
await item.trigger('blur')
|
||||
await wait(250)
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-section-flyout]')).toBeNull()
|
||||
})
|
||||
})
|
||||
88
admin/tests/shell/SectionFlyout.test.ts
Normal file
88
admin/tests/shell/SectionFlyout.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import SectionFlyout from '../../src/components/shell/SectionFlyout.vue'
|
||||
import type { NavigationEntry } from '../../src/api/types'
|
||||
import { clone, navigationFixture } from '../fixtures/typed'
|
||||
import { keydown, resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
function entryWithThree(): NavigationEntry {
|
||||
const entry = clone(navigationFixture.data[0]!)
|
||||
entry.sideMenu.push({ code: 'gizmos', label: 'Gizmos', icon: 'list', order: 0, controller: 'acme.demo.gizmos', sideMenu: [] })
|
||||
entry.sideMenu.push({ code: 'broken', label: 'Broken', icon: 'list', order: 0, controller: 'bad', sideMenu: [] })
|
||||
return entry
|
||||
}
|
||||
|
||||
async function mountFlyout(path = '/acme/demo/gadgets') {
|
||||
await signIn()
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(SectionFlyout, {
|
||||
props: { entry: entryWithThree(), id: 'section-flyout-demo' },
|
||||
global: { plugins: [router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const active = () => document.activeElement?.textContent?.trim()
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('section flyout', () => {
|
||||
it('is a labelled menu of menuitems outside the tab order, the current one aria-current', async () => {
|
||||
const wrapper = await mountFlyout()
|
||||
const menu = wrapper.find('[role="menu"]')
|
||||
expect(menu.attributes('id')).toBe('section-flyout-demo')
|
||||
expect(menu.attributes('aria-label')).toBe('Demo')
|
||||
const items = wrapper.findAll('[role="menuitem"]')
|
||||
expect(items.map((item) => item.text())).toEqual(['Widgets', 'Gadgets', 'Gizmos'])
|
||||
expect(items.every((item) => item.attributes('tabindex') === '-1')).toBe(true)
|
||||
expect(items[1]!.attributes('aria-current')).toBe('page')
|
||||
expect(items[0]!.attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('focuses the first item and moves with arrows, Home and End, wrapping around', async () => {
|
||||
const wrapper = await mountFlyout()
|
||||
const menu = wrapper.find('[role="menu"]').element
|
||||
await (wrapper.vm as unknown as { focusFirst: () => Promise<void> }).focusFirst()
|
||||
expect(active()).toBe('Widgets')
|
||||
keydown(document.activeElement!, 'ArrowDown')
|
||||
expect(active()).toBe('Gadgets')
|
||||
keydown(document.activeElement!, 'End')
|
||||
expect(active()).toBe('Gizmos')
|
||||
keydown(document.activeElement!, 'ArrowDown')
|
||||
expect(active()).toBe('Widgets')
|
||||
keydown(document.activeElement!, 'ArrowUp')
|
||||
expect(active()).toBe('Gizmos')
|
||||
keydown(document.activeElement!, 'Home')
|
||||
expect(active()).toBe('Widgets')
|
||||
expect(keydown(menu, 'x').defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('asks the rail to close on Escape and reports Tab and navigation', async () => {
|
||||
const wrapper = await mountFlyout()
|
||||
const menu = wrapper.find('[role="menu"]').element
|
||||
expect(keydown(menu, 'Escape').defaultPrevented).toBe(true)
|
||||
keydown(menu, 'Tab')
|
||||
await wrapper.findAll('[role="menuitem"]')[2]!.trigger('click')
|
||||
expect(wrapper.emitted('escape')).toHaveLength(1)
|
||||
expect(wrapper.emitted('leave')).toHaveLength(1)
|
||||
expect(wrapper.emitted('navigate')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('tells whether an element sits inside it', async () => {
|
||||
const wrapper = await mountFlyout()
|
||||
const vm = wrapper.vm as unknown as { contains: (element: Element | null) => boolean }
|
||||
expect(vm.contains(wrapper.find('[role="menuitem"]').element)).toBe(true)
|
||||
expect(vm.contains(document.body)).toBe(false)
|
||||
expect(vm.contains(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
58
admin/tests/shell/SectionPanel.test.ts
Normal file
58
admin/tests/shell/SectionPanel.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import SectionPanel from '../../src/components/shell/SectionPanel.vue'
|
||||
import { SIDEBAR_KEY, setCollapsed, sidebarCollapsed } from '../../src/state/useSidebar'
|
||||
import { clone, navigationFixture } from '../fixtures/typed'
|
||||
import { resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setCollapsed(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setCollapsed(false)
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
async function mountPanel(path: string, entry = navigationFixture.data[0]!) {
|
||||
await signIn()
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(SectionPanel, { props: { entry }, global: { plugins: [router] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('section panel', () => {
|
||||
it('is a labelled region with the plugin label and its permitted side menu', async () => {
|
||||
const wrapper = await mountPanel('/acme/demo/gadgets/4')
|
||||
expect(wrapper.find('aside').attributes('aria-label')).toBe('Sekcje')
|
||||
expect(wrapper.find('aside span').text()).toBe('Demo')
|
||||
const links = wrapper.findAll('a')
|
||||
expect(links.map((link) => [link.text(), link.attributes('href')])).toEqual([
|
||||
['Widgets', '/acme/demo/widgets'],
|
||||
['Gadgets', '/acme/demo/gadgets'],
|
||||
])
|
||||
expect(links[0]!.attributes('aria-current')).toBeUndefined()
|
||||
expect(links[1]!.attributes('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('skips side-menu items whose controller id is invalid', async () => {
|
||||
const entry = clone(navigationFixture.data[0]!)
|
||||
entry.sideMenu[0]!.controller = 'not a controller'
|
||||
const wrapper = await mountPanel('/acme/demo/gadgets', entry)
|
||||
expect(wrapper.findAll('a').map((link) => link.text())).toEqual(['Gadgets'])
|
||||
})
|
||||
|
||||
it('collapses on "Zwiń menu" and persists the choice', async () => {
|
||||
const wrapper = await mountPanel('/acme/demo/widgets')
|
||||
const button = wrapper.find('[data-sidebar-collapse]')
|
||||
expect(button.attributes('aria-label')).toBe('Zwiń menu')
|
||||
expect(button.attributes('type')).toBe('button')
|
||||
await button.trigger('click')
|
||||
expect(sidebarCollapsed.value).toBe(true)
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
})
|
||||
})
|
||||
88
admin/tests/shell/UserMenu.test.ts
Normal file
88
admin/tests/shell/UserMenu.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import UserMenu from '../../src/components/shell/UserMenu.vue'
|
||||
import { currentUser } from '../../src/state/useAuth'
|
||||
import { API, profile, requestsTo, resetState, routerAt, signIn, wait } from '../helpers'
|
||||
|
||||
async function mountMenu(user: Partial<typeof profile.data> | null) {
|
||||
let calls: Request[] = []
|
||||
if (user) {
|
||||
calls = await signIn({
|
||||
[`GET ${API}/auth/me`]: { body: { data: { ...profile.data, ...user }, meta: {} } },
|
||||
[`POST ${API}/auth/logout`]: { body: { data: { status: 'logged_out' }, meta: {} } },
|
||||
})
|
||||
}
|
||||
const router = await routerAt('/acme/demo/widgets')
|
||||
const wrapper = mount(UserMenu, { global: { plugins: [router] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
return { wrapper, router, calls }
|
||||
}
|
||||
|
||||
async function open(wrapper: ReturnType<typeof mount>): Promise<HTMLElement> {
|
||||
const trigger = wrapper.find('[data-user-menu]')
|
||||
await trigger.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
const content = document.querySelector<HTMLElement>('[data-user-menu-content]')
|
||||
expect(content).not.toBeNull()
|
||||
return content!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('user menu', () => {
|
||||
it('labels the trigger with the name and shows initials, name and role', async () => {
|
||||
const { wrapper } = await mountMenu({})
|
||||
const trigger = wrapper.find('[data-user-menu]')
|
||||
expect(trigger.attributes('aria-label')).toBe('Menu użytkownika: Dana Dev')
|
||||
expect(trigger.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(trigger.attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.find('[data-user-initials]').text()).toBe('DD')
|
||||
expect(wrapper.find('[data-user-initials]').attributes('aria-hidden')).toBe('true')
|
||||
expect(wrapper.find('[data-user-role]').text()).toBe('Developer')
|
||||
})
|
||||
|
||||
it('falls back to the login for the name and initials and hides a missing role', async () => {
|
||||
const { wrapper } = await mountMenu({ first_name: ' ', last_name: '', login: 'root', role: undefined })
|
||||
expect(wrapper.find('[data-user-identity]').text()).toBe('root')
|
||||
expect(wrapper.find('[data-user-initials]').text()).toBe('RO')
|
||||
expect(wrapper.find('[data-user-role]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses a single initial for a single name', async () => {
|
||||
const { wrapper } = await mountMenu({ first_name: 'ada', last_name: '' })
|
||||
expect(wrapper.find('[data-user-initials]').text()).toBe('A')
|
||||
})
|
||||
|
||||
it('is disabled without a signed-in admin', async () => {
|
||||
const { wrapper } = await mountMenu(null)
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(wrapper.find('[data-user-menu]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-user-initials]').text()).toBe('')
|
||||
})
|
||||
|
||||
it('opens a menu with the e-mail and logs out to the login route', async () => {
|
||||
const { wrapper, router, calls } = await mountMenu({})
|
||||
const content = await open(wrapper)
|
||||
expect(content.getAttribute('role')).toBe('menu')
|
||||
expect(content.textContent).toContain('dev@example.test')
|
||||
const item = content.querySelector<HTMLElement>('[data-action="logout"]')!
|
||||
expect(item.getAttribute('role')).toBe('menuitem')
|
||||
expect(item.textContent?.trim()).toBe('Wyloguj')
|
||||
|
||||
item.click()
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/logout`)).toHaveLength(1)
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
})
|
||||
141
admin/tests/state/useAuth.test.ts
Normal file
141
admin/tests/state/useAuth.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearUser, currentUser, login, logout, me, useAuth } from '../../src/state/useAuth'
|
||||
import { navigation, setNavigation } from '../../src/state/useNavigation'
|
||||
import { setSettings, settings } from '../../src/state/useSettings'
|
||||
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, profile, requestsTo, resetState } from '../helpers'
|
||||
|
||||
const loginOk = (expiresIn: unknown) => ({ body: { data: { token_type: 'cookie', expires_in: expiresIn }, meta: {} } })
|
||||
const refreshOk = { body: { data: { token_type: 'cookie', expires_in: 50 }, meta: {} } }
|
||||
const unauthorized = { status: 401, body: { error: { code: 'unauthenticated', message: 'no', details: {} } } }
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearUser()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('posts the credentials and schedules the proactive refresh at 80 percent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls = mockApi({ [`POST ${API}/auth/login`]: loginOk(100), [`POST ${API}/auth/refresh`]: refreshOk })
|
||||
|
||||
expect(await login('dev', 'secret')).toBe(true)
|
||||
const body = (await requestsTo(calls, 'POST', `${API}/auth/login`)[0]!.clone().json()) as Record<string, string>
|
||||
expect(body).toEqual({ login: 'dev', password: 'secret' })
|
||||
expect(useAuth().expiresIn.value).toBe(100)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(79_999)
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(1)
|
||||
// The refresh reschedules from its own lifetime (50 s -> 40 s).
|
||||
expect(useAuth().expiresIn.value).toBe(50)
|
||||
await vi.advanceTimersByTimeAsync(40_000)
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('answers false on rejected credentials and schedules nothing', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls = mockApi({ [`POST ${API}/auth/login`]: unauthorized, [`POST ${API}/auth/refresh`]: refreshOk })
|
||||
expect(await login('dev', 'bad')).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(10_000_000)
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([0, -5, 'soon', null])('schedules no refresh for the lifetime %j', async (expiresIn) => {
|
||||
vi.useFakeTimers()
|
||||
const calls = mockApi({ [`POST ${API}/auth/login`]: loginOk(expiresIn), [`POST ${API}/auth/refresh`]: refreshOk })
|
||||
expect(await login('dev', 'secret')).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(10_000_000)
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('me and clearUser', () => {
|
||||
it('loads the profile, and clears it on 401', async () => {
|
||||
mockApi({ [`GET ${API}/auth/me`]: { body: profile } })
|
||||
expect((await me())?.login).toBe('dev')
|
||||
expect(currentUser.value?.role?.name).toBe('Developer')
|
||||
|
||||
mockApi({ [`GET ${API}/auth/me`]: unauthorized })
|
||||
expect(await me()).toBeNull()
|
||||
expect(currentUser.value).toBeNull()
|
||||
})
|
||||
|
||||
it('clearUser forgets the user and cancels the pending refresh', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls = mockApi({
|
||||
[`GET ${API}/auth/me`]: { body: profile },
|
||||
[`POST ${API}/auth/login`]: loginOk(10),
|
||||
[`POST ${API}/auth/refresh`]: refreshOk,
|
||||
})
|
||||
await login('dev', 'secret')
|
||||
await me()
|
||||
clearUser()
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(useAuth().expiresIn.value).toBeNull()
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout (T-10-23)', () => {
|
||||
async function signedIn(logoutRoute: Parameters<typeof mockApi>[0][string] | 'throw') {
|
||||
const routes: Parameters<typeof mockApi>[0] = { [`GET ${API}/auth/me`]: { body: profile } }
|
||||
if (logoutRoute !== 'throw') {
|
||||
routes[`POST ${API}/auth/logout`] = logoutRoute
|
||||
}
|
||||
const calls = mockApi(routes)
|
||||
await me()
|
||||
setNavigation(navigationFixture.data)
|
||||
setSettings(settingsFixture.list.data)
|
||||
if (logoutRoute === 'throw') {
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'))
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
it.each([
|
||||
['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }],
|
||||
['answers 500', { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } }],
|
||||
['fails on the network', 'throw' as const],
|
||||
])('clears the user, navigation and settings and routes to login when the call %s', async (_label, route) => {
|
||||
const calls = await signedIn(route)
|
||||
const router = { replace: vi.fn().mockResolvedValue(undefined) }
|
||||
|
||||
await logout(router)
|
||||
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(navigation.value).toEqual([])
|
||||
expect(settings.value).toEqual([])
|
||||
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
|
||||
if (route !== 'throw') {
|
||||
const posted = requestsTo(calls, 'POST', `${API}/auth/logout`)
|
||||
expect(posted).toHaveLength(1)
|
||||
expect(posted[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
}
|
||||
})
|
||||
|
||||
it('works without a router and swallows a failed navigation', async () => {
|
||||
await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } })
|
||||
await expect(logout()).resolves.toBeUndefined()
|
||||
expect(currentUser.value).toBeNull()
|
||||
|
||||
await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } })
|
||||
await expect(logout({ replace: vi.fn().mockRejectedValue(new Error('aborted')) })).resolves.toBeUndefined()
|
||||
expect(currentUser.value).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes the session API through useAuth', () => {
|
||||
const auth = useAuth()
|
||||
expect(auth.user).toBe(currentUser)
|
||||
expect(auth.login).toBe(login)
|
||||
expect(auth.logout).toBe(logout)
|
||||
expect(auth.me).toBe(me)
|
||||
expect(auth.clearUser).toBe(clearUser)
|
||||
})
|
||||
})
|
||||
12
admin/tests/state/useBreadcrumbs.test.ts
Normal file
12
admin/tests/state/useBreadcrumbs.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clearRecordCrumb, recordCrumb, setRecordCrumb } from '../../src/state/useBreadcrumbs'
|
||||
|
||||
describe('record crumb', () => {
|
||||
it('publishes and clears the record title', () => {
|
||||
expect(recordCrumb.value).toBe('')
|
||||
setRecordCrumb('Blue widget')
|
||||
expect(recordCrumb.value).toBe('Blue widget')
|
||||
clearRecordCrumb()
|
||||
expect(recordCrumb.value).toBe('')
|
||||
})
|
||||
})
|
||||
80
admin/tests/state/useNavigation.test.ts
Normal file
80
admin/tests/state/useNavigation.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { NavigationEntry } from '../../src/api/types'
|
||||
import {
|
||||
activeEntry,
|
||||
firstControllerPath,
|
||||
homePath,
|
||||
loadNavigation,
|
||||
navigation,
|
||||
railEntries,
|
||||
setNavigation,
|
||||
useNavigation,
|
||||
} from '../../src/state/useNavigation'
|
||||
import { clone, navigationFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, requestsTo, resetState } from '../helpers'
|
||||
|
||||
function entry(code: string, order: number, controllers: string[], controller = controllers[0] ?? 'x.y.z'): NavigationEntry {
|
||||
return {
|
||||
code,
|
||||
label: code,
|
||||
icon: 'tags',
|
||||
order,
|
||||
controller,
|
||||
sideMenu: controllers.map((id, index) => ({ code: `${code}-${index}`, label: id, icon: 'tags', order: 0, controller: id, sideMenu: [] })),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('loadNavigation', () => {
|
||||
it('stores the server-filtered entries', async () => {
|
||||
const calls = mockApi({ [`GET ${API}/navigation`]: { body: navigationFixture } })
|
||||
expect(await loadNavigation()).toHaveLength(3)
|
||||
expect(navigation.value.map((item) => item.code)).toEqual(['demo', 'tools', 'hidden'])
|
||||
expect(requestsTo(calls, 'GET', `${API}/navigation`)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('empties the navigation when the request fails', async () => {
|
||||
setNavigation(navigationFixture.data)
|
||||
mockApi({ [`GET ${API}/navigation`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } } })
|
||||
expect(await loadNavigation()).toEqual([])
|
||||
expect(navigation.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rail entries', () => {
|
||||
it('omits plugins without a permitted side-menu item and orders by order, then position', () => {
|
||||
setNavigation([entry('b', 20, ['a.b.c']), entry('empty', 1, []), entry('a', 10, ['a.a.c']), entry('c', 20, ['a.c.c'])])
|
||||
expect(railEntries.value.map((item) => item.code)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('opens the first valid side-menu controller, else the entry controller', () => {
|
||||
expect(firstControllerPath(entry('p', 0, ['bad id', 'acme.demo.gadgets']))).toBe('/acme/demo/gadgets')
|
||||
expect(firstControllerPath({ ...entry('p', 0, ['bad id']), controller: 'acme.demo.widgets' })).toBe('/acme/demo/widgets')
|
||||
expect(firstControllerPath({ ...entry('p', 0, []), controller: 'nope' })).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the active plugin by its own controller or any side-menu controller', () => {
|
||||
setNavigation(navigationFixture.data)
|
||||
expect(activeEntry('acme', 'demo')?.code).toBe('demo')
|
||||
expect(activeEntry('acme', 'tools')?.code).toBe('tools')
|
||||
expect(activeEntry('acme', 'hidden')).toBeNull()
|
||||
expect(activeEntry('acme', 'missing')).toBeNull()
|
||||
|
||||
const shared = clone(navigationFixture.data)
|
||||
shared[0]!.controller = 'other.root.index'
|
||||
setNavigation(shared)
|
||||
expect(activeEntry('acme', 'demo')?.code).toBe('demo')
|
||||
})
|
||||
|
||||
it('goes home to the first rail plugin, skipping plugins without a valid path', () => {
|
||||
expect(homePath()).toBeNull()
|
||||
setNavigation([{ ...entry('broken', 1, ['bad id']), controller: 'bad' }, entry('ok', 2, ['acme.tools.hammers'])])
|
||||
expect(homePath()).toBe('/acme/tools/hammers')
|
||||
setNavigation(navigationFixture.data)
|
||||
expect(homePath()).toBe('/acme/demo/widgets')
|
||||
expect(useNavigation().homePath).toBe(homePath)
|
||||
})
|
||||
})
|
||||
47
admin/tests/state/useSettings.test.ts
Normal file
47
admin/tests/state/useSettings.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasSettings,
|
||||
loadSettings,
|
||||
setSettings,
|
||||
settings,
|
||||
settingsEntry,
|
||||
settingsGroups,
|
||||
useSettings,
|
||||
} from '../../src/state/useSettings'
|
||||
import { settingsFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, resetState } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('useSettings (D-21)', () => {
|
||||
it('is hidden until at least one settings page is permitted', async () => {
|
||||
expect(hasSettings.value).toBe(false)
|
||||
mockApi({ [`GET ${API}/settings`]: { body: settingsFixture.list } })
|
||||
expect(await loadSettings()).toHaveLength(3)
|
||||
expect(hasSettings.value).toBe(true)
|
||||
expect(useSettings().settings).toBe(settings)
|
||||
})
|
||||
|
||||
it('hides settings again when the request fails', async () => {
|
||||
setSettings(settingsFixture.list.data)
|
||||
mockApi({ [`GET ${API}/settings`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } } })
|
||||
expect(await loadSettings()).toEqual([])
|
||||
expect(hasSettings.value).toBe(false)
|
||||
})
|
||||
|
||||
it('finds an entry by code', () => {
|
||||
setSettings(settingsFixture.list.data)
|
||||
expect(settingsEntry('mail')?.label).toBe('Mail')
|
||||
expect(settingsEntry('nope')).toBeNull()
|
||||
})
|
||||
|
||||
it('groups by category in first-seen order after sorting by order', () => {
|
||||
setSettings(settingsFixture.list.data)
|
||||
expect(settingsGroups.value.map((group) => [group.category, group.entries.map((entry) => entry.code)])).toEqual([
|
||||
['Appearance', ['branding']],
|
||||
['System', ['mail', 'search']],
|
||||
])
|
||||
})
|
||||
})
|
||||
95
admin/tests/state/useSidebar.test.ts
Normal file
95
admin/tests/state/useSidebar.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NARROW_QUERY, SIDEBAR_KEY, setCollapsed, sidebarCollapsed, useSidebar, watchViewport } from '../../src/state/useSidebar'
|
||||
import { fakeMedia, resetState } from '../helpers'
|
||||
|
||||
let stop: (() => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
stop = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stop?.()
|
||||
setCollapsed(false)
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
describe('useSidebar (D-06, T-10-22)', () => {
|
||||
it('uses the documented storage key and breakpoint', () => {
|
||||
expect(SIDEBAR_KEY).toBe('summer-admin.sidebar')
|
||||
expect(NARROW_QUERY).toBe('(max-width: 1099px)')
|
||||
})
|
||||
|
||||
it('restores the persisted manual choice on a wide viewport', () => {
|
||||
fakeMedia({ [NARROW_QUERY]: false })
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
stop = watchViewport()
|
||||
expect(sidebarCollapsed.value).toBe(true)
|
||||
expect(useSidebar().manual.value).toBe(true)
|
||||
expect(useSidebar().narrow.value).toBe(false)
|
||||
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'yes')
|
||||
stop()
|
||||
stop = watchViewport()
|
||||
expect(sidebarCollapsed.value).toBe(false)
|
||||
})
|
||||
|
||||
it('collapses below the breakpoint without writing the stored choice', () => {
|
||||
const media = fakeMedia({ [NARROW_QUERY]: true })
|
||||
stop = watchViewport()
|
||||
expect(sidebarCollapsed.value).toBe(true)
|
||||
expect(useSidebar().narrow.value).toBe(true)
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBeNull()
|
||||
|
||||
media.change(NARROW_QUERY, false)
|
||||
expect(sidebarCollapsed.value).toBe(false)
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBeNull()
|
||||
|
||||
stop()
|
||||
stop = null
|
||||
expect(media.listeners(NARROW_QUERY)).toBe(0)
|
||||
media.change(NARROW_QUERY, true)
|
||||
expect(useSidebar().narrow.value).toBe(false)
|
||||
})
|
||||
|
||||
it("stores only 'true' or 'false' and nothing else", () => {
|
||||
fakeMedia({})
|
||||
stop = watchViewport()
|
||||
setCollapsed(true)
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
expect(sidebarCollapsed.value).toBe(true)
|
||||
setCollapsed(false)
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
|
||||
expect(sidebarCollapsed.value).toBe(false)
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
})
|
||||
|
||||
it('keeps the choice for the session when storage is unavailable', () => {
|
||||
fakeMedia({})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('full', 'QuotaExceededError')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('denied', 'SecurityError')
|
||||
})
|
||||
stop = watchViewport()
|
||||
expect(sidebarCollapsed.value).toBe(false)
|
||||
setCollapsed(true)
|
||||
expect(sidebarCollapsed.value).toBe(true)
|
||||
})
|
||||
|
||||
it('never collapses by viewport when matchMedia is missing', () => {
|
||||
const original = window.matchMedia
|
||||
// @ts-expect-error simulating a browser without matchMedia
|
||||
window.matchMedia = undefined
|
||||
try {
|
||||
stop = watchViewport()
|
||||
expect(useSidebar().narrow.value).toBe(false)
|
||||
expect(stop()).toBeUndefined()
|
||||
stop = null
|
||||
} finally {
|
||||
window.matchMedia = original
|
||||
}
|
||||
})
|
||||
})
|
||||
54
admin/tests/state/useToasts.test.ts
Normal file
54
admin/tests/state/useToasts.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearToasts, dismissToast, showToast, TOAST_TIMEOUT, toasts, useToasts } from '../../src/state/useToasts'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
clearToasts()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearToasts()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('toast queue', () => {
|
||||
it('queues newest first with success as the default tone', () => {
|
||||
const first = showToast('Saved')
|
||||
const second = showToast('Failed', 'danger')
|
||||
expect(second).toBeGreaterThan(first)
|
||||
expect(toasts.value.map((item) => [item.text, item.tone])).toEqual([
|
||||
['Failed', 'danger'],
|
||||
['Saved', 'success'],
|
||||
])
|
||||
})
|
||||
|
||||
it('dismisses each toast after about 4 seconds', () => {
|
||||
expect(TOAST_TIMEOUT).toBe(4000)
|
||||
showToast('one')
|
||||
vi.advanceTimersByTime(1000)
|
||||
showToast('two')
|
||||
vi.advanceTimersByTime(TOAST_TIMEOUT - 1000)
|
||||
expect(toasts.value.map((item) => item.text)).toEqual(['two'])
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(toasts.value).toEqual([])
|
||||
})
|
||||
|
||||
it('dismisses on request and cancels that toast timer', () => {
|
||||
const id = showToast('bye')
|
||||
const other = showToast('stay')
|
||||
dismissToast(id)
|
||||
expect(toasts.value.map((item) => item.id)).toEqual([other])
|
||||
dismissToast(id)
|
||||
dismissToast(999_999)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('clears every toast and timer', () => {
|
||||
showToast('a')
|
||||
showToast('b')
|
||||
clearToasts()
|
||||
expect(toasts.value).toEqual([])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(useToasts().showToast).toBe(showToast)
|
||||
})
|
||||
})
|
||||
155
admin/tests/ui/ui.test.ts
Normal file
155
admin/tests/ui/ui.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { Trash2 } from '@lucide/vue'
|
||||
import Button from '../../src/components/ui/Button.vue'
|
||||
import ConfirmDialog from '../../src/components/ui/ConfirmDialog.vue'
|
||||
import Toast from '../../src/components/ui/Toast.vue'
|
||||
import { useConfirm } from '../../src/components/ui/confirm'
|
||||
import { showToast, toasts } from '../../src/state/useToasts'
|
||||
import { resetState, routerAt, wait } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('button', () => {
|
||||
it('is a type=button outline button by default and can be disabled', () => {
|
||||
const wrapper = mount(Button, { slots: { default: 'Go' } })
|
||||
const button = wrapper.find('button')
|
||||
expect(button.attributes('type')).toBe('button')
|
||||
expect(button.classes()).toContain('border-border-strong')
|
||||
expect(button.classes()).toContain('h-button')
|
||||
expect(button.text()).toBe('Go')
|
||||
const disabled = mount(Button, { props: { disabled: true, type: 'submit', size: 'sm', variant: 'primary' } })
|
||||
expect(disabled.find('button').attributes('disabled')).toBeDefined()
|
||||
expect(disabled.find('button').attributes('type')).toBe('submit')
|
||||
expect(disabled.find('button').classes()).toContain('h-[38px]')
|
||||
expect(disabled.find('button').classes()).toContain('bg-primary')
|
||||
})
|
||||
|
||||
it('renders its icon hidden from assistive tech and the danger and ghost variants', () => {
|
||||
const danger = mount(Button, { props: { variant: 'danger', icon: Trash2 }, slots: { default: 'Delete' } })
|
||||
expect(danger.find('svg').attributes('aria-hidden')).toBe('true')
|
||||
expect(danger.find('button').classes()).toContain('text-danger')
|
||||
expect(mount(Button, { props: { variant: 'ghost' } }).find('button').classes()).toContain('text-muted')
|
||||
})
|
||||
|
||||
it('renders a router link with the same styling when given a target', async () => {
|
||||
const router = await routerAt('/login')
|
||||
const wrapper = mount(Button, { props: { to: '/settings', variant: 'primary' }, slots: { default: 'Open' }, global: { plugins: [router] } })
|
||||
const link = wrapper.find('a')
|
||||
expect(link.attributes('href')).toBe('/settings')
|
||||
expect(link.classes()).toContain('bg-primary')
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm dialog', () => {
|
||||
function dialog() {
|
||||
return document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||
}
|
||||
|
||||
it('is an alertdialog with the default title and labels, and confirms', async () => {
|
||||
const wrapper = mount(ConfirmDialog, { props: { open: true, message: 'Delete 2 widgets?' }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
const element = dialog()!
|
||||
expect(element.getAttribute('role')).toBe('alertdialog')
|
||||
expect(document.getElementById(element.getAttribute('aria-labelledby')!)?.textContent?.trim()).toBe('Potwierdź')
|
||||
expect(document.getElementById(element.getAttribute('aria-describedby')!)?.textContent).toBe('Delete 2 widgets?')
|
||||
expect(element.querySelector('[data-action="cancel"]')!.textContent?.trim()).toBe('Anuluj')
|
||||
const confirm = element.querySelector<HTMLElement>('[data-action="confirm"]')!
|
||||
expect(confirm.textContent?.trim()).toBe('Potwierdź')
|
||||
expect(confirm.className).toContain('bg-primary')
|
||||
confirm.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('confirm')).toHaveLength(1)
|
||||
expect(wrapper.emitted('update:open')).toEqual([[false]])
|
||||
expect(wrapper.emitted('cancel')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses custom labels, the danger style and cancels on Anuluj', async () => {
|
||||
const wrapper = mount(ConfirmDialog, {
|
||||
props: { open: true, message: 'Sure?', title: 'Remove', confirmLabel: 'Remove', cancelLabel: 'Keep', danger: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
const element = dialog()!
|
||||
expect(element.textContent).toContain('Remove')
|
||||
expect(element.querySelector('[data-action="confirm"]')!.className).toContain('bg-danger')
|
||||
;(element.querySelector('[data-action="cancel"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
expect(wrapper.emitted('confirm')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders a message with markup as text', async () => {
|
||||
mount(ConfirmDialog, { props: { open: true, message: '<b>bold</b>' }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
expect(dialog()!.querySelector('b')).toBeNull()
|
||||
expect(dialog()!.textContent).toContain('<b>bold</b>')
|
||||
})
|
||||
|
||||
it('renders nothing while closed', async () => {
|
||||
mount(ConfirmDialog, { props: { open: false, message: 'x' }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useConfirm', () => {
|
||||
it('resolves true on confirm and false on cancel, and clears the request', async () => {
|
||||
const { request, ask, confirm, cancel } = useConfirm()
|
||||
const yes = ask({ message: 'A?' })
|
||||
expect(request.value).toEqual({ message: 'A?' })
|
||||
confirm()
|
||||
expect(await yes).toBe(true)
|
||||
expect(request.value).toBeNull()
|
||||
|
||||
const no = ask({ message: 'B?', danger: true })
|
||||
cancel()
|
||||
expect(await no).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels a pending request when a second one is asked', async () => {
|
||||
const { request, ask, confirm } = useConfirm()
|
||||
const first = ask({ message: 'first' })
|
||||
const second = ask({ message: 'second' })
|
||||
expect(await first).toBe(false)
|
||||
expect(request.value?.message).toBe('second')
|
||||
confirm()
|
||||
expect(await second).toBe(true)
|
||||
confirm()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toast stack', () => {
|
||||
it('shows success as status and danger as alert, newest first, with a labelled close button', async () => {
|
||||
const wrapper = mount(Toast, { attachTo: document.body })
|
||||
showToast('Saved')
|
||||
showToast('Failed', 'danger')
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[data-tone]')
|
||||
expect(items.map((item) => [item.attributes('role'), item.text()])).toEqual([
|
||||
['alert', 'Failed'],
|
||||
['status', 'Saved'],
|
||||
])
|
||||
const close = items[0]!.find('button')
|
||||
expect(close.attributes('aria-label')).toBe('Zamknij')
|
||||
await close.trigger('click')
|
||||
await wait(0)
|
||||
expect(toasts.value.map((item) => item.text)).toEqual(['Saved'])
|
||||
})
|
||||
|
||||
it('renders toast text without markup', async () => {
|
||||
const wrapper = mount(Toast, { attachTo: document.body })
|
||||
showToast('<img src=x onerror=alert(1)>')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
54
admin/tests/views/App.test.ts
Normal file
54
admin/tests/views/App.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import App from '../../src/App.vue'
|
||||
import NotFoundView from '../../src/views/NotFoundView.vue'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { navigationFixture } from '../fixtures/typed'
|
||||
import { resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = await routerAt(path)
|
||||
const wrapper = mount(App, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('app root', () => {
|
||||
it('renders the login route outside the shell', async () => {
|
||||
const { wrapper } = await mountAt('/login')
|
||||
expect(wrapper.find('nav[aria-label="Wtyczki"]').exists()).toBe(false)
|
||||
expect(wrapper.find('form').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders shell routes inside the shell and an unknown path as not found', async () => {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
const { wrapper } = await mountAt('/nowhere')
|
||||
expect(wrapper.find('nav[aria-label="Wtyczki"]').exists()).toBe(true)
|
||||
expect(wrapper.findComponent(NotFoundView).exists()).toBe(true)
|
||||
expect(wrapper.find('main h1').text()).toBe('Nie znaleziono strony.')
|
||||
})
|
||||
|
||||
it('says so when the admin may open no plugin', async () => {
|
||||
await signIn()
|
||||
const { wrapper } = await mountAt('/')
|
||||
expect(wrapper.find('main h1').text()).toBe('Brak dostępnych sekcji.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('not found view', () => {
|
||||
it('switches its message by the home prop', () => {
|
||||
expect(mount(NotFoundView).find('h1').text()).toBe('Nie znaleziono strony.')
|
||||
expect(mount(NotFoundView, { props: { home: true } }).find('h1').text()).toBe('Brak dostępnych sekcji.')
|
||||
})
|
||||
})
|
||||
127
admin/tests/views/LoginView.test.ts
Normal file
127
admin/tests/views/LoginView.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import LoginView from '../../src/views/LoginView.vue'
|
||||
import { createAdminRouter } from '../../src/app/router'
|
||||
import { currentUser } from '../../src/state/useAuth'
|
||||
import { navigation } from '../../src/state/useNavigation'
|
||||
import { settings } from '../../src/state/useSettings'
|
||||
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, profile, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const loginOk = { body: { data: { token_type: 'cookie', expires_in: 900 }, meta: {} } }
|
||||
const invalid = { status: 401, body: { error: { code: 'invalid_credentials', message: 'Invalid', details: {} } } }
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`POST ${API}/auth/login`]: loginOk,
|
||||
[`GET ${API}/auth/me`]: { body: profile },
|
||||
[`GET ${API}/navigation`]: { body: navigationFixture },
|
||||
[`GET ${API}/settings`]: { body: settingsFixture.list },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountLogin(query: Record<string, string> = {}, overrides: Record<string, Route> = {}) {
|
||||
const calls = mockApi(routes(overrides))
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push({ path: '/login', query })
|
||||
const wrapper = mount(LoginView, { global: { plugins: [router] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
return { wrapper, router, calls }
|
||||
}
|
||||
|
||||
async function submit(wrapper: ReturnType<typeof mount>, login = ' dev ', password = 'secret') {
|
||||
await wrapper.find('input[name="login"]').setValue(login)
|
||||
await wrapper.find('input[name="password"]').setValue(password)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('login view', () => {
|
||||
it('labels both inputs and uses the browser credential autocomplete', async () => {
|
||||
const { wrapper } = await mountLogin()
|
||||
expect(wrapper.find('h1').text()).toBe('Witaj ponownie')
|
||||
const [loginLabel, passwordLabel] = wrapper.findAll('label')
|
||||
expect(loginLabel!.text()).toBe('Login lub e-mail')
|
||||
expect(loginLabel!.find('input').attributes('autocomplete')).toBe('username')
|
||||
expect(passwordLabel!.text()).toBe('Hasło')
|
||||
expect(passwordLabel!.find('input').attributes('type')).toBe('password')
|
||||
expect(passwordLabel!.find('input').attributes('autocomplete')).toBe('current-password')
|
||||
expect(wrapper.find('form').attributes('novalidate')).toBeDefined()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('signs in with the trimmed login, loads navigation and settings, clears the password and redirects', async () => {
|
||||
const { wrapper, router, calls } = await mountLogin({ redirect: '/acme/demo/gadgets' })
|
||||
await submit(wrapper)
|
||||
const [post] = requestsTo(calls, 'POST', `${API}/auth/login`)
|
||||
expect(await post!.json()).toEqual({ login: 'dev', password: 'secret' })
|
||||
expect(currentUser.value?.login).toBe('dev')
|
||||
expect(navigation.value).toHaveLength(3)
|
||||
expect(settings.value).toHaveLength(3)
|
||||
expect((wrapper.find('input[name="password"]').element as HTMLInputElement).value).toBe('')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/gadgets')
|
||||
})
|
||||
|
||||
it.each(['//evil.example/x', 'https://evil.example/'])('ignores the unsafe redirect %j', async (redirect) => {
|
||||
const { wrapper, router } = await mountLogin({ redirect })
|
||||
await submit(wrapper)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
})
|
||||
|
||||
it('shows the alert, marks the password invalid and stays on login for bad credentials', async () => {
|
||||
const { wrapper, router, calls } = await mountLogin({}, { [`POST ${API}/auth/login`]: invalid })
|
||||
await submit(wrapper, 'dev', 'wrong')
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('Nieprawidłowe dane logowania')
|
||||
expect(wrapper.find('input[name="password"]').attributes('aria-invalid')).toBe('true')
|
||||
expect(requestsTo(calls, 'GET', `${API}/auth/me`)).toHaveLength(0)
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats a session the profile call cannot confirm as a failed login', async () => {
|
||||
const { wrapper, router } = await mountLogin({}, { [`GET ${API}/auth/me`]: invalid })
|
||||
await submit(wrapper)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('still signs in when the settings list cannot be loaded', async () => {
|
||||
const { wrapper, router } = await mountLogin({}, { [`GET ${API}/settings`]: invalid })
|
||||
await submit(wrapper)
|
||||
expect(settings.value).toEqual([])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
})
|
||||
|
||||
it('disables the submit button while signing in and ignores a second submit', async () => {
|
||||
let release: (() => void) | null = null
|
||||
const { wrapper, calls } = await mountLogin(
|
||||
{},
|
||||
{
|
||||
[`POST ${API}/auth/login`]: () =>
|
||||
new Promise((resolve) => {
|
||||
release = () => resolve(loginOk)
|
||||
}),
|
||||
},
|
||||
)
|
||||
await wrapper.find('input[name="login"]').setValue('dev')
|
||||
await wrapper.find('input[name="password"]').setValue('secret')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined()
|
||||
release!()
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/login`)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user