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();
});
});