// FE-MOB-AUSERS-001 to FE-MOB-AUSERS-016 import React from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import { fireEvent, render, screen } from '../../../helpers/render'; import { resetAllStores } from '../../../helpers/store'; import { buildAdminHook, buildAdminUser, type AdminHook } from '../../../helpers/mobileAdmin'; import { useTranslation } from '../../../../src/i18n'; import MAdminUsersSection from '../../../../src/mobile/screens/admin/MAdminUsersSection'; // The permissions matrix self-loads and has its own suite — stub it out here. vi.mock('../../../../src/mobile/screens/admin/MAdminPermissionsPanel', () => ({ default: () =>
, })); function Harness({ admin }: { admin: AdminHook }) { const { t, locale } = useTranslation(); return ; } function renderSection(overrides: Record = {}) { const admin = buildAdminHook(overrides); render(); return admin as unknown as Record>; } interface InviteForm { max_uses: number; expires_in_days: number | ''; trip_id: number | ''; } /** * The invite form controls read e.target.value inside the state updater, so the * spy fixture can't be inspected after the fact — the sheet needs real state to * show what a chip or the trip select actually writes. */ function InviteFormHarness({ overrides }: { overrides: Record }) { const { t, locale } = useTranslation(); const [inviteForm, setInviteForm] = React.useState({ max_uses: 1, expires_in_days: 7, trip_id: '', }); const admin = buildAdminHook({ showCreateInvite: true, ...overrides, inviteForm, setInviteForm }); return ; } function isActiveChip(el: HTMLElement): boolean { return el.className.includes('bg-m-act'); } const ME = buildAdminUser({ id: 1, username: 'admin', email: 'admin@example.com', role: 'admin' }); const ALICE = buildAdminUser({ id: 2, username: 'alice', email: 'alice@example.com', role: 'user' }); function buildInvite(overrides: Record = {}) { return { id: 1, token: 'abcdefghijklmnop', max_uses: 3, used_count: 1, expires_at: null, trip_title: null, created_by_name: 'admin', ...overrides, }; } beforeEach(() => { resetAllStores(); }); describe('MAdminUsersSection', () => { it('FE-MOB-AUSERS-001: lists users with role badges, the "you" marker and the count', () => { renderSection({ users: [ME, ALICE], currentUser: ME }); expect(screen.getByText('2 Users')).toBeInTheDocument(); expect(screen.getByText('admin')).toBeInTheDocument(); expect(screen.getByText('alice@example.com')).toBeInTheDocument(); expect(screen.getByText('Administrator')).toBeInTheDocument(); expect(screen.getByText('User')).toBeInTheDocument(); expect(screen.getByText('(You)')).toBeInTheDocument(); }); it('FE-MOB-AUSERS-002: users without an avatar get an initial, users with one get the image', () => { renderSection({ users: [ALICE, buildAdminUser({ id: 3, username: 'bob', avatar_url: '/uploads/bob.png' })] }); expect(screen.getByText('A')).toBeInTheDocument(); expect(screen.getByRole('presentation')).toHaveAttribute('src', '/uploads/bob.png'); }); it('FE-MOB-AUSERS-003: the spinner replaces the list while users are loading', () => { renderSection({ users: [ALICE], isLoading: true }); expect(document.querySelector('.animate-spin')).toBeInTheDocument(); expect(screen.queryByText('alice')).not.toBeInTheDocument(); }); it('FE-MOB-AUSERS-004: tapping a row opens the edit sheet for that user', async () => { const user = userEvent.setup(); const admin = renderSection({ users: [ME, ALICE], currentUser: ME }); await user.click(screen.getByText('alice')); expect(admin.handleEditUser).toHaveBeenCalledWith(ALICE); }); it('FE-MOB-AUSERS-005: the New user button opens the create sheet', async () => { const user = userEvent.setup(); const admin = renderSection(); await user.click(screen.getByRole('button', { name: 'New user' })); expect(admin.setShowCreateUser).toHaveBeenCalledWith(true); }); it('FE-MOB-AUSERS-006: the invite card shows its empty state without invites', () => { renderSection(); expect(screen.getByText('Invite Links')).toBeInTheDocument(); expect(screen.getByText('No invite links created yet')).toBeInTheDocument(); }); it('FE-MOB-AUSERS-007: an active invite shows the truncated token, usage and copy action', () => { renderSection({ invites: [buildInvite()] }); expect(screen.getByText('abcdefghijkl…')).toBeInTheDocument(); expect(screen.getByText('Active')).toBeInTheDocument(); expect(screen.getByText(/1\/3 used/)).toBeInTheDocument(); expect(screen.getByText(/by admin/)).toBeInTheDocument(); expect(screen.getByTitle('Copy link')).toBeInTheDocument(); }); it('FE-MOB-AUSERS-008: unlimited uses render as ∞ and an expiry/trip line is appended', () => { renderSection({ invites: [ buildInvite({ max_uses: 0, used_count: 4, expires_at: '2099-01-02T00:00:00Z', trip_title: 'Japan' }), ], }); expect(screen.getByText(/4\/∞ used/)).toBeInTheDocument(); expect(screen.getByText(/expires 1\/2\/2099/)).toBeInTheDocument(); expect(screen.getByText(/adds to Japan/)).toBeInTheDocument(); }); it('FE-MOB-AUSERS-009: expired and used-up invites lose the copy action', () => { renderSection({ invites: [ buildInvite({ id: 1, token: 'expired-token-xx', expires_at: '2020-01-01T00:00:00Z' }), buildInvite({ id: 2, token: 'usedup-token-xxx', max_uses: 2, used_count: 2 }), ], }); expect(screen.getByText('Expired')).toBeInTheDocument(); expect(screen.getByText('Used up')).toBeInTheDocument(); expect(screen.queryByTitle('Copy link')).not.toBeInTheDocument(); expect(screen.getAllByTitle('Delete')).toHaveLength(2); }); it('FE-MOB-AUSERS-010: copy and delete hit the handlers with token and id', async () => { const user = userEvent.setup(); const admin = renderSection({ invites: [buildInvite({ id: 7, token: 'abcdefghijklmnop' })] }); await user.click(screen.getByTitle('Copy link')); expect(admin.copyInviteLink).toHaveBeenCalledWith('abcdefghijklmnop'); await user.click(screen.getByTitle('Delete')); expect(admin.handleDeleteInvite).toHaveBeenCalledWith(7); }); it('FE-MOB-AUSERS-011: the create-invite sheet is closed until showCreateInvite flips', async () => { const user = userEvent.setup(); const admin = renderSection(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Create Link' })); expect(admin.setShowCreateInvite).toHaveBeenCalledWith(true); }); it('FE-MOB-AUSERS-012: the open sheet marks the active max-uses and expiry chips', () => { renderSection({ showCreateInvite: true, inviteForm: { max_uses: 3, expires_in_days: '', trip_id: '' } }); expect(screen.getByRole('dialog', { name: 'Create Link' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '3×' }).className).toContain('bg-m-act'); expect(screen.getByRole('button', { name: '1×' }).className).not.toContain('bg-m-act'); // Both chip rows end in ∞ — max uses (unlimited) and expiry (never) const [unlimitedUses, neverExpires] = screen.getAllByRole('button', { name: '∞' }); expect(unlimitedUses.className).not.toContain('bg-m-act'); expect(neverExpires.className).toContain('bg-m-act'); }); it('FE-MOB-AUSERS-013: chips patch only their own field of the invite form', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '5×' })); expect(isActiveChip(screen.getByRole('button', { name: '5×' }))).toBe(true); expect(isActiveChip(screen.getByRole('button', { name: '1×' }))).toBe(false); // The expiry row is untouched: 7d is still the active chip expect(isActiveChip(screen.getByRole('button', { name: '7d' }))).toBe(true); await user.click(screen.getByRole('button', { name: '14d' })); expect(isActiveChip(screen.getByRole('button', { name: '14d' }))).toBe(true); expect(isActiveChip(screen.getByRole('button', { name: '7d' }))).toBe(false); expect(isActiveChip(screen.getByRole('button', { name: '5×' }))).toBe(true); }); it('FE-MOB-AUSERS-014: the trip select is hidden without trips', () => { renderSection({ showCreateInvite: true }); expect(screen.getByRole('dialog', { name: 'Create Link' })).toBeInTheDocument(); expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); expect(screen.queryByText('Add to trip (optional)')).not.toBeInTheDocument(); }); it('FE-MOB-AUSERS-015: the trip select stores the numeric id and maps "No trip" back to ""', async () => { const user = userEvent.setup(); const handleCreateInvite = vi.fn(); render( , ); const select = screen.getByRole('combobox') as HTMLSelectElement; expect(screen.getByRole('option', { name: 'No trip' })).toBeInTheDocument(); expect(select.value).toBe(''); fireEvent.change(select, { target: { value: '4' } }); expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('4'); fireEvent.change(select, { target: { value: '' } }); expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(''); await user.click(screen.getByRole('button', { name: 'Create & Copy' })); expect(handleCreateInvite).toHaveBeenCalled(); }); it('FE-MOB-AUSERS-016: cancel, close and Escape all dismiss the sheet', async () => { const user = userEvent.setup(); const admin = renderSection({ showCreateInvite: true }); await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(admin.setShowCreateInvite).toHaveBeenCalledWith(false); await user.click(screen.getByRole('button', { name: 'Close' })); fireEvent.keyDown(document, { key: 'Escape' }); expect(admin.setShowCreateInvite).toHaveBeenCalledTimes(3); }); });