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:
Jakub Zych
2026-09-27 17:53:57 +02:00
parent 8259a456bb
commit 1c2a66df45
61 changed files with 4299 additions and 91 deletions

View 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.')
})
})

View 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)
})
})