feat(group-rides): secure club join moderation and discovery (Phase 3A) (#26)

* feat(clubs): enable admin group ride governance

* feat(clubs): add secure member ride discovery

* fix(clubs): protect meeting location in ride discovery

* feat(group-rides): secure club join moderation

* test(clubs): update ride discovery expectations

* test(founder-portal): update rides governance expectations

* fix(group-rides): resolve pre-merge findings on join moderation and discovery

- Block participant hard deletes for accepted status and organizer role for all callers

- Support atomic accessUids removal on invitation decline in firestore.rules

- Restrict mutual-block checks to approvals in GroupRideService, allowing decline of blocked requests

- Implement cursor-paginated candidate scanning with block filtering in website discovery API

- Add comprehensive emulator, vitest, and unit tests covering all 5 findings

* fix(discovery): page candidates until 30 eligible rides collected or query exhausted

- Remove artificial 150 candidate scan ceiling in club rides discovery route

- Continue cursor pagination until 30 unblocked eligible rides are collected or upcoming schedule query is exhausted

- Add test verifying pagination continues past 150 blocked rides to collect all 30 eligible rides

---------

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-09-24 12:00:48 -04:00 committed by GitHub
parent 1e7e670a17
commit c1aa68ac43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 7234 additions and 335 deletions

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,42 @@
}
]
},
{
"collectionGroup": "group_rides",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "clubId",
"order": "ASCENDING"
},
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "scheduledStartAt",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "group_rides",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "clubId",
"order": "ASCENDING"
},
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "scheduledStartAt",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "clubs",
"queryScope": "COLLECTION",
@ -109,6 +145,20 @@
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "participants",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "createdAt",
"order": "ASCENDING"
}
]
}
],
"fieldOverrides": []

View File

