Files
RenewCMS/web/src/views/__tests__/UsersView.spec.ts
T

843 lines
26 KiB
TypeScript

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<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<typeof vi.fn>
let wrapper: VueWrapper | null = null
interface Handlers {
list?: () => Promise<unknown>
invite?: () => Promise<unknown>
}
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<HTMLElement>(
'[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<HTMLInputElement>('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<UserListItem[]>()
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<UserListItem[]>().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<UserListItem[]>().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<CreatedInvite>()
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)
})
})