import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { FounderMembers } from '../components/founder-portal/founder-members'; import { FounderWall } from '../components/founder-portal/founder-wall'; import { FounderCommunityPage } from '../components/founder-portal/founder-community-page'; import { FounderSidebar } from '../components/founder-portal/founder-sidebar'; import type { FounderClubContext } from '../lib/club-data'; import type { WallPost } from '../lib/club-community'; const mocks = vi.hoisted(() => ({ fetchRoster: vi.fn(), remove: vi.fn(), rank: vi.fn(), subscribe: vi.fn(), create: vi.fn(), reply: vi.fn(), pin: vi.fn(), delete: vi.fn(), context: vi.fn(), auth: vi.fn(), push: vi.fn(), })); vi.mock('../lib/firebase-client', () => ({ firebaseAuth: {} })); vi.mock('firebase/auth', () => ({ onAuthStateChanged: (...args: unknown[]) => mocks.auth(...args) })); vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }), useSearchParams: () => new URLSearchParams() })); vi.mock('../components/founder-portal/founder-portal-shell', () => ({ FounderPortalShell: ({ children }: { children: React.ReactNode }) =>
{children}
})); vi.mock('../lib/club-data', () => ({ DEFAULT_CLUB_RANKS: [{ id: 'captain', title: 'Road Captain' }], fetchClubMembers: (...args: unknown[]) => mocks.fetchRoster(...args), updateMemberRank: (...args: unknown[]) => mocks.rank(...args), fetchFounderClubContext: (...args: unknown[]) => mocks.context(...args), sanitizePortalError: (_err: unknown, fallback = 'Club information could not be loaded.') => fallback, })); vi.mock('../lib/club-community', () => ({ fetchMemberRoster: (...args: unknown[]) => mocks.fetchRoster(...args), removeClubMember: (...args: unknown[]) => mocks.remove(...args), subscribeClubWall: (...args: unknown[]) => mocks.subscribe(...args), createWallPost: (...args: unknown[]) => mocks.create(...args), replyToWallPost: (...args: unknown[]) => mocks.reply(...args), setWallPostPinned: (...args: unknown[]) => mocks.pin(...args), deleteWallPost: (...args: unknown[]) => mocks.delete(...args), })); const context: FounderClubContext = { club: { id: 'ClubID', slug: 'club-slug', name: 'Test Club', ownerUid: 'owner', description: '', visibility: 'private', admissionPolicy: 'request', memberCount: 3, createdAt: '', updatedAt: '' }, currentUser: { uid: 'owner', role: 'owner' }, ownerProfile: null, isDemoFixture: false, permissions: { isAuthenticated: true, role: 'owner', canManageSettings: true, canInviteMembers: true, canApproveApplications: true, canPlanRides: true, canTransferOwnership: true }, }; const members = [ { uid: 'owner', clubId: 'ClubID', status: 'active', role: 'owner', displayName: 'Morgan', displayRank: 'President', updatedAt: '' }, { uid: 'admin', clubId: 'ClubID', status: 'active', role: 'admin', displayName: 'Alex', displayRank: 'Vice President', updatedAt: '' }, { uid: 'member', clubId: 'ClubID', status: 'active', role: 'member', displayName: 'Taylor', displayRank: 'Road Captain', updatedAt: '' }, ]; const post: WallPost = { id: 'post1', clubId: 'ClubID', authorUid: 'member', authorDisplayName: 'Taylor', authorRole: 'member', content: 'Saturday briefing', createdAt: '2026-01-01T00:00:00Z', isPinned: true, fistBumpCount: 0, comments: [] }; let onPosts: (posts: WallPost[]) => void; let onWallError: (err: unknown) => void; let listenerCleanup: ReturnType; beforeEach(() => { vi.clearAllMocks(); HTMLDialogElement.prototype.showModal = function () { this.setAttribute('open', ''); }; HTMLDialogElement.prototype.close = function () { this.removeAttribute('open'); }; mocks.fetchRoster.mockResolvedValue(members); for (const operation of [mocks.remove, mocks.rank, mocks.create, mocks.reply, mocks.pin, mocks.delete]) operation.mockResolvedValue(undefined); listenerCleanup = vi.fn(); mocks.subscribe.mockImplementation((_clubId, deliver, error) => { onPosts = deliver; onWallError = error; deliver([post]); return listenerCleanup; }); mocks.context.mockResolvedValue(context); mocks.auth.mockImplementation((_auth, deliver) => { deliver({ uid: 'owner', isAnonymous: false }); return vi.fn(); }); }); afterEach(cleanup); describe('Members management interactions', () => { it('loads the next roster page by member UID and makes the search scope explicit', async () => { const firstPage = Array.from({ length: 50 }, (_, i) => ({ ...members[2], uid: `rider-${i}`, displayName: `Rider ${i}` })); mocks.fetchRoster.mockResolvedValueOnce(firstPage).mockResolvedValueOnce([{ ...members[2], uid: 'last-rider', displayName: 'Last Rider' }]); render(); await screen.findByText('Rider 49'); expect(screen.getByText(/Search and counts cover loaded members/)).toBeDefined(); fireEvent.click(screen.getByRole('button', { name: 'Load more members' })); await screen.findByText('Last Rider'); expect(mocks.fetchRoster).toHaveBeenLastCalledWith('ClubID', 'rider-49'); expect(screen.queryByRole('button', { name: 'Load more members' })).toBeNull(); }); it('searches ranks and filters administrative roles independently', async () => { render(); await screen.findByText('Taylor'); fireEvent.change(screen.getByLabelText('Search roster'), { target: { value: 'captain' } }); expect(screen.queryByText('Alex')).toBeNull(); expect(screen.getByText('Taylor')).toBeDefined(); fireEvent.change(screen.getByLabelText('Administrative role'), { target: { value: 'admin' } }); expect(screen.getByText('No members match your search.')).toBeDefined(); }); it('opens a rank dialog, saves cosmetic rank and updates the roster', async () => { render(); await screen.findByText('Taylor'); fireEvent.click(screen.getByRole('button', { name: 'Edit rank for Taylor' })); const dialog = screen.getByRole('dialog', { name: 'Edit display rank' }); fireEvent.change(within(dialog).getByLabelText('Display rank'), { target: { value: 'Tail Gunner' } }); fireEvent.click(within(dialog).getByRole('button', { name: 'Save rank' })); await waitFor(() => expect(mocks.rank).toHaveBeenCalledWith('ClubID', 'member', 'Tail Gunner')); await screen.findByText('Tail Gunner'); expect(screen.queryByRole('dialog')).toBeNull(); }); it('requires confirmation before removal and only removes after a successful write', async () => { render(); await screen.findByText('Taylor'); fireEvent.click(screen.getByRole('button', { name: 'Remove Taylor' })); expect(mocks.remove).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: 'Confirm removal' })); await waitFor(() => expect(mocks.remove).toHaveBeenCalledWith('ClubID', 'member')); await waitFor(() => expect(screen.queryByText('Taylor')).toBeNull()); }); it('keeps failed removal reviewable in the dialog, without hiding the rider', async () => { mocks.remove.mockRejectedValueOnce(new Error('permission-denied')); render(); await screen.findByText('Taylor'); fireEvent.click(screen.getByRole('button', { name: 'Remove Taylor' })); fireEvent.click(screen.getByRole('button', { name: 'Confirm removal' })); await screen.findByRole('alert'); expect(screen.getByRole('dialog')).toBeDefined(); expect(screen.getAllByText('Taylor')).toHaveLength(2); }); it('hides owner/self removal and peer-admin removal for admins', async () => { render(); await screen.findByText('Taylor'); expect(screen.queryByRole('button', { name: 'Remove Morgan' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Remove Alex' })).toBeNull(); expect(screen.getByRole('button', { name: 'Remove Taylor' })).toBeDefined(); }); it('shows a load failure rather than a false empty roster and permits retry', async () => { mocks.fetchRoster.mockRejectedValueOnce(new Error('unavailable')); render(); await screen.findByRole('alert'); expect(screen.queryByText('There are no active members to display.')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Refresh roster' })); await screen.findByText('Taylor'); }); }); describe('Wall interactions', () => { it('publishes a pinned announcement and clears draft after persistence', async () => { render(); fireEvent.change(screen.getByLabelText('Post to your club'), { target: { value: 'Friday briefing' } }); fireEvent.click(screen.getByLabelText('Pin leadership announcement')); fireEvent.click(screen.getByRole('button', { name: 'Publish post' })); await waitFor(() => expect(mocks.create).toHaveBeenCalledWith('ClubID', 'Friday briefing', true)); await waitFor(() => expect((screen.getByLabelText('Post to your club') as HTMLTextAreaElement).value).toBe('')); }); it('retains drafts and displays a safe error when publishing fails', async () => { mocks.create.mockRejectedValueOnce(new Error('secret-internal-path')); render(); fireEvent.change(screen.getByLabelText('Post to your club'), { target: { value: 'Keep this draft' } }); fireEvent.click(screen.getByRole('button', { name: 'Publish post' })); await screen.findByRole('alert'); expect((screen.getByLabelText('Post to your club') as HTMLTextAreaElement).value).toBe('Keep this draft'); expect(screen.queryByText('secret-internal-path')).toBeNull(); }); it('submits a reply and unpins through the service', async () => { render(); fireEvent.change(screen.getByLabelText('Reply to Taylor'), { target: { value: 'Confirmed' } }); fireEvent.click(screen.getByRole('button', { name: 'Reply' })); await waitFor(() => expect(mocks.reply).toHaveBeenCalledWith('ClubID', 'post1', 'Confirmed')); await waitFor(() => expect((screen.getByLabelText('Reply to Taylor') as HTMLTextAreaElement).value).toBe('')); fireEvent.click(screen.getByRole('button', { name: 'Unpin post by Taylor' })); await waitFor(() => expect(mocks.pin).toHaveBeenCalledWith('ClubID', 'post1', false)); }); it('requires confirmation before deleting a post and replies', async () => { render(); fireEvent.click(screen.getByRole('button', { name: 'Delete post by Taylor' })); expect(mocks.delete).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: 'Confirm deletion' })); await waitFor(() => expect(mocks.delete).toHaveBeenCalledWith('ClubID', 'post1')); }); it('shows permission failure instead of sample data and cleans up listeners', async () => { mocks.subscribe.mockImplementation((_clubId, deliver, fail) => { fail(new Error('permission-denied')); return listenerCleanup; }); const view = render(); await screen.findByRole('alert'); expect(screen.queryByText('Start the conversation')).toBeNull(); expect((screen.getByRole('button', { name: 'Publish post' }) as HTMLButtonElement).disabled).toBe(true); view.unmount(); expect(listenerCleanup).toHaveBeenCalledOnce(); }); it('never queries or writes to Firestore in an explicit development fixture', () => { render(); expect(mocks.subscribe).not.toHaveBeenCalled(); expect((screen.getByRole('button', { name: 'Publish post' }) as HTMLButtonElement).disabled).toBe(true); }); }); describe('Direct Founder Portal route authorization', () => { it('links both enabled sidebar modules to the club management routes', () => { render(); expect(screen.getByRole('link', { name: /Members/ }).getAttribute('href')).toBe('/clubs/club-slug/manage/members'); expect(screen.getByRole('link', { name: /Wall/ }).getAttribute('href')).toBe('/clubs/club-slug/manage/wall'); expect(screen.getByRole('link', { name: /Stats/ }).getAttribute('href')).toBe('/clubs/club-slug/manage/stats'); expect(screen.getByRole('link', { name: /Moderation/ }).getAttribute('href')).toBe('/clubs/club-slug/manage/moderation'); }); it.each(['members', 'wall'] as const)('denies signed-out users before querying %s data', async (section) => { mocks.auth.mockImplementation((_auth, deliver) => { deliver(null); return vi.fn(); }); render(); await screen.findByText('Authentication Required'); expect(mocks.context).not.toHaveBeenCalled(); expect(mocks.fetchRoster).not.toHaveBeenCalled(); expect(mocks.subscribe).not.toHaveBeenCalled(); }); it.each(['members', 'wall'] as const)('denies ordinary members before loading %s data', async (section) => { mocks.context.mockResolvedValue({ ...context, permissions: { ...context.permissions, canManageSettings: false } }); render(); await screen.findByText('Management Access Restricted'); expect(mocks.fetchRoster).not.toHaveBeenCalled(); expect(mocks.subscribe).not.toHaveBeenCalled(); }); it('resolves slug context before querying the canonical case-sensitive club ID', async () => { render(); await screen.findByText('Taylor'); expect(mocks.context).toHaveBeenCalledWith('slug', 'owner', { allowDemoPreview: false }); expect(mocks.fetchRoster).toHaveBeenCalledWith('ClubID'); }); it('clears the management page on sign-out and discards an in-flight context response', async () => { let authChanged: (user: { uid: string; isAnonymous: boolean } | null) => void = () => {}; let resolveContext: (value: FounderClubContext) => void = () => {}; mocks.auth.mockImplementation((_auth, deliver) => { authChanged = deliver; deliver({ uid: 'owner', isAnonymous: false }); return vi.fn(); }); mocks.context.mockReturnValue(new Promise((resolve) => { resolveContext = resolve; })); render(); const { act } = await import('@testing-library/react'); await act(async () => { authChanged(null); }); await screen.findByText('Authentication Required'); await act(async () => { resolveContext(context); }); expect(screen.getByText('Authentication Required')).toBeDefined(); expect(mocks.fetchRoster).not.toHaveBeenCalled(); }); });