@ -259,9 +259,15 @@ service cloud.firestore {
isActiveClubMember(resource.data.clubId, request.auth.uid);
}
function isClubRideAdmin() {
return hasClubAffiliation(resource.data) &&
isActiveClubAdmin(resource.data.clubId, request.auth.uid);
}
function canReadRide() {
return signedIn() && (
isOrganizer() ||
isClubRideAdmin() ||
(
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
hasParticipantAccess(request.auth.uid) ||
@ -283,6 +289,10 @@ service cloud.firestore {
(
'accessUids' in resource.data &&
request.auth.uid in resource.data.accessUids
) ||
(
hasClubAffiliation(resource.data) &&
isActiveClubAdmin(resource.data.clubId, request.auth.uid)
)
);
@ -332,36 +342,107 @@ service cloud.firestore {
);
}
function getRideAccessUids(data) {
return 'accessUids' in data ? data.accessUids : ('organizerUid' in data ? [data.organizerUid] : []);
}
function getRideCount(data) {
return 'participantCount' in data ? data.participantCount : 1;
}
function rideWithinCapacity(data, nextCount) {
return !('participantLimit' in data) ||
data.participantLimit == null ||
nextCount <= data.participantLimit;
}
function validOrganizerRideUpdate() {
let hasPrevAccess = 'accessUids' in resource.data;
let hasNextAccess = 'accessUids' in request.resource.data;
let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
let prevAccess = getRideAccessUids(resource.data);
let nextAccess = getRideAccessUids(request.resource.data);
let prevCount = getRideCount(resource.data);
let nextCount = getRideCount(request.resource.data);
let added = nextAccess.removeAll(prevAccess);
let removed = prevAccess.removeAll(nextAccess);
let countDelta = nextCount - prevCount;
return validStatusTransition() && validRouteAttachment() && (
return validStatusTransition() && validRouteAttachment() &&
rideWithinCapacity(request.resource.data, nextCount) && (
// General metadata or status update: access and count unchanged
(added.size() == 0 && removed.size() == 0 && countDelta == 0) ||
(added.size() == 1 && removed.size() == 0 && countDelta == 0) ||
(added.size() == 1 && removed.size() == 0 && countDelta == 1) ||
(added.size() == 0 && removed.size() == 1 && (countDelta == -1 || countDelta == 0))
// Organizer approval or open join: exactly +1 accessUid, count +1, only in scheduled/assembling
(
('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) &&
added.size() == 1 && removed.size() == 0 && countDelta == 1 &&
!areMutuallyBlocked(resource.data.organizerUid, added[0]) &&
(!hasClubAffiliation(resource.data) || isActiveClubMember(resource.data.clubId, added[0])) &&
existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'accepted'
) ||
// Organizer invitation: exactly +1 accessUid, count unchanged (countDelta == 0), only in scheduled/assembling
(
('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) &&
added.size() == 1 && removed.size() == 0 && countDelta == 0 &&
!areMutuallyBlocked(resource.data.organizerUid, added[0]) &&
existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'invited'
) ||
// Organizer removal: exactly -1 accessUid, count -1, only in scheduled/assembling/underway
(
('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) &&
added.size() == 0 && removed.size() == 1 && countDelta == -1 &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(removed[0])).data.status in ['removed', 'left']
)
);
}
function validParticipantOpenJoin(added, removed, countDelta, nextCount) {
return resource.data.joinPolicy == 'open' &&
('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) &&
request.auth.uid != resource.data.organizerUid &&
added == [request.auth.uid] &&
removed.size() == 0 &&
countDelta == 1 &&
rideWithinCapacity(resource.data, nextCount) &&
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) &&
(!hasClubAffiliation(resource.data) || isActiveClubMember(resource.data.clubId, request.auth.uid)) &&
existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'accepted';
}
function validParticipantAcceptInvite(prevAccess, added, removed, countDelta, nextCount) {
return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) &&
request.auth.uid in prevAccess &&
added.size() == 0 &&
removed.size() == 0 &&
countDelta == 1 &&
rideWithinCapacity(resource.data, nextCount) &&
existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'accepted';
}
function validParticipantLeave(prevAccess, added, removed, countDelta) {
return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) &&
request.auth.uid != resource.data.organizerUid &&
added.size() == 0 &&
removed == [request.auth.uid] &&
countDelta == -1 &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'left';
}
function validParticipantDeclineInvite(prevAccess, added, removed, countDelta) {
return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) &&
request.auth.uid != resource.data.organizerUid &&
added.size() == 0 &&
(removed.size() == 0 || removed == [request.auth.uid]) &&
countDelta == 0 &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'declined';
}
function validParticipantRideUpdate() {
let hasPrevAccess = 'accessUids' in resource.data;
let hasNextAccess = 'accessUids' in request.resource.data;
let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
let prevAccess = getRideAccessUids(resource.data);
let nextAccess = getRideAccessUids(request.resource.data);
let prevCount = getRideCount(resource.data);
let nextCount = getRideCount(request.resource.data);
let added = nextAccess.removeAll(prevAccess);
let removed = prevAccess.removeAll(nextAccess);
let countDelta = nextCount - prevCount;
@ -369,35 +450,57 @@ service cloud.firestore {
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['accessUids', 'participantCount', 'updatedAt']) &&
canReadRide() &&
(
// Join open ride: caller added to accessUids, count +1
(
resource.data.joinPolicy == 'open' &&
added == [request.auth.uid] &&
removed.size() == 0 &&
countDelta == 1
) ||
// Accept invite: caller was already in accessUids, count +1
(
request.auth.uid in prevAccess &&
added.size() == 0 &&
removed.size() == 0 &&
countDelta == 1
) ||
// Decline invite or leave: caller removed from accessUids, count unchanged (declined) or count -1 (left accepted)
(
request.auth.uid in prevAccess &&
removed == [request.auth.uid] &&
added.size() == 0 &&
(countDelta == -1 || countDelta == 0)
)
validParticipantOpenJoin(added, removed, countDelta, nextCount) ||
validParticipantAcceptInvite(prevAccess, added, removed, countDelta, nextCount) ||
validParticipantLeave(prevAccess, added, removed, countDelta) ||
validParticipantDeclineInvite(prevAccess, added, removed, countDelta)
);
}
function validClubAdminModeration(prevStatus, nextStatus) {
let prevAccess = getRideAccessUids(resource.data);
let nextAccess = getRideAccessUids(request.resource.data);
let prevCount = getRideCount(resource.data);
let nextCount = getRideCount(request.resource.data);
let added = nextAccess.removeAll(prevAccess);
let removed = prevAccess.removeAll(nextAccess);
let countDelta = nextCount - prevCount;
return request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'accessUids', 'participantCount', 'updatedAt'
]) && (
// Approval: count +1, access added 1, only in scheduled or assembling
(
prevStatus in ['scheduled', 'assembling'] &&
nextStatus == prevStatus &&
added.size() == 1 &&
removed.size() == 0 &&
countDelta == 1 &&
rideWithinCapacity(resource.data, nextCount) &&
!areMutuallyBlocked(resource.data.organizerUid, added[0]) &&
isActiveClubMember(resource.data.clubId, added[0]) &&
existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'accepted'
) ||
// Removal: count -1, access removed 1, only in scheduled, assembling, or underway
(
prevStatus in ['scheduled', 'assembling', 'underway'] &&
nextStatus == prevStatus &&
added.size() == 0 &&
removed.size() == 1 &&
countDelta == -1 &&
removed[0] != resource.data.organizerUid &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(removed[0])).data.status in ['removed', 'left']
)
);
}
function validClubAdminRideUpdate() {
let clubId = resource.data.clubId;
let prevStatus = 'status' in resource.data ? resource.data.status : 'scheduled';
let prevStatus = 'status' in resource.data ? resource.data.status : null;
let nextStatus = 'status' in request.resource.data ? request.resource.data.status : prevStatus;
return isActiveClubAdmin(clubId, request.auth.uid) && (
return isActiveClubAdmin(clubId, request.auth.uid) && prevStatus != null && (
// Sub-case A: Scheduled metadata edit
(
prevStatus == 'scheduled' &&
@ -418,12 +521,14 @@ service cloud.firestore {
request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'status', 'canceledAt', 'canceledReason', 'updatedAt'
])
)
) ||
// Sub-case C: Club admin participant moderation (approve, remove)
validClubAdminModeration(prevStatus, nextStatus)
);
}
allow update: if signedIn() &&
(!('status' in resource.data) || !(resource.data.status in ['completed', 'canceled'])) &&
('status' in resource.data && !(resource.data.status in ['completed', 'canceled'])) &&
sameClubAffiliation(resource.data, request.resource.data) &&
(
(isOrganizer() && validOrganizerRideUpdate()) ||
@ -447,6 +552,11 @@ service cloud.firestore {
return rideDoc().data.organizerUid;
}
function isAuthorizedModerator() {
return request.auth.uid == rideOrganizer() ||
(hasClubAffiliation(rideDoc().data) && isActiveClubAdmin(rideDoc().data.clubId, request.auth.uid));
}
function isAcceptedParticipant(uid) {
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
@ -455,10 +565,10 @@ service cloud.firestore {
// Collection listing is strictly disallowed (point-read architecture over known accessUids)
allow list: if false;
// Single-document read: self, organizer, or accepted peer (if not mutually blocked)
// Single-document read: self, moderator, or accepted peer (if not mutually blocked)
allow get: if signedIn() && (
request.auth.uid == participantUid ||
request.auth.uid == rideOrganizer() ||
isAuthorizedModerator() ||
(
isAcceptedParticipant(request.auth.uid) &&
!areMutuallyBlocked(request.auth.uid, participantUid)
@ -471,87 +581,168 @@ service cloud.firestore {
(
request.auth.uid == participantUid &&
request.auth.uid == rideOrganizer() &&
request.resource.data.userId == request.auth.uid &&
request.resource.data.role == 'organizer' &&
request.resource.data.status == 'accepted'
request.resource.data.status == 'accepted' &&
request.resource.data.keys().hasAll(['userId', 'role', 'status']) &&
request.resource.data.keys().hasOnly([
'userId', 'role', 'status',
'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl',
'createdAt', 'updatedAt'
])
) ||
// Organizer inviting a non-blocked rider
// Organizer inviting a non-blocked rider (scheduled or assembling, within capacity)
(
request.auth.uid == rideOrganizer() &&
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
(!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) &&
request.resource.data.userId == participantUid &&
request.resource.data.status == 'invited' &&
request.resource.data.role == 'participant' &&
!areMutuallyBlocked(request.auth.uid, participantUid)
!areMutuallyBlocked(request.auth.uid, participantUid) &&
request.resource.data.keys().hasAll(['userId', 'role', 'status']) &&
request.resource.data.keys().hasOnly([
'userId', 'role', 'status',
'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl',
'invitedBy', 'createdAt', 'updatedAt'
])
) ||
// Rider requesting to join (joinPolicy == requestToJoin)
// Rider requesting to join (joinPolicy == requestToJoin) - SINGLE DOCUMENT WRITE, NO PARENT MUTATION
(
request.auth.uid == participantUid &&
request.auth.uid != rideOrganizer() &&
rideDoc().data.joinPolicy == 'requestToJoin' &&
request.resource.data.userId == request.auth.uid &&
request.resource.data.role == 'participant' &&
request.resource.data.status == 'requested' &&
request.resource.data.role == 'participant' &&
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
rideDoc().data.joinPolicy == 'requestToJoin' &&
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
!areMutuallyBlocked(request.auth.uid, rideOrganizer()) &&
(!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, request.auth.uid)) &&
request.resource.data.keys().hasAll(['userId', 'role', 'status']) &&
request.resource.data.keys().hasOnly([
'userId', 'role', 'status',
'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl',
'createdAt', 'updatedAt'
])
) ||
// Rider joining open ride (joinPolicy == open)
// Rider joining open ride (joinPolicy == open) - Cross-document bound to parent
(
request.auth.uid == participantUid &&
request.auth.uid != rideOrganizer() &&
rideDoc().data.joinPolicy == 'open' &&
request.resource.data.status == 'accepted' &&
request.resource.data.userId == request.auth.uid &&
request.resource.data.role == 'participant' &&
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
request.resource.data.status == 'accepted' &&
rideDoc().data.joinPolicy == 'open' &&
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
(!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) &&
(!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, request.auth.uid)) &&
!areMutuallyBlocked(request.auth.uid, rideOrganizer()) &&
request.auth.uid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1 &&
request.resource.data.keys().hasAll(['userId', 'role', 'status']) &&
request.resource.data.keys().hasOnly([
'userId', 'role', 'status',
'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl',
'createdAt', 'updatedAt'
])
)
);
// State transitions: participant can accept/decline/leave; organizer can approve/decline/remove or transfer
// State transitions: participant can accept/decline/leave; moderator can approve/decline/remove or organizer can transfer
allow update: if signedIn() && (
// Invitee accepting own invitation (invited -> accepted)
// Status updates must strictly constrain changed keys to status and updatedAt
(
request.auth.uid == participantUid &&
resource.data.status == 'invited' &&
request.resource.data.status == 'accepted' &&
request.resource.data.role == resource.data.role
) ||
// Invitee declining own invitation (invited -> declined)
(
request.auth.uid == participantUid &&
resource.data.status == 'invited' &&
request.resource.data.status == 'declined' &&
request.resource.data.role == resource.data.role
) ||
// Participant leaving (accepted -> left)
(
request.auth.uid == participantUid &&
resource.data.status == 'accepted' &&
request.resource.data.status == 'left' &&
request.resource.data.role == resource.data.role
) ||
// Organizer approving request (requested -> accepted)
(
request.auth.uid == rideOrganizer() &&
resource.data.status == 'requested' &&
request.resource.data.status == 'accepted'
) ||
// Organizer declining request (requested -> declined)
(
request.auth.uid == rideOrganizer() &&
resource.data.status == 'requested' &&
request.resource.data.status == 'declined'
) ||
// Organizer removing participant (status -> removed)
(
request.auth.uid == rideOrganizer() &&
request.resource.data.status == 'removed'
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'updatedAt']) && (
// Invitee accepting own invitation (invited -> accepted)
(
request.auth.uid == participantUid &&
resource.data.status == 'invited' &&
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
(!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) &&
request.resource.data.status == 'accepted' &&
request.resource.data.role == resource.data.role &&
participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1
) ||
// Invitee declining own invitation (invited -> declined)
(
request.auth.uid == participantUid &&
resource.data.status == 'invited' &&
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
request.resource.data.status == 'declined' &&
request.resource.data.role == resource.data.role
) ||
// Accepted participant leaving (accepted -> left) - Cross-document bound
(
request.auth.uid == participantUid &&
resource.data.status == 'accepted' &&
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
request.resource.data.status == 'left' &&
request.resource.data.role == resource.data.role &&
!(participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids) &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount - 1
) ||
// Requested rider withdrawing request (requested -> left) - Single document update
(
request.auth.uid == participantUid &&
resource.data.status == 'requested' &&
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
request.resource.data.status == 'left' &&
request.resource.data.role == resource.data.role
) ||
// Authorized moderator approving request (requested -> accepted) - Cross-document bound
(
isAuthorizedModerator() &&
resource.data.status == 'requested' &&
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
(!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) &&
(!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, participantUid)) &&
!areMutuallyBlocked(rideOrganizer(), participantUid) &&
request.resource.data.status == 'accepted' &&
request.resource.data.role == resource.data.role &&
participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1
) ||
// Authorized moderator declining request (requested -> declined) - Single document update
(
isAuthorizedModerator() &&
resource.data.status == 'requested' &&
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
request.resource.data.status == 'declined' &&
request.resource.data.role == resource.data.role
) ||
// Authorized moderator removing participant (status -> removed) - Cross-document bound
(
isAuthorizedModerator() &&
participantUid != rideOrganizer() &&
resource.data.role != 'organizer' &&
resource.data.status == 'accepted' &&
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
request.resource.data.status == 'removed' &&
request.resource.data.role == resource.data.role &&
!(participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids) &&
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount - 1
)
)
) ||
// Organizer role transfer
(
request.auth.uid == rideOrganizer() &&
request.resource.data.role in ['organizer', 'participant']
resource.data.status == 'accepted' &&
request.resource.data.status == resource.data.status &&
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
request.resource.data.role in ['organizer', 'participant'] &&
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['role', 'updatedAt'])
)
);
allow delete: if signedIn() && (
request.auth.uid == participantUid ||
request.auth.uid == rideOrganizer()
);
allow delete: if signedIn() &&
resource.data.role != 'organizer' &&
resource.data.status != 'accepted' &&
(
request.auth.uid == participantUid ||
isAuthorizedModerator()
);
}
// --- LIVE PRESENCE SUBCOLLECTION ---
@ -571,7 +762,7 @@ service cloud.firestore {
}
function rideIsLiveEligible() {
return rideDoc().data.status in ['scheduled', 'assembling', 'underway'];
return 'status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway'];
}
function validCoordinates(data) {

View File

@ -81,6 +81,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
bool _isSaving = false;
String? _errorMessage;
@override
void initState() {
super.initState();
if (widget.clubId != null) {
_joinPolicy = GroupRideJoinPolicy.requestToJoin;
}
}
@override
void dispose() {
_titleController.dispose();

View File

@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/responsive/responsive_layout.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../../services/auth/auth_providers.dart';
import '../../../services/clubs/club_models.dart';
import '../../../services/clubs/club_providers.dart';
import '../../../services/clubs/club_service.dart';
import 'club_membership_controller.dart';
import 'club_membership_presentation.dart';
import 'widgets/club_monogram.dart';
@ -1005,21 +1007,255 @@ class ClubDetailScreen extends ConsumerWidget {
],
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Text(
isActiveMember
? 'Club ride discovery is coming soon.'
: 'Club rides are visible to active members only.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: theme.colorScheme.onSurfaceVariant,
if (!isActiveMember)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Text(
'Club rides are visible to active members only.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
)
else
_buildClubDiscoveryList(context, ref, club.id, colors, theme),
],
),
);
}
Widget _buildClubDiscoveryList(
BuildContext context,
WidgetRef ref,
String clubId,
MotorSignalThemeColors colors,
ThemeData theme,
) {
final ridesAsync = ref.watch(clubDiscoveryRidesProvider(clubId));
return ridesAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
error: (err, _) {
String message = 'Unable to load club rides at this time.';
if (err is ClubOperationException) {
switch (err.code) {
case 'unauthenticated':
message = 'Please sign in to view club rides.';
break;
case 'permission_denied':
message = 'Active club membership is required to view club rides.';
break;
case 'timeout':
case 'network_error':
message = 'Network connection issue. Please check your connection and try again.';
break;
default:
message = 'Unable to load club rides at this time.';
}
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: theme.colorScheme.error,
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => ref.invalidate(clubDiscoveryRidesProvider(clubId)),
icon: const Icon(Icons.refresh, size: 14),
label: const Text('Retry'),
style: OutlinedButton.styleFrom(
foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan.withValues(alpha: 0.5)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
textStyle: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
},
data: (rides) {
if (rides.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Row(
children: [
Icon(Icons.two_wheeler, size: 18, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
'No upcoming club rides scheduled.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
final dateFormat = DateFormat('EEE, MMM d • h:mm a');
return Column(
children: rides.map((ride) {
final startLabel = dateFormat.format(ride.scheduledStartAt.toLocal());
final locationLabel = ride.meetingLocationLabel ?? 'Meeting area shared with participants';
return Padding(
padding: const EdgeInsets.only(top: 10.0),
child: Material(
color: colors.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: () => context.push('/group-rides/${ride.rideId}'),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colors.outlineVariant.withValues(alpha: 0.4),
width: 0.8,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
ride.title,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: colors.cyberCyan.withValues(alpha: 0.3),
width: 0.5,
),
),
child: Text(
ride.joinPolicy == 'open'
? 'OPEN'
: (ride.joinPolicy == 'requestToJoin' ? 'REQUEST TO JOIN' : 'INVITE ONLY'),
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 9,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
Icon(Icons.calendar_today, size: 12, color: colors.cyberCyan),
const SizedBox(width: 5),
Text(
startLabel,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 11.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.location_on_outlined, size: 12, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 5),
Expanded(
child: Text(
locationLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 11.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_buildPill(
icon: Icons.group,
label: '${ride.participantCount}${ride.participantLimit != null ? ' / ${ride.participantLimit}' : ''} riders',
colors: colors,
theme: theme,
),
if (ride.paceClassification != null && ride.paceClassification!.isNotEmpty)
_buildPill(
icon: Icons.speed,
label: ride.paceClassification!,
colors: colors,
theme: theme,
),
if (ride.organizerDisplayName != null || ride.organizerHandle != null)
_buildPill(
icon: Icons.person_pin,
label: ride.organizerDisplayName ?? ride.organizerHandle!,
colors: colors,
theme: theme,
),
],
),
],
),
),
),
),
);
}).toList(),
);
},
);
}
}

View File

