416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
import { initializeLocale, setLocale } from '@/i18n'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { ApiError } from '@/composables/useApi'
|
|
import type { UserListItem } from '@/composables/useUsers'
|
|
import { useSessionStore } from '@/stores/session'
|
|
import { ownerAccess, roleRefFor } from '@/test/access'
|
|
import { renderInApp } from './roleKit'
|
|
import TransferOwnershipDialog from '../TransferOwnershipDialog.vue'
|
|
|
|
const transferOwnership = vi.fn()
|
|
const listUsers = vi.fn()
|
|
const toastRemove = vi.fn()
|
|
const refresh = vi.fn()
|
|
let toastAdd: ReturnType<typeof vi.fn>
|
|
|
|
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ transferOwnership, listUsers }) }))
|
|
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
|
return { ...actual, useToast: () => ({ add: toastAdd, remove: toastRemove }) }
|
|
})
|
|
|
|
const JONAS_EMAIL = 'jonas@renewbuild.example'
|
|
|
|
function accepted(id: number, name: string, email: string, role: string): UserListItem {
|
|
return {
|
|
kind: 'user',
|
|
id,
|
|
username: name.toLowerCase().split(' ')[0]!,
|
|
display_name: name,
|
|
email,
|
|
role: roleRefFor(role),
|
|
status: 'accepted',
|
|
created_at: '2026-06-18T09:00:00Z',
|
|
}
|
|
}
|
|
|
|
const USERS: UserListItem[] = [
|
|
{
|
|
kind: 'invite',
|
|
id: 9,
|
|
email: 'pending@renewbuild.example',
|
|
status: 'pending',
|
|
created_at: '2026-09-27T09:00:00Z',
|
|
},
|
|
accepted(1, 'Marta Alves', 'marta@renewbuild.example', 'owner'),
|
|
accepted(2, 'Jonas Weber', JONAS_EMAIL, 'admin'),
|
|
accepted(3, 'Lea Martin', 'lea@renewbuild.example', 'admin'),
|
|
]
|
|
|
|
let wrapper: VueWrapper | null = null
|
|
|
|
async function mountDialog() {
|
|
setActivePinia(createPinia())
|
|
const session = useSessionStore()
|
|
session.session = { id: 1, verified: true, displayName: '', access: ownerAccess() }
|
|
session.refresh = refresh
|
|
wrapper = renderInApp(TransferOwnershipDialog, { users: USERS, open: true })
|
|
await flushPromises()
|
|
}
|
|
|
|
function dialog() {
|
|
return document.body.querySelector('[role="alertdialog"]') as HTMLElement
|
|
}
|
|
|
|
function button(label: string) {
|
|
return [...dialog().querySelectorAll('button')].find(
|
|
(candidate) => candidate.textContent?.trim() === label,
|
|
) as HTMLButtonElement | undefined
|
|
}
|
|
|
|
function radio(name: string) {
|
|
return [...dialog().querySelectorAll('label')]
|
|
.find((candidate) => candidate.textContent?.includes(name))
|
|
?.querySelector<HTMLButtonElement>('[role="radio"]')
|
|
}
|
|
|
|
function field() {
|
|
return dialog().querySelector('input') as HTMLInputElement
|
|
}
|
|
|
|
async function pickJonas() {
|
|
radio('Jonas Weber')!.click()
|
|
await flushPromises()
|
|
button('Continue')!.click()
|
|
await flushPromises()
|
|
}
|
|
|
|
async function type(value: string) {
|
|
field().value = value
|
|
field().dispatchEvent(new Event('input', { bubbles: true }))
|
|
await flushPromises()
|
|
}
|
|
|
|
async function submit() {
|
|
await pickJonas()
|
|
await type(JONAS_EMAIL)
|
|
button('Transfer ownership')!.click()
|
|
await flushPromises()
|
|
}
|
|
|
|
function emitted(name: string) {
|
|
return wrapper!.findComponent(TransferOwnershipDialog).emitted(name)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
toastAdd = vi.fn()
|
|
refresh.mockResolvedValue(undefined)
|
|
listUsers.mockResolvedValue(USERS)
|
|
toastRemove.mockReset()
|
|
transferOwnership.mockResolvedValue({
|
|
owner: { id: 2, name: 'Jonas Weber' },
|
|
former_owner: { id: 1, name: 'Marta Alves', role: roleRefFor('admin') },
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
wrapper?.unmount()
|
|
wrapper = null
|
|
document.body.innerHTML = ''
|
|
transferOwnership.mockReset()
|
|
refresh.mockReset()
|
|
listUsers.mockReset()
|
|
})
|
|
|
|
describe('Pick a member', () => {
|
|
it('reads the copy and offers accepted users other than the owner, never invites', async () => {
|
|
await mountDialog()
|
|
|
|
expect(dialog().textContent).toContain('Transfer ownership')
|
|
expect(dialog().textContent).toContain(
|
|
'Choose who becomes the owner. Only members can be chosen.',
|
|
)
|
|
expect(radio('Jonas Weber')).toBeDefined()
|
|
expect(radio('Lea Martin')).toBeDefined()
|
|
expect(radio('Marta Alves')).toBeUndefined()
|
|
expect(radio('pending@renewbuild.example')).toBeUndefined()
|
|
expect(button('Cancel')).toBeDefined()
|
|
})
|
|
|
|
it('keeps Continue disabled until a member is chosen', async () => {
|
|
await mountDialog()
|
|
|
|
expect(button('Continue')!.disabled).toBe(true)
|
|
radio('Lea Martin')!.click()
|
|
await flushPromises()
|
|
expect(button('Continue')!.disabled).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Confirm', () => {
|
|
it('names the member, lists the consequences and asks for the email', async () => {
|
|
await mountDialog()
|
|
await pickJonas()
|
|
|
|
const text = dialog().textContent!
|
|
expect(text).toContain('Transfer ownership to Jonas Weber')
|
|
expect(text).toContain('This takes effect straight away.')
|
|
expect(text).toContain('Jonas Weber becomes the owner.')
|
|
expect(text).toContain(
|
|
"You become an Admin. You can't take ownership back unless they transfer it.",
|
|
)
|
|
expect(text).toContain(
|
|
'Only the owner can apply updates, delete a site and change activity retention.',
|
|
)
|
|
expect(text).toContain('Your API keys follow your new role.')
|
|
expect(text).toContain(`Type ${JONAS_EMAIL} to confirm`)
|
|
expect(text).toContain('The email must match exactly.')
|
|
})
|
|
|
|
it.each([
|
|
['', true],
|
|
['jonas@renewbuild', true],
|
|
['Jonas@renewbuild.example', true],
|
|
[` ${JONAS_EMAIL}`, true],
|
|
[`${JONAS_EMAIL} `, true],
|
|
[JONAS_EMAIL, false],
|
|
])('gates Transfer ownership on the exact email %j (disabled: %s)', async (typed, disabled) => {
|
|
await mountDialog()
|
|
await pickJonas()
|
|
await type(typed)
|
|
|
|
expect(button('Transfer ownership')!.disabled).toBe(disabled)
|
|
})
|
|
|
|
it('goes Back to the pick step with the member still chosen', async () => {
|
|
await mountDialog()
|
|
await pickJonas()
|
|
button('Back')!.click()
|
|
await flushPromises()
|
|
|
|
expect(dialog()).not.toBeNull()
|
|
expect(dialog().textContent).toContain('Choose who becomes the owner.')
|
|
expect(radio('Jonas Weber')!.getAttribute('aria-checked')).toBe('true')
|
|
expect(emitted('update:open')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('Transferring', () => {
|
|
it('sends the transfer, locks the dialog in flight, refreshes the session then the list', async () => {
|
|
let release!: (value: unknown) => void
|
|
transferOwnership.mockReturnValue(new Promise((resolve) => (release = resolve)))
|
|
await mountDialog()
|
|
await pickJonas()
|
|
await type(JONAS_EMAIL)
|
|
button('Transfer ownership')!.click()
|
|
await flushPromises()
|
|
|
|
expect(transferOwnership).toHaveBeenCalledWith(2, JONAS_EMAIL)
|
|
expect(button('Transferring…')!.disabled).toBe(true)
|
|
expect(button('Back')!.disabled).toBe(true)
|
|
|
|
release({ owner: { id: 2, name: 'Jonas Weber' }, former_owner: { id: 1 } })
|
|
await flushPromises()
|
|
|
|
expect(refresh).toHaveBeenCalledTimes(1)
|
|
expect(emitted('transferred')).toHaveLength(1)
|
|
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('400 invalid_transfer returns to the pick step with an inline error and asks for a re-read', async () => {
|
|
transferOwnership.mockRejectedValue(
|
|
new ApiError(400, undefined, 'invalid_transfer', { code: 'invalid_transfer' }),
|
|
)
|
|
await mountDialog()
|
|
await submit()
|
|
|
|
expect(dialog().textContent).toContain('Choose who becomes the owner.')
|
|
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
|
|
expect(emitted('stale')).toHaveLength(1)
|
|
expect(emitted('update:open')).toBeUndefined()
|
|
})
|
|
|
|
it('400 confirmation_mismatch shows an error under the field and stays on the step', async () => {
|
|
transferOwnership.mockRejectedValue(
|
|
new ApiError(400, undefined, 'confirmation_mismatch', {
|
|
code: 'confirmation_mismatch',
|
|
}),
|
|
)
|
|
await mountDialog()
|
|
await submit()
|
|
|
|
expect(dialog().textContent).toContain('Transfer ownership to Jonas Weber')
|
|
expect(field().getAttribute('aria-invalid')).toBe('true')
|
|
expect(emitted('update:open')).toBeUndefined()
|
|
})
|
|
|
|
it('403 missing_permission closes and leaves the toast to the API client', async () => {
|
|
transferOwnership.mockRejectedValue(
|
|
new ApiError(403, undefined, 'missing_permission', { code: 'missing_permission' }),
|
|
)
|
|
await mountDialog()
|
|
await submit()
|
|
|
|
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
|
expect(toastAdd).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('403 session_required shows nothing and changes nothing', async () => {
|
|
transferOwnership.mockRejectedValue(
|
|
new ApiError(403, undefined, 'This action needs a real session.', {
|
|
code: 'session_required',
|
|
}),
|
|
)
|
|
await mountDialog()
|
|
await submit()
|
|
|
|
expect(dialog().querySelector('[role="alert"]')).toBeNull()
|
|
expect(toastAdd).not.toHaveBeenCalled()
|
|
expect(emitted('update:open')).toBeUndefined()
|
|
})
|
|
|
|
it('500 stays open with an inline error', async () => {
|
|
transferOwnership.mockRejectedValue(new ApiError(500))
|
|
await mountDialog()
|
|
await submit()
|
|
|
|
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
|
|
expect(emitted('update:open')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('Roles sync: the target role changed first (409)', () => {
|
|
const STALE = new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' })
|
|
const JONAS_NOW_WRITER = accepted(2, 'Jonas Weber', JONAS_EMAIL, 'editor')
|
|
|
|
afterEach(() => {
|
|
localStorage.clear()
|
|
initializeLocale()
|
|
})
|
|
|
|
function alertText() {
|
|
return dialog().querySelector('[role="alert"]')?.textContent ?? ''
|
|
}
|
|
|
|
function firstToast() {
|
|
const argument = toastAdd.mock.calls[0]![0]
|
|
return typeof argument === 'function' ? argument() : argument
|
|
}
|
|
|
|
async function staleAfterSubmit() {
|
|
transferOwnership.mockRejectedValueOnce(STALE)
|
|
listUsers.mockResolvedValue(USERS.map((user) => (user.id === 2 ? JONAS_NOW_WRITER : user)))
|
|
await mountDialog()
|
|
await submit()
|
|
}
|
|
|
|
it('shows the alert with Reload member, keeps the typed email and switches the primary action off', async () => {
|
|
await staleAfterSubmit()
|
|
|
|
expect(alertText()).toContain('Role changed in the meantime')
|
|
expect(alertText()).toContain(
|
|
'Jonas Weber is now Writer. What you typed is kept. Check the change, then try again.',
|
|
)
|
|
expect(button('Reload member')).toBeDefined()
|
|
expect(field().value).toBe(JONAS_EMAIL)
|
|
expect(button('Transfer ownership')!.disabled).toBe(true)
|
|
expect(emitted('update:open')).toBeUndefined()
|
|
expect(toastAdd).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('re-reads the member on Reload member and turns the primary action back on', async () => {
|
|
await staleAfterSubmit()
|
|
listUsers.mockClear()
|
|
|
|
button('Reload member')!.click()
|
|
await flushPromises()
|
|
|
|
expect(listUsers).toHaveBeenCalledTimes(1)
|
|
expect(dialog().querySelector('[role="alert"]')).toBeNull()
|
|
expect(field().value).toBe(JONAS_EMAIL)
|
|
expect(button('Transfer ownership')!.disabled).toBe(false)
|
|
expect(emitted('stale')).toHaveLength(1)
|
|
})
|
|
|
|
it('transfers once reloaded', async () => {
|
|
await staleAfterSubmit()
|
|
button('Reload member')!.click()
|
|
await flushPromises()
|
|
|
|
button('Transfer ownership')!.click()
|
|
await flushPromises()
|
|
|
|
expect(transferOwnership).toHaveBeenLastCalledWith(2, JONAS_EMAIL)
|
|
expect(emitted('transferred')).toHaveLength(1)
|
|
})
|
|
|
|
it('goes back to the member list when the target is gone', async () => {
|
|
transferOwnership.mockRejectedValueOnce(STALE)
|
|
listUsers.mockResolvedValue(USERS.filter((user) => user.id !== 2))
|
|
await mountDialog()
|
|
await submit()
|
|
|
|
expect(dialog().textContent).toContain('Choose who becomes the owner')
|
|
expect(emitted('stale')).toHaveLength(1)
|
|
})
|
|
|
|
it('reads the alert in French', async () => {
|
|
setLocale('fr')
|
|
transferOwnership.mockRejectedValueOnce(STALE)
|
|
listUsers.mockResolvedValue(USERS.map((user) => (user.id === 2 ? JONAS_NOW_WRITER : user)))
|
|
await mountDialog()
|
|
radio('Jonas Weber')!.click()
|
|
await flushPromises()
|
|
button('Continuer')!.click()
|
|
await flushPromises()
|
|
await type(JONAS_EMAIL)
|
|
button('Transférer la propriété')!.click()
|
|
await flushPromises()
|
|
|
|
expect(alertText()).toContain('Rôle modifié entre-temps')
|
|
expect(alertText()).toContain(
|
|
'Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez.',
|
|
)
|
|
expect(button('Recharger le membre')).toBeDefined()
|
|
})
|
|
|
|
describe('when the dialog is gone before the answer arrives', () => {
|
|
async function unmountedBefore409() {
|
|
let reject!: (reason: unknown) => void
|
|
transferOwnership.mockReturnValue(new Promise((_, fail) => (reject = fail)))
|
|
await mountDialog()
|
|
await submit()
|
|
wrapper!.unmount()
|
|
wrapper = null
|
|
reject(STALE)
|
|
await flushPromises()
|
|
}
|
|
|
|
it('raises a warning toast that stays, with Reload', async () => {
|
|
await unmountedBefore409()
|
|
|
|
const toast = firstToast()
|
|
expect(toast.color).toBe('warning')
|
|
expect(toast.duration).toBe(0)
|
|
expect(toast.title).toBe('Ownership not transferred')
|
|
expect(toast.description).toBe(
|
|
"Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again.",
|
|
)
|
|
expect(toast.actions[0].label).toBe('Reload')
|
|
})
|
|
|
|
it('refetches the users and dismisses the toast on Reload', async () => {
|
|
await unmountedBefore409()
|
|
const toast = firstToast()
|
|
|
|
toast.actions[0].onClick()
|
|
|
|
expect(listUsers).toHaveBeenCalledTimes(1)
|
|
expect(toastRemove).toHaveBeenCalledWith(toast.id)
|
|
})
|
|
})
|
|
})
|