feat(SPA): profile page, display name and the new account menu

This commit is contained in:
fsylvain committed 2026-09-29 10:13:09 +02:00
1 parent 55525c06d6
commit b0f7b7892b
29 files changed
+3166 -469

No files matched your search

+52
View File
@@ -379,6 +379,58 @@ describe('The greeting is identical across every load state at a fixed hour', ()
})
})
describe('The greeting names the person by display name, else username', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it.each([
{ displayName: '', name: 'marta' },
{ displayName: 'Marta Alves', name: 'Marta Alves' },
])('reads the name "$name" for display name "$displayName"', async ({ displayName, name }) => {
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
expect(wrapper.find('[data-testid="greeting"]').text()).toBe(`Good morning, ${name}`)
})
it('shows a display name saved after the page was mounted without a reload', async () => {
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName: '' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
session.setDisplayName('Marta Alves')
await flushPromises()
expect(wrapper.find('[data-testid="greeting"]').text()).toBe('Good morning, Marta Alves')
})
})
describe('The greeting handles a missing username', () => {
it('renders the plain greeting without a stray comma when the session has no username', async () => {
vi.useFakeTimers()
+32 -56
View File
@@ -6,11 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import PendingView from '../PendingView.vue'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -40,17 +34,15 @@ function fetchRouter(
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn(
(_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
},
)
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
@@ -148,24 +140,16 @@ describe('Logging out', () => {
await findButton(wrapper, 'Log out')?.trigger('click')
await flushPromises()
expect((findButton(wrapper, 'Log out')?.element as HTMLButtonElement).disabled).toBe(true)
expect(
(findButton(wrapper, 'Log out')?.element as HTMLButtonElement)
.disabled,
).toBe(true)
expect(
(
findButton(wrapper, 'Cancel registration')
?.element as HTMLButtonElement
).disabled,
(findButton(wrapper, 'Cancel registration')?.element as HTMLButtonElement).disabled,
).toBe(true)
resolveLogout?.()
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
@@ -245,9 +229,7 @@ describe('Cancelled — 200 navigates to /register with a toast', () => {
overlayMock.resolveOpen(true)
await flushPromises()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -275,7 +257,7 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(session.session).toEqual({ verified: true })
expect(session.session).toMatchObject({ verified: true, displayName: '' })
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
overlayMock.resolveOpen(true)
@@ -291,12 +273,9 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
})
expect(router.currentRoute.value.path).not.toBe('/home')
await vi.waitFor(
() => expect(router.currentRoute.value.path).toBe('/home'),
{
timeout: 2000,
},
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
pushSpy.mockRestore()
@@ -310,23 +289,20 @@ describe('Cancel failed — the dialog stays open and shows the failure inline',
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])(
'rejects the action instead of navigating for $name',
async ({ handler }) => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': handler,
})
])('rejects the action instead of navigating for $name', async ({ handler }) => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': handler,
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
const props = overlayMock.getProps() as {
action: () => Promise<void>
}
await expect(props.action()).rejects.toThrow()
const props = overlayMock.getProps() as {
action: () => Promise<void>
}
await expect(props.action()).rejects.toThrow()
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(toastAdd).not.toHaveBeenCalled()
},
)
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(toastAdd).not.toHaveBeenCalled()
})
})
+872
View File
@@ -0,0 +1,872 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import App from '@/App.vue'
import { useSessionStore, type Session } from '@/stores/session'
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
let overlayMock: ReturnType<typeof createOverlayMock>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useToast: () => ({ add: toastAdd }),
useOverlay: () => overlayMock,
}
})
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
resolveOpen: (value: unknown) => resolveOpen?.(value),
}
}
const MARTA: Session = {
id: 1,
verified: true,
username: 'marta',
email: 'marta@renew.example',
displayName: '',
}
const DISPLAY_NAME_PATH = '/v1/user/me'
const RESET_PATH = '/v1/auth/password-reset'
const LOGOUT_PATH = '/v1/auth/logout'
const LIMIT_BODY = 'Display name must be 60 characters or fewer.'
const SAVE_FAILED_COPY =
"Couldn't save your display name. Your text is still here. Try again in a moment."
type Handler = (init?: RequestInit) => Promise<unknown>
interface Mounted {
wrapper: VueWrapper
request: ReturnType<typeof vi.fn>
getMe: ReturnType<typeof vi.fn>
}
function deferred<T = unknown>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
interface MountOptions {
session?: Session | null
status?: 'idle' | 'ready'
getMe?: () => Promise<Session>
handlers?: Record<string, Handler>
path?: string
}
let mountedWrapper: VueWrapper | null = null
async function mountApp(options: MountOptions = {}): Promise<Mounted> {
const { session = MARTA, status = 'ready', handlers = {}, path = '/profile' } = options
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const store = useSessionStore()
store.session = session ? { ...session } : null
store.status = status
const request = vi.fn((requestPath: string, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${requestPath}`
const handler = handlers[key]
if (handler) {
return handler(init)
}
if (requestPath === LOGOUT_PATH) {
return Promise.resolve(undefined)
}
return Promise.resolve([])
})
const getMe = vi.fn(options.getMe ?? (() => Promise.resolve({ ...MARTA })))
vi.mocked(useApi).mockReturnValue({ getMe, request } as unknown as ReturnType<typeof useApi>)
await router.push(path)
const wrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
mountedWrapper = wrapper
await flushPromises()
return { wrapper, request, getMe }
}
function displayNameField() {
return document.body.querySelector('input[placeholder="e.g. Marta Alves"]') as HTMLInputElement
}
async function typeInto(value: string) {
const input = displayNameField()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
function pressEnter() {
displayNameField().dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
}
function buttonByText(pattern: RegExp) {
return Array.from(document.body.querySelectorAll('button')).find((button) =>
pattern.test(button.textContent?.trim() ?? ''),
) as HTMLButtonElement | undefined
}
function saveButton() {
return buttonByText(/^(Save|Saving…)$/)!
}
function resetButton() {
return buttonByText(/^(Send reset link|Sending…|Send again)$/)!
}
function byTestId(id: string) {
return document.body.querySelector(`[data-testid="${id}"]`) as HTMLElement | null
}
function counter() {
return byTestId('display-name-counter')!
}
function footer() {
return byTestId('display-name-status')!
}
function isEnabled(button: HTMLButtonElement) {
return !button.disabled
}
async function clickSave() {
saveButton().click()
await flushPromises()
}
async function clickReset() {
resetButton().click()
await flushPromises()
}
function patchBodies(request: ReturnType<typeof vi.fn>) {
return request.mock.calls
.filter(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
}
function callsTo(request: ReturnType<typeof vi.fn>, path: string, method?: string) {
return request.mock.calls.filter(
([requestPath, init]) =>
requestPath === path &&
(method === undefined || (init as RequestInit)?.method === method),
)
}
function chip() {
return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
}
function activate(element: HTMLElement) {
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
function savedResponse(displayName: string) {
return Promise.resolve({ id: 1, username: 'marta', display_name: displayName })
}
const NETWORK_ERROR = () => Promise.reject(new NetworkError())
const SERVER_ERROR = () => Promise.reject(new ApiError(500))
beforeEach(() => {
toastAdd = vi.fn()
overlayMock = createOverlayMock()
localStorage.clear()
authRedirectPending.value = false
})
afterEach(async () => {
vi.useRealTimers()
mountedWrapper?.unmount()
mountedWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Route guards for /profile', () => {
it('renders ProfilePage for a resolved, verified session', async () => {
await mountApp()
expect(router.currentRoute.value.path).toBe('/profile')
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
})
it('redirects a resolved, unverified session to /register/pending', async () => {
await mountApp({ session: { ...MARTA, verified: false } })
await flushPromises()
expect(router.currentRoute.value.path).toBe('/register/pending')
})
it('redirects a 401 from /auth/me to /login?redirect=/profile&reason=expired', async () => {
await mountApp({
session: null,
status: 'idle',
getMe: () => Promise.reject(new ApiError(401)),
})
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
})
})
describe('Default: the usual first visit with no display name', () => {
it('shows the header, the empty field, the account details and the reset button', async () => {
const { request } = await mountApp()
const text = document.body.textContent ?? ''
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
'Profile',
)
expect(document.querySelector('h1')?.textContent?.trim()).toBe('Profile')
expect(text).toContain('Your account on this RenewCMS workspace.')
expect(displayNameField().value).toBe('')
expect(displayNameField().getAttribute('placeholder')).toBe('e.g. Marta Alves')
expect(counter().textContent?.trim()).toBe('0 / 60')
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
expect(isEnabled(saveButton())).toBe(false)
expect(text).toContain('Display name (optional)')
const accountInputs = Array.from(
document.body.querySelectorAll<HTMLInputElement>('input:disabled'),
)
expect(accountInputs.map((input) => input.value)).toEqual([
'marta',
'marta@renew.example',
'••••••••••••',
])
expect(text).toContain(
"To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.",
)
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(byTestId('reset-note')).toBeNull()
expect(request).not.toHaveBeenCalled()
})
it('marks none of the four navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(4)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
it('places the three cards in order Display name, Account, Password', async () => {
await mountApp()
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
el.textContent?.trim(),
)
expect(titles).toEqual(['Display name', 'Account', 'Password'])
})
})
describe('Loading: opened by URL before /auth/me has returned', () => {
it('shows the header and three skeleton cards without AppBoot, then the Default state', async () => {
const pending = deferred<Session>()
await mountApp({
session: null,
status: 'idle',
getMe: () => pending.promise,
})
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
'Profile',
)
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
const busy = document.body.querySelector('[aria-busy="true"]')!
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(3)
expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull()
expect(displayNameField()).toBeNull()
pending.resolve({ ...MARTA })
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(displayNameField()).not.toBeNull()
expect(counter().textContent?.trim()).toBe('0 / 60')
})
})
describe('Opened from the menu, the cached user renders at once', () => {
it('navigates from /home through the account menu with no skeleton and no second /auth/me', async () => {
const { getMe } = await mountApp({ path: '/home' })
chip().click()
await flushPromises()
const profileItem = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Profile')!
activate(profileItem)
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(displayNameField()).not.toBeNull()
expect(getMe).not.toHaveBeenCalled()
})
})
describe('Editing: typing turns Save on', () => {
it('shows the count, Unsaved changes in the warning color and an enabled Save, sending nothing', async () => {
const { request } = await mountApp()
await typeInto('Marta Alves')
expect(counter().textContent?.trim()).toBe('11 / 60')
expect(footer().textContent?.trim()).toBe('Unsaved changes')
expect(footer().classList.contains('text-warning')).toBe(true)
expect(isEnabled(saveButton())).toBe(true)
expect(request).not.toHaveBeenCalled()
})
})
describe('The counter counts Unicode characters after trimming', () => {
it.each([
['"Marta Alves"', 'Marta Alves', 11, 'text-muted', true],
['" Marta Alves "', ' Marta Alves ', 11, 'text-muted', true],
['60 repetitions of "a"', 'a'.repeat(60), 60, 'text-muted', true],
['61 repetitions of "a"', 'a'.repeat(61), 61, 'text-error', false],
['60 repetitions of an emoji', '😀'.repeat(60), 60, 'text-muted', true],
['61 repetitions of an emoji', '😀'.repeat(61), 61, 'text-error', false],
['"Cafe" followed by U+0301', 'Café', 5, 'text-muted', true],
['5 spaces, 60 "a", 5 spaces', ` ${'a'.repeat(60)} `, 60, 'text-muted', true],
])('%s counts %i, %s, Save enabled: %s', async (_label, value, count, colorClass, enabled) => {
await mountApp()
await typeInto(value)
expect(counter().textContent?.trim()).toBe(`${count} / 60`)
expect(counter().classList.contains(colorClass)).toBe(true)
expect(isEnabled(saveButton())).toBe(enabled)
})
})
describe('Too long: over 60 characters shows a field error and keeps Save off', () => {
it('shows the error, ignores Enter and recovers once shortened', async () => {
const { request } = await mountApp()
await typeInto('Marta Alves, Head of Renovations, Brussels and Wallonia office')
expect(counter().textContent?.trim()).toBe('62 / 60')
expect(counter().classList.contains('text-error')).toBe(true)
const error = byTestId('display-name-error')!
expect(error.textContent).toContain('Use 60 characters or fewer.')
expect(error.querySelector('svg, [class*="i-lucide-circle-alert"]')).not.toBeNull()
expect(displayNameField().getAttribute('aria-invalid')).toBe('true')
expect(footer().textContent?.trim()).toBe('Unsaved changes')
expect(footer().classList.contains('text-warning')).toBe(true)
expect(isEnabled(saveButton())).toBe(false)
pressEnter()
await flushPromises()
expect(patchBodies(request)).toHaveLength(0)
await typeInto('Marta Alves')
expect(byTestId('display-name-error')).toBeNull()
expect(counter().textContent?.trim()).toBe('11 / 60')
expect(isEnabled(saveButton())).toBe(true)
})
})
describe('Dirty is measured on the trimmed draft against the saved name', () => {
it.each([
['', ' ', false, 'You appear as “marta”, your username.'],
['Marta Alves', 'Marta Alves', false, 'You appear as “Marta Alves”.'],
['Marta Alves', 'Marta Alves ', false, 'You appear as “Marta Alves”.'],
['Marta Alves', '', true, 'Unsaved changes'],
['Marta Alves', 'Marta Alves', true, 'Unsaved changes'],
])(
'saved %j and typed %j gives Save enabled: %s and %s',
async (saved, typed, enabled, text) => {
await mountApp({ session: { ...MARTA, displayName: saved } })
await typeInto(typed)
expect(isEnabled(saveButton())).toBe(enabled)
expect(footer().textContent?.trim()).toBe(text)
},
)
})
describe('Enter saves when Save is enabled', () => {
it('sends PATCH /v1/user/me once with the trimmed display name', async () => {
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
pressEnter()
await flushPromises()
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
})
it('does nothing when the draft is not dirty', async () => {
const { request } = await mountApp()
pressEnter()
await flushPromises()
expect(patchBodies(request)).toHaveLength(0)
})
})
describe('Saving: the field is disabled and a second submit is ignored', () => {
it('sends the trimmed value, disables and dims the field, and ignores Enter and a second click', async () => {
const pending = deferred()
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => pending.promise },
})
await typeInto(' Marta Alves ')
await clickSave()
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
expect(displayNameField().disabled).toBe(true)
expect(saveButton().textContent?.trim()).toBe('Saving…')
expect(saveButton().querySelector('[class*="i-lucide-loader-circle"], svg')).not.toBeNull()
expect(footer().textContent?.trim()).not.toBe('Unsaved changes')
pressEnter()
saveButton().click()
await flushPromises()
expect(patchBodies(request)).toHaveLength(1)
})
})
describe('Saved: a 200 updates the session, the chip, the footer and raises a toast', () => {
it('replaces the session name, syncs the field, and raises the success toast', async () => {
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
await clickSave()
expect(useSessionStore().session?.displayName).toBe('Marta Alves')
expect(chip().textContent).toContain('Marta Alves')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(displayNameField().value).toBe('Marta Alves')
expect(displayNameField().disabled).toBe(false)
expect(counter().textContent?.trim()).toBe('11 / 60')
expect(footer().textContent?.trim()).toBe('You appear as “Marta Alves”.')
expect(isEnabled(saveButton())).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
id: 'display-name-saved',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Display name saved',
description: 'You now appear as Marta Alves across RenewCMS and in emails.',
})
expect(request).toHaveBeenCalledTimes(1)
})
it('opens no leave confirmation when navigating away afterwards', async () => {
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
await clickSave()
await router.push('/users')
await flushPromises()
expect(overlayMock.create).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/users')
})
})
describe("The server's value wins over what was typed", () => {
it('uses the response display_name in the field, the chip and the toast', async () => {
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto(' Marta Alves ')
await clickSave()
expect(displayNameField().value).toBe('Marta Alves')
expect(chip().textContent).toContain('Marta Alves')
expect(toastAdd.mock.calls[0]![0].description).toContain('Marta Alves across RenewCMS')
})
})
describe('Clearing the display name goes back to the username', () => {
it('sends an empty display_name and shows the username with the removed toast', async () => {
const { request } = await mountApp({
session: { ...MARTA, displayName: 'Marta Alves' },
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('') },
})
await typeInto('')
await clickSave()
expect(patchBodies(request)).toEqual([{ display_name: '' }])
expect(chip().textContent).toContain('marta')
expect(chip().textContent).not.toContain('Marta Alves')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
expect(toastAdd).toHaveBeenCalledWith({
id: 'display-name-saved',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Display name removed',
description: 'You now appear as marta, your username.',
})
})
})
describe('Save failed: a 500 or a network failure keeps the text and offers a retry', () => {
it.each([
['a 500 response', SERVER_ERROR],
['a network failure', NETWORK_ERROR],
])(
'after %s shows the alert, keeps the value and retries with the same body',
async (_label, failure) => {
const attempts = [failure, () => savedResponse('Marta Alves')]
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
})
await typeInto('Marta Alves')
await clickSave()
const alert = document.body.querySelector('[role="alert"]')!
expect(alert.textContent).toContain(SAVE_FAILED_COPY)
expect(displayNameField().value).toBe('Marta Alves')
expect(displayNameField().disabled).toBe(false)
expect(footer().textContent?.trim()).toBe('Unsaved changes')
expect(isEnabled(saveButton())).toBe(true)
expect(useSessionStore().session?.displayName).toBe('')
expect(toastAdd).not.toHaveBeenCalled()
await clickSave()
expect(patchBodies(request)).toEqual([
{ display_name: 'Marta Alves' },
{ display_name: 'Marta Alves' },
])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
},
)
it('hides the alert while the retry is in flight', async () => {
const retry = deferred()
const attempts = [SERVER_ERROR, () => retry.promise]
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
})
await typeInto('Marta Alves')
await clickSave()
expect(document.body.querySelector('[role="alert"]')).not.toBeNull()
await clickSave()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(saveButton().textContent?.trim()).toBe('Saving…')
})
})
describe('A 400 from the API', () => {
it('shows the field error and disables Save for the display-name message', async () => {
await mountApp({
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
Promise.reject(new ApiError(400, undefined, LIMIT_BODY)),
},
})
await typeInto('Marta Alves')
await clickSave()
expect(byTestId('display-name-error')?.textContent).toContain('Use 60 characters or fewer.')
expect(isEnabled(saveButton())).toBe(false)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('shows the Save failed alert and keeps the text for any other 400', async () => {
await mountApp({
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
Promise.reject(new ApiError(400, undefined, 'invalid request body')),
},
})
await typeInto('Marta Alves')
await clickSave()
expect(document.body.querySelector('[role="alert"]')?.textContent).toContain(
SAVE_FAILED_COPY,
)
expect(displayNameField().value).toBe('Marta Alves')
expect(byTestId('display-name-error')).toBeNull()
})
})
describe('Leaving with unsaved changes asks for confirmation', () => {
it('opens a neutral ConfirmDialog, stays on Keep editing and leaves on Discard changes', async () => {
await mountApp()
await typeInto('Marta Alves')
const first = router.push('/users')
await flushPromises()
const props = overlayMock.getProps()!
expect(props.title).toBe('Leave without saving?')
expect(props.description).toBe("Your display name change hasn't been saved.")
expect(props.confirmLabel).toBe('Keep editing')
expect(props.cancelLabel).toBe('Discard changes')
expect(props.tone).toBe('neutral')
overlayMock.resolveOpen(true)
await first
expect(router.currentRoute.value.path).toBe('/profile')
expect(displayNameField().value).toBe('Marta Alves')
const second = router.push('/users')
await flushPromises()
overlayMock.resolveOpen(false)
await second
expect(router.currentRoute.value.path).toBe('/users')
expect(useSessionStore().session?.displayName).toBe('')
})
it('opens nothing when nothing is unsaved', async () => {
await mountApp()
await router.push('/users')
await flushPromises()
expect(overlayMock.create).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/users')
})
it('opens the dialog when Log out is used with a dirty draft', async () => {
const { request } = await mountApp()
await typeInto('Marta Alves')
chip().click()
await flushPromises()
const logOut = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Log out')!
activate(logOut)
await flushPromises()
expect(callsTo(request, LOGOUT_PATH, 'POST')).toHaveLength(1)
expect(overlayMock.create).toHaveBeenCalledTimes(1)
})
it('redirects without the dialog when a 401 while saving is handled globally', async () => {
await mountApp({
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () => {
authRedirectPending.value = true
router.push({
path: '/login',
query: { redirect: '/profile', reason: 'expired' },
})
return Promise.reject(new ApiError(401))
},
},
})
await typeInto('Marta Alves')
await clickSave()
await flushPromises()
expect(overlayMock.create).not.toHaveBeenCalled()
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
expect(document.body.textContent).not.toContain(SAVE_FAILED_COPY)
})
})
describe('The Home greeting follows a display name saved on /profile', () => {
it('shows the saved name on /home without a reload', async () => {
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
await clickSave()
await router.push('/home')
await flushPromises()
expect(document.body.querySelector('[data-testid="greeting"]')?.textContent?.trim()).toBe(
'Good morning, Marta Alves',
)
expect(chip().textContent).toContain('Marta Alves')
vi.restoreAllMocks()
})
})
describe('Reset: sending, sent and Send again', () => {
it('posts the session email with no confirmation and shows Sending… with no note', async () => {
const pending = deferred()
const { request } = await mountApp({
session: { ...MARTA, displayName: 'Marta Alves' },
handlers: { [`POST ${RESET_PATH}`]: () => pending.promise },
})
await clickReset()
const [call] = callsTo(request, RESET_PATH, 'POST')
expect(JSON.parse((call![1] as RequestInit).body as string)).toEqual({
email: 'marta@renew.example',
})
expect(resetButton().textContent?.trim()).toBe('Sending…')
expect(resetButton().disabled).toBe(true)
expect(byTestId('reset-note')).toBeNull()
expect(chip().textContent).toContain('Marta Alves')
})
it('shows the sent note, sends again with the note hidden meanwhile, and resets on remount', async () => {
const second = deferred()
const responses = [() => Promise.resolve({ message: 'ok' }), () => second.promise]
const { request } = await mountApp({
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
})
await clickReset()
expect(resetButton().textContent?.trim()).toBe('Send again')
expect(resetButton().disabled).toBe(false)
const note = byTestId('reset-note')!
expect(note.getAttribute('role')).toBe('status')
expect(note.textContent).toContain('Reset link sent')
expect(note.textContent).toContain(
'Check marta@renew.example. The link works once, within 1 hour.',
)
await clickReset()
expect(callsTo(request, RESET_PATH, 'POST')).toHaveLength(2)
expect(byTestId('reset-note')).toBeNull()
second.resolve({ message: 'ok' })
await flushPromises()
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
await router.push('/home')
await flushPromises()
await router.push('/profile')
await flushPromises()
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(byTestId('reset-note')).toBeNull()
})
})
describe('Reset: too many requests disables the button for the Retry-After window', () => {
it.each([
['a Retry-After of 42', 42, 42],
['a Retry-After of 300', 300, 300],
['no Retry-After header', undefined, 300],
['a Retry-After of 0', 0, 300],
])(
'with %s the button is enabled again after %i seconds',
async (_label, retryAfter, seconds) => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
await mountApp({
handlers: {
[`POST ${RESET_PATH}`]: () => Promise.reject(new ApiError(429, retryAfter)),
},
})
await clickReset()
const note = byTestId('reset-note')!
expect(note.getAttribute('role')).toBe('status')
expect(note.textContent).toContain('Too many requests')
expect(note.textContent).toContain(
"You've asked for several links recently. Wait a few minutes, then try again.",
)
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(resetButton().disabled).toBe(true)
vi.advanceTimersByTime((seconds - 1) * 1000)
await flushPromises()
expect(resetButton().disabled).toBe(true)
vi.advanceTimersByTime(1000)
await flushPromises()
expect(resetButton().disabled).toBe(false)
expect(byTestId('reset-note')).toBeNull()
},
)
})
describe('Reset: failed leaves the button pressable', () => {
it.each([
['a 500 response', SERVER_ERROR],
['a network failure', NETWORK_ERROR],
])(
'after %s shows the error note and recovers on a successful retry',
async (_label, failure) => {
const responses = [failure, () => Promise.resolve({ message: 'ok' })]
await mountApp({
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
})
await clickReset()
const note = byTestId('reset-note')!
expect(note.getAttribute('role')).toBe('status')
expect(note.textContent).toContain("Couldn't send the link")
expect(note.textContent).toContain('Check your connection and try again.')
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(resetButton().disabled).toBe(false)
await clickReset()
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
expect(resetButton().textContent?.trim()).toBe('Send again')
},
)
})
+26
View File
@@ -288,6 +288,32 @@ describe('Default — the list loads newest first, with display name, "you" tag
})
})
describe('Display names in the Users list', () => {
it('shows each display name with its username beside it and the chip with the session name', async () => {
const fixture = defaultFixture().map((item) =>
item.kind === 'user' && item.username === 'marta'
? { ...item, display_name: 'Marta Alves' }
: item,
)
await mountPage({ list: () => Promise.resolve(fixture) })
useSessionStore().session = {
verified: true,
username: 'marta',
id: 1,
displayName: 'Marta Alves',
}
await flushPromises()
expect(rowTexts().slice(2)).toEqual([
'Inès Duarte ines Accepted 18 Jun 2026',
'jonas Accepted 2 Apr 2026',
'Marta Alves marta you Accepted 12 Mar 2026',
])
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
expect(footer.textContent).toContain('Marta Alves')
})
})
describe('Loading — skeleton rows after 150 ms', () => {
it('draws nothing until 150 ms, then five skeleton rows, then the rows', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+79 -173
View File
@@ -6,11 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import VerifyView from '../VerifyView.vue'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -81,16 +75,11 @@ afterEach(async () => {
describe('Verifying — the code submits automatically on load', () => {
it('shows the full-card spinner and calls verify once with the code', async () => {
const verify = vi.fn(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': verify,
},
)
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
})
expect(wrapper.find('[role="status"]').text()).toContain(
'Verifying your account…',
)
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(verify).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
@@ -119,16 +108,13 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
description: 'Welcome to RenewCMS.',
})
await vi.waitFor(
() => expect(router.currentRoute.value.path).toBe('/home'),
{
timeout: 2000,
},
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
expect(toastAdd).toHaveBeenCalledTimes(1)
const session = useSessionStore()
expect(session.session).toEqual({ verified: true })
expect(session.session).toMatchObject({ verified: true, displayName: '' })
})
})
@@ -152,9 +138,7 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
expect(wrapper.text()).toContain(
'Cancel your registration and start over to get a new link.',
)
expect(
findButton(wrapper, 'Cancel registration and start over'),
).toBeTruthy()
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
@@ -165,27 +149,19 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
describe('Cancel registration and start over — success', () => {
it('sends DELETE with onForbidden passthrough, navigates to /register and toasts', async () => {
const deleteRegister = vi.fn(() => mockResponse(200))
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': deleteRegister,
},
)
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': deleteRegister,
})
await findButton(
wrapper,
'Cancel registration and start over',
)?.trigger('click')
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({ method: 'DELETE' }),
)
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -206,38 +182,25 @@ describe('Cancel registration and start over — failure shows an alert, not a r
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])(
'shows the failure alert and keeps the panel for $name',
async ({ handler }) => {
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': handler,
},
)
])('shows the failure alert and keeps the panel for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': handler,
})
await findButton(
wrapper,
'Cancel registration and start over',
)?.trigger('click')
await flushPromises()
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't cancel registration")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect(wrapper.text()).toContain(
"This verification link doesn't work",
)
expect(
findButton(wrapper, 'Cancel registration and start over'),
).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(toastAdd).not.toHaveBeenCalled()
},
)
expect(wrapper.text()).toContain("Couldn't cancel registration")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect(wrapper.text()).toContain("This verification link doesn't work")
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(toastAdd).not.toHaveBeenCalled()
})
})
describe('Log out from the Invalid-or-expired state', () => {
@@ -254,9 +217,7 @@ describe('Log out from the Invalid-or-expired state', () => {
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
@@ -270,15 +231,9 @@ describe('No session — the inline log-in form replaces the spinner', () => {
expect(wrapper.text()).toContain(
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
)
expect(
wrapper.find('input[placeholder="Your username"]').exists(),
).toBe(true)
expect(
wrapper.find('input[autocomplete="current-password"]').exists(),
).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Log in and verify',
)
expect(wrapper.find('input[placeholder="Your username"]').exists()).toBe(true)
expect(wrapper.find('input[autocomplete="current-password"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
expect(wrapper.text()).not.toContain('Forgot password?')
expect(wrapper.text()).not.toContain('First time here?')
expect(router.currentRoute.value.path).toBe('/register/validate')
@@ -310,26 +265,17 @@ describe('No session — successful login retries verification with the same cod
const verify = vi.fn()
verify.mockResolvedValueOnce(mockResponse(401))
verify.mockImplementationOnce(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': verify,
'POST /v1/auth/login': () => mockResponse(200),
},
)
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
'POST /v1/auth/login': () => mockResponse(200),
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('[role="status"]').text()).toContain(
'Verifying your account…',
)
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({
@@ -349,24 +295,14 @@ describe('No session — wrong credentials during the inline login', () => {
'POST /v1/auth/login': () => mockResponse(403),
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('wrong-password')
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('wrong-password')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain(
"That username and password don't match",
)
expect(wrapper.text()).toContain(
'Check both and try again. Passwords are case-sensitive.',
)
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(wrapper.text()).toContain("That username and password don't match")
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
expect(verify).toHaveBeenCalledTimes(1)
@@ -378,23 +314,16 @@ describe('No session — too many login attempts', () => {
vi.useFakeTimers()
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': () =>
mockResponse(429, null, { 'Retry-After': '30' }),
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '30' }),
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Too many attempts')
expect(wrapper.text()).toContain(
'For your security, wait a moment before trying again.',
)
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Try again in 0:30')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
@@ -402,9 +331,7 @@ describe('No session — too many login attempts', () => {
await vi.advanceTimersByTimeAsync(30000)
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Log in and verify',
)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
})
})
@@ -415,50 +342,31 @@ describe('No session — server or network error during the inline login', () =>
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])(
'shows the network/server alert and keeps the fields for $name',
async ({ handler }) => {
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': handler,
},
)
])('shows the network/server alert and keeps the fields for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': handler,
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain(
'Check your connection and try again.',
)
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
).toBe('marta')
expect(
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
).toBe('limewash2026')
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).disabled,
).toBe(false)
},
)
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
).toBe('marta')
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
).toBe('limewash2026')
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.disabled,
).toBe(false)
})
})
describe('Missing code — no request is made', () => {
@@ -475,8 +383,6 @@ describe('Missing code — no request is made', () => {
expect(action).toBeTruthy()
await action?.trigger('click')
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})