feat: users row actions "Cancel invite" on Pending rows and "Renew invite" on Expired rows
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user