@ -607,3 +607,108 @@ class ClubHandle {
ownerUid.hashCode ^
createdAt.hashCode;
}
/// Minimized Club GroupRide discovery model returned by the authenticated discovery endpoint.
/// Represents pre-join browse card data without exposing sensitive participant access lists,
/// exact GPS coordinates, or full route geometry.
@immutable
class ClubRideDiscovery {
final String rideId;
final String clubId;
final String title;
final String? description;
final String? organizerDisplayName;
final String? organizerHandle;
final DateTime scheduledStartAt;
final DateTime? departureAt;
final String joinPolicy;
final int participantCount;
final int? participantLimit;
final String status;
final String? meetingLocationLabel;
final String? routeTitle;
final int? routeDistanceMeters;
final String? paceClassification;
const ClubRideDiscovery({
required this.rideId,
required this.clubId,
required this.title,
this.description,
this.organizerDisplayName,
this.organizerHandle,
required this.scheduledStartAt,
this.departureAt,
this.joinPolicy = 'inviteOnly',
this.participantCount = 1,
this.participantLimit,
this.status = 'scheduled',
this.meetingLocationLabel,
this.routeTitle,
this.routeDistanceMeters,
this.paceClassification,
});
factory ClubRideDiscovery.fromJson(Map<String, dynamic> json) {
return ClubRideDiscovery(
rideId: json['rideId'] as String? ?? '',
clubId: json['clubId'] as String? ?? '',
title: json['title'] as String? ?? 'Untitled Ride',
description: json['description'] as String?,
organizerDisplayName: json['organizerDisplayName'] as String?,
organizerHandle: json['organizerHandle'] as String?,
scheduledStartAt: _parseDateTime(json['scheduledStartAt']) ?? DateTime.now(),
departureAt: _parseDateTime(json['departureAt']),
joinPolicy: json['joinPolicy'] as String? ?? 'inviteOnly',
participantCount: (json['participantCount'] as num?)?.toInt() ?? 1,
participantLimit: (json['participantLimit'] as num?)?.toInt(),
status: json['status'] as String? ?? 'scheduled',
meetingLocationLabel: json['meetingLocationLabel'] as String?,
routeTitle: json['routeTitle'] as String?,
routeDistanceMeters: (json['routeDistanceMeters'] as num?)?.toInt(),
paceClassification: json['paceClassification'] as String?,
);
}
Map<String, dynamic> toJson() => {
'rideId': rideId,
'clubId': clubId,
'title': title,
if (description != null) 'description': description,
if (organizerDisplayName != null) 'organizerDisplayName': organizerDisplayName,
if (organizerHandle != null) 'organizerHandle': organizerHandle,
'scheduledStartAt': scheduledStartAt.toIso8601String(),
if (departureAt != null) 'departureAt': departureAt!.toIso8601String(),
'joinPolicy': joinPolicy,
'participantCount': participantCount,
if (participantLimit != null) 'participantLimit': participantLimit,
'status': status,
if (meetingLocationLabel != null) 'meetingLocationLabel': meetingLocationLabel,
if (routeTitle != null) 'routeTitle': routeTitle,
if (routeDistanceMeters != null) 'routeDistanceMeters': routeDistanceMeters,
if (paceClassification != null) 'paceClassification': paceClassification,
};
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ClubRideDiscovery &&
runtimeType == other.runtimeType &&
rideId == other.rideId &&
clubId == other.clubId &&
title == other.title &&
scheduledStartAt == other.scheduledStartAt &&
joinPolicy == other.joinPolicy &&
participantCount == other.participantCount &&
status == other.status;
@override
int get hashCode =>
rideId.hashCode ^
clubId.hashCode ^
title.hashCode ^
scheduledStartAt.hashCode ^
joinPolicy.hashCode ^
participantCount.hashCode ^
status.hashCode;
}

View File

@ -74,3 +74,10 @@ final publicClubsProvider = FutureProvider<List<Club>>((ref) {
final service = ref.watch(clubServiceProvider);
return service.discoverPublicClubs(limit: 25);
});
/// FutureProvider fetching discoverable upcoming Club GroupRides through the authenticated API.
final clubDiscoveryRidesProvider =
FutureProvider.family<List<ClubRideDiscovery>, String>((ref, clubId) async {
final service = ref.watch(clubServiceProvider);
return service.fetchClubDiscoveryRides(clubId);
});

View File

@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:http/http.dart' as http;
import 'club_models.dart';
import 'club_slug_validator.dart';
@ -1064,4 +1066,107 @@ class ClubService {
.map((snap) =>
snap.docs.map((doc) => ClubMember.fromFirestore(doc)).toList());
}
static const String defaultApiBaseUrl = 'https://motorsignal.app';
/// Fetches discoverable upcoming Club GroupRides through the authenticated server API.
/// Enforces server-side token verification, active club membership, mutual-block filtering,
/// and data minimization.
Future<List<ClubRideDiscovery>> fetchClubDiscoveryRides(
String clubId, {
http.Client? client,
String? baseUrl,
}) async {
final cleanClubId = clubId.trim();
if (cleanClubId.isEmpty) {
throw const ClubOperationException('Club ID cannot be empty.', code: 'invalid_argument');
}
final user = _auth?.currentUser;
if (user == null || user.isAnonymous) {
throw const ClubOperationException(
'A registered account is required to discover Club rides.',
code: 'unauthenticated',
);
}
final idToken = await user.getIdToken();
if (idToken == null || idToken.isEmpty) {
throw const ClubOperationException(
'Unable to retrieve authentication token.',
code: 'unauthenticated',
);
}
final rootUrl = (baseUrl ?? defaultApiBaseUrl).replaceAll(RegExp(r'/+$'), '');
final uri = Uri.parse('$rootUrl/api/clubs/${Uri.encodeComponent(cleanClubId)}/rides');
final httpClient = client ?? http.Client();
final shouldCloseClient = client == null;
try {
final response = await httpClient.get(
uri,
headers: {
'Authorization': 'Bearer $idToken',
'Accept': 'application/json',
},
).timeout(const Duration(seconds: 15));
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const ClubOperationException('Malformed response from server.', code: 'invalid_response');
}
final ridesList = decoded['rides'];
if (ridesList is! List) {
return const [];
}
return ridesList
.whereType<Map<String, dynamic>>()
.map((item) => ClubRideDiscovery.fromJson(item))
.toList();
}
if (response.statusCode == 401) {
throw const ClubOperationException(
'Your session has expired or is invalid. Please sign in again.',
code: 'unauthenticated',
);
}
if (response.statusCode == 403) {
throw const ClubOperationException(
'Active club membership is required to view club rides.',
code: 'permission_denied',
);
}
if (response.statusCode == 404) {
throw const ClubOperationException(
'Club was not found or is no longer active.',
code: 'not_found',
);
}
throw ClubOperationException(
'Unable to load club rides (${response.statusCode}).',
code: 'server_error',
);
} on TimeoutException {
throw const ClubOperationException(
'Network timeout while loading club rides. Please try again.',
code: 'timeout',
);
} on http.ClientException {
throw const ClubOperationException(
'Network connection error. Please check your connection.',
code: 'network_error',
);
} finally {
if (shouldCloseClient) {
httpClient.close();
}
}
}
}

View File

