feat: users row actions "Cancel invite" on Pending rows and "Renew invite" on Expired rows

This commit is contained in:
fsylvain committed 2026-09-30 02:48:49 +02:00
1 parent 1ece778caf
commit 142325730d
14 files changed
+1156 -35

No files matched your search

@@ -0,0 +1,577 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UButton from '@nuxt/ui/components/Button.vue'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { 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 CANCEL_PATH = '/v1/user/invite/2'
const RENEW_PATH = '/v1/user/invite/1/renew'
const CANCEL_NOTICE = "Couldn't cancel the invite. Try again in a moment."
const CANCEL_DESCRIPTION =
'The link sent to sam.oliveira@renew.example will stop working. You can invite the address again later.'
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',
display_name: 'Paul Huber',
status: 'expired',
created_at: '2026-08-03T09:00:00Z',
},
{
kind: 'user',
id: 3,
username: 'ines',
email: 'ines.duarte@renew.example',
role: '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: 'admin',
display_name: '',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
},
{
kind: 'user',
id: 1,
username: 'marta',
email: 'marta@renew.example',
role: 'owner',
display_name: '',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
},
]
}
function twoExpiredFixture(): UserListItem[] {
const [first, second, ...rest] = defaultFixture()
return [
first,
second,
{
kind: 'invite',
id: 7,
email: 'old@renew.example',
status: 'expired',
created_at: '2026-07-01T09:00:00Z',
},
...rest,
]
}
function renewedResponse(id = 1, email = 'p.huber@gmail.com') {
return {
id,
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 {
fixture?: UserListItem[]
cancel?: () => Promise<unknown>
renew?: (path: string) => Promise<unknown>
}
async function mountPage(
{ fixture, cancel, renew }: Handlers = {},
desktop = true,
role: string | null = 'owner',
) {
setViewport(desktop)
setActivePinia(createPinia())
const session = useSessionStore()
const sessionRole = role ?? undefined
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: sessionRole,
}
session.status = 'ready'
requestMock = vi.fn((path: string, options?: RequestInit) => {
if (path === LIST_PATH) {
return Promise.resolve(fixture ?? defaultFixture())
}
if (options?.method === 'DELETE') {
return (cancel ?? (() => Promise.resolve(undefined)))()
}
if (options?.method === 'POST' && path === '/v1/user/invite') {
return Promise.resolve(renewedResponse(3, 'lea.martin@renew.example'))
}
if (options?.method === 'POST' && path.endsWith('/renew')) {
return (renew ?? (() => Promise.resolve(renewedResponse())))(path)
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi
.fn()
.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: sessionRole }),
request: requestMock,
})
await router.push('/users')
const Host = defineComponent({ render: () => h(UApp, null, () => h(UsersView)) })
wrapper = mount(Host, {
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 rowOf(email: string) {
return rows().find((row) => row.textContent?.includes(email))!
}
function buttonsIn(root: ParentNode) {
return Array.from(root.querySelectorAll('button'))
}
function buttonIn(root: ParentNode, text: string) {
return buttonsIn(root).find((button) => button.textContent?.trim() === text)
}
function dialog() {
return document.body.querySelector('[role="alertdialog"]')
}
function dialogButton(text: string) {
return buttonIn(dialog()!, text)
}
async function click(button: HTMLElement | undefined) {
button?.click()
await flushPromises()
}
function methodCalls(method: string) {
return requestMock.mock.calls.filter(([, options]) => options?.method === method)
}
function listCalls() {
return requestMock.mock.calls.filter(([path]) => path === LIST_PATH)
}
async function openCancelDialog() {
await click(buttonIn(rowOf('sam.oliveira@renew.example'), 'Cancel invite'))
}
async function confirmCancel() {
await click(dialogButton('Cancel invite'))
}
function iconOf(button: HTMLElement) {
return wrapper!
.findAllComponents(UButton)
.find((component) => component.element === button)
?.props('icon')
}
beforeEach(() => {
toastAdd = vi.fn()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
vi.clearAllMocks()
})
describe.each([
{ name: 'desktop', desktop: true },
{ name: 'below md', desktop: false },
])(
'Default: actions sit on the right of the Pending and Expired rows only ($name)',
({ desktop }) => {
it('shows Cancel invite, Renew invite and nothing on Accepted rows', async () => {
await mountPage({}, desktop)
const [first, second, ...accepted] = rows()
const cancel = buttonIn(first, 'Cancel invite')!
const renew = buttonIn(second, 'Renew invite')!
expect(iconOf(cancel)).toBe('i-lucide-user-x')
expect(cancel.className).toContain('text-error')
expect(iconOf(renew)).toBe('i-lucide-refresh-cw')
expect(renew.className).toContain('text-highlighted')
expect(accepted).toHaveLength(3)
accepted.forEach((row) => expect(buttonsIn(row)).toHaveLength(0))
expect(cancel.className).toContain(desktop ? 'h-7' : 'h-11')
})
},
)
describe.each([
{ name: 'desktop', desktop: true },
{ name: 'below md', desktop: false },
])('Non-owners see no row actions ($name)', ({ desktop }) => {
it.each([
{ role: 'admin', present: false },
{ role: null, present: false },
{ role: 'owner', present: true },
])('role $role shows the actions: $present', async ({ role, present }) => {
await mountPage({}, desktop, role)
const labels = Array.from(document.body.querySelectorAll('button')).map((button) =>
button.textContent?.trim(),
)
expect(labels.filter((label) => label === 'Cancel invite')).toHaveLength(present ? 1 : 0)
expect(labels.filter((label) => label === 'Renew invite')).toHaveLength(present ? 1 : 0)
})
})
describe('Invite-only rule', () => {
it('offers no Renew on a Pending row and no Cancel on an Expired row', async () => {
await mountPage()
expect(buttonIn(rowOf('sam.oliveira@renew.example'), 'Renew invite')).toBeUndefined()
expect(buttonIn(rowOf('p.huber@gmail.com'), 'Cancel invite')).toBeUndefined()
})
})
describe('Cancel invite: confirm', () => {
it('opens the danger dialog and sends nothing', async () => {
await mountPage()
await openCancelDialog()
expect(dialog()?.textContent).toContain('Cancel this invite?')
expect(dialog()?.textContent).toContain(CANCEL_DESCRIPTION)
expect(dialogButton('Keep invite')).toBeDefined()
expect(dialogButton('Cancel invite')).toBeDefined()
expect(methodCalls('DELETE')).toHaveLength(0)
})
})
describe('Cancel invite: Keep invite', () => {
it('closes the dialog and changes nothing', async () => {
await mountPage()
await openCancelDialog()
await click(dialogButton('Keep invite'))
expect(dialog()).toBeNull()
expect(methodCalls('DELETE')).toHaveLength(0)
expect(rows()).toHaveLength(5)
})
})
describe('Cancel invite: sending', () => {
it('sends once, shows Cancelling and locks the dialog', async () => {
const pending = deferred<unknown>()
await mountPage({ cancel: () => pending.promise })
await openCancelDialog()
await confirmCancel()
expect(methodCalls('DELETE')).toHaveLength(1)
expect(methodCalls('DELETE')[0][0]).toBe(CANCEL_PATH)
expect(methodCalls('DELETE')[0][1].body).toBeUndefined()
expect(dialogButton('Cancelling…')).toBeDefined()
expect(dialogButton('Keep invite')!.disabled).toBe(true)
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(dialog()).not.toBeNull()
})
})
describe('Cancel invite: failed', () => {
it.each([
{ name: 'a 500 response', failure: () => new ApiError(500) },
{
name: 'a 500 with other text',
failure: () => new ApiError(500, undefined, 'db is down'),
},
{ name: 'a 404 response', failure: () => new ApiError(404) },
{ name: 'a network failure', failure: () => new NetworkError() },
])('keeps the dialog open with the notice on $name', async ({ failure }) => {
await mountPage({ cancel: () => Promise.reject(failure()) })
await openCancelDialog()
await confirmCancel()
expect(dialog()).not.toBeNull()
expect(dialog()!.textContent).toContain(CANCEL_NOTICE)
expect(dialog()!.textContent).not.toContain('db is down')
expect(dialog()!.querySelectorAll('[role="alert"]')).toHaveLength(1)
expect(dialogButton('Keep invite')!.disabled).toBe(false)
expect(dialogButton('Cancel invite')!.disabled).toBe(false)
expect(rows()).toHaveLength(5)
expect(toastAdd).not.toHaveBeenCalled()
})
})
describe('Cancel invite: retrying after a failure', () => {
it('closes and removes the row on the second attempt', async () => {
const outcomes = [() => Promise.reject(new ApiError(500)), () => Promise.resolve(undefined)]
await mountPage({ cancel: () => outcomes.shift()!() })
await openCancelDialog()
await confirmCancel()
await confirmCancel()
expect(methodCalls('DELETE')).toHaveLength(2)
expect(dialog()).toBeNull()
expect(rows()).toHaveLength(4)
})
})
describe('Cancel invite: done', () => {
it('removes the row, keeps the order, skips the reload and raises the toast', async () => {
await mountPage()
await openCancelDialog()
await confirmCancel()
expect(dialog()).toBeNull()
expect(rows()).toHaveLength(4)
expect(rows()[0].textContent).toContain('p.huber@gmail.com')
expect(rows().some((row) => row.textContent?.includes('sam.oliveira'))).toBe(false)
expect(listCalls()).toHaveLength(1)
expect(toastAdd).toHaveBeenCalledWith({
id: 'invite-cancelled-2',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Invite cancelled',
description: 'sam.oliveira@renew.example can no longer use the link.',
})
})
})
describe('401 and 403 are left to the API client', () => {
it.each([401, 403])('cancel with %i adds no notice and no toast', async (status) => {
await mountPage({ cancel: () => Promise.reject(new ApiError(status)) })
await openCancelDialog()
await confirmCancel()
expect(document.body.textContent).not.toContain(CANCEL_NOTICE)
expect(toastAdd).not.toHaveBeenCalled()
expect(rows()).toHaveLength(5)
})
it.each([401, 403])('renew with %i adds no toast', async (status) => {
await mountPage({ renew: () => Promise.reject(new ApiError(status)) })
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
expect(toastAdd).not.toHaveBeenCalled()
expect(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite')!.disabled).toBe(false)
})
})
describe('Renew invite: sending', () => {
it('sends at once, marks only that button busy and ignores a second click', async () => {
const pending = deferred<unknown>()
await mountPage({ renew: () => pending.promise })
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
expect(methodCalls('POST')).toHaveLength(1)
expect(methodCalls('POST')[0][0]).toBe(RENEW_PATH)
expect(methodCalls('POST')[0][1].body).toBeUndefined()
expect(dialog()).toBeNull()
const busy = buttonIn(rowOf('p.huber@gmail.com'), 'Renewing…')!
expect(busy.disabled).toBe(true)
expect(iconOf(busy)).toBe('i-lucide-loader-circle')
expect(busy.className).toContain('opacity-50')
expect(buttonIn(rowOf('sam.oliveira@renew.example'), 'Cancel invite')!.disabled).toBe(false)
await click(busy)
expect(methodCalls('POST')).toHaveLength(1)
})
it('leaves another expired row usable', async () => {
const pending = deferred<unknown>()
await mountPage({ fixture: twoExpiredFixture(), renew: () => pending.promise })
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
const other = buttonIn(rowOf('old@renew.example'), 'Renew invite')!
expect(other.disabled).toBe(false)
await click(other)
expect(methodCalls('POST')).toHaveLength(2)
})
})
describe('Renew invite: done', () => {
it('moves the row to the top as Pending and raises the toast', async () => {
await mountPage()
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
const [first, second] = rows()
expect(first.textContent).toContain('p.huber@gmail.com')
expect(first.textContent).toContain('Pending')
expect(first.textContent).toContain('Paul Huber')
expect(first.className).toContain('bg-elevated')
expect(buttonIn(first, 'Cancel invite')).toBeDefined()
expect(second.textContent).toContain('sam.oliveira@renew.example')
expect(rows().filter((row) => row.textContent?.includes('p.huber@gmail.com'))).toHaveLength(
1,
)
expect(rows()).toHaveLength(5)
expect(listCalls()).toHaveLength(1)
expect(toastAdd).toHaveBeenCalledWith({
id: 'invite-renewed-1',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Invite renewed',
description: 'p.huber@gmail.com has 3 days to accept the new link.',
})
})
})
describe('Renew invite: failed', () => {
it.each([
{ name: 'a 500 response', failure: () => new ApiError(500) },
{ name: 'a 500 with other text', failure: () => new ApiError(500, undefined, 'smtp down') },
{ name: 'a 404 response', failure: () => new ApiError(404) },
{ name: 'a network failure', failure: () => new NetworkError() },
])('raises the error toast and keeps the row on $name', async ({ failure }) => {
await mountPage({ renew: () => Promise.reject(failure()) })
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(toastAdd).toHaveBeenCalledWith({
id: 'invite-renew-failed-1',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't renew the invite",
description: 'Try again in a moment.',
})
expect(rows()[1].textContent).toContain('p.huber@gmail.com')
expect(rows()[1].textContent).toContain('Expired')
expect(buttonIn(rows()[1], 'Renew invite')!.disabled).toBe(false)
expect(rows()).toHaveLength(5)
})
})
describe('Renew invite: retrying after a failure', () => {
it('raises the success toast and puts the row on top', async () => {
const outcomes = [
() => Promise.reject(new ApiError(500)),
() => Promise.resolve(renewedResponse()),
]
await mountPage({ renew: () => outcomes.shift()!() })
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
expect(toastAdd).toHaveBeenLastCalledWith(
expect.objectContaining({ id: 'invite-renewed-1' }),
)
expect(rows()[0].textContent).toContain('p.huber@gmail.com')
expect(rows()[0].textContent).toContain('Pending')
})
})
describe('Cancelling a just-renewed row', () => {
it('removes it and raises Invite cancelled', async () => {
await mountPage({ cancel: () => Promise.resolve(undefined) })
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
await click(buttonIn(rows()[0], 'Cancel invite'))
await confirmCancel()
expect(methodCalls('DELETE')[0][0]).toBe('/v1/user/invite/1')
expect(rows()).toHaveLength(4)
expect(toastAdd).toHaveBeenLastCalledWith(
expect.objectContaining({ id: 'invite-cancelled-1', title: 'Invite cancelled' }),
)
})
})
describe('Actions after a new invite is sent', () => {
it('ends the new first row with Cancel invite', async () => {
await mountPage()
await click(buttonIn(document.body, 'Invite user'))
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="name@example.com"]',
)!
input.value = 'lea.martin@renew.example'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
await click(buttonIn(document.body, 'Send invite'))
expect(rows()[0].textContent).toContain('lea.martin@renew.example')
expect(buttonIn(rows()[0], 'Cancel invite')).toBeDefined()
})
})
+19 -12
View File
@@ -279,8 +279,8 @@ describe('Default — the list loads newest first, with display name, "you" tag
expect(findButton('Invite user')).toBeTruthy()
expect(rowTexts()).toEqual([
'sam.oliveira@renew.example — admin Pending 27 Sep 2026',
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite',
'p.huber@gmail.com Paul Huber 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',
@@ -338,8 +338,8 @@ describe('Default — the list loads newest first, with display name, "you" tag
await mountPage({}, false)
expect(rowTexts()).toEqual([
'sam.oliveira@renew.example admin Pending Created 27 Sep 2026',
'p.huber@gmail.com Paul Huber admin Expired Created 3 Aug 2026',
'sam.oliveira@renew.example admin Pending Created 27 Sep 2026 Cancel invite',
'p.huber@gmail.com Paul Huber 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',
@@ -457,13 +457,20 @@ describe('Non-owners cannot invite', () => {
expect(findButton('Invite user')).toBeUndefined()
})
it.each([true, false])('shows no hint for an admin on marta and jonas alone (desktop %s)', async (desktop) => {
await mountPage({ list: () => Promise.resolve(defaultFixture().slice(3)) }, desktop, 'admin')
it.each([true, false])(
'shows no hint for an admin on marta and jonas alone (desktop %s)',
async (desktop) => {
await mountPage(
{ list: () => Promise.resolve(defaultFixture().slice(3)) },
desktop,
'admin',
)
expect(rows()).toHaveLength(2)
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
expect(findButton('Invite user')).toBeUndefined()
})
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', () => {
@@ -598,8 +605,8 @@ describe('Invite sent — a 201 closes the modal, adds a Pending row on top and
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',
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite',
'p.huber@gmail.com Paul Huber 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',