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