174 lines
11 KiB
TypeScript
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') } };
|
|
}
|