import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NextRequest } from 'next/server'; import { GET } from '../app/api/group-rides/[rideId]/join-requests/route'; import { getAdminAuth, getAdminFirestore } from '../lib/firebase-admin'; vi.mock('../lib/firebase-admin', () => ({ getAdminAuth: vi.fn(), getAdminFirestore: vi.fn(), })); describe('GroupRide Join Requests Moderation API (GET /api/group-rides/[rideId]/join-requests)', () => { let mockAuth: any; let mockFirestore: any; let groupRidesStore: Map; let participantsStore: Map; // key: rideId -> array of participant docs { id, data } let clubsStore: Map; let clubMembersStore: Map; // key: `${clubId}/${uid}` beforeEach(() => { vi.restoreAllMocks(); groupRidesStore = new Map(); participantsStore = new Map(); clubsStore = new Map(); clubMembersStore = new Map(); mockAuth = { verifyIdToken: vi.fn(async (token: string) => { if (token === 'token_alice_organizer') { return { uid: 'alice' }; } if (token === 'token_bob_admin') { return { uid: 'bob' }; } if (token === 'token_owner') { return { uid: 'club_owner_user' }; } if (token === 'token_charlie_member') { return { uid: 'charlie' }; } if (token === 'token_dave_outsider') { return { uid: 'dave' }; } if (token === 'token_eve_inactive') { return { uid: 'eve' }; } throw new Error('Invalid token'); }), }; mockFirestore = { collection: (colName: string) => { if (colName === 'group_rides') { return { doc: (rideId: string) => ({ get: async () => { const data = groupRidesStore.get(rideId); return { exists: !!data, data: () => data, }; }, collection: (subCol: string) => { if (subCol === 'participants') { const createQuery = (filters: any) => ({ where: (field: string, op: string, val: any) => { return createQuery({ ...filters, [field]: { op, val } }); }, orderBy: (field: string, dir: string) => createQuery({ ...filters, orderBy: { field, dir } }), limit: (num: number) => createQuery({ ...filters, limit: num }), get: async () => { const all = participantsStore.get(rideId) || []; let results = all.filter((p) => { if (filters.status && filters.status.op === '==' && p.data.status !== filters.status.val) { return false; } return true; }); if (filters.orderBy) { results = [...results].sort((a, b) => { const valA = a.data[filters.orderBy.field] || ''; const valB = b.data[filters.orderBy.field] || ''; if (valA < valB) return filters.orderBy.dir === 'desc' ? 1 : -1; if (valA > valB) return filters.orderBy.dir === 'desc' ? -1 : 1; return 0; }); } if (filters.limit) { results = results.slice(0, filters.limit); } return { empty: results.length === 0, docs: results.map((r) => ({ id: r.id, data: () => r.data, })), }; }, }); return createQuery({}); } throw new Error(`Unexpected subcollection: ${subCol}`); }, }), }; } if (colName === 'clubs') { return { doc: (clubId: string) => ({ get: async () => { const data = clubsStore.get(clubId); return { exists: !!data, data: () => data, }; }, collection: (subCol: string) => { if (subCol === 'members') { return { doc: (uid: string) => ({ get: async () => { const data = clubMembersStore.get(`${clubId}/${uid}`); return { exists: !!data, data: () => data, }; }, }), }; } throw new Error(`Unexpected subcollection: ${subCol}`); }, }), }; } throw new Error(`Unexpected collection: ${colName}`); }, }; (getAdminAuth as any).mockReturnValue(mockAuth); (getAdminFirestore as any).mockReturnValue(mockFirestore); // Setup club_alpha clubsStore.set('club_alpha', { id: 'club_alpha', name: 'Alpha Riders' }); // Club memberships clubMembersStore.set('club_alpha/club_owner_user', { uid: 'club_owner_user', role: 'owner', status: 'active', }); clubMembersStore.set('club_alpha/bob', { uid: 'bob', role: 'admin', status: 'active', }); clubMembersStore.set('club_alpha/charlie', { uid: 'charlie', role: 'member', status: 'active', }); clubMembersStore.set('club_alpha/eve', { uid: 'eve', role: 'admin', status: 'suspended', }); // Seed ride_club groupRidesStore.set('ride_club', { id: 'ride_club', organizerUid: 'alice', clubId: 'club_alpha', title: 'Saturday Club Run', status: 'scheduled', }); // Seed ride_personal groupRidesStore.set('ride_personal', { id: 'ride_personal', organizerUid: 'alice', clubId: null, title: 'Alice Solo Friends Run', status: 'scheduled', }); }); const createRequest = (rideId: string, authHeader?: string) => { const headers: Record = {}; if (authHeader !== undefined) { headers['authorization'] = authHeader; } return new NextRequest(`https://motorsignal.app/api/group-rides/${rideId}/join-requests`, { method: 'GET', headers, }); }; // ============================================================ // 1. Authentication Gates (401) // ============================================================ it('returns 401 when Authorization header is missing', async () => { const req = createRequest('ride_club'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); expect(res.status).toBe(401); const body = await res.json(); expect(body.error).toBe('unauthorized'); expect(body.detail).toContain('Missing or malformed Authorization header'); }); it('returns 401 when Authorization header does not start with Bearer', async () => { const req = createRequest('ride_club', 'Basic invalid'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); expect(res.status).toBe(401); const body = await res.json(); expect(body.error).toBe('unauthorized'); }); it('returns 401 when token verification fails', async () => { const req = createRequest('ride_club', 'Bearer invalid_or_expired'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); expect(res.status).toBe(401); const body = await res.json(); expect(body.error).toBe('unauthorized'); expect(body.detail).toContain('Invalid or expired authentication token'); }); // ============================================================ // 2. Ride ID Validation (400) // ============================================================ it('returns 400 for empty or whitespace rideId', async () => { const req = createRequest(' ', 'Bearer token_alice_organizer'); const res = await GET(req, { params: Promise.resolve({ rideId: ' ' }) }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toBe('invalid_request'); }); it('returns 400 for rideId containing whitespace', async () => { const req = createRequest('ride 123', 'Bearer token_alice_organizer'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride 123' }) }); expect(res.status).toBe(400); }); it('returns 400 for rideId exceeding 64 characters', async () => { const longId = 'r'.repeat(65); const req = createRequest(longId, 'Bearer token_alice_organizer'); const res = await GET(req, { params: Promise.resolve({ rideId: longId }) }); expect(res.status).toBe(400); }); // ============================================================ // 3. Existence Gate (404) // ============================================================ it('returns 404 when group ride does not exist', async () => { const req = createRequest('nonexistent_ride', 'Bearer token_alice_organizer'); const res = await GET(req, { params: Promise.resolve({ rideId: 'nonexistent_ride' }) }); expect(res.status).toBe(404); const body = await res.json(); expect(body.error).toBe('not_found'); expect(body.detail).toContain('Group ride not found'); }); // ============================================================ // 4. Authorization Gates (403 vs 200) // ============================================================ it('returns 403 when caller is ordinary club member (not organizer or admin)', async () => { const req = createRequest('ride_club', 'Bearer token_charlie_member'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toBe('forbidden'); expect(body.detail).toContain('Only the organizer or a club administrator'); }); it('returns 403 when caller is non-member on club ride', async () => { const req = createRequest('ride_club', 'Bearer token_dave_outsider'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toBe('forbidden'); }); it('returns 403 when club admin role is inactive/suspended', async () => { const req = createRequest('ride_club', 'Bearer token_eve_inactive'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toBe('forbidden'); }); it('returns 403 when caller is non-organizer on personal ride (no club)', async () => { const req = createRequest('ride_personal', 'Bearer token_bob_admin'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_personal' }) }); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toBe('forbidden'); }); it('returns 200 when caller is ride organizer (personal ride)', async () => { const req = createRequest('ride_personal', 'Bearer token_alice_organizer'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_personal' }) }); expect(res.status).toBe(200); const body = await res.json(); expect(body.rideId).toBe('ride_personal'); expect(body.requests).toEqual([]); }); it('returns 200 when caller is ride organizer (club ride)', async () => { const req = createRequest('ride_club', 'Bearer token_alice_organizer'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); expect(res.status).toBe(200); const body = await res.json(); expect(body.rideId).toBe('ride_club'); expect(body.requests).toEqual([]); }); it('returns 200 when caller is active club admin (role admin)', async () => { const req = createRequest('ride_club', 'Bearer token_bob_admin'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); expect(res.status).toBe(200); const body = await res.json(); expect(body.rideId).toBe('ride_club'); expect(body.requests).toEqual([]); }); it('returns 200 when caller is active club owner (role owner)', async () => { const req = createRequest('ride_club', 'Bearer token_owner'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); expect(res.status).toBe(200); const body = await res.json(); expect(body.rideId).toBe('ride_club'); }); // ============================================================ // 5. Query Filtering, Ordering, Bounds, and Data Minimization // ============================================================ it('filters only requested status, orders by createdAt asc, limits to 50, and minimizes DTO', async () => { const participantsList = [ { id: 'user_req_2', data: { userId: 'user_req_2', role: 'participant', status: 'requested', createdAt: '2026-09-25T11:00:00Z', riderDisplayName: 'Rider Two', riderHandle: 'rider2', riderAvatarPreset: 'preset_helmet', riderAvatarUrl: null, latitude: 34.123, // SENSITIVE live_presence: { lat: 34.123 }, // SENSITIVE }, }, { id: 'user_accepted', data: { userId: 'user_accepted', role: 'participant', status: 'accepted', createdAt: '2026-09-25T09:00:00Z', }, }, { id: 'user_declined', data: { userId: 'user_declined', role: 'participant', status: 'declined', createdAt: '2026-09-25T09:30:00Z', }, }, { id: 'user_req_1', data: { userId: 'user_req_1', role: 'participant', status: 'requested', createdAt: '2026-09-25T10:00:00Z', riderDisplayName: 'Rider One', riderHandle: 'rider1', riderAvatarPreset: null, riderAvatarUrl: 'https://example.com/avatar.jpg', }, }, ]; participantsStore.set('ride_club', participantsList); const req = createRequest('ride_club', 'Bearer token_alice_organizer'); const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); expect(res.status).toBe(200); expect(res.headers.get('Cache-Control')).toBe('no-store, max-age=0'); const body = await res.json(); expect(body.rideId).toBe('ride_club'); expect(body.requests.length).toBe(2); // Verified ordered by createdAt ASC: user_req_1 (10:00) then user_req_2 (11:00) expect(body.requests[0].userId).toBe('user_req_1'); expect(body.requests[0].riderDisplayName).toBe('Rider One'); expect(body.requests[0].riderHandle).toBe('rider1'); expect(body.requests[0].riderAvatarUrl).toBe('https://example.com/avatar.jpg'); expect(body.requests[0].createdAt).toBe('2026-09-25T10:00:00Z'); expect(body.requests[1].userId).toBe('user_req_2'); expect(body.requests[1].riderDisplayName).toBe('Rider Two'); expect(body.requests[1].riderAvatarPreset).toBe('preset_helmet'); expect(body.requests[1].createdAt).toBe('2026-09-25T11:00:00Z'); // Data minimization verification: sensitive fields must not exist expect((body.requests[1] as any).latitude).toBeUndefined(); expect((body.requests[1] as any).live_presence).toBeUndefined(); const rawJson = JSON.stringify(body); expect(rawJson).not.toContain('34.123'); expect(rawJson).not.toContain('user_accepted'); expect(rawJson).not.toContain('user_declined'); }); });