MotorSignal-App/website/tests/club-community-data.test.ts

174 lines
11 KiB
TypeScript

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<string, Record<string, unknown>>(), 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') } };
}