726 lines
27 KiB
TypeScript
726 lines
27 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
import { GET } from '../app/api/clubs/[clubId]/rides/route';
|
|
import { getAdminAuth, getAdminFirestore } from '../lib/firebase-admin';
|
|
|
|
vi.mock('../lib/firebase-admin', () => ({
|
|
getAdminAuth: vi.fn(),
|
|
getAdminFirestore: vi.fn(),
|
|
}));
|
|
|
|
describe('Club GroupRide Secure Member Discovery API (GET /api/clubs/[clubId]/rides)', () => {
|
|
let mockAuth: any;
|
|
let mockFirestore: any;
|
|
|
|
// In-memory store for mock firestore
|
|
let clubsStore: Map<string, any>;
|
|
let clubMembersStore: Map<string, any>; // key: `${clubId}/${uid}`
|
|
let userBlocksStore: Map<string, Set<string>>; // key: uid -> Set of blocked uids
|
|
let groupRidesStore: Array<{ id: string; data: any }>;
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
|
|
clubsStore = new Map();
|
|
clubMembersStore = new Map();
|
|
userBlocksStore = new Map();
|
|
groupRidesStore = [];
|
|
|
|
mockAuth = {
|
|
verifyIdToken: vi.fn(async (token: string) => {
|
|
if (token === 'valid_token_charlie') {
|
|
return { uid: 'charlie' };
|
|
}
|
|
if (token === 'valid_token_alice') {
|
|
return { uid: 'alice' };
|
|
}
|
|
if (token === 'valid_token_bob') {
|
|
return { uid: 'bob' };
|
|
}
|
|
if (token === 'valid_token_dave') {
|
|
return { uid: 'dave' };
|
|
}
|
|
throw new Error('Invalid token');
|
|
}),
|
|
};
|
|
|
|
mockFirestore = {
|
|
collection: (colName: string) => {
|
|
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}`);
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (colName === 'users') {
|
|
return {
|
|
doc: (uid: string) => ({
|
|
collection: (subCol: string) => {
|
|
if (subCol === 'blocks') {
|
|
return {
|
|
get: async () => {
|
|
const blockedSet = userBlocksStore.get(uid) || new Set();
|
|
return {
|
|
docs: Array.from(blockedSet).map((bUid) => ({
|
|
id: bUid,
|
|
data: () => ({ blockedUid: bUid }),
|
|
})),
|
|
};
|
|
},
|
|
doc: (targetUid: string) => ({
|
|
get: async () => {
|
|
const blockedSet = userBlocksStore.get(uid) || new Set();
|
|
const exists = blockedSet.has(targetUid);
|
|
return {
|
|
exists,
|
|
data: () => (exists ? { blockedUid: targetUid } : undefined),
|
|
};
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected subcollection: ${subCol}`);
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (colName === 'group_rides') {
|
|
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 }),
|
|
startAfter: (lastDoc: any) => createQuery({ ...filters, startAfterId: lastDoc?.id }),
|
|
get: async () => {
|
|
let results = groupRidesStore.filter((r) => {
|
|
const d = r.data;
|
|
if (filters.clubId && d.clubId !== filters.clubId.val) return false;
|
|
if (filters.status && filters.status.op === 'in') {
|
|
if (!filters.status.val.includes(d.status)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (filters.orderBy) {
|
|
const { field, dir } = filters.orderBy;
|
|
results = [...results].sort((a, b) => {
|
|
const valA = a.data[field] || '';
|
|
const valB = b.data[field] || '';
|
|
return dir === 'desc' ? (valB > valA ? 1 : -1) : (valA > valB ? 1 : -1);
|
|
});
|
|
}
|
|
|
|
if (filters.startAfterId) {
|
|
const idx = results.findIndex((r) => r.id === filters.startAfterId);
|
|
if (idx !== -1) {
|
|
results = results.slice(idx + 1);
|
|
}
|
|
}
|
|
|
|
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 collection: ${colName}`);
|
|
},
|
|
};
|
|
|
|
(getAdminAuth as any).mockReturnValue(mockAuth);
|
|
(getAdminFirestore as any).mockReturnValue(mockFirestore);
|
|
|
|
// Setup standard active club_alpha
|
|
clubsStore.set('club_alpha', {
|
|
id: 'club_alpha',
|
|
name: 'Alpha Riders',
|
|
archivedAt: null,
|
|
});
|
|
|
|
// Alice is active owner
|
|
clubMembersStore.set('club_alpha/alice', {
|
|
uid: 'alice',
|
|
clubId: 'club_alpha',
|
|
role: 'owner',
|
|
status: 'active',
|
|
});
|
|
|
|
// Bob is active admin
|
|
clubMembersStore.set('club_alpha/bob', {
|
|
uid: 'bob',
|
|
clubId: 'club_alpha',
|
|
role: 'admin',
|
|
status: 'active',
|
|
});
|
|
|
|
// Charlie is active member
|
|
clubMembersStore.set('club_alpha/charlie', {
|
|
uid: 'charlie',
|
|
clubId: 'club_alpha',
|
|
role: 'member',
|
|
status: 'active',
|
|
});
|
|
|
|
// Eve is inactive requested member
|
|
clubMembersStore.set('club_alpha/eve', {
|
|
uid: 'eve',
|
|
clubId: 'club_alpha',
|
|
role: 'member',
|
|
status: 'requested',
|
|
});
|
|
});
|
|
|
|
const createRequest = (clubId: string, authHeader?: string) => {
|
|
const headers: Record<string, string> = {};
|
|
if (authHeader !== undefined) {
|
|
headers['authorization'] = authHeader;
|
|
}
|
|
return new NextRequest(`https://motorsignal.app/api/clubs/${clubId}/rides`, {
|
|
method: 'GET',
|
|
headers,
|
|
});
|
|
};
|
|
|
|
// ============================================================
|
|
// 1. Authentication Gates (401)
|
|
// ============================================================
|
|
it('returns 401 when Authorization header is missing', async () => {
|
|
const req = createRequest('club_alpha');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
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('club_alpha', 'Basic some_creds');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
expect(res.status).toBe(401);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('unauthorized');
|
|
});
|
|
|
|
it('returns 401 when token verification fails or is expired', async () => {
|
|
const req = createRequest('club_alpha', 'Bearer invalid_or_expired_token');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
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. Club ID Validation (400)
|
|
// ============================================================
|
|
it('returns 400 for empty or whitespace clubId', async () => {
|
|
const req = createRequest(' ', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: ' ' }) });
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('invalid_request');
|
|
});
|
|
|
|
it('returns 400 for clubId with whitespace inside', async () => {
|
|
const req = createRequest('club alpha', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club alpha' }) });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 400 for clubId exceeding 64 characters', async () => {
|
|
const longId = 'a'.repeat(65);
|
|
const req = createRequest(longId, 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: longId }) });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
// ============================================================
|
|
// 3. Club Existence & Status (404)
|
|
// ============================================================
|
|
it('returns 404 when club does not exist', async () => {
|
|
const req = createRequest('club_nonexistent', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_nonexistent' }) });
|
|
expect(res.status).toBe(404);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('not_found');
|
|
expect(body.detail).toContain('Club not found');
|
|
});
|
|
|
|
it('returns 404 when club is archived', async () => {
|
|
clubsStore.set('club_archived', {
|
|
id: 'club_archived',
|
|
name: 'Archived Club',
|
|
archivedAt: '2026-09-01T00:00:00Z',
|
|
});
|
|
const req = createRequest('club_archived', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_archived' }) });
|
|
expect(res.status).toBe(404);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('not_found');
|
|
expect(body.detail).toContain('archived');
|
|
});
|
|
|
|
// ============================================================
|
|
// 4. Membership Authorization (403 vs 200)
|
|
// ============================================================
|
|
it('returns 403 when authenticated user is not a club member', async () => {
|
|
// Dave has valid token but is not in club_alpha/members
|
|
const req = createRequest('club_alpha', 'Bearer valid_token_dave');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('forbidden');
|
|
expect(body.detail).toContain('Active club membership required');
|
|
});
|
|
|
|
it('returns 403 when authenticated user is an inactive club member', async () => {
|
|
// Eve is in club_alpha/members but status is 'requested'
|
|
mockAuth.verifyIdToken.mockResolvedValueOnce({ uid: 'eve' });
|
|
const req = createRequest('club_alpha', 'Bearer valid_token_eve');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('forbidden');
|
|
});
|
|
|
|
it('returns 200 with empty list when club has no upcoming rides', async () => {
|
|
const req = createRequest('club_alpha', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.clubId).toBe('club_alpha');
|
|
expect(body.rides).toEqual([]);
|
|
});
|
|
|
|
// ============================================================
|
|
// 5. Server-Side Mutual-Block Enforcement
|
|
// ============================================================
|
|
it('excludes rides when caller blocks organizer, organizer blocks caller, and includes unblocked rides', async () => {
|
|
// Seed 4 rides:
|
|
// 1. ride_alice organized by Alice (scheduled)
|
|
// 2. ride_bob organized by Bob (assembling)
|
|
// 3. ride_charlie organized by Charlie (scheduled, self)
|
|
// 4. ride_foreign organized by Frank (scheduled)
|
|
groupRidesStore.push(
|
|
{
|
|
id: 'ride_alice',
|
|
data: {
|
|
id: 'ride_alice',
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'alice',
|
|
title: 'Alice Mountain Ride',
|
|
status: 'scheduled',
|
|
scheduledStartAt: '2026-09-25T10:00:00Z',
|
|
accessUids: ['alice'],
|
|
participantCount: 1,
|
|
meetingPoint: { displayName: 'North Gap', latitude: 34.5, longitude: -83.9 },
|
|
},
|
|
},
|
|
{
|
|
id: 'ride_bob',
|
|
data: {
|
|
id: 'ride_bob',
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'bob',
|
|
title: 'Bob Valley Loop',
|
|
status: 'assembling',
|
|
scheduledStartAt: '2026-09-25T11:00:00Z',
|
|
accessUids: ['bob'],
|
|
participantCount: 1,
|
|
meetingPoint: { displayName: 'South Gap', latitude: 34.6, longitude: -83.8 },
|
|
},
|
|
},
|
|
{
|
|
id: 'ride_charlie',
|
|
data: {
|
|
id: 'ride_charlie',
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'charlie',
|
|
title: 'Charlie Afternoon Cruise',
|
|
status: 'scheduled',
|
|
scheduledStartAt: '2026-09-25T14:00:00Z',
|
|
accessUids: ['charlie'],
|
|
participantCount: 1,
|
|
meetingPoint: { displayName: 'Town Square', latitude: 34.7, longitude: -83.7 },
|
|
},
|
|
}
|
|
);
|
|
|
|
// Charlie blocks Alice
|
|
userBlocksStore.set('charlie', new Set(['alice']));
|
|
// Bob blocks Charlie (reverse block)
|
|
userBlocksStore.set('bob', new Set(['charlie']));
|
|
|
|
const req = createRequest('club_alpha', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
|
|
// Alice ride must be EXCLUDED (caller blocks organizer)
|
|
// Bob ride must be EXCLUDED (organizer blocks caller)
|
|
// Charlie ride must be INCLUDED (caller is organizer, no mutual block)
|
|
expect(body.rides.length).toBe(1);
|
|
expect(body.rides[0].rideId).toBe('ride_charlie');
|
|
expect(body.rides[0].title).toBe('Charlie Afternoon Cruise');
|
|
});
|
|
|
|
// ============================================================
|
|
// 6. Status Filtering (scheduled & assembling only)
|
|
// ============================================================
|
|
it('includes scheduled and assembling rides; excludes underway, completed, and canceled rides', async () => {
|
|
groupRidesStore.push(
|
|
{
|
|
id: 'ride_sched',
|
|
data: {
|
|
id: 'ride_sched',
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'alice',
|
|
title: 'Scheduled Ride',
|
|
status: 'scheduled',
|
|
scheduledStartAt: '2026-09-25T09:00:00Z',
|
|
},
|
|
},
|
|
{
|
|
id: 'ride_assem',
|
|
data: {
|
|
id: 'ride_assem',
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'alice',
|
|
title: 'Assembling Ride',
|
|
status: 'assembling',
|
|
scheduledStartAt: '2026-09-25T09:30:00Z',
|
|
},
|
|
},
|
|
{
|
|
id: 'ride_underway',
|
|
data: {
|
|
id: 'ride_underway',
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'alice',
|
|
title: 'Underway Ride',
|
|
status: 'underway',
|
|
scheduledStartAt: '2026-09-25T10:00:00Z',
|
|
},
|
|
},
|
|
{
|
|
id: 'ride_completed',
|
|
data: {
|
|
id: 'ride_completed',
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'alice',
|
|
title: 'Completed Ride',
|
|
status: 'completed',
|
|
scheduledStartAt: '2026-09-25T11:00:00Z',
|
|
},
|
|
},
|
|
{
|
|
id: 'ride_canceled',
|
|
data: {
|
|
id: 'ride_canceled',
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'alice',
|
|
title: 'Canceled Ride',
|
|
status: 'canceled',
|
|
scheduledStartAt: '2026-09-25T12:00:00Z',
|
|
},
|
|
}
|
|
);
|
|
|
|
const req = createRequest('club_alpha', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
|
|
const returnedIds = body.rides.map((r: any) => r.rideId);
|
|
expect(returnedIds).toContain('ride_sched');
|
|
expect(returnedIds).toContain('ride_assem');
|
|
expect(returnedIds).not.toContain('ride_underway');
|
|
expect(returnedIds).not.toContain('ride_completed');
|
|
expect(returnedIds).not.toContain('ride_canceled');
|
|
expect(returnedIds.length).toBe(2);
|
|
});
|
|
|
|
// ============================================================
|
|
// 7. Response Data Minimization & Physical-Location Privacy
|
|
// ============================================================
|
|
it('strictly minimizes response data: excludes accessUids, exact coordinates, full route geometry, and live presence', async () => {
|
|
groupRidesStore.push({
|
|
id: 'ride_minimized',
|
|
data: {
|
|
id: 'ride_minimized',
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'alice',
|
|
organizerDisplayName: 'Alice W.',
|
|
organizerHandle: 'alice_rider',
|
|
title: 'Safe Scenic Cruise',
|
|
description: 'Casual pace cruise through foothills',
|
|
status: 'scheduled',
|
|
scheduledStartAt: '2026-09-26T10:00:00Z',
|
|
departureAt: '2026-09-26T10:30:00Z',
|
|
joinPolicy: 'requestToJoin',
|
|
participantCount: 3,
|
|
participantLimit: 15,
|
|
accessUids: ['alice', 'rider2', 'rider3'], // SENSITIVE
|
|
meetingPoint: {
|
|
displayName: 'Dahlonega Town Square',
|
|
address: '1 Public Square, Dahlonega, GA',
|
|
latitude: 34.5328, // SENSITIVE
|
|
longitude: -83.9849, // SENSITIVE
|
|
locationType: 'meetup',
|
|
},
|
|
routeId: 'private_route_internal_123', // SENSITIVE
|
|
routeTitle: 'Foothills Run',
|
|
routeDistanceMeters: 45000,
|
|
routeStopCount: 2,
|
|
paceClassification: 'Casual',
|
|
live_presence: { alice: { lat: 34.5, lon: -83.9 } }, // SENSITIVE
|
|
},
|
|
});
|
|
|
|
const req = createRequest('club_alpha', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.rides.length).toBe(1);
|
|
|
|
const ride = body.rides[0];
|
|
|
|
// Allowed minimized fields
|
|
expect(ride.rideId).toBe('ride_minimized');
|
|
expect(ride.clubId).toBe('club_alpha');
|
|
expect(ride.title).toBe('Safe Scenic Cruise');
|
|
expect(ride.description).toBe('Casual pace cruise through foothills');
|
|
expect(ride.organizerDisplayName).toBe('Alice W.');
|
|
expect(ride.organizerHandle).toBe('alice_rider');
|
|
expect(ride.scheduledStartAt).toBe('2026-09-26T10:00:00Z');
|
|
expect(ride.departureAt).toBe('2026-09-26T10:30:00Z');
|
|
expect(ride.joinPolicy).toBe('requestToJoin');
|
|
expect(ride.participantCount).toBe(3);
|
|
expect(ride.participantLimit).toBe(15);
|
|
expect(ride.status).toBe('scheduled');
|
|
expect(ride.meetingLocationLabel).toBe('Dahlonega Town Square');
|
|
expect(ride.routeTitle).toBe('Foothills Run');
|
|
expect(ride.routeDistanceMeters).toBe(45000);
|
|
expect(ride.paceClassification).toBe('Casual');
|
|
|
|
// Strictly forbidden fields
|
|
expect((ride as any).organizerUid).toBeUndefined();
|
|
expect((ride as any).accessUids).toBeUndefined();
|
|
expect((ride as any).latitude).toBeUndefined();
|
|
expect((ride as any).longitude).toBeUndefined();
|
|
expect((ride as any).meetingPoint).toBeUndefined();
|
|
expect((ride as any).routeId).toBeUndefined();
|
|
expect((ride as any).waypoints).toBeUndefined();
|
|
expect((ride as any).routePolyline).toBeUndefined();
|
|
expect((ride as any).live_presence).toBeUndefined();
|
|
expect((ride as any).telemetry).toBeUndefined();
|
|
expect((ride as any).stops).toBeUndefined();
|
|
});
|
|
|
|
it('location privacy: returns safe displayName and never leaks raw formatted address or coordinates', async () => {
|
|
groupRidesStore.push({
|
|
id: 'ride_loc_safe',
|
|
data: {
|
|
id: 'ride_loc_safe',
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'alice',
|
|
title: 'Mountain Ridge Loop',
|
|
status: 'scheduled',
|
|
scheduledStartAt: '2026-09-27T10:00:00Z',
|
|
meetingPoint: {
|
|
displayName: 'Dahlonega Square',
|
|
address: '13 South Park Street, Dahlonega, GA 30533',
|
|
latitude: 34.5328,
|
|
longitude: -83.9849,
|
|
},
|
|
},
|
|
});
|
|
|
|
const req = createRequest('club_alpha', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
const ride = body.rides.find((r: any) => r.rideId === 'ride_loc_safe');
|
|
expect(ride).toBeDefined();
|
|
|
|
// Safe display name is returned
|
|
expect(ride.meetingLocationLabel).toBe('Dahlonega Square');
|
|
|
|
// Formatted address and coordinates must not appear anywhere in serialized response
|
|
const jsonStr = JSON.stringify(body);
|
|
expect(jsonStr).not.toContain('13 South Park Street');
|
|
expect(jsonStr).not.toContain('34.5328');
|
|
expect(jsonStr).not.toContain('-83.9849');
|
|
expect((ride as any).meetingPoint).toBeUndefined();
|
|
expect((ride as any).latitude).toBeUndefined();
|
|
expect((ride as any).longitude).toBeUndefined();
|
|
});
|
|
|
|
it('location privacy: returns null meetingLocationLabel when displayName is missing and does not fall back to address', async () => {
|
|
groupRidesStore.push({
|
|
id: 'ride_loc_no_name',
|
|
data: {
|
|
id: 'ride_loc_no_name',
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'alice',
|
|
title: 'Secret Meetup Run',
|
|
status: 'scheduled',
|
|
scheduledStartAt: '2026-09-27T11:00:00Z',
|
|
meetingPoint: {
|
|
displayName: null,
|
|
address: '123 Example Street, Example City, GA',
|
|
latitude: 33.7490,
|
|
longitude: -84.3880,
|
|
},
|
|
},
|
|
});
|
|
|
|
const req = createRequest('club_alpha', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
const ride = body.rides.find((r: any) => r.rideId === 'ride_loc_no_name');
|
|
expect(ride).toBeDefined();
|
|
|
|
// When displayName is missing, meetingLocationLabel MUST be null
|
|
expect(ride.meetingLocationLabel).toBeNull();
|
|
|
|
// Raw address and coordinates must NOT appear anywhere in the serialized response
|
|
const jsonStr = JSON.stringify(body);
|
|
expect(jsonStr).not.toContain('123 Example Street');
|
|
expect(jsonStr).not.toContain('33.7490');
|
|
expect(jsonStr).not.toContain('-84.3880');
|
|
expect((ride as any).meetingPoint).toBeUndefined();
|
|
expect((ride as any).latitude).toBeUndefined();
|
|
expect((ride as any).longitude).toBeUndefined();
|
|
});
|
|
|
|
it('pagination through blocks: pages candidate rides until 30 eligible rides collected or exhausted', async () => {
|
|
// Charlie blocks a high-volume organizer
|
|
userBlocksStore.set('charlie', new Set(['blocked_organizer']));
|
|
|
|
// Seed 40 scheduled rides: first 15 from blocked_organizer, next 25 from alice
|
|
for (let i = 0; i < 15; i++) {
|
|
groupRidesStore.push({
|
|
id: `ride_blocked_${i.toString().padStart(2, '0')}`,
|
|
data: {
|
|
id: `ride_blocked_${i.toString().padStart(2, '0')}`,
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'blocked_organizer',
|
|
title: `Blocked Ride ${i}`,
|
|
status: 'scheduled',
|
|
scheduledStartAt: `2026-09-25T${(10 + Math.floor(i / 6)).toString().padStart(2, '0')}:${((i % 6) * 10).toString().padStart(2, '0')}:00Z`,
|
|
},
|
|
});
|
|
}
|
|
|
|
for (let i = 0; i < 25; i++) {
|
|
groupRidesStore.push({
|
|
id: `ride_alice_${i.toString().padStart(2, '0')}`,
|
|
data: {
|
|
id: `ride_alice_${i.toString().padStart(2, '0')}`,
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'alice',
|
|
title: `Alice Ride ${i}`,
|
|
status: 'scheduled',
|
|
scheduledStartAt: `2026-09-26T${(10 + Math.floor(i / 6)).toString().padStart(2, '0')}:${((i % 6) * 10).toString().padStart(2, '0')}:00Z`,
|
|
},
|
|
});
|
|
}
|
|
|
|
const req = createRequest('club_alpha', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
|
|
// Must return all 25 unblocked rides from Alice (paged past the 15 blocked rides)
|
|
expect(body.rides.length).toBe(25);
|
|
for (const r of body.rides) {
|
|
expect(r.rideId).toMatch(/^ride_alice_/);
|
|
}
|
|
});
|
|
|
|
it('pagination continues past 150 blocked candidates until 30 eligible rides found or exhausted', async () => {
|
|
// Charlie blocks a spammy/blocked organizer
|
|
userBlocksStore.set('charlie', new Set(['heavy_blocked_organizer']));
|
|
|
|
// Seed 160 scheduled rides from heavy_blocked_organizer
|
|
for (let i = 0; i < 160; i++) {
|
|
groupRidesStore.push({
|
|
id: `ride_blocked_dense_${i.toString().padStart(3, '0')}`,
|
|
data: {
|
|
id: `ride_blocked_dense_${i.toString().padStart(3, '0')}`,
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'heavy_blocked_organizer',
|
|
title: `Blocked Ride ${i}`,
|
|
status: 'scheduled',
|
|
scheduledStartAt: `2026-09-25T${(10 + Math.floor(i / 60)).toString().padStart(2, '0')}:${((i % 60)).toString().padStart(2, '0')}:00Z`,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Seed 30 scheduled rides from unblocked organizer alice
|
|
for (let i = 0; i < 30; i++) {
|
|
groupRidesStore.push({
|
|
id: `ride_alice_dense_${i.toString().padStart(2, '0')}`,
|
|
data: {
|
|
id: `ride_alice_dense_${i.toString().padStart(2, '0')}`,
|
|
clubId: 'club_alpha',
|
|
organizerUid: 'alice',
|
|
title: `Alice Ride ${i}`,
|
|
status: 'scheduled',
|
|
scheduledStartAt: `2026-09-28T${(10 + Math.floor(i / 6)).toString().padStart(2, '0')}:${((i % 6) * 10).toString().padStart(2, '0')}:00Z`,
|
|
},
|
|
});
|
|
}
|
|
|
|
const req = createRequest('club_alpha', 'Bearer valid_token_charlie');
|
|
const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
|
|
// Must page past all 160 blocked rides and collect all 30 eligible rides from Alice
|
|
expect(body.rides.length).toBe(30);
|
|
for (const r of body.rides) {
|
|
expect(r.rideId).toMatch(/^ride_alice_dense_/);
|
|
}
|
|
});
|
|
});
|