import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mock = vi.hoisted(() => ({ user: { uid: 'owner', isAnonymous: false } as { uid: string; isAnonymous: boolean } | null, store: new Map>(), writes: vi.fn(), subscription: vi.fn(), })); vi.mock('../lib/firebase-client', () => ({ firestore: {}, firebaseAuth: { get currentUser() { return mock.user; } } })); vi.mock('firebase/firestore', () => ({ doc: (_db: unknown, ...parts: string[]) => ({ path: parts.length ? parts.join('/') : 'clubs/ClubID/wall_posts/generated' }), collection: (_db: unknown, ...parts: string[]) => ({ path: parts.join('/') }), query: (...args: unknown[]) => args, where: (...args: unknown[]) => args, limit: (value: number) => value, orderBy: (...args: unknown[]) => args, documentId: () => '__name__', startAfter: (value: string) => ({ afterUid: value }), getDocs: vi.fn(async (q: unknown[]) => { const after = q.find((item) => typeof item === 'object' && item !== null && 'afterUid' in item) as { afterUid: string } | undefined; return { docs: Array.from(mock.store.entries()) .filter(([key, value]) => key.startsWith('clubs/ClubID/members/') && value.status === 'active' && (!after || key.split('/').pop()! > after.afterUid)) .sort(([a], [b]) => a < b ? -1 : 1).slice(0, 50) .map(([key, value]) => ({ id: key.split('/').pop(), data: () => value })) }; }), getDoc: vi.fn(async (ref: { path: string }) => ({ exists: () => mock.store.has(ref.path), data: () => mock.store.get(ref.path) })), runTransaction: vi.fn(async (_db: unknown, callback: (tx: unknown) => unknown) => callback({ get: async (ref: { path: string }) => ({ exists: () => mock.store.has(ref.path), data: () => mock.store.get(ref.path) }), update: (ref: { path: string }, data: unknown) => mock.writes(ref.path, data), })), updateDoc: async (ref: { path: string }, data: unknown) => mock.writes(ref.path, data), setDoc: async (ref: { path: string }, data: unknown) => mock.writes(ref.path, data), deleteDoc: async (ref: { path: string }) => mock.writes(ref.path, 'deleted'), serverTimestamp: () => 'SERVER_TIMESTAMP', onSnapshot: (...args: unknown[]) => mock.subscription(...args), collectionGroup: vi.fn(), })); import { fetchClubMembers, updateMemberRank } from '../lib/club-data'; import { createWallPost, fetchMemberRoster, removeClubMember, replyToWallPost, subscribeClubWall } from '../lib/club-community'; import { getDoc, getDocs } from 'firebase/firestore'; beforeEach(() => { vi.clearAllMocks(); mock.user = { uid: 'owner', isAnonymous: false }; mock.store.clear(); mock.store.set('clubs/ClubID', { ownerUid: 'owner', memberCount: 3, archivedAt: null }); mock.store.set('clubs/ClubID/members/owner', { role: 'owner', status: 'active', displayRank: 'President' }); mock.store.set('clubs/ClubID/members/admin', { role: 'admin', status: 'active', displayRank: 'Officer' }); mock.store.set('clubs/ClubID/members/member', { role: 'member', status: 'active', displayRank: 'Road Captain' }); mock.store.set('public_profiles/owner', { displayName: 'Morgan', username: 'morgan' }); mock.writes.mockReset(); }); afterEach(() => vi.unstubAllEnvs()); describe('Roster uses canonical club identity and privacy-filtered profiles', () => { it('preserves case-sensitive club IDs and pages beyond 50 active members without overlap', async () => { for (let i = 0; i < 60; i++) mock.store.set(`clubs/ClubID/members/rider${i}`, { role: 'member', status: 'active' }); const result = await fetchClubMembers('ClubID', 'owner'); expect(result).toHaveLength(50); expect(result.every((item) => item.clubId === 'ClubID')).toBe(true); expect(getDocs).toHaveBeenCalledWith([{ path: 'clubs/ClubID/members' }, ['status', '==', 'active'], ['__name__'], 50]); const second = await fetchClubMembers('ClubID', 'owner', { afterUid: result[result.length - 1].uid }); expect(second).toHaveLength(13); expect(new Set([...result, ...second].map((item) => item.uid)).size).toBe(63); }); it('never returns demo members in production, including signed-out preview attempts', async () => { vi.stubEnv('NODE_ENV', 'production'); const members = await fetchClubMembers('ClubID', undefined, { allowDemoPreview: true }); expect(members).toHaveLength(3); expect(getDocs).toHaveBeenCalled(); }); it('hydrates only allowed public profiles, with no private account reads', async () => { mock.store.set('public_profiles/admin', { displayName: 'Restricted profile' }); vi.mocked(getDoc).mockImplementationOnce(async () => { throw new Error('permission-denied'); }); const members = await fetchMemberRoster('ClubID'); expect(members.find((item) => item.uid === 'admin')?.displayName).toBe(null); expect(members.find((item) => item.uid === 'owner')?.displayName).toBe('Morgan'); expect(vi.mocked(getDoc).mock.calls.every(([ref]) => (ref as unknown as { path: string }).path.startsWith('public_profiles/'))).toBe(true); }); it('updates only cosmetic rank fields after checking current management access', async () => { await updateMemberRank('ClubID', 'member', ' Tail Gunner '); expect(mock.writes).toHaveBeenCalledExactlyOnceWith('clubs/ClubID/members/member', { displayRank: 'Tail Gunner', updatedAt: 'SERVER_TIMESTAMP' }); }); it.each(['', ' '.repeat(3), 'x'.repeat(61)])('rejects an invalid rank %j', async (rank) => { await expect(updateMemberRank('ClubID', 'member', rank)).rejects.toThrow(); expect(mock.writes).not.toHaveBeenCalled(); }); it('rejects stale or non-admin membership before rank writes', async () => { mock.user = { uid: 'member', isAnonymous: false }; await expect(updateMemberRank('ClubID', 'member', 'President')).rejects.toThrow('permission-denied'); expect(mock.writes).not.toHaveBeenCalled(); }); it('does not edit ranks on removed members', async () => { mock.store.set('clubs/ClubID/members/member', { role: 'member', status: 'removed' }); await expect(updateMemberRank('ClubID', 'member', 'Member')).rejects.toThrow('no longer active'); expect(mock.writes).not.toHaveBeenCalled(); }); }); describe('Atomic club member removal', () => { it('preserves the member document and binds the club count decrement to its UID', async () => { await removeClubMember('ClubID', 'member'); expect(mock.writes.mock.calls).toEqual([ ['clubs/ClubID/members/member', { status: 'removed', removedAt: 'SERVER_TIMESTAMP', updatedAt: 'SERVER_TIMESTAMP' }], ['clubs/ClubID', { memberCount: 2, lastMembershipChangeUid: 'member', updatedAt: 'SERVER_TIMESTAMP' }], ]); }); it('owner may remove an admin', async () => { await removeClubMember('ClubID', 'admin'); expect(mock.writes).toHaveBeenCalledTimes(2); }); it('admin cannot remove a peer admin or owner', async () => { mock.user = { uid: 'admin', isAnonymous: false }; mock.store.set('clubs/ClubID/members/peer', { role: 'admin', status: 'active' }); for (const uid of ['peer', 'owner']) await expect(removeClubMember('ClubID', uid)).rejects.toThrow('permission-denied'); expect(mock.writes).not.toHaveBeenCalled(); }); it('cannot remove self, or decrement again after removal', async () => { await expect(removeClubMember('ClubID', 'owner')).rejects.toThrow('leave-club'); mock.store.set('clubs/ClubID/members/member', { role: 'member', status: 'removed' }); await expect(removeClubMember('ClubID', 'member')).rejects.toThrow('no longer active'); expect(mock.writes).not.toHaveBeenCalled(); }); it('blocks inactive admins and archived clubs', async () => { mock.user = { uid: 'admin', isAnonymous: false }; mock.store.set('clubs/ClubID/members/admin', { role: 'admin', status: 'removed' }); await expect(removeClubMember('ClubID', 'member')).rejects.toThrow('permission-denied'); mock.user = { uid: 'owner', isAnonymous: false }; mock.store.set('clubs/ClubID', { ownerUid: 'owner', memberCount: 3, archivedAt: 'date' }); await expect(removeClubMember('ClubID', 'member')).rejects.toThrow('not-found'); expect(mock.writes).not.toHaveBeenCalled(); }); }); describe('Wall persistence', () => { it('uses current caller identity, role, public profile and server post timestamp', async () => { await createWallPost('ClubID', ' Announcement ', true); expect(mock.writes).toHaveBeenCalledWith('clubs/ClubID/wall_posts/generated', expect.objectContaining({ authorUid: 'owner', authorDisplayName: 'Morgan', authorRole: 'owner', authorDisplayRank: 'President', content: 'Announcement', createdAt: 'SERVER_TIMESTAMP', isPinned: true, comments: [], })); }); it('propagates failed writes instead of returning a false success', async () => { mock.writes.mockRejectedValueOnce(new Error('unavailable')); await expect(createWallPost('ClubID', 'Announcement', true)).rejects.toThrow('unavailable'); }); it('preserves preceding mobile comments when appending a web reply', async () => { const existing = { id: 'old', postId: 'post1', authorUid: 'member', content: 'Mobile reply', createdAt: '2026-01-01T00:00:00.000Z' }; mock.store.set('clubs/ClubID/wall_posts/post1', { comments: [existing] }); await replyToWallPost('ClubID', 'post1', ' Web reply '); const comments = mock.writes.mock.calls[0][1].comments; expect(comments).toHaveLength(2); expect(comments[0]).toEqual(existing); expect(comments[1]).toMatchObject({ postId: 'post1', authorUid: 'owner', content: 'Web reply' }); expect(typeof comments[1].createdAt).toBe('string'); }); it('sorts pinned posts first, handles empty snapshots, and returns listener cleanup', () => { const cleanup = vi.fn(); mock.subscription.mockReturnValue(cleanup); const onPosts = vi.fn(), onError = vi.fn(); expect(subscribeClubWall('ClubID', onPosts, onError)).toBe(cleanup); const deliver = mock.subscription.mock.calls[0][1]; deliver({ docs: [{ id: 'recent', data: () => ({ ...postData(), content: 'newest' }) }, { id: 'pinned', data: () => ({ ...postData(), isPinned: true }) }] }); expect(onPosts.mock.calls[0][0].map((item: { id: string }) => item.id)).toEqual(['pinned', 'recent']); deliver({ docs: [] }); expect(onPosts).toHaveBeenLastCalledWith([]); }); it.each([null, { uid: 'owner', isAnonymous: true }])('denies missing or anonymous caller %j', async (user) => { mock.user = user; await expect(createWallPost('ClubID', 'Announcement', true)).rejects.toThrow('unauthenticated'); await expect(removeClubMember('ClubID', 'member')).rejects.toThrow('unauthenticated'); expect(mock.writes).not.toHaveBeenCalled(); }); }); function postData() { return { authorUid: 'member', authorDisplayName: 'Rider', authorRole: 'member', comments: [], createdAt: { toDate: () => new Date('2026-01-01') } }; }