843 lines
26 KiB
TypeScript
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)
|
|
})
|
|
})
|