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 { ApiError, NetworkError, useApi } from '@/composables/useApi' import type { CreatedInvite, UserListItem } from '@/composables/useUsers' import { useSessionStore } from '@/stores/session' import UsersView from '../UsersView.vue' import { accessFor, roleRefFor } from '@/test/access' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn(), } }) let toastAdd: ReturnType vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => ({ add: toastAdd }), } }) const LIST_PATH = '/v1/user/' const INVITE_PATH = '/v1/user/invite' const INVALID_EMAIL = 'Enter a valid email address.' const FAILED_TITLE = "Couldn't send the invite" const FAILED_DESCRIPTION = 'Try again in a moment.' const VALID_EMAIL = 'lea.martin@renew.example' function defaultFixture(): UserListItem[] { return [ { kind: 'invite', id: 2, email: 'sam.oliveira@renew.example', role: roleRefFor('admin'), status: 'pending', created_at: '2026-09-27T09:00:00Z', }, { kind: 'invite', id: 1, email: 'p.huber@gmail.com', role: roleRefFor('admin'), status: 'expired', created_at: '2026-08-03T09:00:00Z', }, { kind: 'user', id: 3, username: 'ines', email: 'ines.duarte@renew.example', role: roleRefFor('admin'), display_name: 'Inès Duarte', status: 'accepted', created_at: '2026-06-18T09:00:00Z', }, { kind: 'user', id: 2, username: 'jonas', email: 'jonas@renew.example', role: roleRefFor('admin'), display_name: '', status: 'accepted', created_at: '2026-04-02T09:00:00Z', }, { kind: 'user', id: 1, username: 'marta', email: 'marta@renew.example', role: roleRefFor('owner'), display_name: '', status: 'accepted', created_at: '2026-03-12T09:00:00Z', }, ] } function onlyYouFixture(): UserListItem[] { return defaultFixture().slice(4) } function inviteFixture(email = VALID_EMAIL): CreatedInvite { return { id: 3, kind: 'invite', email, role: roleRefFor('admin'), status: 'pending', created_at: new Date().toISOString(), expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), } } function deferred() { let resolve!: (value: T) => void const promise = new Promise((res) => { resolve = res }) return { promise, resolve } } function setViewport(desktop: boolean) { window.matchMedia = ((query: string) => ({ matches: desktop, media: query, onchange: null, addEventListener: () => undefined, removeEventListener: () => undefined, addListener: () => undefined, removeListener: () => undefined, dispatchEvent: () => false, })) as unknown as typeof window.matchMedia } let requestMock: ReturnType let wrapper: VueWrapper | null = null interface Handlers { list?: () => Promise invite?: () => Promise } async function mountPage( { list, invite }: Handlers = {}, desktop = true, role: string | undefined = 'owner', ) { setViewport(desktop) setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true, username: 'marta', id: 1, displayName: '', access: accessFor(role ?? undefined), } session.status = 'ready' requestMock = vi.fn((path: string) => { if (path === LIST_PATH) { return (list ?? (() => Promise.resolve(defaultFixture())))() } if (path === INVITE_PATH) { return (invite ?? (() => Promise.resolve(inviteFixture())))() } throw new Error(`Unexpected request: ${path}`) }) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, access: accessFor(role ?? undefined), }), request: requestMock, }) await router.push('/users') wrapper = mount(UsersView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return wrapper } function rows() { return Array.from( document.body.querySelectorAll( '[data-testid="user-row"], [data-testid="user-card"]', ), ) } function textOf(element: Element) { const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) const parts: string[] = [] while (walker.nextNode()) { const text = walker.currentNode.textContent?.trim() if (text) { parts.push(text) } } return parts.join(' ') } function rowTexts() { return rows().map(textOf) } function findButton(text: string) { return Array.from(document.body.querySelectorAll('button')).find( (button) => button.textContent?.trim() === text, ) } async function clickButton(text: string) { findButton(text)?.click() await flushPromises() } function emailInput() { return document.body.querySelector('input[placeholder="name@example.com"]')! } function dialog() { return document.body.querySelector('[role="dialog"]') } function dispatch(element: HTMLElement, type: string) { element.dispatchEvent(new Event(type, { bubbles: true })) } async function typeEmail(value: string) { const input = emailInput() input.value = value dispatch(input, 'input') await flushPromises() } async function blurEmail() { dispatch(emailInput(), 'blur') await flushPromises() } async function openModal() { const opener = findButton('Invite user')! opener.focus() opener.click() await flushPromises() } async function submitWith(value: string) { await typeEmail(value) await clickButton('Send invite') } function inviteCalls() { return requestMock.mock.calls.filter(([path]) => path === INVITE_PATH) } function listCalls() { return requestMock.mock.calls.filter(([path]) => path === LIST_PATH) } function alertText() { return document.body.querySelector('[role="alert"]')?.textContent?.trim() ?? '' } beforeEach(() => { toastAdd = vi.fn() }) afterEach(async () => { wrapper?.unmount() wrapper = null vi.useRealTimers() await router.push('/login') document.body.innerHTML = '' }) describe('Default — the list loads newest first, with display name, "you" tag and status badges', () => { it('shows the header, the rows in fixture order and the badges on desktop', async () => { await mountPage() const breadcrumb = document.body.querySelector( '[aria-label="breadcrumb"], nav:not([aria-label="Main"])', ) expect(breadcrumb?.textContent).toContain('Users') expect(document.body.querySelector('h1')?.textContent).toBe('Users') expect(document.body.textContent).toContain( 'Everyone who can sign in and work on your articles.', ) expect(findButton('Invite user')).toBeTruthy() expect(rowTexts()).toEqual([ 'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite', 'p.huber@gmail.com — admin Expired 3 Aug 2026 Renew invite', 'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026', 'jonas@renew.example — admin Accepted 2 Apr 2026', 'marta@renew.example you — owner Accepted 12 Mar 2026', ]) expect(rowTexts().join(' ')).not.toMatch(/(ines|jonas|marta)(?!@)/) expect(rows().filter((row) => /\byou\b/.test(textOf(row)))).toHaveLength(1) const header = document.body.querySelector( '[data-testid="user-row"]', )!.previousElementSibling! expect(textOf(header)).toBe('Email Display name Role Invite status Created') expect(header.children).toHaveLength(6) expect(document.body.querySelectorAll('[data-testid="user-row"]')).toHaveLength(5) }) it('styles the owner badge filled, the admin badge outlined, dashes muted and the you tag on marta only', async () => { await mountPage() const badge = (row: Element, role: string) => Array.from(row.querySelectorAll('span')).findLast( (el) => el.textContent?.trim() === role, )! const [sam, huber, ines, jonas, marta] = rows() expect(badge(marta, 'owner').className).toContain('bg-primary') expect(badge(marta, 'owner').className).not.toContain('bg-transparent') expect(badge(jonas, 'admin').className).toContain('bg-transparent') expect(badge(jonas, 'admin').className).not.toContain('bg-primary') expect(badge(jonas, '—').className).toContain('text-muted') expect(badge(sam, '—').className).toContain('text-muted') expect(badge(huber, '—').className).toContain('text-muted') expect(badge(ines, 'Inès Duarte').className).toContain('text-highlighted') expect(badge(ines, 'Inès Duarte').className).not.toContain('text-muted') expect(badge(marta, 'you')).toBeInstanceOf(HTMLElement) expect(rows().filter((row) => badge(row, 'you'))).toEqual([marta]) }) it('styles the same badges and hides the display-name line when empty on cards below md', async () => { await mountPage({}, false) const spans = (row: Element) => Array.from(row.querySelectorAll('span')) const [, huber, ines, jonas, marta] = rows() const find = (row: Element, text: string) => spans(row).findLast((el) => el.textContent?.trim() === text) expect(find(marta, 'owner')!.className).toContain('bg-primary') expect(find(jonas, 'admin')!.className).toContain('bg-transparent') expect(find(jonas, '—')).toBeUndefined() expect(find(ines, 'Inès Duarte')).toBeInstanceOf(HTMLElement) expect(find(huber, '—')).toBeUndefined() expect(find(marta, 'you')).toBeInstanceOf(HTMLElement) expect(find(jonas, 'you')).toBeUndefined() }) it('renders the same rows as cards below md', async () => { await mountPage({}, false) expect(rowTexts()).toEqual([ 'sam.oliveira@renew.example admin Pending Created 27 Sep 2026 Cancel invite', 'p.huber@gmail.com admin Expired Created 3 Aug 2026 Renew invite', 'ines.duarte@renew.example Inès Duarte admin Accepted Created 18 Jun 2026', 'jonas@renew.example admin Accepted Created 2 Apr 2026', 'marta@renew.example you owner Accepted Created 12 Mar 2026', ]) expect(document.body.querySelector('[data-testid="user-row"]')).toBeNull() }) it('gives the Users link aria-current on /users', async () => { await mountPage() const link = document.body.querySelector('nav[aria-label="Main"] a[href="/users"]') expect(link?.getAttribute('aria-current')).toBe('page') }) }) describe('Display names in the Users list', () => { it('shows each saved name, a dash for empty ones, the role 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', access: accessFor('owner'), } await flushPromises() expect(rowTexts().slice(2)).toEqual([ 'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026', 'jonas@renew.example — admin Accepted 2 Apr 2026', 'marta@renew.example you Marta Alves owner 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'] }) const pending = deferred() await mountPage({ list: () => pending.promise }) vi.advanceTimersByTime(149) await flushPromises() expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() expect(document.body.querySelector('[data-slot="base"].animate-pulse')).toBeNull() vi.advanceTimersByTime(1) await flushPromises() const busy = document.body.querySelector('[aria-busy="true"]')! expect(busy.querySelectorAll('.animate-pulse').length).toBeGreaterThanOrEqual(5) expect(rows()).toHaveLength(0) pending.resolve(defaultFixture()) await flushPromises() expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() expect(rows()).toHaveLength(5) }) it('draws role and status placeholders on the desktop table skeleton only', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await mountPage({ list: () => deferred().promise }) vi.advanceTimersByTime(150) await flushPromises() const tableBars = document.body.querySelectorAll('[aria-busy="true"] .animate-pulse') expect(tableBars).toHaveLength(25) wrapper?.unmount() wrapper = null document.body.innerHTML = '' await mountPage({ list: () => deferred().promise }, false) vi.advanceTimersByTime(150) await flushPromises() const cardBars = document.body.querySelectorAll('[aria-busy="true"] .animate-pulse') expect(cardBars).toHaveLength(10) }) }) describe('Only you — nobody has been invited yet', () => { it('shows the one row, the hint and Invite user', async () => { await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }) expect(rowTexts()).toEqual(['marta@renew.example you — owner Accepted 12 Mar 2026']) expect(document.body.textContent).toContain( 'Invite someone so they can sign in with their own account instead of sharing yours.', ) expect(findButton('Invite user')).toBeTruthy() }) }) describe('Roles without users.invite cannot invite', () => { it.each([ { name: 'a writer on desktop', role: 'member', desktop: true }, { name: 'a writer below md', role: 'member', desktop: false }, { name: 'a missing role on desktop', role: null, desktop: true }, { name: 'an unknown role below md', role: 'guest', desktop: false }, ])('renders the list but no Invite user for $name', async ({ role, desktop }) => { await mountPage({}, desktop, role) expect(findButton('Invite user')).toBeUndefined() expect(rows()).toHaveLength(5) }) it('hides the Only-you hint for a writer', async () => { await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }, true, 'member') expect(rows()).toHaveLength(1) expect(document.body.textContent).not.toContain('Invite someone so they can sign in') expect(findButton('Invite user')).toBeUndefined() }) it.each([true, false])( 'shows no hint for a writer on marta and jonas alone (desktop %s)', async (desktop) => { await mountPage( { list: () => Promise.resolve(defaultFixture().slice(3)) }, desktop, 'member', ) expect(rows()).toHaveLength(2) expect(document.body.textContent).not.toContain('Invite someone so they can sign in') expect(findButton('Invite user')).toBeUndefined() }, ) }) describe('Error — a 500 or a network failure replaces the list with an alert and a retry', () => { it.each([ { name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)), }, { name: 'a network failure', failure: () => Promise.reject(new NetworkError()), }, ])('shows the alert and recovers after Try again on $name', async ({ failure }) => { await mountPage({ list: failure }) expect(alertText()).toContain("Couldn't load users") expect(alertText()).toContain('Check your connection and try again.') expect(findButton('Try again')).toBeTruthy() expect(document.body.querySelector('[role="alert"] button[aria-label="Close"]')).toBeNull() expect(rows()).toHaveLength(0) expect(findButton('Invite user')).toBeTruthy() requestMock.mockImplementation((path: string) => path === LIST_PATH ? Promise.resolve(defaultFixture()) : Promise.reject(new Error(path)), ) await clickButton('Try again') expect(listCalls()).toHaveLength(2) expect(rows()).toHaveLength(5) expect(document.body.querySelector('[role="alert"]')).toBeNull() }) }) describe('Invite: default — opening the modal', () => { it('shows the copy, an empty focused Email and both buttons, and sends nothing', async () => { await mountPage() await openModal() expect(dialog()?.textContent).toContain('Invite user') expect(dialog()?.textContent).toContain('They get an email with a link to set a password.') expect(emailInput().value).toBe('') expect(document.activeElement).toBe(emailInput()) expect(findButton('Cancel')).toBeTruthy() expect(findButton('Send invite')).toBeTruthy() expect(inviteCalls()).toHaveLength(0) expect(rows()).toHaveLength(5) }) it('hides the pinned button below md while the modal is open', async () => { await mountPage({}, false) const pinned = findButton('Invite user')! expect(pinned.style.display).not.toBe('none') await openModal() expect(pinned.style.display).toBe('none') await clickButton('Cancel') expect(pinned.style.display).not.toBe('none') }) }) describe('Invite: validation — an invalid address is refused before any request', () => { it.each([ { value: 'sam.oliveira@renew', trigger: 'blur' }, { value: 'not-an-email', trigger: 'submit' }, { value: '', trigger: 'submit' }, ])('refuses "$value" on $trigger', async ({ value, trigger }) => { await mountPage() await openModal() await typeEmail(value) if (trigger === 'blur') { await blurEmail() } else { await clickButton('Send invite') expect(document.activeElement).toBe(emailInput()) } expect(dialog()?.textContent).toContain(INVALID_EMAIL) expect(inviteCalls()).toHaveLength(0) }) it('clears the error as soon as the address is fixed', async () => { await mountPage() await openModal() await typeEmail('sam.oliveira@renew') await blurEmail() expect(dialog()?.textContent).toContain(INVALID_EMAIL) await typeEmail('sam.oliveira@renew.example') expect(dialog()?.textContent).not.toContain(INVALID_EMAIL) }) }) describe('Invite: sending — the form is locked and cannot be dismissed', () => { it('sends the JSON body, disables the form and ignores Esc and the backdrop', async () => { const pending = deferred() await mountPage({ invite: () => pending.promise }) await openModal() await submitWith(VALID_EMAIL) expect(inviteCalls()).toHaveLength(1) const [, options] = inviteCalls()[0] expect(options.method).toBe('POST') expect(JSON.parse(options.body)).toEqual({ email: VALID_EMAIL }) expect(emailInput().disabled).toBe(true) expect(findButton('Cancel')!.disabled).toBe(true) expect(findButton('Sending…')).toBeTruthy() document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) await flushPromises() expect(dialog()).toBeTruthy() document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) await flushPromises() expect(dialog()).toBeTruthy() pending.resolve(inviteFixture()) await flushPromises() }) }) describe('Invite sent — a 201 closes the modal, adds a Pending row on top and raises a toast', () => { it('closes, prepends the highlighted row without a refetch and toasts', async () => { await mountPage() await openModal() await submitWith(VALID_EMAIL) expect(dialog()).toBeNull() expect(listCalls()).toHaveLength(1) const texts = rowTexts() expect(texts).toHaveLength(6) expect(texts[0]).toContain('lea.martin@renew.example — admin Pending Today') expect(texts.slice(1)).toEqual([ 'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite', 'p.huber@gmail.com — admin Expired 3 Aug 2026 Renew invite', 'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026', 'jonas@renew.example — admin Accepted 2 Apr 2026', 'marta@renew.example you — owner Accepted 12 Mar 2026', ]) expect(rows()[0].className).toContain('bg-elevated') expect(rows()[1].className).not.toContain('bg-elevated') expect(toastAdd).toHaveBeenCalledWith({ id: 'invite-sent-3', color: 'success', icon: 'i-lucide-circle-check', title: 'Invite sent', description: 'lea.martin@renew.example has 3 days to accept.', }) }) it.each([ [1, 'has 1 day to accept.'], [7, 'has 7 days to accept.'], ])('words a %s-day expiry from expires_at', async (days, wording) => { const created = new Date() await mountPage({ invite: () => Promise.resolve({ ...inviteFixture(), created_at: created.toISOString(), expires_at: new Date( created.getTime() + days * 24 * 60 * 60 * 1000, ).toISOString(), }), }) await openModal() await submitWith(VALID_EMAIL) expect(toastAdd.mock.calls[0]![0].description).toBe(`${VALID_EMAIL} ${wording}`) }) it('returns focus to Invite user', async () => { await mountPage() await openModal() await submitWith(VALID_EMAIL) expect(document.activeElement).toBe(findButton('Invite user')) }) }) describe('Invite sent to an address whose invite had expired replaces the old row', () => { it('keeps exactly one Pending row for the address, first', async () => { await mountPage({ invite: () => Promise.resolve(inviteFixture('p.huber@gmail.com')), }) await openModal() await submitWith('p.huber@gmail.com') const matching = rowTexts().filter((text) => text.includes('p.huber@gmail.com')) expect(matching).toHaveLength(1) expect(rowTexts()[0]).toContain('p.huber@gmail.com — admin Pending') expect(rows()).toHaveLength(5) }) }) describe('Invite sent into the Only-you state keeps a coherent list', () => { it('puts the new row above marta and hides the hint', async () => { await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }) await openModal() await submitWith(VALID_EMAIL) const texts = rowTexts() expect(texts).toHaveLength(2) expect(texts[0]).toContain('lea.martin@renew.example — admin Pending') expect(texts[1]).toContain('marta@renew.example you') expect(document.body.textContent).not.toContain('Invite someone so they can sign in') }) }) describe('Invite: 409 conflicts are told apart by the error code', () => { it.each([ { email: 'jonas@renew.example', code: 'email_has_account' as const, error: 'This address already has an account.', }, { email: 'sam.oliveira@renew.example', code: 'invite_pending' as const, error: "There's already a pending invite for this address.", }, ])('shows "$error" for the code $code', async ({ email, code, error }) => { await mountPage({ invite: () => Promise.reject(new ApiError(409, undefined, 'ignored body', { code })), }) await openModal() await submitWith(email) expect(dialog()).not.toBeNull() expect(emailInput().value).toBe(email) expect(emailInput().disabled).toBe(false) expect(dialog()?.textContent).toContain(error) expect(document.body.querySelector('[role="alert"]')).toBeNull() }) it('ignores the server wording and falls back to the generic alert on an unknown 409', async () => { await mountPage({ invite: () => Promise.reject( new ApiError(409, undefined, 'This address already has an account.'), ), }) await openModal() await submitWith('jonas@renew.example') expect(alertText()).toContain(FAILED_TITLE) expect(alertText()).toContain(FAILED_DESCRIPTION) expect(dialog()?.textContent).not.toContain('This address already has an account.') expect(emailInput().disabled).toBe(false) }) }) describe("A 400 that got past this form's own check", () => { it('keeps the address and shows the alert and the field error', async () => { await mountPage({ invite: () => Promise.reject(new ApiError(400)) }) await openModal() await submitWith(VALID_EMAIL) expect(emailInput().value).toBe(VALID_EMAIL) expect(emailInput().disabled).toBe(false) expect(alertText()).toContain( "Something in the form isn't valid. Check the highlighted fields.", ) expect(dialog()?.textContent).toContain(INVALID_EMAIL) }) }) describe('Invite: rate limited — a 429 shows a dialog alert and keeps the address', () => { it('shows the fixed alert, no field error, then succeeds on retry', async () => { const outcomes = [ () => Promise.reject( new ApiError(429, undefined, 'Too many requests. Please try again later.'), ), () => Promise.resolve(inviteFixture()), ] await mountPage({ invite: () => outcomes.shift()!() }) await openModal() await submitWith(VALID_EMAIL) expect(alertText()).toBe('Too many invites. Wait a moment, then try again.') expect(dialog()?.textContent).not.toContain('Too many requests') expect(dialog()?.textContent).not.toContain(INVALID_EMAIL) expect(emailInput().value).toBe(VALID_EMAIL) expect(emailInput().disabled).toBe(false) expect(rows()).toHaveLength(5) await clickButton('Send invite') expect(dialog()).toBeNull() expect(rowTexts()[0]).toContain('lea.martin@renew.example — admin Pending') expect(toastAdd).toHaveBeenCalledTimes(1) }) }) describe('Invite: failed — a 500 or a network failure keeps the address and offers a retry', () => { it.each([ { name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)), }, { name: 'a 500 with the API text', failure: () => Promise.reject(new ApiError(500, undefined, FAILED_TITLE)), }, { name: 'a network failure', failure: () => Promise.reject(new NetworkError()), }, ])('shows the alert once on $name', async ({ failure }) => { await mountPage({ invite: failure }) await openModal() await submitWith(VALID_EMAIL) expect(alertText()).toBe(`${FAILED_TITLE}${FAILED_DESCRIPTION}`) expect(document.body.querySelectorAll('[role="alert"]')).toHaveLength(1) expect(emailInput().value).toBe(VALID_EMAIL) expect(emailInput().disabled).toBe(false) expect(dialog()?.textContent).not.toContain(INVALID_EMAIL) expect(rows()).toHaveLength(5) }) }) describe('Retrying after a failure succeeds', () => { it('sends the same body again and completes', async () => { const outcomes = [ () => Promise.reject(new ApiError(500)), () => Promise.resolve(inviteFixture()), ] await mountPage({ invite: () => outcomes.shift()!() }) await openModal() await submitWith(VALID_EMAIL) await clickButton('Send invite') expect(inviteCalls()).toHaveLength(2) expect(inviteCalls()[1][1].body).toBe(inviteCalls()[0][1].body) expect(dialog()).toBeNull() expect(rowTexts()[0]).toContain('lea.martin@renew.example — admin Pending') expect(toastAdd).toHaveBeenCalledTimes(1) }) }) describe('Cancel discards the address with no confirmation', () => { it('closes at once, sends nothing, restores focus and reopens empty', async () => { await mountPage() await openModal() await typeEmail(VALID_EMAIL) await clickButton('Cancel') expect(dialog()).toBeNull() expect(inviteCalls()).toHaveLength(0) expect(document.activeElement).toBe(findButton('Invite user')) await openModal() expect(emailInput().value).toBe('') expect(dialog()?.textContent).not.toContain(INVALID_EMAIL) }) })