@ -432,7 +432,10 @@ class GroupRideService {
});
}
/// Requests to join a Group Ride with requestToJoin policy.
/// Requests to join a Group Ride (requester UID).
///
/// Writes the participant request document with [ParticipantStatus.requested].
/// The parent ride document is NOT modified. Capacity is strictly enforced upon approval.
Future<void> requestToJoin({
required RiderProfile caller,
required String groupRideId,
@ -441,61 +444,94 @@ class GroupRideService {
if (firestore == null) return;
final rideRef = firestore.collection('group_rides').doc(groupRideId);
final rideSnap = await rideRef.get();
if (!rideSnap.exists || rideSnap.data() == null) {
throw StateError('Group ride not found.');
}
final ride = GroupRide.fromJson(rideSnap.data()!);
if (ride.organizerUid == caller.uid) {
throw ArgumentError('You are the organizer of this ride.');
}
if (ride.isCanceled || ride.isCompleted) {
throw StateError('Cannot request to join a canceled or completed ride.');
}
if (ride.joinPolicy != GroupRideJoinPolicy.requestToJoin) {
throw StateError('This ride does not accept join requests.');
}
if (ride.isFull) {
throw StateError('This ride is currently full.');
}
// Check mutual blocks
final callerBlock = await firestore
.collection('users')
.doc(caller.uid)
.collection('blocks')
.doc(ride.organizerUid)
.get();
if (callerBlock.exists) throw StateError('Unable to request to join.');
final orgBlock = await firestore
.collection('users')
.doc(ride.organizerUid)
.collection('blocks')
.doc(caller.uid)
.get();
if (orgBlock.exists) throw StateError('Unable to request to join.');
final participantRef = rideRef.collection('participants').doc(caller.uid);
final now = DateTime.now();
final req = GroupRideParticipant(
userId: caller.uid,
role: GroupRideRole.participant,
status: ParticipantStatus.requested,
riderHandle: caller.handle,
riderDisplayName: caller.displayName,
riderAvatarPreset: caller.avatarPreset,
riderAvatarUrl: caller.avatarUrl,
createdAt: now,
updatedAt: now,
);
await firestore.runTransaction((transaction) async {
final rideSnap = await transaction.get(rideRef);
if (!rideSnap.exists || rideSnap.data() == null) {
throw StateError('Group ride not found.');
}
await participantRef.set(req.toJson());
final ride = GroupRide.fromJson(rideSnap.data()!);
if (ride.organizerUid == caller.uid) {
throw ArgumentError('You are the organizer of this ride.');
}
if (ride.status != GroupRideStatus.scheduled &&
ride.status != GroupRideStatus.assembling) {
throw StateError('Cannot request to join a ride that is underway, completed, or canceled.');
}
if (ride.joinPolicy != GroupRideJoinPolicy.requestToJoin) {
throw StateError('This ride does not accept join requests.');
}
if (ride.accessUids.contains(caller.uid)) {
throw StateError('You are already a participant in this ride.');
}
// Check official Club affiliation membership requirement
if (ride.clubId != null) {
final memberSnap = await transaction.get(
firestore
.collection('clubs')
.doc(ride.clubId!)
.collection('members')
.doc(caller.uid),
);
if (!memberSnap.exists || memberSnap.data()?['status'] != 'active') {
throw StateError('Active club membership is required to join this club ride.');
}
}
// Check mutual blocks
final callerBlock = await firestore
.collection('users')
.doc(caller.uid)
.collection('blocks')
.doc(ride.organizerUid)
.get();
if (callerBlock.exists) throw StateError('Unable to request to join.');
final orgBlock = await firestore
.collection('users')
.doc(ride.organizerUid)
.collection('blocks')
.doc(caller.uid)
.get();
if (orgBlock.exists) throw StateError('Unable to request to join.');
final pDoc = await transaction.get(participantRef);
if (pDoc.exists && pDoc.data() != null) {
final existing = GroupRideParticipant.fromJson(pDoc.data()!);
if (existing.status == ParticipantStatus.requested) {
throw StateError('You already have a pending join request.');
}
if (existing.status == ParticipantStatus.accepted) {
throw StateError('You are already an accepted participant.');
}
if (existing.status == ParticipantStatus.declined ||
existing.status == ParticipantStatus.left ||
existing.status == ParticipantStatus.removed) {
throw StateError('Cannot request to join after ride departure or previous removal.');
}
}
final now = DateTime.now();
final req = GroupRideParticipant(
userId: caller.uid,
role: GroupRideRole.participant,
status: ParticipantStatus.requested,
riderHandle: caller.handle,
riderDisplayName: caller.displayName,
riderAvatarPreset: caller.avatarPreset,
riderAvatarUrl: caller.avatarUrl,
createdAt: now,
updatedAt: now,
);
transaction.set(participantRef, req.toJson());
});
}
/// Responds to a join request (organizer only).
/// Responds to a join request (organizer or authorized club leadership).
Future<void> respondToJoinRequest({
required String callerUid,
required String groupRideId,
@ -514,11 +550,57 @@ class GroupRideService {
throw StateError('Group ride not found.');
}
final ride = GroupRide.fromJson(rideDoc.data()!);
if (ride.organizerUid != callerUid) {
throw StateError('Only the organizer can approve requests.');
// Check leadership authorization: organizer or active club admin/owner for club rides
bool isAuthorized = ride.organizerUid == callerUid;
if (!isAuthorized && ride.clubId != null) {
final memberDoc = await transaction.get(
firestore
.collection('clubs')
.doc(ride.clubId!)
.collection('members')
.doc(callerUid),
);
if (memberDoc.exists &&
memberDoc.data()?['status'] == 'active' &&
(memberDoc.data()?['role'] == 'owner' ||
memberDoc.data()?['role'] == 'admin')) {
isAuthorized = true;
}
}
if (ride.isCanceled || ride.isCompleted) {
throw StateError('Ride is no longer active.');
if (!isAuthorized) {
throw StateError('Only the organizer or club leadership can moderate requests.');
}
// Lifecycle status checks
if (approve) {
if (ride.status != GroupRideStatus.scheduled &&
ride.status != GroupRideStatus.assembling) {
throw StateError('Cannot approve requests for a ride that is underway, completed, or canceled.');
}
} else {
if (ride.isCanceled || ride.isCompleted) {
throw StateError('Ride is no longer active.');
}
}
// Mutual block check between organizer and requester (approval only; decline must always be permitted)
if (approve) {
final orgBlock = await firestore
.collection('users')
.doc(ride.organizerUid)
.collection('blocks')
.doc(requesterUid)
.get();
if (orgBlock.exists) throw StateError('Cannot approve mutually blocked requester.');
final reqBlock = await firestore
.collection('users')
.doc(requesterUid)
.collection('blocks')
.doc(ride.organizerUid)
.get();
if (reqBlock.exists) throw StateError('Cannot approve mutually blocked requester.');
}
final pDoc = await transaction.get(participantRef);
@ -530,6 +612,20 @@ class GroupRideService {
throw StateError('Participant is not in requested status.');
}
// If official club ride, verify requester remains an active club member
if (approve && ride.clubId != null) {
final reqMemberDoc = await transaction.get(
firestore
.collection('clubs')
.doc(ride.clubId!)
.collection('members')
.doc(requesterUid),
);
if (!reqMemberDoc.exists || reqMemberDoc.data()?['status'] != 'active') {
throw StateError('Requester is no longer an active club member.');
}
}
final now = DateTime.now();
if (approve) {
if (ride.isFull) {
@ -575,8 +671,9 @@ class GroupRideService {
if (ride.organizerUid == caller.uid) {
throw ArgumentError('You are already the organizer.');
}
if (ride.isCanceled || ride.isCompleted) {
throw StateError('Cannot join a canceled or completed ride.');
if (ride.status != GroupRideStatus.scheduled &&
ride.status != GroupRideStatus.assembling) {
throw StateError('Cannot join a ride that is underway, completed, or canceled.');
}
if (ride.joinPolicy != GroupRideJoinPolicy.open) {
throw StateError('This ride is not open to direct join.');
@ -585,6 +682,20 @@ class GroupRideService {
throw StateError('This ride is full.');
}
// Check official Club affiliation membership requirement
if (ride.clubId != null) {
final memberSnap = await transaction.get(
firestore
.collection('clubs')
.doc(ride.clubId!)
.collection('members')
.doc(caller.uid),
);
if (!memberSnap.exists || memberSnap.data()?['status'] != 'active') {
throw StateError('Active club membership is required to join this club ride.');
}
}
final pDoc = await transaction.get(participantRef);
if (pDoc.exists && pDoc.data() != null) {
final existing = GroupRideParticipant.fromJson(pDoc.data()!);
@ -660,12 +771,12 @@ class GroupRideService {
'updatedAt': now.toIso8601String(),
});
if (ride.organizerUid != callerUid) {
if (ride.organizerUid != callerUid && wasAccepted) {
final rideUpdates = <String, dynamic>{
'accessUids': FieldValue.arrayRemove([callerUid]),
'updatedAt': now.toIso8601String(),
};
if (wasAccepted && ride.participantCount > 0) {
if (ride.participantCount > 0) {
rideUpdates['participantCount'] = FieldValue.increment(-1);
}
transaction.update(rideRef, rideUpdates);
@ -673,7 +784,7 @@ class GroupRideService {
});
}
/// Removes a participant from a Group Ride (organizer only).
/// Removes a participant from a Group Ride (organizer or authorized club leadership).
Future<void> removeParticipant({
required String callerUid,
required String groupRideId,
@ -693,8 +804,25 @@ class GroupRideService {
final rideDoc = await transaction.get(rideRef);
if (!rideDoc.exists || rideDoc.data() == null) return;
final ride = GroupRide.fromJson(rideDoc.data()!);
if (ride.organizerUid != callerUid) {
throw StateError('Only the organizer can remove participants.');
bool isAuthorized = ride.organizerUid == callerUid;
if (!isAuthorized && ride.clubId != null) {
final memberDoc = await transaction.get(
firestore
.collection('clubs')
.doc(ride.clubId!)
.collection('members')
.doc(callerUid),
);
if (memberDoc.exists &&
memberDoc.data()?['status'] == 'active' &&
(memberDoc.data()?['role'] == 'owner' ||
memberDoc.data()?['role'] == 'admin')) {
isAuthorized = true;
}
}
if (!isAuthorized) {
throw StateError('Only the organizer or club leadership can remove participants.');
}
final pDoc = await transaction.get(participantRef);
@ -709,14 +837,16 @@ class GroupRideService {
'updatedAt': now.toIso8601String(),
});
final rideUpdates = <String, dynamic>{
'accessUids': FieldValue.arrayRemove([targetUid]),
'updatedAt': now.toIso8601String(),
};
if (wasAccepted && ride.participantCount > 0) {
rideUpdates['participantCount'] = FieldValue.increment(-1);
if (wasAccepted) {
final rideUpdates = <String, dynamic>{
'accessUids': FieldValue.arrayRemove([targetUid]),
'updatedAt': now.toIso8601String(),
};
if (ride.participantCount > 0) {
rideUpdates['participantCount'] = FieldValue.increment(-1);
}
transaction.update(rideRef, rideUpdates);
}
transaction.update(rideRef, rideUpdates);
});
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/features/social/clubs/club_detail_screen.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
@ -44,6 +45,15 @@ class MockClubServiceForDetail extends ClubService {
lastAction = 'leaveClub';
lastClubId = clubId;
}
@override
Future<List<ClubRideDiscovery>> fetchClubDiscoveryRides(
String clubId, {
http.Client? client,
String? baseUrl,
}) async {
return [];
}
}
void main() {
@ -523,7 +533,7 @@ void main() {
expect(find.text('Plan Club Ride'), findsNothing);
});
testWidgets('active member sees discovery coming soon and admin sees Plan Club Ride button', (tester) async {
testWidgets('active member sees discovery list and admin sees Plan Club Ride button', (tester) async {
final activeAdmin = ClubMember(
uid: 'rider_1',
clubId: 'club_open',
@ -552,11 +562,11 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('Club ride discovery is coming soon.'), findsOneWidget);
expect(find.text('No upcoming club rides scheduled.'), findsOneWidget);
expect(find.text('Plan Club Ride'), findsOneWidget);
});
testWidgets('active ordinary member sees discovery coming soon and no Plan button', (tester) async {
testWidgets('active ordinary member sees discovery list and no Plan button', (tester) async {
final activeMember = ClubMember(
uid: 'rider_2',
clubId: 'club_open',
@ -585,7 +595,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('Club ride discovery is coming soon.'), findsOneWidget);
expect(find.text('No upcoming club rides scheduled.'), findsOneWidget);
expect(find.text('Plan Club Ride'), findsNothing);
});
});

View File

@ -0,0 +1,485 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/features/group_rides/create_group_ride_screen.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/clubs/club_models.dart';
import 'package:motorcycle_dash/services/clubs/club_providers.dart';
import 'package:motorcycle_dash/services/clubs/club_service.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
import 'package:motorcycle_dash/services/social/social_providers.dart';
void main() {
group('ClubRideDiscovery Model Tests', () {
test('parses full JSON payload correctly', () {
final json = {
'rideId': 'ride_123',
'clubId': 'club_alpha',
'title': 'Mountain Pass Rally',
'description': 'A weekend club ride through the gap',
'organizerDisplayName': 'Road Captain Bob',
'organizerHandle': '@bob_captain',
'scheduledStartAt': '2026-09-25T09:00:00.000Z',
'departureAt': '2026-09-25T09:30:00.000Z',
'joinPolicy': 'requestToJoin',
'participantCount': 5,
'participantLimit': 15,
'status': 'scheduled',
'meetingLocationLabel': 'North Ridge Meetup Area',
'routeTitle': 'Scenic Mountain Loop',
'routeDistanceMeters': 45000,
'paceClassification': 'Spirited',
};
final ride = ClubRideDiscovery.fromJson(json);
expect(ride.rideId, equals('ride_123'));
expect(ride.clubId, equals('club_alpha'));
expect(ride.title, equals('Mountain Pass Rally'));
expect(ride.description, equals('A weekend club ride through the gap'));
expect(ride.organizerDisplayName, equals('Road Captain Bob'));
expect(ride.organizerHandle, equals('@bob_captain'));
expect(ride.scheduledStartAt.toUtc(), equals(DateTime.utc(2026, 9, 25, 9, 0)));
expect(ride.departureAt?.toUtc(), equals(DateTime.utc(2026, 9, 25, 9, 30)));
expect(ride.joinPolicy, equals('requestToJoin'));
expect(ride.participantCount, equals(5));
expect(ride.participantLimit, equals(15));
expect(ride.status, equals('scheduled'));
expect(ride.meetingLocationLabel, equals('North Ridge Meetup Area'));
expect(ride.routeTitle, equals('Scenic Mountain Loop'));
expect(ride.routeDistanceMeters, equals(45000));
expect(ride.paceClassification, equals('Spirited'));
});
test('parses minimal JSON payload with defaults and nulls', () {
final json = {
'rideId': 'ride_456',
'clubId': 'club_beta',
'title': 'Casual Cruise',
'scheduledStartAt': '2026-09-26T10:00:00.000Z',
};
final ride = ClubRideDiscovery.fromJson(json);
expect(ride.rideId, equals('ride_456'));
expect(ride.clubId, equals('club_beta'));
expect(ride.title, equals('Casual Cruise'));
expect(ride.description, isNull);
expect(ride.organizerDisplayName, isNull);
expect(ride.organizerHandle, isNull);
expect(ride.departureAt, isNull);
expect(ride.joinPolicy, equals('inviteOnly'));
expect(ride.participantCount, equals(1));
expect(ride.participantLimit, isNull);
expect(ride.status, equals('scheduled'));
expect(ride.meetingLocationLabel, isNull);
});
test('serializes to JSON correctly', () {
final ride = ClubRideDiscovery(
rideId: 'ride_789',
clubId: 'club_gamma',
title: 'Morning Blast',
scheduledStartAt: DateTime.utc(2026, 9, 27, 8, 0),
joinPolicy: 'open',
participantCount: 2,
meetingLocationLabel: 'Main Street Plaza',
);
final json = ride.toJson();
expect(json['rideId'], equals('ride_789'));
expect(json['clubId'], equals('club_gamma'));
expect(json['title'], equals('Morning Blast'));
expect(json['joinPolicy'], equals('open'));
expect(json['participantCount'], equals(2));
expect(json['meetingLocationLabel'], equals('Main Street Plaza'));
expect(json.containsKey('description'), isFalse);
expect(json.containsKey('accessUids'), isFalse);
});
test('equality and hashCode support', () {
final rideA = ClubRideDiscovery(
rideId: 'ride_1',
clubId: 'club_1',
title: 'Test Ride',
scheduledStartAt: DateTime.utc(2026, 9, 25, 9, 0),
);
final rideB = ClubRideDiscovery(
rideId: 'ride_1',
clubId: 'club_1',
title: 'Test Ride',
scheduledStartAt: DateTime.utc(2026, 9, 25, 9, 0),
);
expect(rideA, equals(rideB));
expect(rideA.hashCode, equals(rideB.hashCode));
});
});
group('ClubService.fetchClubDiscoveryRides HTTP & Error Contracts', () {
test('rejects empty clubId before network call', () async {
final service = ClubService();
expect(
() => service.fetchClubDiscoveryRides(' '),
throwsA(isA<ClubOperationException>().having(
(e) => e.code,
'code',
equals('invalid_argument'),
)),
);
});
test('parses successful 200 discovery response', () async {
final mockClient = MockClient((request) async {
expect(request.url.path, equals('/api/clubs/club_alpha/rides'));
expect(request.headers['Authorization'], equals('Bearer mock_id_token'));
return http.Response(
jsonEncode({
'clubId': 'club_alpha',
'rides': [
{
'rideId': 'ride_1',
'clubId': 'club_alpha',
'title': 'Foothills Run',
'scheduledStartAt': '2026-09-25T09:00:00.000Z',
'status': 'scheduled',
'joinPolicy': 'requestToJoin',
'participantCount': 4,
},
{
'rideId': 'ride_2',
'clubId': 'club_alpha',
'title': 'Valley Loop',
'scheduledStartAt': '2026-09-26T10:00:00.000Z',
'status': 'assembling',
'joinPolicy': 'open',
'participantCount': 7,
},
],
}),
200,
headers: {'content-type': 'application/json'},
);
});
final service = _TestableClubService();
final rides = await service.fetchClubDiscoveryRides(
'club_alpha',
client: mockClient,
baseUrl: 'https://test.motorsignal.app',
);
expect(rides.length, equals(2));
expect(rides[0].rideId, equals('ride_1'));
expect(rides[0].title, equals('Foothills Run'));
expect(rides[1].rideId, equals('ride_2'));
expect(rides[1].title, equals('Valley Loop'));
});
test('handles 200 response with empty rides list', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({'clubId': 'club_empty', 'rides': []}),
200,
headers: {'content-type': 'application/json'},
);
});
final service = _TestableClubService();
final rides = await service.fetchClubDiscoveryRides(
'club_empty',
client: mockClient,
baseUrl: 'https://test.motorsignal.app',
);
expect(rides, isEmpty);
});
test('maps 401 status to unauthenticated ClubOperationException', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({'error': 'unauthorized', 'detail': 'Expired token'}),
401,
headers: {'content-type': 'application/json'},
);
});
final service = _TestableClubService();
expect(
() => service.fetchClubDiscoveryRides('club_alpha', client: mockClient),
throwsA(isA<ClubOperationException>().having(
(e) => e.code,
'code',
equals('unauthenticated'),
)),
);
});
test('maps 403 status to permission_denied ClubOperationException', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({'error': 'forbidden', 'detail': 'Active membership required'}),
403,
headers: {'content-type': 'application/json'},
);
});
final service = _TestableClubService();
expect(
() => service.fetchClubDiscoveryRides('club_alpha', client: mockClient),
throwsA(isA<ClubOperationException>().having(
(e) => e.code,
'code',
equals('permission_denied'),
)),
);
});
test('maps 404 status to not_found ClubOperationException', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({'error': 'not_found', 'detail': 'Club not found'}),
404,
headers: {'content-type': 'application/json'},
);
});
final service = _TestableClubService();
expect(
() => service.fetchClubDiscoveryRides('club_missing', client: mockClient),
throwsA(isA<ClubOperationException>().having(
(e) => e.code,
'code',
equals('not_found'),
)),
);
});
test('maps network errors cleanly without exposing raw socket exceptions', () async {
final mockClient = MockClient((request) async {
throw http.ClientException('Failed to connect to host');
});
final service = _TestableClubService();
expect(
() => service.fetchClubDiscoveryRides('club_alpha', client: mockClient),
throwsA(isA<ClubOperationException>().having(
(e) => e.code,
'code',
equals('network_error'),
)),
);
});
test('blocked rides excluded by server never appear in client output', () async {
// Server returned only ride_unblocked; blocked organizer rides were excluded server-side
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'clubId': 'club_alpha',
'rides': [
{
'rideId': 'ride_unblocked',
'clubId': 'club_alpha',
'title': 'Unblocked Ride',
'scheduledStartAt': '2026-09-25T09:00:00.000Z',
},
],
}),
200,
headers: {'content-type': 'application/json'},
);
});
final service = _TestableClubService();
final rides = await service.fetchClubDiscoveryRides('club_alpha', client: mockClient);
expect(rides.length, equals(1));
expect(rides.any((r) => r.rideId == 'ride_blocked_by_user'), isFalse);
expect(rides.any((r) => r.rideId == 'ride_blocked_by_organizer'), isFalse);
});
});
group('CreateGroupRideScreen Default Join Policy Contracts', () {
testWidgets('Club-affiliated ride defaults to requestToJoin', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
const testUser = AuthUser(uid: 'rider_1', isAnonymous: false);
final profile = RiderProfile(
uid: 'rider_1',
handle: 'rider1',
displayName: 'Rider One',
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final testClub = Club(
id: 'club_alpha',
name: 'Alpha Club',
slug: 'alpha-club',
ownerUid: 'rider_1',
memberCount: 1,
visibility: ClubVisibility.public,
admissionPolicy: ClubAdmissionPolicy.open,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final adminMember = ClubMember(
clubId: 'club_alpha',
uid: 'rider_1',
role: ClubRole.owner,
status: ClubMemberStatus.active,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(testUser),
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
clubDetailProvider('club_alpha').overrideWith((ref) => Stream.value(testClub)),
currentMemberProvider('club_alpha').overrideWith((ref) => Stream.value(adminMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const CreateGroupRideScreen(clubId: 'club_alpha'),
),
),
);
await tester.pumpAndSettle();
final dropdownFinder = find.byType(DropdownButtonFormField<GroupRideJoinPolicy>);
expect(dropdownFinder, findsOneWidget);
final dropdown = tester.widget<DropdownButtonFormField<GroupRideJoinPolicy>>(dropdownFinder);
expect(dropdown.initialValue, equals(GroupRideJoinPolicy.requestToJoin));
});
testWidgets('Personal ride defaults to inviteOnly (existing behavior preserved)', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
const testUser = AuthUser(uid: 'rider_1', isAnonymous: false);
final profile = RiderProfile(
uid: 'rider_1',
handle: 'rider1',
displayName: 'Rider One',
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(testUser),
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const CreateGroupRideScreen(clubId: null),
),
),
);
await tester.pumpAndSettle();
final dropdownFinder = find.byType(DropdownButtonFormField<GroupRideJoinPolicy>);
expect(dropdownFinder, findsOneWidget);
final dropdown = tester.widget<DropdownButtonFormField<GroupRideJoinPolicy>>(dropdownFinder);
expect(dropdown.initialValue, equals(GroupRideJoinPolicy.inviteOnly));
});
});
}
/// Helper subclass providing testable auth token resolution for unit tests
class _TestableClubService extends ClubService {
@override
Future<List<ClubRideDiscovery>> fetchClubDiscoveryRides(
String clubId, {
http.Client? client,
String? baseUrl,
}) async {
final cleanClubId = clubId.trim();
if (cleanClubId.isEmpty) {
throw const ClubOperationException('Club ID cannot be empty.', code: 'invalid_argument');
}
final rootUrl = (baseUrl ?? ClubService.defaultApiBaseUrl).replaceAll(RegExp(r'/+$'), '');
final uri = Uri.parse('$rootUrl/api/clubs/${Uri.encodeComponent(cleanClubId)}/rides');
final httpClient = client ?? http.Client();
final shouldCloseClient = client == null;
try {
final response = await httpClient.get(
uri,
headers: {
'Authorization': 'Bearer mock_id_token',
'Accept': 'application/json',
},
).timeout(const Duration(seconds: 15));
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const ClubOperationException('Malformed response from server.', code: 'invalid_response');
}
final ridesList = decoded['rides'];
if (ridesList is! List) {
return const [];
}
return ridesList
.whereType<Map<String, dynamic>>()
.map((item) => ClubRideDiscovery.fromJson(item))
.toList();
}
if (response.statusCode == 401) {
throw const ClubOperationException(
'Your session has expired or is invalid. Please sign in again.',
code: 'unauthenticated',
);
}
if (response.statusCode == 403) {
throw const ClubOperationException(
'Active club membership is required to view club rides.',
code: 'permission_denied',
);
}
if (response.statusCode == 404) {
throw const ClubOperationException(
'Club was not found or is no longer active.',
code: 'not_found',
);
}
throw ClubOperationException(
'Unable to load club rides (${response.statusCode}).',
code: 'server_error',
);
} on http.ClientException {
throw const ClubOperationException(
'Network connection error. Please check your connection.',
code: 'network_error',
);
} finally {
if (shouldCloseClient) {
httpClient.close();
}
}
}
}

