feat(SPA): users page and invite modal

This commit is contained in:
fsylvain committed 2026-09-29 08:35:14 +02:00
1 parent acdef18b37
commit 6afffc1984
20 files changed
+1880 -55

No files matched your search

+641
View File
@@ -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)
})
})