import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { FounderRoutes } from '../components/founder-portal/founder-routes'; import { FounderChat } from '../components/founder-portal/founder-chat'; 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 { ClubRoute, ChatMessage } from '../lib/club-community'; const mocks = vi.hoisted(() => ({ fetchRoutes: vi.fn(), createRoute: vi.fn(), deleteRoute: vi.fn(), subscribeChat: vi.fn(), sendChat: 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', () => ({ fetchFounderClubContext: (...args: unknown[]) => mocks.context(...args), sanitizePortalError: (_err: unknown, fallback = 'Club error') => fallback, })); vi.mock('../lib/club-community', () => ({ fetchClubRoutes: (...args: unknown[]) => mocks.fetchRoutes(...args), createClubRoute: (...args: unknown[]) => mocks.createRoute(...args), deleteClubRoute: (...args: unknown[]) => mocks.deleteRoute(...args), subscribeClubChat: (...args: unknown[]) => mocks.subscribeChat(...args), sendClubChatMessage: (...args: unknown[]) => mocks.sendChat(...args), FIXTURE_CHAT_MESSAGES: { general: [ { id: 'msg-1', clubId: 'ClubID', channelId: 'general', authorUid: 'owner', authorDisplayName: 'Morgan Vance', authorHandle: '@morgan_v', authorDisplayRank: 'President', authorRole: 'owner', content: 'Welcome to squad chat!', createdAt: '2026-06-01T12:00:00Z', }, ], officers: [ { id: 'msg-off-1', clubId: 'ClubID', channelId: 'officers', authorUid: 'owner', authorDisplayName: 'Morgan Vance', authorHandle: '@morgan_v', authorDisplayRank: 'President', authorRole: 'owner', content: 'Officers briefing notes.', createdAt: '2026-06-01T11:00:00Z', }, ], 'ride-intel': [], }, })); const mockContext: FounderClubContext = { club: { id: 'ClubID', slug: 'atlantic-moto', name: 'Atlantic Moto Club', ownerUid: 'owner', description: '', visibility: 'private', admissionPolicy: 'request', memberCount: 5, 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 sampleRoutes: ClubRoute[] = [ { id: 'r1', clubId: 'ClubID', title: 'Blood Mountain Gap Loop', description: 'Technical mountain sweepers through US-19/129 with fast transitions.', distanceMiles: 48.6, estimatedMinutes: 65, elevationGainFt: 3120, curvatureRating: 'High Twisties', surfaceType: 'Paved Asphalt', startLocation: 'Dahlonega Outpost', endLocation: 'Vogel State Park', gpxUrl: '/fixtures/blood-mountain.gpx', curatorUid: 'owner', curatorName: 'Morgan Vance', createdAt: '2026-03-15T10:00:00Z', }, { id: 'r2', clubId: 'ClubID', title: 'Coastal Causeways Cruise', description: 'Relaxed scenic run along salt marshes.', distanceMiles: 32.0, estimatedMinutes: 45, elevationGainFt: 120, curvatureRating: 'Scenic Cruise', surfaceType: 'Paved Asphalt', startLocation: 'Savannah Riverfront', endLocation: 'Tybee Island', gpxUrl: null, curatorUid: 'owner', curatorName: 'Morgan Vance', createdAt: '2026-04-10T10:00:00Z', }, ]; beforeEach(() => { vi.clearAllMocks(); mocks.fetchRoutes.mockResolvedValue(sampleRoutes); mocks.createRoute.mockResolvedValue(undefined); mocks.deleteRoute.mockResolvedValue(undefined); mocks.sendChat.mockResolvedValue(undefined); mocks.context.mockResolvedValue(mockContext); mocks.auth.mockImplementation((_auth, deliver) => { deliver({ uid: 'owner', isAnonymous: false }); return vi.fn(); }); mocks.subscribeChat.mockImplementation((_clubId, _channelId, onMessages) => { onMessages([ { id: 'msg-1', clubId: 'ClubID', channelId: 'general', authorUid: 'owner', authorDisplayName: 'Morgan Vance', authorHandle: '@morgan_v', authorDisplayRank: 'President', authorRole: 'owner', content: 'Welcome to squad chat!', createdAt: '2026-06-01T12:00:00Z', }, ]); return vi.fn(); }); }); afterEach(cleanup); describe('Founder Routes Management', () => { it('renders curated routes with metrics, waypoints, and curvature badges', async () => { render(); await screen.findByText('Blood Mountain Gap Loop'); expect(screen.getByText('Coastal Causeways Cruise')).toBeDefined(); expect(screen.getByText('48.6 mi')).toBeDefined(); expect(screen.getByText('+3120 ft')).toBeDefined(); expect(screen.getByText('Dahlonega Outpost')).toBeDefined(); expect(screen.getAllByText('High Twisties').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('Scenic Cruise').length).toBeGreaterThanOrEqual(1); }); it('filters routes by search query and curvature rating', async () => { render(); await screen.findByText('Blood Mountain Gap Loop'); // Search filter const searchInput = screen.getByPlaceholderText(/Search routes by name/i); fireEvent.change(searchInput, { target: { value: 'Coastal' } }); expect(screen.queryByText('Blood Mountain Gap Loop')).toBeNull(); expect(screen.getByText('Coastal Causeways Cruise')).toBeDefined(); // Reset search fireEvent.change(searchInput, { target: { value: '' } }); expect(screen.getByText('Blood Mountain Gap Loop')).toBeDefined(); // Curvature select filter const curvatureSelect = screen.getByLabelText(/Filter by Curvature/i); fireEvent.change(curvatureSelect, { target: { value: 'High Twisties' } }); expect(screen.getByText('Blood Mountain Gap Loop')).toBeDefined(); expect(screen.queryByText('Coastal Causeways Cruise')).toBeNull(); }); it('opens curate route modal and submits new route', async () => { render(); await screen.findByText('Curate Route'); fireEvent.click(screen.getByRole('button', { name: /Curate Route/i })); expect(screen.getByRole('heading', { name: 'Curate Club Route' })).toBeDefined(); fireEvent.change(screen.getByPlaceholderText(/e\.g\. Wolfpen Gap Apex Loop/i), { target: { value: 'Cherohala High Ridge Run' }, }); fireEvent.change(screen.getByPlaceholderText(/Shell Outpost/i), { target: { value: 'Tellico Plains' }, }); fireEvent.change(screen.getByPlaceholderText(/Vogel State Park/i), { target: { value: 'Robbinsville Overlook' }, }); fireEvent.click(screen.getByRole('button', { name: /Save & Publish Route/i })); await waitFor(() => { expect(mocks.createRoute).toHaveBeenCalledWith( 'ClubID', expect.objectContaining({ title: 'Cherohala High Ridge Run', startLocation: 'Tellico Plains', endLocation: 'Robbinsville Overlook', }) ); }); }); it('opens delete confirmation modal and deletes route', async () => { render(); await screen.findByText('Blood Mountain Gap Loop'); const deleteButtons = screen.getAllByTitle('Delete Route'); fireEvent.click(deleteButtons[0]); expect(screen.getByRole('heading', { name: 'Delete Club Route?' })).toBeDefined(); fireEvent.click(screen.getByRole('button', { name: 'Confirm Delete' })); await waitFor(() => { expect(mocks.deleteRoute).toHaveBeenCalledWith('ClubID', 'r1'); }); }); }); describe('Founder Chat Management', () => { it('renders chat message stream and channel tabs', async () => { render(); await screen.findByText('Welcome to squad chat!'); expect(screen.getAllByText('#general-squad').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('#officers-lounge')).toBeDefined(); expect(screen.getByText('#road-intel')).toBeDefined(); expect(screen.getByText('Morgan Vance')).toBeDefined(); }); it('switches between channels and loads corresponding messages', async () => { render(); await screen.findByText('Welcome to squad chat!'); fireEvent.click(screen.getByText('#road-intel')); await waitFor(() => { expect(mocks.subscribeChat).toHaveBeenCalledWith( 'ClubID', 'ride-intel', expect.any(Function), expect.any(Function) ); }); }); it('sends a new chat message on form submission', async () => { render(); await screen.findByText('Welcome to squad chat!'); const input = screen.getByPlaceholderText(/Message #general-squad/i); fireEvent.change(input, { target: { value: 'Saturday ride briefing is posted!' } }); fireEvent.click(screen.getByRole('button', { name: /Send/i })); await waitFor(() => { expect(mocks.sendChat).toHaveBeenCalledWith( 'ClubID', 'general', 'Saturday ride briefing is posted!' ); }); }); it('restricts Officers Lounge channel for non-officers', async () => { const memberContext: FounderClubContext = { ...mockContext, currentUser: { uid: 'regular-member', role: 'member' }, permissions: { ...mockContext.permissions, role: 'member', canManageSettings: false, }, }; render(); fireEvent.click(screen.getByText('#officers-lounge')); await screen.findByText('Officers Lounge Restricted'); expect(screen.getByText(/strictly reserved for club Founders and Admins/i)).toBeDefined(); }); }); describe('Founder Community Page & Sidebar Integration', () => { it('renders routes section via FounderCommunityPage', async () => { render(); await screen.findByText('Curated Club Routes'); expect(mocks.context).toHaveBeenCalledWith('atlantic-moto', 'owner', { allowDemoPreview: false }); }); it('renders chat section via FounderCommunityPage', async () => { render(); await screen.findByText('Squad Live Chat'); expect(mocks.context).toHaveBeenCalledWith('atlantic-moto', 'owner', { allowDemoPreview: false }); }); it('verifies all sidebar navigation links are enabled without SOON badges', () => { render( ); const routesLink = screen.getByRole('link', { name: /Routes/i }); expect(routesLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/routes'); const chatLink = screen.getByRole('link', { name: /Chat/i }); expect(chatLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/chat'); const membersLink = screen.getByRole('link', { name: /Members/i }); expect(membersLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/members'); const wallLink = screen.getByRole('link', { name: /Wall/i }); expect(wallLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/wall'); const statsLink = screen.getByRole('link', { name: /Stats/i }); expect(statsLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/stats'); const moderationLink = screen.getByRole('link', { name: /Moderation/i }); expect(moderationLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/moderation'); // Confirm NO sidebar item has the SOON badge anymore expect(screen.queryByText('SOON')).toBeNull(); }); });