404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
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 }) => <div>{children}</div>,
|
|
}));
|
|
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(<FounderRoutes context={mockContext} />);
|
|
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(<FounderRoutes context={mockContext} />);
|
|
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(<FounderRoutes context={mockContext} />);
|
|
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(<FounderRoutes context={mockContext} />);
|
|
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');
|
|
});
|
|
});
|
|
|
|
// WEB-W-routes regression: fixture fallback routes must be labelled as examples so a
|
|
// rider can never mistake sample content for the club's real curated routes.
|
|
it('labels example routes when the club has none of its own', async () => {
|
|
mocks.fetchRoutes.mockResolvedValue(sampleRoutes.map((r) => ({ ...r, isExample: true })));
|
|
render(<FounderRoutes context={mockContext} />);
|
|
await screen.findByText('Blood Mountain Gap Loop');
|
|
|
|
expect(screen.getByText(/example routes/i)).toBeDefined();
|
|
expect(screen.getByText(/Examples$/)).toBeDefined();
|
|
expect(screen.getAllByText('Example').length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe('Founder Chat Management', () => {
|
|
it('renders chat message stream and channel tabs', async () => {
|
|
render(<FounderChat context={mockContext} />);
|
|
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(<FounderChat context={mockContext} />);
|
|
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(<FounderChat context={mockContext} />);
|
|
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!'
|
|
);
|
|
});
|
|
});
|
|
|
|
// WEB-W-chat regression: fixture fallback messages must be labelled as examples and the
|
|
// header must not claim a live connection when only sample content is shown.
|
|
it('labels example messages when the channel has no real posts', async () => {
|
|
mocks.subscribeChat.mockImplementation((_clubId, _channelId, onMessages) => {
|
|
onMessages([
|
|
{
|
|
id: 'example-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',
|
|
isExample: true,
|
|
},
|
|
]);
|
|
return vi.fn();
|
|
});
|
|
|
|
render(<FounderChat context={mockContext} />);
|
|
await screen.findByText('Welcome to squad chat!');
|
|
|
|
expect(screen.getByText('Example thread')).toBeDefined();
|
|
expect(screen.getByText(/example messages/i)).toBeDefined();
|
|
expect(screen.queryByText('Live Connected')).toBeNull();
|
|
});
|
|
|
|
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(<FounderChat context={memberContext} />);
|
|
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(<FounderCommunityPage clubId="atlantic-moto" section="routes" />);
|
|
await screen.findByText('Curated Club Routes');
|
|
expect(mocks.context).toHaveBeenCalledWith('atlantic-moto', 'owner', { allowDemoPreview: false });
|
|
});
|
|
|
|
it('renders chat section via FounderCommunityPage', async () => {
|
|
render(<FounderCommunityPage clubId="atlantic-moto" section="chat" />);
|
|
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(
|
|
<FounderSidebar
|
|
club={mockContext.club}
|
|
permissions={mockContext.permissions}
|
|
activeNav="overview"
|
|
/>
|
|
);
|
|
|
|
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();
|
|
});
|
|
});
|