feat(SPA): users page and invite modal
This commit is contained in:
1 parent
acdef18b37
commit
6afffc1984
20 files changed
+1880
-55
No files matched your search
@@ -0,0 +1,641 @@
|
||||
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'
|
||||
|
||||
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 full email address, like name@example.com.'
|
||||
const FAILED_ALERT = "Couldn't send the invite. 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',
|
||||
status: 'pending',
|
||||
created_at: '2026-09-27T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'invite',
|
||||
id: 1,
|
||||
email: 'p.huber@gmail.com',
|
||||
status: 'expired',
|
||||
created_at: '2026-08-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
id: 3,
|
||||
username: 'ines',
|
||||
display_name: 'Inès Duarte',
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
id: 2,
|
||||
username: 'jonas',
|
||||
display_name: '',
|
||||
status: 'accepted',
|
||||
created_at: '2026-04-02T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
id: 1,
|
||||
username: 'marta',
|
||||
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,
|
||||
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) {
|
||||
setViewport(desktop)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1 }
|
||||
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 }),
|
||||
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 Pending 27 Sep 2026',
|
||||
'p.huber@gmail.com Expired 3 Aug 2026',
|
||||
'Inès Duarte ines Accepted 18 Jun 2026',
|
||||
'jonas Accepted 2 Apr 2026',
|
||||
'marta you Accepted 12 Mar 2026',
|
||||
])
|
||||
expect(rows().filter((row) => /\byou\b/.test(textOf(row)))).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain('Invite status')
|
||||
})
|
||||
|
||||
it('renders the same rows as cards below md', async () => {
|
||||
await mountPage({}, false)
|
||||
|
||||
expect(rowTexts()).toEqual([
|
||||
'sam.oliveira@renew.example Pending Created 27 Sep 2026',
|
||||
'p.huber@gmail.com Expired Created 3 Aug 2026',
|
||||
'Inès Duarte ines Accepted Created 18 Jun 2026',
|
||||
'jonas Accepted Created 2 Apr 2026',
|
||||
'marta you 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('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)
|
||||
})
|
||||
})
|
||||
|
||||
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 you 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('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 a user')
|
||||
expect(dialog()?.textContent).toContain(
|
||||
"We'll email them a link to choose a username and password. The link expires after 3 days.",
|
||||
)
|
||||
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 Pending')
|
||||
expect(texts.slice(1)).toEqual([
|
||||
'sam.oliveira@renew.example Pending 27 Sep 2026',
|
||||
'p.huber@gmail.com Expired 3 Aug 2026',
|
||||
'Inès Duarte ines Accepted 18 Jun 2026',
|
||||
'jonas Accepted 2 Apr 2026',
|
||||
'marta you 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('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 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 Pending')
|
||||
expect(texts[1]).toContain('marta you Accepted')
|
||||
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite: already a user (409) — the field error depends on the response text', () => {
|
||||
it.each([
|
||||
{
|
||||
email: 'jonas@renew.example',
|
||||
body: 'This address already has an account.',
|
||||
error: 'This address already has an account.',
|
||||
},
|
||||
{
|
||||
email: 'sam.oliveira@renew.example',
|
||||
body: "There's already a pending invite for this address.",
|
||||
error: "There's already a pending invite for this address.",
|
||||
},
|
||||
{
|
||||
email: 'jonas@renew.example',
|
||||
body: 'Something else',
|
||||
error: 'This address already has an account.',
|
||||
},
|
||||
])('shows "$error" for the body "$body"', async ({ email, body, error }) => {
|
||||
await mountPage({ invite: () => Promise.reject(new ApiError(409, undefined, body)) })
|
||||
await openModal()
|
||||
await submitWith(email)
|
||||
|
||||
expect(dialog()).toBeTruthy()
|
||||
expect(emailInput().value).toBe(email)
|
||||
expect(emailInput().disabled).toBe(false)
|
||||
expect(dialog()?.textContent).toContain(error)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
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 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_ALERT)),
|
||||
},
|
||||
{ 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_ALERT)
|
||||
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 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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user