// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-061 import type { Mock } from 'vitest'; import { act, render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { delay, http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { usePermissionsStore } from '../../store/permissionsStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip } from '../../../tests/helpers/factories'; import TripMembersModal from './TripMembersModal'; const defaultProps = { isOpen: true, onClose: vi.fn(), tripId: 1, tripTitle: 'Test Trip', }; const ownerUser = buildUser({ id: 1, username: 'owner' }); const memberUser = buildUser({ id: 2, username: 'alice' }); type AddToast = NonNullable; let addToast: Mock; interface MemberRow { id: number; username: string; avatar_url?: string | null; is_guest?: boolean; } /** Replaces the roster handler; `owner` defaults to the seeded owner user. */ function mockRoster(members: MemberRow[], owner: MemberRow = { id: ownerUser.id, username: ownerUser.username, avatar_url: null }): void { server.use( http.get('/api/trips/1/members', () => HttpResponse.json({ owner, members, current_user_id: ownerUser.id }) ), ); } /** Owner-only permissions so the share/invite column renders for the seeded owner. */ function asShareOwner(): void { seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); server.use(http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: null }))); } /** A browser on https, where the async clipboard API is the path taken. */ function mockClipboard(): Mock<(text: string) => Promise> { const writeText = vi.fn(async () => {}); Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true }); Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true, writable: true }); return writeText; } /** * A `location` whose `reload` is a spy. * * jsdom's own `reload` cannot be redefined, so the whole object has to be * replaced, and `{ ...window.location }` is the trap: its fields are getters on * the prototype, so the copy has no `href` for axios to resolve a relative URL * against and the request never reaches MSW. The fields are therefore read out * by name. */ /** * A `location` whose `reload` is a spy, for the two flows that reload the app. * * jsdom's own `reload` cannot be redefined, so the whole object is replaced, and * the replacement is a flat copy without the prototype's getters. That is enough * for a test that only asserts the reload, but it leaves a request with nothing * to resolve a relative URL against, so a test that asserts the call itself must * not use this. The original is put back after every test: left in place, it * broke every later case in the file. */ let realLocation: PropertyDescriptor | undefined; function stubReload(): Mock<() => void> { const reload = vi.fn(); realLocation ??= Object.getOwnPropertyDescriptor(window, 'location'); Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true, configurable: true }); return reload; } beforeEach(() => { resetAllStores(); server.use( http.get('/api/trips/1/members', () => HttpResponse.json({ owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null }, members: [], current_user_id: ownerUser.id, }) ), http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: null }) ), http.get('/api/auth/users', () => HttpResponse.json({ users: [memberUser] }) ), ); seedStore(useAuthStore, { user: ownerUser, isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Test Trip' }) }); addToast = vi.fn(() => 0); window.__addToast = addToast; }); afterEach(() => { delete window.__addToast; vi.useRealTimers(); vi.restoreAllMocks(); if (realLocation) { Object.defineProperty(window, 'location', realLocation); realLocation = undefined; } Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true }); }); describe('TripMembersModal', () => { it('FE-COMP-MEMBERS-001: renders without crashing', () => { render(); expect(document.body).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-002: shows the Trip settings title', () => { render(); // trip.settings.title = "Trip settings" expect(screen.getByText('Trip settings')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-003: shows owner username after load', async () => { render(); expect(await screen.findByText('owner')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-004: shows Owner label', async () => { render(); expect(await screen.findByText('Owner')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-005: shows Access section heading', async () => { render(); // Text is "Access (1 person)" so use regex expect(await screen.findByText(/Access/i)).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-006: shows member when members are loaded', async () => { server.use( http.get('/api/trips/1/members', () => HttpResponse.json({ owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null }, members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }], current_user_id: ownerUser.id, }) ) ); render(); expect(await screen.findByText('alice')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-007: shows Invite User section', async () => { render(); expect(await screen.findByText('Invite User')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-008: shows Invite button', async () => { render(); expect(await screen.findByRole('button', { name: /Invite/i })).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-009: Cancel/close button is present', () => { render(); // Modal has a close button (×) const closeBtn = screen.queryByRole('button', { name: /close/i }) || document.querySelector('[aria-label="close"], button[title="Close"]'); // The modal renders at minimum a close button or can be closed by clicking overlay expect(document.body).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-010: shows member count of 1 with owner', async () => { render(); // 1 person (just owner) expect(await screen.findByText(/1 person/i)).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-011: members count increases when member is added', async () => { server.use( http.get('/api/trips/1/members', () => HttpResponse.json({ owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null }, members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }], current_user_id: ownerUser.id, }) ) ); render(); expect(await screen.findByText(/2 people/i)).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-012: shows "you" label next to current user', async () => { render(); // A badge beside the name on the desktop; the phone keeps "(you)". expect(await screen.findByText('you')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-013: shows remove access button for members (not owner)', async () => { server.use( http.get('/api/trips/1/members', () => HttpResponse.json({ owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null }, members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }], current_user_id: ownerUser.id, }) ) ); render(); await screen.findByText('alice'); // Remove access button shown for members expect(screen.getByRole('button', { name: 'Remove access' })).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-014: remove member calls DELETE API', async () => { const user = userEvent.setup(); let deleteCalled = false; server.use( http.get('/api/trips/1/members', () => HttpResponse.json({ owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null }, members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }], current_user_id: ownerUser.id, }) ), http.delete('/api/trips/1/members/:userId', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }) ); render(); await screen.findByText('alice'); await user.click(screen.getByRole('button', { name: 'Remove access' })); // The question comes first, in the app's own dialog. expect(screen.getByText('Remove access for this user?')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Confirm' })); await waitFor(() => expect(deleteCalled).toBe(true)); }); it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => { render(); expect(screen.getByText('Trip settings')).toBeInTheDocument(); }); // ── Share Link Section (016-021) ─────────────────────────────────────────── it('FE-COMP-MEMBERS-016: share link section not rendered for non-owner', async () => { const nonOwner = buildUser({ id: 99, username: 'stranger' }); seedStore(useAuthStore, { user: nonOwner, isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) }); seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); render(); // Wait for members list to load so the component is fully rendered await screen.findByText(/Access/i); expect(screen.queryByText('Public Link')).not.toBeInTheDocument(); }); it('FE-COMP-MEMBERS-017: share link section visible for owner', async () => { seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); render(); expect(await screen.findByText('Public Link')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-018: create share link shows URL after clicking create', async () => { const user = userEvent.setup(); seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); // GET returns null token initially; POST returns a new token server.use( http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: null })), http.post('/api/trips/1/share-link', () => HttpResponse.json({ token: 'abc123', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false, }) ), ); render(); await user.click(await screen.findByRole('button', { name: 'Sharing' })); const createBtn = await screen.findByText('Create link'); await user.click(createBtn); await waitFor(() => { const input = screen.getByDisplayValue(/\/shared\/abc123/); expect(input).toBeInTheDocument(); }); // Each toggle says whether it is on, not only by its colour (#2604). expect(screen.getByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false'); }); it('FE-COMP-MEMBERS-019: copy share link calls clipboard.writeText', async () => { const user = userEvent.setup(); seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); const writeText = mockClipboard(); server.use( http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok99', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false, }) ), ); render(); const copyBtn = await screen.findByText('Copy'); await user.click(copyBtn); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('tok99')); await screen.findByText('Copied'); }); it('FE-COMP-MEMBERS-020: delete share link removes URL and shows create button', async () => { const user = userEvent.setup(); seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); let deleteHandlerCalled = false; server.use( http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok99', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false, }) ), http.delete('/api/trips/1/share-link', () => { deleteHandlerCalled = true; return HttpResponse.json({ success: true }); }), ); render(); const deleteBtn = await screen.findByText('Delete link'); await user.click(deleteBtn); expect(deleteHandlerCalled).toBe(true); await screen.findByText('Create link'); }); it('FE-COMP-MEMBERS-021: clicking permission toggle calls POST with updated perms', async () => { const user = userEvent.setup(); seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); let postedPerms: Record | null = null; server.use( http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok99', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false, }) ), http.post('/api/trips/1/share-link', async ({ request }) => { postedPerms = await request.json() as Record; return HttpResponse.json({ token: 'tok99', ...postedPerms }); }), ); render(); // Wait for the share section to load await screen.findByText('Public Link'); // Click the "Packing" permission pill to toggle it on const packingBtn = await screen.findByText('Packing'); await user.click(packingBtn); await waitFor(() => { expect(postedPerms).not.toBeNull(); expect(postedPerms).toMatchObject({ share_packing: true }); }); }); // ── Member management (022-025) ──────────────────────────────────────────── it('FE-COMP-MEMBERS-022: adding a member via select + invite calls POST', async () => { const user = userEvent.setup(); let postBody: Record | null = null; server.use( http.post('/api/trips/1/members', async ({ request }) => { postBody = await request.json() as Record; return HttpResponse.json({ success: true }); }), ); render(); // Wait for Invite section to load await screen.findByText('Invite User'); // Open the CustomSelect by clicking its trigger button (shows placeholder) const selectTrigger = screen.getByText('Select user…'); await user.click(selectTrigger); // alice option appears in the portal dropdown const aliceOption = await screen.findByRole('button', { name: 'alice' }); await user.click(aliceOption); // Click the member "Invite" button (exact — the Share area also has a // "Create invite link" button that a loose /Invite/i would match too). const inviteBtn = screen.getByRole('button', { name: 'Invite' }); await user.click(inviteBtn); await waitFor(() => { expect(postBody).not.toBeNull(); }); }); it('FE-COMP-MEMBERS-023: invite button is disabled when no user is selected', async () => { render(); await screen.findByText('Invite User'); const inviteBtn = screen.getByRole('button', { name: /Invite/i }); expect(inviteBtn).toBeDisabled(); }); it('FE-COMP-MEMBERS-024: leave trip calls DELETE for current user', async () => { const user = userEvent.setup(); // No reload stub here on purpose: this case asserts the DELETE, and a stubbed // location is exactly what stops the request from reaching it. jsdom's own // reload is a no-op that only logs. seedStore(useAuthStore, { user: memberUser, isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); let deleteCalledForUserId: string | null = null; server.use( http.get('/api/trips/1/members', () => HttpResponse.json({ owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null }, members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }], current_user_id: memberUser.id, }) ), http.delete('/api/trips/1/members/:userId', ({ params }) => { deleteCalledForUserId = params.userId as string; return HttpResponse.json({ success: true }); }), ); render(); await screen.findByText('alice'); await user.click(screen.getByRole('button', { name: 'Leave trip' })); expect(screen.getByText('Leave trip? You will lose access.')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Confirm' })); await waitFor(() => { expect(deleteCalledForUserId).toBe(String(memberUser.id)); }); }); it('FE-COMP-MEMBERS-025: "all have access" message shown when all users are members', async () => { server.use( http.get('/api/trips/1/members', () => HttpResponse.json({ owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null }, members: [{ id: memberUser.id, username: memberUser.username, avatar_url: null }], current_user_id: ownerUser.id, }) ), http.get('/api/auth/users', () => HttpResponse.json({ users: [memberUser] }) ), ); render(); expect(await screen.findByText('All users already have access.')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-026: owner sees the guests section and can add a guest (#1362)', async () => { let createdName: string | null = null; server.use( http.post('/api/trips/1/guests', async ({ request }) => { createdName = ((await request.json()) as { name: string }).name; return HttpResponse.json({ member: { id: 99, username: createdName, is_guest: true } }); }), ); render(); // The guests section + add affordance is shown to the owner. await screen.findByText('Guests'); const input = screen.getByPlaceholderText('Guest name'); await userEvent.type(input, 'Grandpa'); await userEvent.click(screen.getByRole('button', { name: /Add guest/i })); await waitFor(() => expect(createdName).toBe('Grandpa')); }); it('FE-COMP-MEMBERS-027: a guest member is shown in the guests section with a Guest badge, not the members list (#1362)', async () => { server.use( http.get('/api/trips/1/members', () => HttpResponse.json({ owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null, is_guest: false }, members: [ { id: 2, username: 'alice', avatar_url: null, is_guest: false }, { id: 3, username: 'Grandma', avatar_url: null, is_guest: true }, ], current_user_id: ownerUser.id, }) ), ); render(); await screen.findByText('Grandma'); // The guest carries a "Guest" badge. expect(screen.getAllByText('Guest').length).toBeGreaterThan(0); // The head band counts the owner and the real member (2), not the guest. expect(screen.getByText('2 people')).toBeInTheDocument(); }); // ── Avatars and load failures (028-031) ─────────────────────────────────── it('FE-COMP-MEMBERS-028: a member with an avatar renders the image instead of the initial', async () => { mockRoster([{ id: 2, username: 'alice', avatar_url: '/uploads/avatars/alice.png' }]); render(); await screen.findByText('alice'); expect(document.querySelector('img[src="/uploads/avatars/alice.png"]')).toBeInTheDocument(); // The owner has no avatar and still gets a letter tile. expect(screen.getByText('O')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-029: a nameless member falls back to a question mark', async () => { mockRoster([], { id: ownerUser.id, username: '', avatar_url: null }); render(); expect(await screen.findByText('?')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-030: a failing roster load shows an error toast and an empty list', async () => { server.use(http.get('/api/trips/1/members', () => HttpResponse.json({}, { status: 500 }))); render(); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to load members', 'error', undefined)); expect(screen.getByText('0 people')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-031: a failing share-link load still offers link creation', async () => { asShareOwner(); server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 }))); render(); await screen.findByText('Create link'); expect(screen.getByText('Public Link')).toBeInTheDocument(); }); // ── Share link errors and defaults (032-036) ────────────────────────────── it('FE-COMP-MEMBERS-032: a failing share-link creation is reported', async () => { const user = userEvent.setup(); asShareOwner(); server.use(http.post('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 }))); render(); await user.click(await screen.findByText('Create link')); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not create link', 'error', undefined)); expect(screen.getByText('Create link')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-033: missing permission flags fall back to map+bookings, and Map is not toggleable', async () => { const user = userEvent.setup(); asShareOwner(); let postedPerms: Record | null = null; server.use( http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' })), http.post('/api/trips/1/share-link', async ({ request }) => { postedPerms = (await request.json()) as Record; return HttpResponse.json({ token: 'tok77', ...postedPerms }); }), ); render(); // The always-on Map & Plan pill ignores clicks. await user.click(await screen.findByText('Map & Plan')); expect(postedPerms).toBeNull(); await user.click(screen.getByText('Costs')); await waitFor(() => expect(postedPerms).toMatchObject({ share_map: true, share_bookings: true, share_packing: false, share_budget: true, share_collab: false, })); }); it('FE-COMP-MEMBERS-033b: the two narrowing options post with the link (#1712)', async () => { const user = userEvent.setup(); asShareOwner(); let postedPerms: Record | null = null; server.use( http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77', share_hide_images: true })), http.post('/api/trips/1/share-link', async ({ request }) => { postedPerms = (await request.json()) as Record; return HttpResponse.json({ token: 'tok77' }); }), ); render(); await user.click(await screen.findByText('Travel & stays only')); await waitFor(() => expect(postedPerms).toMatchObject({ share_travel_only: true, share_hide_images: true })); }); it('FE-COMP-MEMBERS-034: a failing permission update is reported', async () => { const user = userEvent.setup(); asShareOwner(); server.use( http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' })), http.post('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 })), ); render(); await user.click(await screen.findByText('Packing')); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not create link', 'error', undefined)); }); it('FE-COMP-MEMBERS-035: a failing share-link deletion keeps the link', async () => { const user = userEvent.setup(); asShareOwner(); server.use( http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' })), http.delete('/api/trips/1/share-link', () => HttpResponse.json({}, { status: 500 })), ); render(); await user.click(await screen.findByText('Delete link')); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined)); expect(screen.getByDisplayValue(/\/shared\/tok77/)).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-036: the copied badge resets two seconds after the last copy', async () => { asShareOwner(); const writeText = mockClipboard(); server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok77' }))); const view = render(); const copyBtn = (await screen.findByText('Copy')).closest('button')!; vi.useFakeTimers(); fireEvent.click(copyBtn); // The copy is awaited now, so the badge lands a microtask after the click. await act(async () => {}); expect(screen.getByText('Copied')).toBeInTheDocument(); // A second copy replaces the pending reset rather than stacking timers. fireEvent.click(screen.getByText('Copied').closest('button')!); await act(async () => {}); act(() => { vi.advanceTimersByTime(2000); }); expect(screen.getByText('Copy')).toBeInTheDocument(); expect(writeText).toHaveBeenCalledTimes(2); view.unmount(); }); // ── Trip invite link (037-039) ──────────────────────────────────────────── it('FE-COMP-MEMBERS-037: an invite link can be created, copied, regenerated and disabled', async () => { const user = userEvent.setup(); asShareOwner(); const writeText = mockClipboard(); const tokens = ['inv1', 'inv2']; server.use( http.post('/api/trips/1/invite-link', async () => { await delay(20); return HttpResponse.json({ token: tokens.shift() }); }), http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })), ); const view = render(); await user.click(await screen.findByRole('button', { name: 'Sharing' })); const createBtn = await screen.findByRole('button', { name: /Create invite link/i }); fireEvent.click(createBtn); expect(createBtn).toBeDisabled(); await screen.findByDisplayValue(/\/join\/inv1$/); await user.click(screen.getByText('Copy').closest('button')!); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/join/inv1')); await screen.findByText('Copied'); // Copying again swaps the pending reset instead of queueing a second one. await user.click(screen.getByText('Copied').closest('button')!); const regenerate = screen.getByRole('button', { name: /Regenerate/i }); fireEvent.click(regenerate); expect(regenerate).toBeDisabled(); expect(screen.getByRole('button', { name: /Disable/i })).toBeDisabled(); await screen.findByDisplayValue(/\/join\/inv2$/); await user.click(screen.getByRole('button', { name: /Disable/i })); await screen.findByRole('button', { name: /Create invite link/i }); view.unmount(); }); it('FE-COMP-MEMBERS-038: a failing invite-link creation is reported', async () => { const user = userEvent.setup(); asShareOwner(); server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 }))); render(); await user.click(await screen.findByRole('button', { name: 'Sharing' })); await user.click(await screen.findByRole('button', { name: /Create invite link/i })); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not create link', 'error', undefined)); expect(screen.getByRole('button', { name: /Create invite link/i })).toBeEnabled(); }); it('FE-COMP-MEMBERS-039: a failing invite-link removal keeps the link', async () => { const user = userEvent.setup(); asShareOwner(); server.use( http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'inv9' })), http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })), ); render(); await user.click(await screen.findByRole('button', { name: 'Sharing' })); await user.click(await screen.findByRole('button', { name: /Disable/i })); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined)); expect(screen.getByDisplayValue(/\/join\/inv9$/)).toBeInTheDocument(); }); // ── Member management errors and ownership transfer (040-045) ───────────── it('FE-COMP-MEMBERS-040: a failing invite surfaces the server error', async () => { const user = userEvent.setup(); server.use(http.post('/api/trips/1/members', () => HttpResponse.json({ error: 'User is already a member' }, { status: 409 }))); render(); await screen.findByText('Invite User'); await user.click(screen.getByText('Select user…')); await user.click(await screen.findByRole('button', { name: 'alice' })); await user.click(screen.getByRole('button', { name: 'Invite' })); await waitFor(() => expect(addToast).toHaveBeenCalledWith('User is already a member', 'error', undefined)); }); it('FE-COMP-MEMBERS-041: a successful invite reloads the roster and notifies the planner', async () => { const user = userEvent.setup(); const onMembersChanged = vi.fn(); server.use(http.post('/api/trips/1/members', () => HttpResponse.json({ success: true }))); render(); await screen.findByText('Invite User'); await user.click(screen.getByText('Select user…')); await user.click(await screen.findByRole('button', { name: 'alice' })); await user.click(screen.getByRole('button', { name: 'Invite' })); await waitFor(() => expect(onMembersChanged).toHaveBeenCalled()); expect(addToast).toHaveBeenCalledWith('alice added', 'success', undefined); }); // #2478: the pick showed nowhere, only the Invite button knew about it. it('FE-COMP-MEMBERS-057: the picked user shows in the field before the invite is sent', async () => { const user = userEvent.setup(); server.use(http.post('/api/trips/1/members', () => HttpResponse.json({ success: true }))); render(); await screen.findByText('Invite User'); const trigger = screen.getByText('Select user…').closest('button') as HTMLButtonElement; await user.click(trigger); await user.click(await screen.findByRole('button', { name: 'alice' })); expect(trigger).toHaveTextContent('alice'); expect(screen.queryByText('Select user…')).toBeNull(); expect(screen.getByRole('button', { name: 'Invite' })).toBeEnabled(); // Once sent, the field is free for the next pick again. await user.click(screen.getByRole('button', { name: 'Invite' })); await waitFor(() => expect(trigger).toHaveTextContent('Select user…')); }); it('FE-COMP-MEMBERS-042: transferring ownership reloads the app', async () => { const onClose = vi.fn(); const reload = stubReload(); mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]); let transferBody: Record | null = null; server.use( http.post('/api/trips/1/transfer', async ({ request }) => { transferBody = (await request.json()) as Record; await delay(20); return HttpResponse.json({ success: true }); }), ); render(); const crown = await screen.findByRole('button', { name: 'Make owner' }); fireEvent.click(crown); fireEvent.click(await screen.findByRole('button', { name: 'Confirm' })); expect(crown).toBeDisabled(); await waitFor(() => expect(reload).toHaveBeenCalled()); expect(transferBody).toMatchObject({ newOwnerId: memberUser.id }); expect(onClose).toHaveBeenCalled(); }); it('FE-COMP-MEMBERS-043: declining the transfer confirmation does nothing', async () => { const user = userEvent.setup(); mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]); let transferCalled = false; server.use(http.post('/api/trips/1/transfer', () => { transferCalled = true; return HttpResponse.json({}); })); render(); await user.click(await screen.findByRole('button', { name: 'Make owner' })); expect(screen.getByText('Transfer ownership to alice? You will become a regular member.')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(screen.queryByText('Transfer ownership to alice? You will become a regular member.')).not.toBeInTheDocument(); expect(transferCalled).toBe(false); }); it('FE-COMP-MEMBERS-044: a failing transfer re-enables the button', async () => { const user = userEvent.setup(); mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]); server.use(http.post('/api/trips/1/transfer', () => HttpResponse.json({ error: 'Not allowed' }, { status: 403 }))); render(); await user.click(await screen.findByRole('button', { name: 'Make owner' })); await user.click(screen.getByRole('button', { name: 'Confirm' })); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Not allowed', 'error', undefined)); expect(screen.getByRole('button', { name: 'Make owner' })).toBeEnabled(); }); it('FE-COMP-MEMBERS-045: member removal is cancellable and reports failures', async () => { const user = userEvent.setup(); mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]); let deletes = 0; server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({}, { status: 500 }); })); render(); await user.click(await screen.findByRole('button', { name: 'Remove access' })); await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(deletes).toBe(0); await user.click(screen.getByRole('button', { name: 'Remove access' })); await user.click(screen.getByRole('button', { name: 'Confirm' })); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove', 'error', undefined)); expect(screen.getByText('alice')).toBeInTheDocument(); }); // ── Guests (046-051) ────────────────────────────────────────────────────── const guestRow = { id: 3, username: 'Grandma', avatar_url: null, is_guest: true }; it('FE-COMP-MEMBERS-046: pressing Enter adds a guest, an empty name is ignored', async () => { const user = userEvent.setup(); const onMembersChanged = vi.fn(); let createdName: string | null = null; server.use( http.post('/api/trips/1/guests', async ({ request }) => { createdName = ((await request.json()) as { name: string }).name; return HttpResponse.json({ member: { id: 9, username: createdName, is_guest: true } }); }), ); render(); const input = await screen.findByPlaceholderText('Guest name'); fireEvent.keyDown(input, { key: 'Enter' }); expect(createdName).toBeNull(); await user.type(input, 'Grandpa'); fireEvent.keyDown(input, { key: 'Enter' }); await waitFor(() => expect(createdName).toBe('Grandpa')); expect(addToast).toHaveBeenCalledWith('Guest added', 'success', undefined); await waitFor(() => expect(onMembersChanged).toHaveBeenCalled()); }); it('FE-COMP-MEMBERS-047: a failing guest creation is reported', async () => { const user = userEvent.setup(); server.use(http.post('/api/trips/1/guests', () => HttpResponse.json({ error: 'Guest limit reached' }, { status: 400 }))); render(); await user.type(await screen.findByPlaceholderText('Guest name'), 'Grandpa'); await user.click(screen.getByRole('button', { name: /Add guest/i })); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Guest limit reached', 'error', undefined)); }); it('FE-COMP-MEMBERS-048: renaming a guest saves on Enter', async () => { const user = userEvent.setup(); mockRoster([guestRow]); let renamed: { id: string; name: string } | null = null; server.use( http.put('/api/trips/1/guests/:userId', async ({ params, request }) => { renamed = { id: params.userId as string, name: ((await request.json()) as { name: string }).name }; return HttpResponse.json({ success: true }); }), ); render(); await user.click(await screen.findByRole('button', { name: 'Rename' })); const input = screen.getByDisplayValue('Grandma'); fireEvent.change(input, { target: { value: 'Granny' } }); fireEvent.keyDown(input, { key: 'Enter' }); await waitFor(() => expect(renamed).toEqual({ id: '3', name: 'Granny' })); await waitFor(() => expect(screen.queryByDisplayValue('Granny')).not.toBeInTheDocument()); }); it('FE-COMP-MEMBERS-048b: Enter followed by a blur renames only once', async () => { const user = userEvent.setup(); mockRoster([guestRow]); let renames = 0; server.use(http.put('/api/trips/1/guests/:userId', () => { renames++; return HttpResponse.json({ success: true }); })); render(); await user.click(await screen.findByRole('button', { name: 'Rename' })); const input = screen.getByDisplayValue('Grandma'); fireEvent.change(input, { target: { value: 'Granny' } }); fireEvent.keyDown(input, { key: 'Enter' }); fireEvent.blur(input); await waitFor(() => expect(screen.queryByDisplayValue('Granny')).not.toBeInTheDocument()); expect(renames).toBe(1); }); it('FE-COMP-MEMBERS-049: Escape cancels a rename and a blank name saves nothing', async () => { const user = userEvent.setup(); mockRoster([guestRow]); let renames = 0; server.use(http.put('/api/trips/1/guests/:userId', () => { renames++; return HttpResponse.json({ success: true }); })); render(); await user.click(await screen.findByRole('button', { name: 'Rename' })); fireEvent.keyDown(screen.getByDisplayValue('Grandma'), { key: 'Escape' }); expect(screen.queryByDisplayValue('Grandma')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Rename' })); const input = screen.getByDisplayValue('Grandma'); fireEvent.change(input, { target: { value: ' ' } }); fireEvent.blur(input); await waitFor(() => expect(screen.queryByDisplayValue(' ')).not.toBeInTheDocument()); expect(renames).toBe(0); }); it('FE-COMP-MEMBERS-050: a failing rename is reported', async () => { const user = userEvent.setup(); mockRoster([guestRow]); server.use(http.put('/api/trips/1/guests/:userId', () => HttpResponse.json({ error: 'Name taken' }, { status: 409 }))); render(); await user.click(await screen.findByRole('button', { name: 'Rename' })); const input = screen.getByDisplayValue('Grandma'); fireEvent.change(input, { target: { value: 'Granny' } }); fireEvent.keyDown(input, { key: 'Enter' }); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Name taken', 'error', undefined)); }); it('FE-COMP-MEMBERS-051: removing a guest is confirmed, refreshes costs and notifies', async () => { const user = userEvent.setup(); const onMembersChanged = vi.fn(); mockRoster([guestRow]); let deletedId: string | null = null; server.use( http.delete('/api/trips/1/guests/:userId', async ({ params }) => { deletedId = params.userId as string; await delay(20); return HttpResponse.json({ success: true }); }), ); render(); const trash = await screen.findByRole('button', { name: 'Remove access' }); await user.click(trash); expect(screen.getByText('Remove this guest? Their assignments are removed and their share of each expense goes to the others in its split.')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(deletedId).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Remove access' })); fireEvent.click(await screen.findByRole('button', { name: 'Delete' })); await waitFor(() => expect(screen.getByRole('button', { name: 'Remove access' })).toBeDisabled()); await waitFor(() => expect(deletedId).toBe('3')); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Guest removed', 'success', undefined)); expect(onMembersChanged).toHaveBeenCalled(); }); it('FE-COMP-MEMBERS-052: a failing guest removal is reported', async () => { const user = userEvent.setup(); mockRoster([guestRow]); server.use(http.delete('/api/trips/1/guests/:userId', () => HttpResponse.json({}, { status: 500 }))); render(); await user.click(await screen.findByRole('button', { name: 'Remove access' })); await user.click(screen.getByRole('button', { name: 'Delete' })); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove', 'error', undefined)); expect(screen.getByText('Grandma')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-061: a guest who paid for something is kept, with the reason (#2604)', async () => { const user = userEvent.setup(); mockRoster([guestRow]); server.use(http.delete('/api/trips/1/guests/:userId', () => HttpResponse.json({ error: 'refused' }, { status: 409 }))); render(); await user.click(await screen.findByRole('button', { name: 'Remove access' })); await user.click(screen.getByRole('button', { name: 'Delete' })); await waitFor(() => expect(addToast).toHaveBeenCalledWith( 'This guest paid for an expense or is part of a recorded payment. Change the payer or delete the payment first.', 'error', undefined, )); expect(screen.getByText('Grandma')).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-053: without member_manage a plain member sees no invite or guest controls', async () => { const stranger = buildUser({ id: 99, username: 'stranger' }); seedStore(useAuthStore, { user: stranger, isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); seedStore(usePermissionsStore, { permissions: { member_manage: 'trip_owner', share_manage: 'trip_owner' } }); mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]); render(); await screen.findByText('alice'); expect(screen.queryByText('Invite User')).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText('Guest name')).not.toBeInTheDocument(); expect(screen.queryByText('Guests')).not.toBeInTheDocument(); expect(screen.queryByText('Public Link')).not.toBeInTheDocument(); // Nothing to remove either — a plain member can only leave themselves. expect(screen.queryByRole('button', { name: 'Remove access' })).not.toBeInTheDocument(); }); // A self-hosted install served over plain HTTP has no navigator.clipboard, and // the unguarded call threw before the button ever showed feedback. it('FE-COMP-MEMBERS-055: the share link copies through execCommand without a clipboard API', async () => { const user = userEvent.setup(); asShareOwner(); Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, writable: true }); Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true }); const execCommand = vi.fn(() => true); Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true }); server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok55' }))); render(); await user.click((await screen.findByText('Copy')).closest('button')!); expect(execCommand).toHaveBeenCalledWith('copy'); await screen.findByText('Copied'); expect(document.querySelector('textarea')).toBeNull(); }); it('FE-COMP-MEMBERS-056: the invite link copies through execCommand without a clipboard API', async () => { const user = userEvent.setup(); asShareOwner(); Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, writable: true }); Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true }); const execCommand = vi.fn(() => true); Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true }); server.use(http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'inv56' }))); render(); await user.click((await screen.findByText('Copy')).closest('button')!); expect(execCommand).toHaveBeenCalledWith('copy'); await screen.findByText('Copied'); expect(document.querySelector('textarea')).toBeNull(); }); it('FE-COMP-MEMBERS-054: the invite-link copied badge resets after two seconds', async () => { asShareOwner(); const writeText = mockClipboard(); server.use(http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'inv9' }))); const view = render(); const copyBtn = (await screen.findByText('Copy')).closest('button')!; vi.useFakeTimers(); fireEvent.click(copyBtn); await act(async () => {}); expect(screen.getByText('Copied')).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(2000); }); expect(screen.getByText('Copy')).toBeInTheDocument(); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/join/inv9')); view.unmount(); }); }); describe('TripMembersModal on a phone', () => { let width: number; beforeEach(() => { width = window.innerWidth; Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true, writable: true }); }); afterEach(() => { Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true }); }); it('FE-COMP-MEMBERS-058b: the public link toggles say whether they are on (#2604)', async () => { seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'abc123', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false, }))); render(); expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByRole('button', { name: 'Map & Plan' })).toHaveAttribute('aria-disabled', 'true'); }); it('FE-COMP-MEMBERS-058: keeps the sheet it had, with "(you)" beside the name', async () => { render(); expect(await screen.findByText(/\(you\)/i)).toBeInTheDocument(); expect(screen.getByText(/Access \(1/)).toBeInTheDocument(); }); it('FE-COMP-MEMBERS-059: asks with the browser before a member goes, and only then removes', async () => { const user = userEvent.setup(); const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]); let deletes = 0; server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({ success: true }); })); render(); await user.click(await screen.findByTitle('Remove access')); expect(confirmSpy).toHaveBeenCalledWith('Remove access for this user?'); expect(deletes).toBe(0); confirmSpy.mockReturnValue(true); await user.click(screen.getByTitle('Remove access')); await waitFor(() => expect(deletes).toBe(1)); }); it('FE-COMP-MEMBERS-060: a guest is renamed and removed through the same questions', async () => { const user = userEvent.setup(); vi.spyOn(window, 'confirm').mockReturnValue(true); mockRoster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]); let renamed: string | null = null; let deleted = false; server.use( http.put('/api/trips/1/guests/:userId', async ({ request }) => { renamed = ((await request.json()) as { name: string }).name; return HttpResponse.json({ success: true }); }), http.delete('/api/trips/1/guests/:userId', () => { deleted = true; return HttpResponse.json({ success: true }); }), ); render(); await user.click(await screen.findByTitle('Rename')); const input = screen.getByDisplayValue('Grandma'); fireEvent.keyDown(input, { key: 'Escape' }); await user.click(screen.getByTitle('Rename')); fireEvent.change(screen.getByDisplayValue('Grandma'), { target: { value: 'Granny' } }); fireEvent.keyDown(screen.getByDisplayValue('Granny'), { key: 'Enter' }); await waitFor(() => expect(renamed).toBe('Granny')); await user.click(screen.getByTitle('Remove access')); await waitFor(() => expect(deleted).toBe(true)); }); });