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 vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ transferOwnership, listUsers }) })) vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() 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('[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) }) }) })