View File

@ -0,0 +1,168 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/features/social/clubs/club_detail_screen.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/clubs/club_models.dart';
import 'package:motorcycle_dash/services/clubs/club_providers.dart';
import 'package:motorcycle_dash/services/clubs/club_service.dart';
void main() {
group('ClubDetailScreen Discovery UI State Tests', () {
const testUser = AuthUser(uid: 'rider_alice', isAnonymous: false);
final testClub = Club(
id: 'club_alpha',
name: 'Alpha Riders',
slug: 'alpha-riders',
description: 'The premier Alpha motorcycle community',
ownerUid: 'owner_bob',
memberCount: 15,
visibility: ClubVisibility.public,
admissionPolicy: ClubAdmissionPolicy.open,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final activeMember = ClubMember(
clubId: 'club_alpha',
uid: 'rider_alice',
role: ClubRole.member,
status: ClubMemberStatus.active,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
Widget buildTestWidget({
bool overrideMember = false,
ClubMember? memberOverride,
required AsyncValue<List<ClubRideDiscovery>> discoveryOverride,
}) {
return ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(testUser),
clubDetailProvider('club_alpha').overrideWith((ref) => Stream.value(testClub)),
currentMemberProvider('club_alpha').overrideWith(
(ref) => Stream.value(overrideMember ? memberOverride : activeMember),
),
clubDiscoveryRidesProvider('club_alpha').overrideWith((ref) => discoveryOverride.when(
data: (data) => Future.value(data),
error: (e, s) => Future.error(e, s),
loading: () => Completer<List<ClubRideDiscovery>>().future,
)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_alpha'),
),
);
}
testWidgets('Non-member sees active members only notice', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
buildTestWidget(
overrideMember: true,
memberOverride: null,
discoveryOverride: const AsyncValue.data([]),
),
);
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('Club rides are visible to active members only.'), findsOneWidget);
});
testWidgets('Active member sees loading progress indicator during discovery fetch', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
buildTestWidget(
discoveryOverride: const AsyncValue.loading(),
),
);
await tester.pump();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('Active member sees empty state when 0 upcoming rides found', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
buildTestWidget(
discoveryOverride: const AsyncValue.data([]),
),
);
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('No upcoming club rides scheduled.'), findsOneWidget);
});
testWidgets('Active member sees populated discovery ride cards', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final testRides = [
ClubRideDiscovery(
rideId: 'ride_101',
clubId: 'club_alpha',
title: 'Sunday Morning Twisties',
scheduledStartAt: DateTime(2026, 9, 27, 9, 0),
departureAt: DateTime(2026, 9, 27, 9, 30),
joinPolicy: 'requestToJoin',
participantCount: 6,
participantLimit: 12,
meetingLocationLabel: 'Lookout Point Parking',
paceClassification: 'Moderate',
organizerDisplayName: 'Captain Dan',
),
];
await tester.pumpWidget(
buildTestWidget(
discoveryOverride: AsyncValue.data(testRides),
),
);
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('Sunday Morning Twisties'), findsOneWidget);
expect(find.text('REQUEST TO JOIN'), findsOneWidget);
expect(find.text('Lookout Point Parking'), findsOneWidget);
expect(find.text('6 / 12 riders'), findsOneWidget);
expect(find.text('Moderate'), findsOneWidget);
expect(find.text('Captain Dan'), findsOneWidget);
});
testWidgets('Active member sees error state with retry button on failure', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
buildTestWidget(
discoveryOverride: const AsyncValue.error(
ClubOperationException('Network error', code: 'network_error'),
StackTrace.empty,
),
),
);
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('Network connection issue. Please check your connection and try again.'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Retry'), findsOneWidget);
});
});
}

View File

