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:
parent
1e7e670a17
commit
c1aa68ac43
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
"collectionGroup": "clubs",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
|
|
@ -109,6 +145,20 @@
|
||||||
"order": "DESCENDING"
|
"order": "DESCENDING"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "participants",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fieldOverrides": []
|
"fieldOverrides": []
|
||||||
|
|
|
||||||
393
firestore.rules
393
firestore.rules
|
|
@ -259,9 +259,15 @@ service cloud.firestore {
|
||||||
isActiveClubMember(resource.data.clubId, request.auth.uid);
|
isActiveClubMember(resource.data.clubId, request.auth.uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isClubRideAdmin() {
|
||||||
|
return hasClubAffiliation(resource.data) &&
|
||||||
|
isActiveClubAdmin(resource.data.clubId, request.auth.uid);
|
||||||
|
}
|
||||||
|
|
||||||
function canReadRide() {
|
function canReadRide() {
|
||||||
return signedIn() && (
|
return signedIn() && (
|
||||||
isOrganizer() ||
|
isOrganizer() ||
|
||||||
|
isClubRideAdmin() ||
|
||||||
(
|
(
|
||||||
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
|
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
|
||||||
hasParticipantAccess(request.auth.uid) ||
|
hasParticipantAccess(request.auth.uid) ||
|
||||||
|
|
@ -283,6 +289,10 @@ service cloud.firestore {
|
||||||
(
|
(
|
||||||
'accessUids' in resource.data &&
|
'accessUids' in resource.data &&
|
||||||
request.auth.uid in resource.data.accessUids
|
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() {
|
function validOrganizerRideUpdate() {
|
||||||
let hasPrevAccess = 'accessUids' in resource.data;
|
let prevAccess = getRideAccessUids(resource.data);
|
||||||
let hasNextAccess = 'accessUids' in request.resource.data;
|
let nextAccess = getRideAccessUids(request.resource.data);
|
||||||
let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
|
let prevCount = getRideCount(resource.data);
|
||||||
let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
|
let nextCount = getRideCount(request.resource.data);
|
||||||
|
|
||||||
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
|
|
||||||
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
|
|
||||||
|
|
||||||
let added = nextAccess.removeAll(prevAccess);
|
let added = nextAccess.removeAll(prevAccess);
|
||||||
let removed = prevAccess.removeAll(nextAccess);
|
let removed = prevAccess.removeAll(nextAccess);
|
||||||
let countDelta = nextCount - prevCount;
|
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() == 0 && removed.size() == 0 && countDelta == 0) ||
|
||||||
(added.size() == 1 && removed.size() == 0 && countDelta == 0) ||
|
// Organizer approval or open join: exactly +1 accessUid, count +1, only in scheduled/assembling
|
||||||
(added.size() == 1 && removed.size() == 0 && countDelta == 1) ||
|
(
|
||||||
(added.size() == 0 && removed.size() == 1 && (countDelta == -1 || countDelta == 0))
|
('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() {
|
function validParticipantRideUpdate() {
|
||||||
let hasPrevAccess = 'accessUids' in resource.data;
|
let prevAccess = getRideAccessUids(resource.data);
|
||||||
let hasNextAccess = 'accessUids' in request.resource.data;
|
let nextAccess = getRideAccessUids(request.resource.data);
|
||||||
let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
|
let prevCount = getRideCount(resource.data);
|
||||||
let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
|
let nextCount = getRideCount(request.resource.data);
|
||||||
|
|
||||||
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
|
|
||||||
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
|
|
||||||
|
|
||||||
let added = nextAccess.removeAll(prevAccess);
|
let added = nextAccess.removeAll(prevAccess);
|
||||||
let removed = prevAccess.removeAll(nextAccess);
|
let removed = prevAccess.removeAll(nextAccess);
|
||||||
let countDelta = nextCount - prevCount;
|
let countDelta = nextCount - prevCount;
|
||||||
|
|
@ -369,35 +450,57 @@ service cloud.firestore {
|
||||||
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['accessUids', 'participantCount', 'updatedAt']) &&
|
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['accessUids', 'participantCount', 'updatedAt']) &&
|
||||||
canReadRide() &&
|
canReadRide() &&
|
||||||
(
|
(
|
||||||
// Join open ride: caller added to accessUids, count +1
|
validParticipantOpenJoin(added, removed, countDelta, nextCount) ||
|
||||||
(
|
validParticipantAcceptInvite(prevAccess, added, removed, countDelta, nextCount) ||
|
||||||
resource.data.joinPolicy == 'open' &&
|
validParticipantLeave(prevAccess, added, removed, countDelta) ||
|
||||||
added == [request.auth.uid] &&
|
validParticipantDeclineInvite(prevAccess, added, removed, countDelta)
|
||||||
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)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function validClubAdminRideUpdate() {
|
||||||
let clubId = resource.data.clubId;
|
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;
|
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
|
// Sub-case A: Scheduled metadata edit
|
||||||
(
|
(
|
||||||
prevStatus == 'scheduled' &&
|
prevStatus == 'scheduled' &&
|
||||||
|
|
@ -418,12 +521,14 @@ service cloud.firestore {
|
||||||
request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
||||||
'status', 'canceledAt', 'canceledReason', 'updatedAt'
|
'status', 'canceledAt', 'canceledReason', 'updatedAt'
|
||||||
])
|
])
|
||||||
)
|
) ||
|
||||||
|
// Sub-case C: Club admin participant moderation (approve, remove)
|
||||||
|
validClubAdminModeration(prevStatus, nextStatus)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
allow update: if signedIn() &&
|
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) &&
|
sameClubAffiliation(resource.data, request.resource.data) &&
|
||||||
(
|
(
|
||||||
(isOrganizer() && validOrganizerRideUpdate()) ||
|
(isOrganizer() && validOrganizerRideUpdate()) ||
|
||||||
|
|
@ -447,6 +552,11 @@ service cloud.firestore {
|
||||||
return rideDoc().data.organizerUid;
|
return rideDoc().data.organizerUid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAuthorizedModerator() {
|
||||||
|
return request.auth.uid == rideOrganizer() ||
|
||||||
|
(hasClubAffiliation(rideDoc().data) && isActiveClubAdmin(rideDoc().data.clubId, request.auth.uid));
|
||||||
|
}
|
||||||
|
|
||||||
function isAcceptedParticipant(uid) {
|
function isAcceptedParticipant(uid) {
|
||||||
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
|
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
|
||||||
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
|
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)
|
// Collection listing is strictly disallowed (point-read architecture over known accessUids)
|
||||||
allow list: if false;
|
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() && (
|
allow get: if signedIn() && (
|
||||||
request.auth.uid == participantUid ||
|
request.auth.uid == participantUid ||
|
||||||
request.auth.uid == rideOrganizer() ||
|
isAuthorizedModerator() ||
|
||||||
(
|
(
|
||||||
isAcceptedParticipant(request.auth.uid) &&
|
isAcceptedParticipant(request.auth.uid) &&
|
||||||
!areMutuallyBlocked(request.auth.uid, participantUid)
|
!areMutuallyBlocked(request.auth.uid, participantUid)
|
||||||
|
|
@ -471,87 +581,168 @@ service cloud.firestore {
|
||||||
(
|
(
|
||||||
request.auth.uid == participantUid &&
|
request.auth.uid == participantUid &&
|
||||||
request.auth.uid == rideOrganizer() &&
|
request.auth.uid == rideOrganizer() &&
|
||||||
|
request.resource.data.userId == request.auth.uid &&
|
||||||
request.resource.data.role == 'organizer' &&
|
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() &&
|
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.status == 'invited' &&
|
||||||
request.resource.data.role == 'participant' &&
|
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 == participantUid &&
|
||||||
request.auth.uid != rideOrganizer() &&
|
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.status == 'requested' &&
|
||||||
request.resource.data.role == 'participant' &&
|
rideDoc().data.joinPolicy == 'requestToJoin' &&
|
||||||
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
|
('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 == participantUid &&
|
||||||
request.auth.uid != rideOrganizer() &&
|
request.auth.uid != rideOrganizer() &&
|
||||||
rideDoc().data.joinPolicy == 'open' &&
|
request.resource.data.userId == request.auth.uid &&
|
||||||
request.resource.data.status == 'accepted' &&
|
|
||||||
request.resource.data.role == 'participant' &&
|
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() && (
|
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 &&
|
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'updatedAt']) && (
|
||||||
resource.data.status == 'invited' &&
|
// Invitee accepting own invitation (invited -> accepted)
|
||||||
request.resource.data.status == 'accepted' &&
|
(
|
||||||
request.resource.data.role == resource.data.role
|
request.auth.uid == participantUid &&
|
||||||
) ||
|
resource.data.status == 'invited' &&
|
||||||
// Invitee declining own invitation (invited -> declined)
|
('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.auth.uid == participantUid &&
|
request.resource.data.status == 'accepted' &&
|
||||||
resource.data.status == 'invited' &&
|
request.resource.data.role == resource.data.role &&
|
||||||
request.resource.data.status == 'declined' &&
|
participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids &&
|
||||||
request.resource.data.role == resource.data.role
|
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1
|
||||||
) ||
|
) ||
|
||||||
// Participant leaving (accepted -> left)
|
// Invitee declining own invitation (invited -> declined)
|
||||||
(
|
(
|
||||||
request.auth.uid == participantUid &&
|
request.auth.uid == participantUid &&
|
||||||
resource.data.status == 'accepted' &&
|
resource.data.status == 'invited' &&
|
||||||
request.resource.data.status == 'left' &&
|
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
|
||||||
request.resource.data.role == resource.data.role
|
request.resource.data.status == 'declined' &&
|
||||||
) ||
|
request.resource.data.role == resource.data.role
|
||||||
// Organizer approving request (requested -> accepted)
|
) ||
|
||||||
(
|
// Accepted participant leaving (accepted -> left) - Cross-document bound
|
||||||
request.auth.uid == rideOrganizer() &&
|
(
|
||||||
resource.data.status == 'requested' &&
|
request.auth.uid == participantUid &&
|
||||||
request.resource.data.status == 'accepted'
|
resource.data.status == 'accepted' &&
|
||||||
) ||
|
('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
|
||||||
// Organizer declining request (requested -> declined)
|
request.resource.data.status == 'left' &&
|
||||||
(
|
request.resource.data.role == resource.data.role &&
|
||||||
request.auth.uid == rideOrganizer() &&
|
!(participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids) &&
|
||||||
resource.data.status == 'requested' &&
|
getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount - 1
|
||||||
request.resource.data.status == 'declined'
|
) ||
|
||||||
) ||
|
// Requested rider withdrawing request (requested -> left) - Single document update
|
||||||
// Organizer removing participant (status -> removed)
|
(
|
||||||
(
|
request.auth.uid == participantUid &&
|
||||||
request.auth.uid == rideOrganizer() &&
|
resource.data.status == 'requested' &&
|
||||||
request.resource.data.status == 'removed'
|
('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
|
// Organizer role transfer
|
||||||
(
|
(
|
||||||
request.auth.uid == rideOrganizer() &&
|
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() && (
|
allow delete: if signedIn() &&
|
||||||
request.auth.uid == participantUid ||
|
resource.data.role != 'organizer' &&
|
||||||
request.auth.uid == rideOrganizer()
|
resource.data.status != 'accepted' &&
|
||||||
);
|
(
|
||||||
|
request.auth.uid == participantUid ||
|
||||||
|
isAuthorizedModerator()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- LIVE PRESENCE SUBCOLLECTION ---
|
// --- LIVE PRESENCE SUBCOLLECTION ---
|
||||||
|
|
@ -571,7 +762,7 @@ service cloud.firestore {
|
||||||
}
|
}
|
||||||
|
|
||||||
function rideIsLiveEligible() {
|
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) {
|
function validCoordinates(data) {
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.clubId != null) {
|
||||||
|
_joinPolicy = GroupRideJoinPolicy.requestToJoin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_titleController.dispose();
|
_titleController.dispose();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import '../../../core/responsive/responsive_layout.dart';
|
import '../../../core/responsive/responsive_layout.dart';
|
||||||
import '../../../core/theme/app_typography.dart';
|
import '../../../core/theme/app_typography.dart';
|
||||||
import '../../../core/theme/theme_extensions.dart';
|
import '../../../core/theme/theme_extensions.dart';
|
||||||
import '../../../services/auth/auth_providers.dart';
|
import '../../../services/auth/auth_providers.dart';
|
||||||
import '../../../services/clubs/club_models.dart';
|
import '../../../services/clubs/club_models.dart';
|
||||||
import '../../../services/clubs/club_providers.dart';
|
import '../../../services/clubs/club_providers.dart';
|
||||||
|
import '../../../services/clubs/club_service.dart';
|
||||||
import 'club_membership_controller.dart';
|
import 'club_membership_controller.dart';
|
||||||
import 'club_membership_presentation.dart';
|
import 'club_membership_presentation.dart';
|
||||||
import 'widgets/club_monogram.dart';
|
import 'widgets/club_monogram.dart';
|
||||||
|
|
@ -1005,21 +1007,255 @@ class ClubDetailScreen extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Padding(
|
if (!isActiveMember)
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
Padding(
|
||||||
child: Text(
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||||
isActiveMember
|
child: Text(
|
||||||
? 'Club ride discovery is coming soon.'
|
'Club rides are visible to active members only.',
|
||||||
: 'Club rides are visible to active members only.',
|
style: TextStyle(
|
||||||
style: TextStyle(
|
fontFamily: AppTypography.fontBody,
|
||||||
fontFamily: AppTypography.fontBody,
|
fontSize: 12.5,
|
||||||
fontSize: 12.5,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -607,3 +607,108 @@ class ClubHandle {
|
||||||
ownerUid.hashCode ^
|
ownerUid.hashCode ^
|
||||||
createdAt.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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,3 +74,10 @@ final publicClubsProvider = FutureProvider<List<Club>>((ref) {
|
||||||
final service = ref.watch(clubServiceProvider);
|
final service = ref.watch(clubServiceProvider);
|
||||||
return service.discoverPublicClubs(limit: 25);
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import 'club_models.dart';
|
import 'club_models.dart';
|
||||||
import 'club_slug_validator.dart';
|
import 'club_slug_validator.dart';
|
||||||
|
|
||||||
|
|
@ -1064,4 +1066,107 @@ class ClubService {
|
||||||
.map((snap) =>
|
.map((snap) =>
|
||||||
snap.docs.map((doc) => ClubMember.fromFirestore(doc)).toList());
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
Future<void> requestToJoin({
|
||||||
required RiderProfile caller,
|
required RiderProfile caller,
|
||||||
required String groupRideId,
|
required String groupRideId,
|
||||||
|
|
@ -441,61 +444,94 @@ class GroupRideService {
|
||||||
if (firestore == null) return;
|
if (firestore == null) return;
|
||||||
|
|
||||||
final rideRef = firestore.collection('group_rides').doc(groupRideId);
|
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 participantRef = rideRef.collection('participants').doc(caller.uid);
|
||||||
final now = DateTime.now();
|
|
||||||
|
|
||||||
final req = GroupRideParticipant(
|
await firestore.runTransaction((transaction) async {
|
||||||
userId: caller.uid,
|
final rideSnap = await transaction.get(rideRef);
|
||||||
role: GroupRideRole.participant,
|
if (!rideSnap.exists || rideSnap.data() == null) {
|
||||||
status: ParticipantStatus.requested,
|
throw StateError('Group ride not found.');
|
||||||
riderHandle: caller.handle,
|
}
|
||||||
riderDisplayName: caller.displayName,
|
|
||||||
riderAvatarPreset: caller.avatarPreset,
|
|
||||||
riderAvatarUrl: caller.avatarUrl,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
);
|
|
||||||
|
|
||||||
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({
|
Future<void> respondToJoinRequest({
|
||||||
required String callerUid,
|
required String callerUid,
|
||||||
required String groupRideId,
|
required String groupRideId,
|
||||||
|
|
@ -514,11 +550,57 @@ class GroupRideService {
|
||||||
throw StateError('Group ride not found.');
|
throw StateError('Group ride not found.');
|
||||||
}
|
}
|
||||||
final ride = GroupRide.fromJson(rideDoc.data()!);
|
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) {
|
if (!isAuthorized) {
|
||||||
throw StateError('Ride is no longer active.');
|
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);
|
final pDoc = await transaction.get(participantRef);
|
||||||
|
|
@ -530,6 +612,20 @@ class GroupRideService {
|
||||||
throw StateError('Participant is not in requested status.');
|
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();
|
final now = DateTime.now();
|
||||||
if (approve) {
|
if (approve) {
|
||||||
if (ride.isFull) {
|
if (ride.isFull) {
|
||||||
|
|
@ -575,8 +671,9 @@ class GroupRideService {
|
||||||
if (ride.organizerUid == caller.uid) {
|
if (ride.organizerUid == caller.uid) {
|
||||||
throw ArgumentError('You are already the organizer.');
|
throw ArgumentError('You are already the organizer.');
|
||||||
}
|
}
|
||||||
if (ride.isCanceled || ride.isCompleted) {
|
if (ride.status != GroupRideStatus.scheduled &&
|
||||||
throw StateError('Cannot join a canceled or completed ride.');
|
ride.status != GroupRideStatus.assembling) {
|
||||||
|
throw StateError('Cannot join a ride that is underway, completed, or canceled.');
|
||||||
}
|
}
|
||||||
if (ride.joinPolicy != GroupRideJoinPolicy.open) {
|
if (ride.joinPolicy != GroupRideJoinPolicy.open) {
|
||||||
throw StateError('This ride is not open to direct join.');
|
throw StateError('This ride is not open to direct join.');
|
||||||
|
|
@ -585,6 +682,20 @@ class GroupRideService {
|
||||||
throw StateError('This ride is full.');
|
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);
|
final pDoc = await transaction.get(participantRef);
|
||||||
if (pDoc.exists && pDoc.data() != null) {
|
if (pDoc.exists && pDoc.data() != null) {
|
||||||
final existing = GroupRideParticipant.fromJson(pDoc.data()!);
|
final existing = GroupRideParticipant.fromJson(pDoc.data()!);
|
||||||
|
|
@ -660,12 +771,12 @@ class GroupRideService {
|
||||||
'updatedAt': now.toIso8601String(),
|
'updatedAt': now.toIso8601String(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ride.organizerUid != callerUid) {
|
if (ride.organizerUid != callerUid && wasAccepted) {
|
||||||
final rideUpdates = <String, dynamic>{
|
final rideUpdates = <String, dynamic>{
|
||||||
'accessUids': FieldValue.arrayRemove([callerUid]),
|
'accessUids': FieldValue.arrayRemove([callerUid]),
|
||||||
'updatedAt': now.toIso8601String(),
|
'updatedAt': now.toIso8601String(),
|
||||||
};
|
};
|
||||||
if (wasAccepted && ride.participantCount > 0) {
|
if (ride.participantCount > 0) {
|
||||||
rideUpdates['participantCount'] = FieldValue.increment(-1);
|
rideUpdates['participantCount'] = FieldValue.increment(-1);
|
||||||
}
|
}
|
||||||
transaction.update(rideRef, rideUpdates);
|
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({
|
Future<void> removeParticipant({
|
||||||
required String callerUid,
|
required String callerUid,
|
||||||
required String groupRideId,
|
required String groupRideId,
|
||||||
|
|
@ -693,8 +804,25 @@ class GroupRideService {
|
||||||
final rideDoc = await transaction.get(rideRef);
|
final rideDoc = await transaction.get(rideRef);
|
||||||
if (!rideDoc.exists || rideDoc.data() == null) return;
|
if (!rideDoc.exists || rideDoc.data() == null) return;
|
||||||
final ride = GroupRide.fromJson(rideDoc.data()!);
|
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);
|
final pDoc = await transaction.get(participantRef);
|
||||||
|
|
@ -709,14 +837,16 @@ class GroupRideService {
|
||||||
'updatedAt': now.toIso8601String(),
|
'updatedAt': now.toIso8601String(),
|
||||||
});
|
});
|
||||||
|
|
||||||
final rideUpdates = <String, dynamic>{
|
if (wasAccepted) {
|
||||||
'accessUids': FieldValue.arrayRemove([targetUid]),
|
final rideUpdates = <String, dynamic>{
|
||||||
'updatedAt': now.toIso8601String(),
|
'accessUids': FieldValue.arrayRemove([targetUid]),
|
||||||
};
|
'updatedAt': now.toIso8601String(),
|
||||||
if (wasAccepted && ride.participantCount > 0) {
|
};
|
||||||
rideUpdates['participantCount'] = FieldValue.increment(-1);
|
if (ride.participantCount > 0) {
|
||||||
|
rideUpdates['participantCount'] = FieldValue.increment(-1);
|
||||||
|
}
|
||||||
|
transaction.update(rideRef, rideUpdates);
|
||||||
}
|
}
|
||||||
transaction.update(rideRef, rideUpdates);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/core/theme/theme_extensions.dart';
|
||||||
import 'package:motorcycle_dash/features/social/clubs/club_detail_screen.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_providers.dart';
|
||||||
|
|
@ -44,6 +45,15 @@ class MockClubServiceForDetail extends ClubService {
|
||||||
lastAction = 'leaveClub';
|
lastAction = 'leaveClub';
|
||||||
lastClubId = clubId;
|
lastClubId = clubId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ClubRideDiscovery>> fetchClubDiscoveryRides(
|
||||||
|
String clubId, {
|
||||||
|
http.Client? client,
|
||||||
|
String? baseUrl,
|
||||||
|
}) async {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -523,7 +533,7 @@ void main() {
|
||||||
expect(find.text('Plan Club Ride'), findsNothing);
|
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(
|
final activeAdmin = ClubMember(
|
||||||
uid: 'rider_1',
|
uid: 'rider_1',
|
||||||
clubId: 'club_open',
|
clubId: 'club_open',
|
||||||
|
|
@ -552,11 +562,11 @@ void main() {
|
||||||
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('CLUB RIDES'), findsOneWidget);
|
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);
|
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(
|
final activeMember = ClubMember(
|
||||||
uid: 'rider_2',
|
uid: 'rider_2',
|
||||||
clubId: 'club_open',
|
clubId: 'club_open',
|
||||||
|
|
@ -585,7 +595,7 @@ void main() {
|
||||||
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('CLUB RIDES'), findsOneWidget);
|
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);
|
expect(find.text('Plan Club Ride'), findsNothing);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
class _FakeGroupRideLifecycleService implements GroupRideService {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,17 +76,12 @@ export default function ClubFounderRidesPage({ params }: PageProps) {
|
||||||
setPendingApplicationsCount(0);
|
setPendingApplicationsCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// In demo preview mode: load fixture rides for UI/design validation
|
// Load club rides for upcoming and past management views
|
||||||
if (isDemoPreview) {
|
const ridesResult = await fetchClubRides(ctx.club.id, currentUser?.uid, {
|
||||||
const ridesResult = await fetchClubRides(ctx.club.id, currentUser?.uid, {
|
allowDemoPreview: isDemoPreview,
|
||||||
allowDemoPreview: true,
|
});
|
||||||
});
|
setUpcomingRides(ridesResult.upcomingRides);
|
||||||
setUpcomingRides(ridesResult.upcomingRides);
|
setPastRides(ridesResult.pastRides);
|
||||||
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.
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(sanitizePortalError(err, "Club rides couldn't be loaded."));
|
setError(sanitizePortalError(err, "Club rides couldn't be loaded."));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -151,82 +146,7 @@ export default function ClubFounderRidesPage({ params }: PageProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal production operation: truthful discovery-pending state.
|
// Renders authoritative Founder Portal rides management view
|
||||||
// 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
|
|
||||||
return (
|
return (
|
||||||
<FounderPortalShell
|
<FounderPortalShell
|
||||||
club={context.club}
|
club={context.club}
|
||||||
|
|
|
||||||
|
|
@ -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 { firestore } from './firebase-client';
|
||||||
import {
|
import {
|
||||||
Club,
|
Club,
|
||||||
|
|
@ -1178,16 +1178,49 @@ export interface ClubRidesResult {
|
||||||
pastRides: ClubUpcomingRide[];
|
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:
|
* GOVERNANCE AUTHORIZATION:
|
||||||
* - GroupRide.clubId is persisted by the Flutter application.
|
* - Scoped to clubId, status, and scheduledStartAt matching Firestore security rules and composite indexes.
|
||||||
* - Firestore intentionally does not authorize a broad client-side clubId list query,
|
* - Active Club Owners and Admins have administrative visibility over all official club rides.
|
||||||
* because discovery must preserve mutual-block privacy.
|
* - Unrestricted collection reads are strictly avoided.
|
||||||
* - Production callers must not infer Club membership as GroupRide participation or
|
* - Demo preview remains available when explicitly requested.
|
||||||
* live-location access.
|
|
||||||
* - The explicit development preview remains fixture-backed.
|
|
||||||
*/
|
*/
|
||||||
export async function fetchClubRides(
|
export async function fetchClubRides(
|
||||||
clubId: string,
|
clubId: string,
|
||||||
|
|
@ -1207,8 +1240,37 @@ export async function fetchClubRides(
|
||||||
throw new Error('not-found');
|
throw new Error('not-found');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
const upcomingQuery = query(
|
||||||
'Club ride discovery is not available until a privacy-preserving production read path is implemented.'
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,6 +11,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"firebase": "12.18.0",
|
"firebase": "12.18.0",
|
||||||
|
"firebase-admin": "^14.4.0",
|
||||||
"next": "16.3.4",
|
"next": "16.3.4",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1"
|
"react-dom": "19.1.1"
|
||||||
|
|
|
||||||
|
|
@ -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_/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -982,15 +982,15 @@ async function runAllTests() {
|
||||||
assert.equal(unauthPerms.canPlanRides, false, 'Unauthenticated user has canPlanRides = false');
|
assert.equal(unauthPerms.canPlanRides, false, 'Unauthenticated user has canPlanRides = false');
|
||||||
assert(ridesPageSrc.includes('Authentication Required'), 'Rides page must guard against unauthenticated users');
|
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
|
// F. Production rides page renders authoritative FounderRides component
|
||||||
assert(ridesPageSrc.includes('Club Rides Discovery Pending'), 'Production rides page must display Club Rides Discovery Pending notice');
|
assert(ridesPageSrc.includes('<FounderRides'), 'Production rides page must render FounderRides component');
|
||||||
assert(ridesPageSrc.includes('DISCOVERY PENDING'), 'Production rides page must display DISCOVERY PENDING badge');
|
assert(ridesPageSrc.includes('upcomingRides={upcomingRides}'), 'Production rides page must pass upcomingRides');
|
||||||
assert(ridesPageSrc.includes('Club-affiliated Group Rides can now be created in the mobile application'), 'Must truthfully describe current mobile capability');
|
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'));
|
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("where('clubId', '==', normalized)"), 'fetchClubRides must scope to clubId');
|
||||||
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('limit(30)'), 'fetchClubRides must bound query');
|
||||||
|
|
||||||
// H. Action hardening: All unsupported actions are disabled with explanatory tooltips
|
// 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');
|
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)
|
// J. Location privacy invariant (ClubMember != GroupRideParticipant)
|
||||||
assert(founderRidesSrc.includes(verbatimNotice), 'Missing verbatim live location privacy notice in FounderRides');
|
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
|
// K. Demo preview development gating
|
||||||
assert(fetchRidesBody.includes("options?.allowDemoPreview && process.env.NODE_ENV === 'development'"), 'fetchClubRides must gate demo preview to development');
|
assert(fetchRidesBody.includes("options?.allowDemoPreview && process.env.NODE_ENV === 'development'"), 'fetchClubRides must gate demo preview to development');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue