import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { defineComponent, h } from 'vue' import uiPlugin from '@nuxt/ui/vue-plugin' import UApp from '@nuxt/ui/components/App.vue' import router from '@/router' import { ApiError, useApi } from '@/composables/useApi' import type { UserListItem } from '@/composables/useUsers' import { useSessionStore } from '@/stores/session' import { accessFor, adminAccess, customAccess, ownerAccess, roleRefFor } from '@/test/access' import type { Access } from '@/lib/permissions' import { CONTRIBUTOR, WORLD } from '@/components/__tests__/userRolesKit' import UsersView from '../UsersView.vue' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) let toastAdd: ReturnType vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => ({ add: toastAdd }) } }) const LIST_PATH = '/v1/user/' const ROLE_PATH = '/v1/role/' const JONAS_EMAIL = 'jonas@renewbuild.example' const OWNER_HINT = "The owner can't be changed or removed. Transfer ownership first." const OWN_ROW_HINT = "You can't change your own role. Ask another owner or admin." function accepted( id: number, username: string, name: string, email: string, role: string, extra: Record = {}, ): UserListItem { return { kind: 'user', id, username, display_name: name, email, role: roleRefFor(role), status: 'accepted', created_at: '2026-06-18T09:00:00Z', ...extra, } as UserListItem } function fixture(): UserListItem[] { return [ { kind: 'invite', id: 2, email: 'sam.oliveira@renew.example', role: { id: 4, name: 'Contributor', built_in: false, locked: false }, status: 'expired', created_at: '2026-08-03T09:00:00Z', }, { kind: 'invite', id: 3, email: 'pending@renew.example', status: 'pending', created_at: '2026-09-27T09:00:00Z', }, accepted(4, 'ines', 'Ines Duarte', 'ines.duarte@renew.example', 'member', { role_assigned_by: { id: 1, name: 'Marta Alves' }, }), accepted(3, 'lea', 'Lea Martin', 'lea@renewbuild.example', 'admin'), accepted(2, 'jonas', 'Jonas Weber', JONAS_EMAIL, 'admin'), accepted(1, 'marta', 'Marta Alves', 'marta@renewbuild.example', 'owner'), ] } function without(access: Access, key: string): Access { return { ...access, permissions: access.permissions.filter((grant) => grant.key !== key) } } 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 let getMe: ReturnType let wrapper: VueWrapper | null = null let answers: Record Promise> = {} let listItems: UserListItem[] = [] async function mountPage(access: Access = ownerAccess(), sessionId = 1, desktop = true) { setViewport(desktop) setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true, username: 'me', id: sessionId, displayName: '', access } session.status = 'ready' requestMock = vi.fn((path: string, options?: RequestInit) => { const key = `${options?.method ?? 'GET'} ${path}` if (answers[key]) { return answers[key]!(options) } if (key === `GET ${LIST_PATH}`) { return Promise.resolve(listItems) } if (key === `GET ${ROLE_PATH}`) { return Promise.resolve(WORLD) } throw new Error(`Unexpected request: ${key}`) }) getMe = vi.fn().mockResolvedValue({ verified: true, id: sessionId, access: adminAccess() }) vi.mocked(useApi).mockReturnValue({ getMe, request: requestMock } as unknown as ReturnType< typeof useApi >) await router.push('/users') wrapper = mount(defineComponent({ render: () => h(UApp, null, () => h(UsersView)) }), { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() } function calls(key: string) { return requestMock.mock.calls.filter( ([path, options]) => `${options?.method ?? 'GET'} ${path}` === key, ) } function trigger(name: string) { return document.body.querySelector(`[aria-label="Actions for ${name}"]`) } async function openMenu(name: string) { trigger(name)!.click() await flushPromises() } function menuItems() { return [...document.body.querySelectorAll('[role="menuitem"]')] } function labelOfItem(item: HTMLElement) { return item.querySelector('span span')?.textContent?.trim() ?? '' } function menuItem(label: string) { return menuItems().find((item) => labelOfItem(item) === label) } function menuLabels() { return menuItems().map(labelOfItem) } function buttonNamed(label: string) { return [...document.body.querySelectorAll('button')].find( (candidate) => candidate.textContent?.trim() === label, ) } function rowOf(email: string) { return [...document.body.querySelectorAll('[data-testid="user-row"]')].find( (row) => row.textContent?.includes(email), )! } function dialog() { return document.body.querySelector('[role="alertdialog"], [role="dialog"]') } async function click(target: HTMLElement | undefined) { target?.click() await flushPromises() } function radio(name: string) { return [...dialog()!.querySelectorAll('label')] .find((label) => label.querySelector('[data-slot="label"]')?.textContent?.includes(name)) ?.querySelector('[role="radio"]') } beforeEach(() => { toastAdd = vi.fn() answers = {} listItems = fixture() Element.prototype.scrollIntoView = vi.fn() }) afterEach(async () => { wrapper?.unmount() wrapper = null await router.push('/login') document.body.innerHTML = '' }) describe('What the Users page offers per grant', () => { it.each([ ['users.view', false, false], ['users.view, users.invite', true, false], ['users.view, users.change_role, roles.view', false, true], ['users.view, users.invite, users.change_role, roles.view', true, true], ])('grants %s: invite %s, menu %s', async (grants, invite, menu) => { await mountPage(customAccess(grants, 'Custom'), 9) expect(Boolean(buttonNamed('Invite user'))).toBe(invite) expect(trigger('Ines Duarte') !== null).toBe(menu) if (menu) { await openMenu('Ines Duarte') expect(menuLabels()).toEqual(['Change role']) } }) it('offers the Owner the invite button and Change role', async () => { await mountPage() expect(buttonNamed('Invite user')).toBeInstanceOf(HTMLButtonElement) await openMenu('Ines Duarte') expect(menuLabels()).toEqual(['Change role']) expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBeNull() }) it('keeps invite rows to Cancel invite and Renew invite only', async () => { await mountPage() expect(trigger('sam.oliveira@renew.example')).toBeNull() expect(buttonNamed('Renew invite')).toBeInstanceOf(HTMLButtonElement) expect(buttonNamed('Cancel invite')).toBeInstanceOf(HTMLButtonElement) }) it('shows no Assigned by text in the table or the cards', async () => { await mountPage() expect(document.body.textContent).not.toContain('Assigned by') wrapper?.unmount() await mountPage(ownerAccess(), 1, false) expect(document.body.textContent).not.toContain('Assigned by') expect(document.body.textContent).not.toContain('Marta Alves assigned') }) }) describe('Your own row and the owner row', () => { it('offers Change role disabled with the reason on the row marked you', async () => { await mountPage(adminAccess(), 2) await openMenu('Jonas Weber') expect(menuLabels()).toEqual(['Change role']) expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true') expect(menuItem('Change role')?.textContent).toContain(OWN_ROW_HINT) }) it('gives an Admin the owner row with Change role disabled and no Transfer ownership', async () => { await mountPage(adminAccess(), 2) await openMenu('Marta Alves') expect(menuLabels()).toEqual(['Change role']) expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true') expect(menuItem('Change role')?.textContent).toContain(OWNER_HINT) }) it('gives the Owner on their own row Change role disabled and Transfer ownership enabled', async () => { await mountPage() await openMenu('Marta Alves') expect(menuLabels()).toEqual(['Change role', 'Transfer ownership']) expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true') expect(menuItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull() }) it('gives users.view alone no menu on the owner row', async () => { await mountPage(customAccess('users.view', 'Viewer'), 9) expect(trigger('Marta Alves')).toBeNull() }) it('hides Transfer ownership everywhere without system.transfer_ownership', async () => { await mountPage(without(ownerAccess(), 'system.transfer_ownership')) await openMenu('Marta Alves') expect(menuLabels()).toEqual(['Change role']) }) }) function sheetItems() { return [...document.body.querySelectorAll('[role="menu"] [role="menuitem"]')] } function sheetLabels() { return sheetItems().map((item) => item.textContent?.trim() ?? '') } function sheetItem(label: string) { return sheetItems().find((item) => item.textContent?.trim() === label) } describe('The mobile Users menu', () => { it('lists Change role disabled with the reason on the owner card', async () => { await mountPage(adminAccess(), 2, false) expect(document.body.querySelector('[data-testid="user-card"]')).not.toBeNull() await openMenu('Marta Alves') expect(sheetLabels()).toEqual(['Change role']) expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true') expect(document.body.querySelector('[role="menu"]')?.textContent).toContain(OWNER_HINT) }) it('offers Transfer ownership to the Owner on their own card and opens the dialog', async () => { await mountPage(ownerAccess(), 1, false) await openMenu('Marta Alves') expect(sheetLabels()).toEqual(['Change role', 'Transfer ownership']) expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true') expect(sheetItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull() await click(sheetItem('Transfer ownership')) expect(dialog()?.textContent).toContain('Transfer ownership') }) it('offers a plain Change role on another member card', async () => { await mountPage(ownerAccess(), 1, false) await openMenu('Ines Duarte') expect(sheetLabels()).toEqual(['Change role']) expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBeNull() await click(sheetItem('Change role')) expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(1) }) }) describe('Changing without roles.view', () => { it('disables Change role with a hint and never reads the roles', async () => { await mountPage(customAccess('users.view, users.change_role', 'Custom'), 9) await openMenu('Ines Duarte') expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true') await click(menuItem('Change role')) expect(dialog()).toBeNull() expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(0) }) }) describe('Changing the role', () => { it('updates only that row from the answer and does not reload the list', async () => { answers['PUT /v1/user/4/role'] = () => Promise.resolve( accepted(4, 'ines', 'Ines Duarte', 'ines.duarte@renew.example', 'admin', { role: { id: CONTRIBUTOR.id, name: 'Contributor', built_in: false, locked: false, }, }), ) await mountPage() await openMenu('Ines Duarte') await click(menuItem('Change role')) expect(dialog()?.textContent).toContain('Change role for Ines Duarte') await click(radio('Contributor')) await click( [...dialog()!.querySelectorAll('button')].find( (b) => b.textContent?.trim() === 'Change role', ), ) expect(calls('PUT /v1/user/4/role')).toHaveLength(1) expect(JSON.parse(calls('PUT /v1/user/4/role')[0]![1].body)).toEqual({ role_id: CONTRIBUTOR.id, }) expect(rowOf('ines.duarte@renew.example').textContent).toContain('Contributor') expect(rowOf(JONAS_EMAIL).textContent).toContain('admin') expect(calls(`GET ${LIST_PATH}`)).toHaveLength(1) expect(toastAdd).toHaveBeenCalledTimes(1) expect(dialog()).toBeNull() }) it('re-reads the list and closes on 409 user_role_changed', async () => { answers['PUT /v1/user/4/role'] = () => Promise.reject( new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' }), ) await mountPage() await openMenu('Ines Duarte') await click(menuItem('Change role')) await click(radio('Contributor')) await click( [...dialog()!.querySelectorAll('button')].find( (b) => b.textContent?.trim() === 'Change role', ), ) expect(calls(`GET ${LIST_PATH}`)).toHaveLength(2) expect(dialog()).toBeNull() }) }) describe('Transferring ownership', () => { it('refreshes the session then the list and shows the toast', async () => { answers['POST /v1/site/transfer-ownership'] = () => Promise.resolve({ owner: { id: 2, name: 'Jonas Weber' }, former_owner: { id: 1, name: 'Marta Alves', role: roleRefFor('admin') }, }) await mountPage() await openMenu('Marta Alves') await click(menuItem('Transfer ownership')) await click(radio('Jonas Weber')) await click( [...dialog()!.querySelectorAll('button')].find( (b) => b.textContent?.trim() === 'Continue', ), ) const input = dialog()!.querySelector('input')! input.value = JONAS_EMAIL input.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() await click( [...dialog()!.querySelectorAll('button')].find( (b) => b.textContent?.trim() === 'Transfer ownership', ), ) expect(JSON.parse(calls('POST /v1/site/transfer-ownership')[0]![1].body)).toEqual({ user_id: 2, confirm_email: JONAS_EMAIL, }) expect(getMe).toHaveBeenCalledTimes(1) expect(calls(`GET ${LIST_PATH}`)).toHaveLength(2) expect(toastAdd).toHaveBeenCalledTimes(1) expect(dialog()).toBeNull() }) }) describe('Invite rows keep their role', () => { it('keeps the Contributor role on a renewed invite', async () => { answers['POST /v1/user/invite/2/renew'] = () => Promise.resolve({ id: 2, kind: 'invite', email: 'sam.oliveira@renew.example', status: 'pending', created_at: '2026-10-01T09:00:00Z', expires_at: '2026-10-04T09:00:00Z', }) await mountPage() await click(buttonNamed('Renew invite')) const row = rowOf('sam.oliveira@renew.example') expect(row.textContent).toContain('Contributor') expect(row.textContent).toContain('Pending') expect(toastAdd).toHaveBeenCalledTimes(1) }) it('shows the escalation refusal and leaves the row Expired', async () => { answers['POST /v1/user/invite/2/renew'] = () => Promise.reject(new ApiError(403, undefined, 'escalation', { code: 'escalation' })) await mountPage() await click(buttonNamed('Renew invite')) expect(rowOf('sam.oliveira@renew.example').textContent).toContain('Expired') expect(toastAdd).toHaveBeenCalledTimes(1) const toast = toastAdd.mock.calls[0]![0] const content = typeof toast === 'function' ? toast() : toast expect(content.title).toBe("You can't grant that") expect(content.description).toBe('You can only grant what your role has.') }) it('puts the invite at the top with the chosen role badge', async () => { answers['POST /v1/user/invite'] = () => Promise.resolve({ id: 8, kind: 'invite', email: 'lea.martin@renew.example', role: { id: 3, name: 'Writer', built_in: true, locked: false }, status: 'pending', created_at: '2026-10-01T09:00:00Z', expires_at: '2026-10-04T09:00:00Z', }) await mountPage() await click(buttonNamed('Invite user')) const input = document.body.querySelector( 'input[placeholder="name@example.com"]', )! input.value = 'lea.martin@renew.example' input.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() await click(buttonNamed('Send invite')) expect(JSON.parse(calls('POST /v1/user/invite')[0]![1].body)).toEqual({ email: 'lea.martin@renew.example', role_id: 3, }) const first = document.body.querySelector('[data-testid="user-row"]')! expect(first.textContent).toContain('lea.martin@renew.example') expect(first.textContent).toContain('writer') expect(accessFor('owner')).toBeDefined() }) })