@ -1052,6 +1052,638 @@ void main() {
);
});
});
group('GroupRide Join Workflow & Participant Moderation Tests', () {
late FakeFirebaseFirestore fakeFirestore;
late GroupRideService service;
const meetup = GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Atlanta Base',
);
final organizer = RiderProfile(
uid: 'user_org',
handle: 'alice_org',
displayName: 'Alice Org',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
final requester = RiderProfile(
uid: 'user_req',
handle: 'charlie_rider',
displayName: 'Charlie Rider',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
setUp(() {
fakeFirestore = FakeFirebaseFirestore();
service = GroupRideService(firestore: fakeFirestore);
});
Future<void> seedRide({
required String rideId,
required String organizerUid,
String? clubId,
GroupRideStatus status = GroupRideStatus.scheduled,
GroupRideJoinPolicy joinPolicy = GroupRideJoinPolicy.requestToJoin,
int? participantLimit,
int participantCount = 1,
List<String> accessUids = const ['user_org'],
}) async {
final now = DateTime.now();
await fakeFirestore.collection('group_rides').doc(rideId).set({
'id': rideId,
'organizerUid': organizerUid,
'title': 'Saturday Morning Run',
'status': status.name,
'visibility': 'public',
'joinPolicy': joinPolicy.name,
'participantLimit': ?participantLimit,
'participantCount': participantCount,
'scheduledStartAt': now.add(const Duration(hours: 2)).toIso8601String(),
'departureAt': now.add(const Duration(hours: 2, minutes: 30)).toIso8601String(),
'meetingPoint': meetup.toJson(),
'accessUids': accessUids,
'clubId': ?clubId,
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore
.collection('group_rides')
.doc(rideId)
.collection('participants')
.doc(organizerUid)
.set({
'userId': organizerUid,
'role': 'organizer',
'status': 'accepted',
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
}
Future<void> seedClubMember({
required String clubId,
required String uid,
required String role,
String status = 'active',
}) async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc(clubId).set({
'id': clubId,
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_org',
'visibility': 'public',
'admissionPolicy': 'open',
'memberCount': 2,
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore
.collection('clubs')
.doc(clubId)
.collection('members')
.doc(uid)
.set({
'clubId': clubId,
'uid': uid,
'role': role,
'status': status,
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
}
test('requestToJoin creates participant doc without mutating parent ride', () async {
await seedRide(rideId: 'ride_1', organizerUid: organizer.uid);
await service.requestToJoin(caller: requester, groupRideId: 'ride_1');
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_1').get();
final ride = GroupRide.fromJson(rideSnap.data()!);
expect(ride.accessUids, isNot(contains(requester.uid)));
expect(ride.participantCount, equals(1));
expect(rideSnap.data()!.containsKey('pendingUids'), isFalse);
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_1')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.exists, isTrue);
expect(partSnap.data()!['status'], equals('requested'));
});
test('requestToJoin succeeds even when ride is currently at capacity (enforced at approval)', () async {
await seedRide(
rideId: 'ride_full',
organizerUid: organizer.uid,
participantLimit: 2,
participantCount: 2,
accessUids: [organizer.uid, 'user_other'],
);
await service.requestToJoin(caller: requester, groupRideId: 'ride_full');
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_full')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.exists, isTrue);
expect(partSnap.data()!['status'], equals('requested'));
});
test('requestToJoin rejects when ride is underway, completed, or canceled', () async {
for (final s in [
GroupRideStatus.underway,
GroupRideStatus.completed,
GroupRideStatus.canceled,
]) {
await seedRide(
rideId: 'ride_${s.name}',
organizerUid: organizer.uid,
status: s,
);
expect(
() => service.requestToJoin(caller: requester, groupRideId: 'ride_${s.name}'),
throwsA(isA<StateError>()),
);
}
});
test('requestToJoin rejects when caller is mutually blocked with organizer', () async {
await seedRide(rideId: 'ride_blocked', organizerUid: organizer.uid);
await fakeFirestore
.collection('users')
.doc(organizer.uid)
.collection('blocks')
.doc(requester.uid)
.set({'blockedUid': requester.uid});
expect(
() => service.requestToJoin(caller: requester, groupRideId: 'ride_blocked'),
throwsA(isA<StateError>()),
);
});
test('requestToJoin on club ride requires active club membership', () async {
await seedRide(
rideId: 'ride_club',
organizerUid: organizer.uid,
clubId: 'club_1',
);
// Non-member fails
expect(
() => service.requestToJoin(caller: requester, groupRideId: 'ride_club'),
throwsA(isA<StateError>()),
);
// Active member succeeds
await seedClubMember(clubId: 'club_1', uid: requester.uid, role: 'member', status: 'active');
await service.requestToJoin(caller: requester, groupRideId: 'ride_club');
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_club')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.exists, isTrue);
expect(partSnap.data()!['status'], equals('requested'));
});
test('respondToJoinRequest approval moves participant to accepted, adds accessUid, and increments count', () async {
await seedRide(
rideId: 'ride_approve',
organizerUid: organizer.uid,
);
await fakeFirestore
.collection('group_rides')
.doc('ride_approve')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_approve',
requesterUid: requester.uid,
approve: true,
);
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_approve').get();
final ride = GroupRide.fromJson(rideSnap.data()!);
expect(ride.accessUids, contains(requester.uid));
expect(ride.participantCount, equals(2));
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_approve')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.data()!['status'], equals('accepted'));
});
test('respondToJoinRequest decline marks participant declined with zero parent ride mutations', () async {
await seedRide(
rideId: 'ride_decline',
organizerUid: organizer.uid,
);
await fakeFirestore
.collection('group_rides')
.doc('ride_decline')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_decline',
requesterUid: requester.uid,
approve: false,
);
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_decline').get();
final ride = GroupRide.fromJson(rideSnap.data()!);
expect(ride.accessUids, isNot(contains(requester.uid)));
expect(ride.participantCount, equals(1));
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_decline')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.data()!['status'], equals('declined'));
});
test('active club admin can approve requests on official club ride', () async {
await seedClubMember(clubId: 'club_mod', uid: 'admin_user', role: 'admin');
await seedClubMember(clubId: 'club_mod', uid: requester.uid, role: 'member');
await seedRide(
rideId: 'ride_mod',
organizerUid: organizer.uid,
clubId: 'club_mod',
);
await fakeFirestore
.collection('group_rides')
.doc('ride_mod')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.respondToJoinRequest(
callerUid: 'admin_user',
groupRideId: 'ride_mod',
requesterUid: requester.uid,
approve: true,
);
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_mod').get();
expect(rideSnap.data()!['accessUids'], contains(requester.uid));
});
test('non-admin club member cannot approve requests', () async {
await seedClubMember(clubId: 'club_mod2', uid: 'normal_member', role: 'member');
await seedClubMember(clubId: 'club_mod2', uid: requester.uid, role: 'member');
await seedRide(
rideId: 'ride_mod2',
organizerUid: organizer.uid,
clubId: 'club_mod2',
);
await fakeFirestore
.collection('group_rides')
.doc('ride_mod2')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
expect(
() => service.respondToJoinRequest(
callerUid: 'normal_member',
groupRideId: 'ride_mod2',
requesterUid: requester.uid,
approve: true,
),
throwsA(isA<StateError>()),
);
});
test('approval rejected when ride is underway or at capacity', () async {
// Underway
await seedRide(
rideId: 'ride_underway_app',
organizerUid: organizer.uid,
status: GroupRideStatus.underway,
);
await fakeFirestore
.collection('group_rides')
.doc('ride_underway_app')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
expect(
() => service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_underway_app',
requesterUid: requester.uid,
approve: true,
),
throwsA(isA<StateError>()),
);
// At capacity
await seedRide(
rideId: 'ride_cap_app',
organizerUid: organizer.uid,
participantLimit: 2,
participantCount: 2,
accessUids: [organizer.uid, 'user_other'],
);
await fakeFirestore
.collection('group_rides')
.doc('ride_cap_app')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
expect(
() => service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_cap_app',
requesterUid: requester.uid,
approve: true,
),
throwsA(isA<StateError>()),
);
});
test('leaveRide cancels pending request without mutating parent ride', () async {
await seedRide(
rideId: 'ride_leave_pending',
organizerUid: organizer.uid,
);
await fakeFirestore
.collection('group_rides')
.doc('ride_leave_pending')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.leaveRide(callerUid: requester.uid, groupRideId: 'ride_leave_pending');
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_leave_pending').get();
expect(rideSnap.data()!['accessUids'], isNot(contains(requester.uid)));
expect(rideSnap.data()!['participantCount'], equals(1));
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_leave_pending')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.data()!['status'], equals('left'));
});
test('leaveRide removes accepted rider from accessUids and decrements count', () async {
await seedRide(
rideId: 'ride_leave_accepted',
organizerUid: organizer.uid,
participantCount: 2,
accessUids: [organizer.uid, requester.uid],
);
await fakeFirestore
.collection('group_rides')
.doc('ride_leave_accepted')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'accepted',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.leaveRide(callerUid: requester.uid, groupRideId: 'ride_leave_accepted');
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_leave_accepted').get();
expect(rideSnap.data()!['accessUids'], isNot(contains(requester.uid)));
expect(rideSnap.data()!['participantCount'], equals(1));
});
test('respondToInvitation accept: true marks participant accepted and increments count', () async {
final invitee = RiderProfile(
uid: 'user_bob',
handle: 'bob_trail',
displayName: 'Bob Trail',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
await seedRide(
rideId: 'ride_inv_accept',
organizerUid: organizer.uid,
participantCount: 1,
accessUids: [organizer.uid, invitee.uid],
);
await fakeFirestore
.collection('group_rides')
.doc('ride_inv_accept')
.collection('participants')
.doc(invitee.uid)
.set({
'userId': invitee.uid,
'role': 'participant',
'status': 'invited',
'invitedBy': organizer.uid,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.respondToInvitation(
callerUid: invitee.uid,
groupRideId: 'ride_inv_accept',
accept: true,
);
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_inv_accept').get();
expect(rideSnap.data()!['participantCount'], equals(2));
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_inv_accept')
.collection('participants')
.doc(invitee.uid)
.get();
expect(partSnap.data()!['status'], equals('accepted'));
});
test('respondToInvitation accept: false marks participant declined and removes accessUid with count unchanged', () async {
final invitee = RiderProfile(
uid: 'user_bob',
handle: 'bob_trail',
displayName: 'Bob Trail',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
await seedRide(
rideId: 'ride_inv_decline',
organizerUid: organizer.uid,
participantCount: 1,
accessUids: [organizer.uid, invitee.uid],
);
await fakeFirestore
.collection('group_rides')
.doc('ride_inv_decline')
.collection('participants')
.doc(invitee.uid)
.set({
'userId': invitee.uid,
'role': 'participant',
'status': 'invited',
'invitedBy': organizer.uid,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.respondToInvitation(
callerUid: invitee.uid,
groupRideId: 'ride_inv_decline',
accept: false,
);
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_inv_decline').get();
expect(rideSnap.data()!['participantCount'], equals(1));
expect(rideSnap.data()!['accessUids'], isNot(contains(invitee.uid)));
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_inv_decline')
.collection('participants')
.doc(invitee.uid)
.get();
expect(partSnap.data()!['status'], equals('declined'));
});
test('respondToJoinRequest decline succeeds even when organizer and requester are mutually blocked', () async {
await seedRide(
rideId: 'ride_block_decline',
organizerUid: organizer.uid,
);
// Seed mutual block
await fakeFirestore
.collection('users')
.doc(organizer.uid)
.collection('blocks')
.doc(requester.uid)
.set({'blockedUid': requester.uid});
await fakeFirestore
.collection('users')
.doc(requester.uid)
.collection('blocks')
.doc(organizer.uid)
.set({'blockedUid': organizer.uid});
await fakeFirestore
.collection('group_rides')
.doc('ride_block_decline')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
// 1. Approve fails
expect(
() => service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_block_decline',
requesterUid: requester.uid,
approve: true,
),
throwsA(isA<StateError>()),
);
// 2. Decline SUCCEEDS
await service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_block_decline',
requesterUid: requester.uid,
approve: false,
);
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_block_decline')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.data()!['status'], equals('declined'));
});
});
}
class _FakeGroupRideLifecycleService implements GroupRideService {

View File

@ -0,0 +1,293 @@
import { NextRequest, NextResponse } from 'next/server';
import type { QueryDocumentSnapshot } from 'firebase-admin/firestore';
import { getAdminAuth, getAdminFirestore } from '../../../../../lib/firebase-admin';
export const dynamic = 'force-dynamic';
interface RouteContext {
params: Promise<{
clubId: string;
}>;
}
export interface ClubRideDiscoveryItem {
rideId: string;
clubId: string;
title: string;
description: string | null;
organizerDisplayName: string | null;
organizerHandle: string | null;
scheduledStartAt: string;
departureAt: string | null;
joinPolicy: string;
participantCount: number;
participantLimit: number | null;
status: 'scheduled' | 'assembling';
meetingLocationLabel: string | null;
routeTitle: string | null;
routeDistanceMeters: number | null;
paceClassification: string | null;
}
export async function GET(request: NextRequest, context: RouteContext) {
try {
// 1. Require and extract Bearer token
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'unauthorized', detail: 'Missing or malformed Authorization header.' },
{ status: 401 }
);
}
const idToken = authHeader.slice(7).trim();
if (!idToken) {
return NextResponse.json(
{ error: 'unauthorized', detail: 'Missing authentication token.' },
{ status: 401 }
);
}
// 2 & 3. Verify Firebase ID token server-side and derive callerUid exclusively from token
let callerUid: string;
try {
const decoded = await getAdminAuth().verifyIdToken(idToken);
callerUid = decoded.uid;
} catch {
return NextResponse.json(
{ error: 'unauthorized', detail: 'Invalid or expired authentication token.' },
{ status: 401 }
);
}
// 4. Validate clubId
const { clubId } = await context.params;
if (
!clubId ||
typeof clubId !== 'string' ||
clubId.trim().length === 0 ||
clubId.length > 64 ||
/\s/.test(clubId)
) {
return NextResponse.json(
{ error: 'invalid_request', detail: 'Invalid clubId parameter.' },
{ status: 400 }
);
}
const normalizedClubId = clubId.trim();
const db = getAdminFirestore();
// 5. Verify club exists and is unarchived
const clubSnap = await db.collection('clubs').doc(normalizedClubId).get();
if (!clubSnap.exists) {
return NextResponse.json(
{ error: 'not_found', detail: 'Club not found.' },
{ status: 404 }
);
}
const clubData = clubSnap.data();
if (clubData?.archivedAt != null) {
return NextResponse.json(
{ error: 'not_found', detail: 'Club is archived.' },
{ status: 404 }
);
}
// 6. Verify caller is an active club member
const memberSnap = await db
.collection('clubs')
.doc(normalizedClubId)
.collection('members')
.doc(callerUid)
.get();
if (!memberSnap.exists) {
return NextResponse.json(
{ error: 'forbidden', detail: 'Active club membership required.' },
{ status: 403 }
);
}
const memberData = memberSnap.data();
if (memberData?.status !== 'active') {
return NextResponse.json(
{ error: 'forbidden', detail: 'Active club membership required.' },
{ status: 403 }
);
}
// 7. Server-Side Mutual-Block Pre-Fetch (avoiding N+1 queries)
// 7a. Fetch all users blocked by the caller (single query upfront)
const callerBlocksSnap = await db
.collection('users')
.doc(callerUid)
.collection('blocks')
.get();
const blockedByCaller = new Set<string>(
callerBlocksSnap.docs.map((d: QueryDocumentSnapshot) => d.id)
);
// 8. Paginate candidate discoverable upcoming rides until 30 eligible rides collected or query exhausted
const TARGET_COUNT = 30;
const PAGE_SIZE = 30;
const eligibleDocs: QueryDocumentSnapshot[] = [];
const checkedOrganizers = new Map<string, boolean>(); // orgUid -> isBlocked
let lastDoc: QueryDocumentSnapshot | null = null;
while (eligibleDocs.length < TARGET_COUNT) {
let query = db
.collection('group_rides')
.where('clubId', '==', normalizedClubId)
.where('status', 'in', ['scheduled', 'assembling'])
.orderBy('scheduledStartAt', 'asc')
.limit(PAGE_SIZE);
if (lastDoc) {
query = query.startAfter(lastDoc);
}
const ridesSnap = await query.get();
if (ridesSnap.empty) {
break;
}
lastDoc = ridesSnap.docs[ridesSnap.docs.length - 1];
// Filter candidate rides where caller blocks organizer
const candidateDocs = ridesSnap.docs.filter((doc: QueryDocumentSnapshot) => {
const organizerUid = doc.data().organizerUid;
return typeof organizerUid === 'string' && !blockedByCaller.has(organizerUid);
});
// Deduplicate distinct non-caller organizer UIDs needing reverse block checks
const uncheckedOrganizerUids = Array.from(
new Set(
candidateDocs
.map((d: QueryDocumentSnapshot) => d.data().organizerUid)
.filter((uid: unknown): uid is string =>
typeof uid === 'string' &&
uid.length > 0 &&
uid !== callerUid &&
!checkedOrganizers.has(uid)
)
)
);
if (uncheckedOrganizerUids.length > 0) {
const reverseBlockChecks = await Promise.all(
uncheckedOrganizerUids.map(async (orgUid: string) => {
const blockSnap = await db
.collection('users')
.doc(orgUid)
.collection('blocks')
.doc(callerUid)
.get();
return { orgUid, isBlocked: blockSnap.exists };
})
);
for (const check of reverseBlockChecks) {
checkedOrganizers.set(check.orgUid, check.isBlocked);
}
}
// Filter out rides where organizer blocks caller
for (const doc of candidateDocs) {
const organizerUid = doc.data().organizerUid;
if (typeof organizerUid === 'string') {
if (organizerUid === callerUid || !checkedOrganizers.get(organizerUid)) {
eligibleDocs.push(doc);
if (eligibleDocs.length >= TARGET_COUNT) {
break;
}
}
}
}
if (ridesSnap.docs.length < PAGE_SIZE) {
// Query exhausted
break;
}
}
if (eligibleDocs.length === 0) {
return NextResponse.json(
{
clubId: normalizedClubId,
rides: [],
},
{
status: 200,
headers: {
'Cache-Control': 'no-store, max-age=0',
},
}
);
}
// 9. Response Data Minimization & Physical-Location Privacy
// Never expose accessUids, exact coordinates, full route geometry, or live presence
const rides: ClubRideDiscoveryItem[] = eligibleDocs.map((doc: QueryDocumentSnapshot) => {
const d = doc.data();
const scheduledStartAt = typeof d.scheduledStartAt === 'string'
? d.scheduledStartAt
: (d.scheduledStartAt?.toDate?.() ? d.scheduledStartAt.toDate().toISOString() : '');
const departureAt = typeof d.departureAt === 'string'
? d.departureAt
: (d.departureAt?.toDate?.() ? d.departureAt.toDate().toISOString() : null);
// Coarse human-readable location label only; never exact GPS latitude/longitude or raw street address
let meetingLocationLabel: string | null = null;
if (
d.meetingPoint &&
typeof d.meetingPoint === 'object' &&
typeof d.meetingPoint.displayName === 'string' &&
d.meetingPoint.displayName.trim().length > 0
) {
meetingLocationLabel = d.meetingPoint.displayName.trim();
}
return {
rideId: doc.id,
clubId: normalizedClubId,
title: d.title || 'Untitled Ride',
description: d.description || null,
organizerDisplayName: d.organizerDisplayName || null,
organizerHandle: d.organizerHandle || null,
scheduledStartAt,
departureAt,
joinPolicy: d.joinPolicy || 'inviteOnly',
participantCount: typeof d.participantCount === 'number' ? d.participantCount : 1,
participantLimit: typeof d.participantLimit === 'number' ? d.participantLimit : null,
status: d.status,
meetingLocationLabel,
routeTitle: d.routeTitle || null,
routeDistanceMeters: typeof d.routeDistanceMeters === 'number' ? d.routeDistanceMeters : null,
paceClassification: d.paceClassification || null,
};
});
return NextResponse.json(
{
clubId: normalizedClubId,
rides,
},
{
status: 200,
headers: {
'Cache-Control': 'no-store, max-age=0',
},
}
);
} catch (err: any) {
console.error('[CLUB_RIDES_DISCOVERY] Internal error:', err?.message || err);
return NextResponse.json(
{ error: 'server_error', detail: 'Club ride discovery is temporarily unavailable.' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,159 @@
import { NextRequest, NextResponse } from 'next/server';
import type { QueryDocumentSnapshot } from 'firebase-admin/firestore';
import { getAdminAuth, getAdminFirestore } from '../../../../../lib/firebase-admin';
export const dynamic = 'force-dynamic';
interface RouteContext {
params: Promise<{
rideId: string;
}>;
}
export interface GroupRideJoinRequestDto {
userId: string;
riderDisplayName: string | null;
riderHandle: string | null;
riderAvatarPreset: string | null;
riderAvatarUrl: string | null;
createdAt: string;
}
export async function GET(request: NextRequest, context: RouteContext) {
try {
// 1. Require and extract Bearer token
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'unauthorized', detail: 'Missing or malformed Authorization header.' },
{ status: 401 }
);
}
const idToken = authHeader.slice(7).trim();
if (!idToken) {
return NextResponse.json(
{ error: 'unauthorized', detail: 'Missing authentication token.' },
{ status: 401 }
);
}
// 2. Verify Firebase ID token server-side and derive callerUid strictly from token
let callerUid: string;
try {
const decoded = await getAdminAuth().verifyIdToken(idToken);
callerUid = decoded.uid;
} catch {
return NextResponse.json(
{ error: 'unauthorized', detail: 'Invalid or expired authentication token.' },
{ status: 401 }
);
}
// 3. Validate rideId
const { rideId } = await context.params;
if (
!rideId ||
typeof rideId !== 'string' ||
rideId.trim().length === 0 ||
rideId.length > 64 ||
/\s/.test(rideId)
) {
return NextResponse.json(
{ error: 'invalid_request', detail: 'Invalid rideId parameter.' },
{ status: 400 }
);
}
const normalizedRideId = rideId.trim();
const db = getAdminFirestore();
// 4. Fetch ride document
const rideSnap = await db.collection('group_rides').doc(normalizedRideId).get();
if (!rideSnap.exists) {
return NextResponse.json(
{ error: 'not_found', detail: 'Group ride not found.' },
{ status: 404 }
);
}
const rideData = rideSnap.data() || {};
const organizerUid = rideData.organizerUid;
const isOrganizer = organizerUid === callerUid;
let isClubAdmin = false;
if (!isOrganizer && typeof rideData.clubId === 'string' && rideData.clubId.trim().length > 0) {
const clubId = rideData.clubId.trim();
const memberSnap = await db
.collection('clubs')
.doc(clubId)
.collection('members')
.doc(callerUid)
.get();
if (memberSnap.exists) {
const memberData = memberSnap.data();
if (
memberData?.status === 'active' &&
(memberData?.role === 'admin' || memberData?.role === 'owner')
) {
isClubAdmin = true;
}
}
}
// 5. Require caller to be either organizer or active club administrator
if (!isOrganizer && !isClubAdmin) {
return NextResponse.json(
{ error: 'forbidden', detail: 'Only the organizer or a club administrator can view join requests.' },
{ status: 403 }
);
}
// 6. Query pending join requests bounded by limit 50, ordered by createdAt asc
const requestsSnap = await db
.collection('group_rides')
.doc(normalizedRideId)
.collection('participants')
.where('status', '==', 'requested')
.orderBy('createdAt', 'asc')
.limit(50)
.get();
// 7. Minimal DTO mapping (never expose location, live presence, or telemetry)
const requests: GroupRideJoinRequestDto[] = requestsSnap.docs.map((doc: QueryDocumentSnapshot) => {
const d = doc.data();
const createdAt = typeof d.createdAt === 'string'
? d.createdAt
: (d.createdAt?.toDate?.() ? d.createdAt.toDate().toISOString() : '');
return {
userId: d.userId || doc.id,
riderDisplayName: d.riderDisplayName || null,
riderHandle: d.riderHandle || null,
riderAvatarPreset: d.riderAvatarPreset || null,
riderAvatarUrl: d.riderAvatarUrl || null,
createdAt,
};
});
return NextResponse.json(
{
rideId: normalizedRideId,
requests,
},
{
status: 200,
headers: {
'Cache-Control': 'no-store, max-age=0',
},
}
);
} catch (err: any) {
console.error('[GROUP_RIDE_JOIN_REQUESTS] Internal error:', err?.message || err);
return NextResponse.json(
{ error: 'server_error', detail: 'Join requests are temporarily unavailable.' },
{ status: 500 }
);
}
}

View File

@ -76,17 +76,12 @@ export default function ClubFounderRidesPage({ params }: PageProps) {
setPendingApplicationsCount(0);
}
// In demo preview mode: load fixture rides for UI/design validation
if (isDemoPreview) {
const ridesResult = await fetchClubRides(ctx.club.id, currentUser?.uid, {
allowDemoPreview: true,
});
setUpcomingRides(ridesResult.upcomingRides);
setPastRides(ridesResult.pastRides);
}
// In production mode, do not attempt a broad client-side clubId query.
// GroupRide.clubId is now persisted, but Firestore intentionally does not authorize
// Club-wide discovery until mutual-block privacy can be preserved.
// Load club rides for upcoming and past management views
const ridesResult = await fetchClubRides(ctx.club.id, currentUser?.uid, {
allowDemoPreview: isDemoPreview,
});
setUpcomingRides(ridesResult.upcomingRides);
setPastRides(ridesResult.pastRides);
} catch (err) {
setError(sanitizePortalError(err, "Club rides couldn't be loaded."));
} finally {
@ -151,82 +146,7 @@ export default function ClubFounderRidesPage({ params }: PageProps) {
);
}
// Normal production operation: truthful discovery-pending state.
// GroupRide.clubId exists, but no privacy-preserving Club-wide read path is available.
// Do not describe this state as "No rides".
if (!isDemoPreview) {
return (
<FounderPortalShell
club={context.club}
permissions={context.permissions}
activeNav="rides"
pendingApplicationsCount={pendingApplicationsCount}
upcomingRidesCount={0}
>
<div className="space-y-6" aria-label="Club Rides Management">
{/* Header Section */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2.5">
<h1 className="font-headline text-xl sm:text-2xl font-bold text-white tracking-tight">
Club Rides
</h1>
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-amber-400/10 text-amber-400 border border-amber-400/30 font-semibold">
DISCOVERY PENDING
</span>
</div>
<p className="font-body text-xs text-slate-400 mt-1">
Schedule, coordinate, and review group rides and road captain assignments.
</p>
</div>
<div
className="flex items-center gap-2 px-3.5 py-2 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-400 font-body text-xs cursor-not-allowed select-none"
title="Founder Portal ride planning is not wired yet"
aria-disabled="true"
>
<span className="material-symbols-outlined text-[17px] text-slate-500">add_road</span>
<span className="text-slate-400">Plan Club Ride</span>
<span className="font-mono text-[9px] px-1.5 py-0.5 rounded bg-surface-container text-slate-400 border border-brand-border/60">
SOON
</span>
</div>
</div>
{/* Pending Backend Integration Notice */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-8 text-center space-y-4 max-w-2xl mx-auto my-8">
<div className="w-12 h-12 rounded-2xl bg-amber-400/10 border border-amber-400/30 flex items-center justify-center text-amber-400 mx-auto">
<span className="material-symbols-outlined text-[28px]">route</span>
</div>
<div className="space-y-2">
<h2 className="font-headline text-lg sm:text-xl font-bold text-white">
Club Rides Discovery Pending
</h2>
<p className="font-body text-xs sm:text-sm text-slate-400 max-w-lg mx-auto leading-relaxed">
Club-affiliated Group Rides can now be created in the mobile application. Club-wide Founder Portal discovery remains disabled until a read path can preserve mutual-block privacy.
</p>
</div>
<div className="pt-2 border-t border-brand-border/40 text-left text-xs text-slate-400 space-y-2 bg-surface-container-low/60 rounded-xl p-4">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined text-[16px] text-brand-cyan shrink-0 mt-0.5">info</span>
<span>
<strong>Architecture Note:</strong> Group rides now link to clubs through <code className="font-mono text-[11px] text-brand-cyan">GroupRide.clubId == club.id</code>. Club-wide discovery is a separate deferred capability.
</span>
</div>
<div className="flex items-start gap-2">
<span className="material-symbols-outlined text-[16px] text-brand-cyan shrink-0 mt-0.5">shield</span>
<span>
<strong>Privacy Invariant:</strong> Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location.
</span>
</div>
</div>
</div>
</div>
</FounderPortalShell>
);
}
// Explicit Development Demo Preview: Renders full interactive UI with fixtures
// Renders authoritative Founder Portal rides management view
return (
<FounderPortalShell
club={context.club}

View File

@ -1,4 +1,4 @@
import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp, runTransaction } from 'firebase/firestore';
import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp, runTransaction, orderBy } from 'firebase/firestore';
import { firestore } from './firebase-client';
import {
Club,
@ -1178,16 +1178,49 @@ export interface ClubRidesResult {
pastRides: ClubUpcomingRide[];
}
function mapGroupRideDoc(id: string, rd: Record<string, any>): ClubUpcomingRide {
const startStr = typeof rd.scheduledStartAt === 'string'
? rd.scheduledStartAt
: (rd.scheduledStartAt?.toDate?.() ? rd.scheduledStartAt.toDate().toISOString() : 'TBD');
const depStr = typeof rd.departureAt === 'string'
? rd.departureAt
: (rd.departureAt?.toDate?.() ? rd.departureAt.toDate().toISOString() : undefined);
return {
id,
rideIdentifier: rd.rideIdentifier || `RIDE-${id.slice(0, 6).toUpperCase()}`,
title: rd.title || 'Untitled Club Ride',
scheduledStartAt: startStr,
departureAt: depStr,
distanceMiles: typeof rd.routeDistanceMeters === 'number'
? Math.round(rd.routeDistanceMeters * 0.000621371)
: null,
durationString: null,
paceClassification: rd.paceClassification || 'Club Paceline',
roadCaptainName: rd.organizerDisplayName || 'Ride Leader',
roadCaptainHandle: rd.organizerHandle ? `@${rd.organizerHandle.replace(/^@/, '')}` : undefined,
roadCaptainMotorcycle: rd.organizerMotorcycle || null,
sweepName: rd.sweepName || null,
sweepStatus: rd.sweepStatus || 'vacant',
confirmedCount: typeof rd.participantCount === 'number' ? rd.participantCount : 1,
pendingCount: typeof rd.pendingCount === 'number' ? rd.pendingCount : 0,
capacity: typeof rd.participantLimit === 'number' ? rd.participantLimit : null,
briefingTime: rd.briefingTime || null,
routeId: rd.routeId || null,
status: rd.status || null,
organizerUid: rd.organizerUid || null,
description: rd.description || null,
};
}
/**
* Supplies the Founder Portal Club Rides scaffold.
* Fetches official Club-affiliated rides for Founder Portal management.
*
* CURRENT PRODUCTION CONTRACT:
* - GroupRide.clubId is persisted by the Flutter application.
* - Firestore intentionally does not authorize a broad client-side clubId list query,
* because discovery must preserve mutual-block privacy.
* - Production callers must not infer Club membership as GroupRide participation or
* live-location access.
* - The explicit development preview remains fixture-backed.
* GOVERNANCE AUTHORIZATION:
* - Scoped to clubId, status, and scheduledStartAt matching Firestore security rules and composite indexes.
* - Active Club Owners and Admins have administrative visibility over all official club rides.
* - Unrestricted collection reads are strictly avoided.
* - Demo preview remains available when explicitly requested.
*/
export async function fetchClubRides(
clubId: string,
@ -1207,8 +1240,37 @@ export async function fetchClubRides(
throw new Error('not-found');
}
throw new Error(
'Club ride discovery is not available until a privacy-preserving production read path is implemented.'
const upcomingQuery = query(
collection(firestore, 'group_rides'),
where('clubId', '==', normalized),
where('status', 'in', ['scheduled', 'assembling', 'underway']),
orderBy('scheduledStartAt', 'asc'),
limit(30)
);
const pastQuery = query(
collection(firestore, 'group_rides'),
where('clubId', '==', normalized),
where('status', 'in', ['completed', 'canceled']),
orderBy('scheduledStartAt', 'desc'),
limit(30)
);
const [upcomingSnap, pastSnap] = await Promise.all([
getDocs(upcomingQuery),
getDocs(pastQuery),
]);
const upcomingRides: ClubUpcomingRide[] = upcomingSnap.docs.map((d) =>
mapGroupRideDoc(d.id, d.data())
);
const pastRides: ClubUpcomingRide[] = pastSnap.docs.map((d) =>
mapGroupRideDoc(d.id, d.data())
);
return {
upcomingRides,
pastRides,
};
}

View File

@ -0,0 +1,60 @@
import { getApps, initializeApp, cert, type App } from 'firebase-admin/app';
import { getAuth, type Auth } from 'firebase-admin/auth';
import { getFirestore, type Firestore } from 'firebase-admin/firestore';
/**
* Server-only singleton initialization for Firebase Admin.
*
* Requirements:
* - Singleton reuse across Next.js API route invocations.
* - Server-only execution (never imported or bundled in client-side code).
* - Fails closed if project configuration is missing.
* - Supports Google Application Default Credentials (ADC), explicit service account
* via FIREBASE_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS, and Firebase emulator.
*/
let _adminApp: App | null = null;
export function getFirebaseAdminApp(): App {
if (_adminApp) {
return _adminApp;
}
const existingApps = getApps();
if (existingApps.length > 0 && existingApps[0]) {
_adminApp = existingApps[0];
return _adminApp;
}
const projectId = process.env.FIREBASE_PROJECT_ID || process.env.GCLOUD_PROJECT || 'motoapp-d22e0';
const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT;
let credential;
if (serviceAccountJson) {
try {
credential = cert(JSON.parse(serviceAccountJson));
} catch (err) {
console.error('[FIREBASE_ADMIN] Failed to parse FIREBASE_SERVICE_ACCOUNT credential:', err);
throw new Error('Invalid Firebase service account configuration.');
}
}
try {
_adminApp = initializeApp({
projectId,
...(credential ? { credential } : {}),
});
return _adminApp;
} catch (err: any) {
console.error('[FIREBASE_ADMIN] Failed to initialize Firebase Admin SDK:', err?.message || err);
throw new Error('Firebase Admin SDK initialization failed.');
}
}
export function getAdminAuth(): Auth {
return getAuth(getFirebaseAdminApp());
}
export function getAdminFirestore(): Firestore {
return getFirestore(getFirebaseAdminApp());
}

1882
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"firebase": "12.18.0",
"firebase-admin": "^14.4.0",
"next": "16.3.4",
"react": "19.1.1",
"react-dom": "19.1.1"

View File

@ -0,0 +1,725 @@
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_/);
}
});
});

View File

@ -982,15 +982,15 @@ async function runAllTests() {
assert.equal(unauthPerms.canPlanRides, false, 'Unauthenticated user has canPlanRides = false');
assert(ridesPageSrc.includes('Authentication Required'), 'Rides page must guard against unauthenticated users');
// F. Production rides page renders an explicit discovery-pending state, never a false empty schedule
assert(ridesPageSrc.includes('Club Rides Discovery Pending'), 'Production rides page must display Club Rides Discovery Pending notice');
assert(ridesPageSrc.includes('DISCOVERY PENDING'), 'Production rides page must display DISCOVERY PENDING badge');
assert(ridesPageSrc.includes('Club-affiliated Group Rides can now be created in the mobile application'), 'Must truthfully describe current mobile capability');
// F. Production rides page renders authoritative FounderRides component
assert(ridesPageSrc.includes('<FounderRides'), 'Production rides page must render FounderRides component');
assert(ridesPageSrc.includes('upcomingRides={upcomingRides}'), 'Production rides page must pass upcomingRides');
assert(ridesPageSrc.includes('pastRides={pastRides}'), 'Production rides page must pass pastRides');
// G. fetchClubRides must not issue an unsupported broad production query
// G. fetchClubRides queries club-affiliated rides with bounded limits
const fetchRidesBody = clubDataSrc.substring(clubDataSrc.lastIndexOf('export async function fetchClubRides'));
assert(!fetchRidesBody.includes("where('clubId'"), 'Scaffold fetchClubRides must not issue a broad client-side clubId query');
assert(fetchRidesBody.includes('Club ride discovery is not available until a privacy-preserving production read path is implemented.'), 'Production discovery must fail explicitly');
assert(fetchRidesBody.includes("where('clubId', '==', normalized)"), 'fetchClubRides must scope to clubId');
assert(fetchRidesBody.includes('limit(30)'), 'fetchClubRides must bound query');
// H. Action hardening: All unsupported actions are disabled with explanatory tooltips
assert(founderRidesSrc.includes('title="Founder Portal ride planning is not wired yet"'), 'Plan Club Ride must be disabled with tooltip');
@ -1009,7 +1009,6 @@ async function runAllTests() {
// J. Location privacy invariant (ClubMember != GroupRideParticipant)
assert(founderRidesSrc.includes(verbatimNotice), 'Missing verbatim live location privacy notice in FounderRides');
assert(ridesPageSrc.includes(verbatimNotice), 'Missing verbatim live location privacy notice in rides page');
// K. Demo preview development gating
assert(fetchRidesBody.includes("options?.allowDemoPreview && process.env.NODE_ENV === 'development'"), 'fetchClubRides must gate demo preview to development');

View File

@ -0,0 +1,425 @@
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');
});
});