MotorSignal-App/website/tests/group-ride-join-requests.te...

426 lines
16 KiB
TypeScript

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<string, any>;
let participantsStore: Map<string, any[]>; // key: rideId -> array of participant docs { id, data }
let clubsStore: Map<string, any>;
let clubMembersStore: Map<string, any>; // 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<string, string> = {};
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');
});
});