feat(group-rides): add club-affiliated rides (#10)

Add optional immutable Club affiliation, authorized Club Owner/Admin ride planning, privacy-preserving point access, and narrowly scoped Club admin ride controls.

Club-wide ride discovery remains deferred pending a privacy-preserving architecture.
This commit is contained in:
Billie Ponder 2026-09-20 07:03:48 -04:00 committed by GitHub
parent e784c4895d
commit 68d4539f90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 2523 additions and 52 deletions

View File

@ -4,7 +4,7 @@ import {
assertSucceeds, assertSucceeds,
initializeTestEnvironment, initializeTestEnvironment,
} from '@firebase/rules-unit-testing'; } from '@firebase/rules-unit-testing';
import { collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc, writeBatch, query, where } from 'firebase/firestore'; import { collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc, writeBatch, query, where, orderBy, limit } from 'firebase/firestore';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@ -4362,5 +4362,588 @@ describe('Social Architecture Security Rules', () => {
}); });
}); });
}); });
});
describe('Club GroupRide Security Rules & Attack Matrix', () => {
async function seedClubGroupRideEnv() {
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
// Users
await setDoc(doc(db, 'users/alice'), { id: 'alice' });
await setDoc(doc(db, 'users/bob'), { id: 'bob' });
await setDoc(doc(db, 'users/charlie'), { id: 'charlie' });
await setDoc(doc(db, 'users/dave'), { id: 'dave' });
await setDoc(doc(db, 'users/eve_invited'), { id: 'eve_invited' });
await setDoc(doc(db, 'users/eve_requested'), { id: 'eve_requested' });
await setDoc(doc(db, 'users/eve_declined'), { id: 'eve_declined' });
await setDoc(doc(db, 'users/eve_left'), { id: 'eve_left' });
await setDoc(doc(db, 'users/eve_removed'), { id: 'eve_removed' });
await setDoc(doc(db, 'users/frank'), { id: 'frank' });
// Club Alpha (Active)
await setDoc(doc(db, 'clubs/club_alpha'), {
id: 'club_alpha',
name: 'Club Alpha',
slug: 'club-alpha',
ownerUid: 'alice',
memberCount: 3,
visibility: 'public',
admissionPolicy: 'open',
createdAt: new Date(),
updatedAt: new Date(),
archivedAt: null,
});
await setDoc(doc(db, 'clubs/club_alpha/members/alice'), {
uid: 'alice',
clubId: 'club_alpha',
role: 'owner',
status: 'active',
});
await setDoc(doc(db, 'clubs/club_alpha/members/bob'), {
uid: 'bob',
clubId: 'club_alpha',
role: 'admin',
status: 'active',
});
await setDoc(doc(db, 'clubs/club_alpha/members/charlie'), {
uid: 'charlie',
clubId: 'club_alpha',
role: 'member',
status: 'active',
});
await setDoc(doc(db, 'clubs/club_alpha/members/eve_invited'), {
uid: 'eve_invited',
clubId: 'club_alpha',
role: 'member',
status: 'invited',
});
await setDoc(doc(db, 'clubs/club_alpha/members/eve_requested'), {
uid: 'eve_requested',
clubId: 'club_alpha',
role: 'member',
status: 'requested',
});
await setDoc(doc(db, 'clubs/club_alpha/members/eve_declined'), {
uid: 'eve_declined',
clubId: 'club_alpha',
role: 'member',
status: 'declined',
});
await setDoc(doc(db, 'clubs/club_alpha/members/eve_left'), {
uid: 'eve_left',
clubId: 'club_alpha',
role: 'member',
status: 'left',
});
await setDoc(doc(db, 'clubs/club_alpha/members/eve_removed'), {
uid: 'eve_removed',
clubId: 'club_alpha',
role: 'member',
status: 'removed',
});
// Club Beta (Active, Frank owner)
await setDoc(doc(db, 'clubs/club_beta'), {
id: 'club_beta',
name: 'Club Beta',
slug: 'club-beta',
ownerUid: 'frank',
memberCount: 1,
visibility: 'public',
admissionPolicy: 'open',
createdAt: new Date(),
updatedAt: new Date(),
archivedAt: null,
});
await setDoc(doc(db, 'clubs/club_beta/members/frank'), {
uid: 'frank',
clubId: 'club_beta',
role: 'owner',
status: 'active',
});
// Club Archived
await setDoc(doc(db, 'clubs/club_archived'), {
id: 'club_archived',
name: 'Club Archived',
slug: 'club-archived',
ownerUid: 'alice',
memberCount: 1,
visibility: 'public',
admissionPolicy: 'open',
createdAt: new Date(),
updatedAt: new Date(),
archivedAt: new Date(),
});
await setDoc(doc(db, 'clubs/club_archived/members/alice'), {
uid: 'alice',
clubId: 'club_archived',
role: 'owner',
status: 'active',
});
});
}
const baseRideData = (id, organizerUid, clubId = undefined) => ({
id,
organizerUid,
title: 'Scenic Mountain Run',
status: 'scheduled',
scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z',
participantCount: 1,
accessUids: [organizerUid],
visibility: 'friendsOnly',
joinPolicy: 'inviteOnly',
meetingPoint: {
latitude: 34.6,
longitude: -83.9,
displayName: 'Dahlonega Square',
locationType: 'meetup',
},
...(clubId !== undefined ? { clubId } : {}),
});
// 1. LEGACY GROUPRIDES
test('1. Legacy GroupRides: create and update without clubId or with explicit null', async () => {
await seedClubGroupRideEnv();
const alice = testEnv.authenticatedContext('alice').firestore();
// 1a. Create legacy ride with missing clubId
await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_legacy_missing'), baseRideData('ride_legacy_missing', 'alice')));
// 1b. Create legacy ride with explicit null clubId
await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_legacy_null'), baseRideData('ride_legacy_null', 'alice', null)));
// 1c. Update legacy ride (missing clubId) without introducing clubId
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_missing'), {
title: 'Updated Legacy Title',
updatedAt: '2026-09-10T10:00:00Z',
}));
// 1d. Update legacy ride (explicit null clubId) keeping clubId null
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_null'), {
title: 'Updated Legacy Null Title',
clubId: null,
updatedAt: '2026-09-10T10:00:00Z',
}));
// 1e. Update legacy ride (missing clubId) with explicit null clubId succeeds (equivalent legacy state)
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_missing'), {
title: 'Updated Legacy Missing to Null Title',
clubId: null,
updatedAt: '2026-09-10T10:05:00Z',
}));
// 1f. Update legacy ride (explicit null clubId) without mentioning clubId succeeds (equivalent legacy state)
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_null'), {
title: 'Updated Legacy Null to Missing Title',
updatedAt: '2026-09-10T10:05:00Z',
}));
});
// 2. CREATE AUTHORIZATION
test('2. Club affiliation create authorization: owner/admin allowed; members, non-members, archived clubs denied', async () => {
await seedClubGroupRideEnv();
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
const charlie = testEnv.authenticatedContext('charlie').firestore();
const dave = testEnv.authenticatedContext('dave').firestore();
const frank = testEnv.authenticatedContext('frank').firestore();
// 2a. Active Club Owner (Alice) can create affiliated ride
await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_club_alice'), baseRideData('ride_club_alice', 'alice', 'club_alpha')));
// 2b. Active Club Admin (Bob) can create affiliated ride
await assertSucceeds(setDoc(doc(bob, 'group_rides/ride_club_bob'), baseRideData('ride_club_bob', 'bob', 'club_alpha')));
// 2c. Active ordinary Member (Charlie) CANNOT create affiliated ride
await assertFails(setDoc(doc(charlie, 'group_rides/ride_club_charlie'), baseRideData('ride_club_charlie', 'charlie', 'club_alpha')));
// 2d. Inactive/non-active memberships CANNOT create affiliated ride
for (const status of ['invited', 'requested', 'declined', 'left', 'removed']) {
const eve = testEnv.authenticatedContext(`eve_${status}`).firestore();
await assertFails(setDoc(doc(eve, `group_rides/ride_club_eve_${status}`), baseRideData(`group_rides/ride_club_eve_${status}`, `eve_${status}`, 'club_alpha')));
}
// 2e. Non-member (Dave) CANNOT create affiliated ride
await assertFails(setDoc(doc(dave, 'group_rides/ride_club_dave'), baseRideData('ride_club_dave', 'dave', 'club_alpha')));
// 2f. Admin of Club B (Frank) CANNOT create ride affiliated with Club A
await assertFails(setDoc(doc(frank, 'group_rides/ride_club_frank_wrong'), baseRideData('ride_club_frank_wrong', 'frank', 'club_alpha')));
// 2g. Cannot create ride for non-existent club
await assertFails(setDoc(doc(alice, 'group_rides/ride_club_missing'), baseRideData('ride_club_missing', 'alice', 'club_nonexistent')));
// 2h. Cannot create ride for archived club
await assertFails(setDoc(doc(alice, 'group_rides/ride_club_archived'), baseRideData('ride_club_archived', 'alice', 'club_archived')));
// 2i. Blank, whitespace, or malformed clubId is denied
await assertFails(setDoc(doc(alice, 'group_rides/ride_club_empty'), baseRideData('ride_club_empty', 'alice', '')));
await assertFails(setDoc(doc(alice, 'group_rides/ride_club_whitespace'), baseRideData('ride_club_whitespace', 'alice', ' ')));
await assertFails(setDoc(doc(alice, 'group_rides/ride_club_spaces'), baseRideData('ride_club_spaces', 'alice', 'club alpha with space')));
});
// 3. AFFILIATION IMMUTABILITY
test('3. Club affiliation immutability: cannot add, clear, or change clubId on existing rides', async () => {
await seedClubGroupRideEnv();
const alice = testEnv.authenticatedContext('alice').firestore();
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
await setDoc(doc(db, 'group_rides/ride_immut_personal'), baseRideData('ride_immut_personal', 'alice'));
await setDoc(doc(db, 'group_rides/ride_immut_club'), baseRideData('ride_immut_club', 'alice', 'club_alpha'));
});
// 3a. Personal ride cannot later gain clubId
await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_personal'), {
clubId: 'club_alpha',
}));
// 3b. Affiliated ride cannot clear clubId (set to null)
await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_club'), {
clubId: null,
}));
// 3c. Affiliated ride cannot change Club A -> Club B
await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_club'), {
clubId: 'club_beta',
}));
// 3d. Unrelated update cannot smuggle clubId change
await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_club'), {
title: 'Attempted Smuggle',
clubId: 'club_beta',
}));
// 3e. Legitimate update preserving same clubId succeeds
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_immut_club'), {
title: 'Legitimate Title Update',
clubId: 'club_alpha',
}));
});
// 4. ADMIN MANAGEMENT AUTHORITY & LIFECYCLE
test('4. Club Admin management authority: allowed planned metadata when scheduled; dedicated cancellation; underway/terminal restrictions', async () => {
await seedClubGroupRideEnv();
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
const charlie = testEnv.authenticatedContext('charlie').firestore();
const frank = testEnv.authenticatedContext('frank').firestore();
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
// Scheduled club ride organized by Alice
await setDoc(doc(db, 'group_rides/ride_admin_scheduled'), baseRideData('ride_admin_scheduled', 'alice', 'club_alpha'));
// Assembling club ride
await setDoc(doc(db, 'group_rides/ride_admin_assembling'), {
...baseRideData('ride_admin_assembling', 'alice', 'club_alpha'),
status: 'assembling',
});
// Underway club ride
await setDoc(doc(db, 'group_rides/ride_admin_underway'), {
...baseRideData('ride_admin_underway', 'alice', 'club_alpha'),
status: 'underway',
});
});
// 4a. Active Club Admin Bob can edit allowed scheduled metadata
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
title: 'Updated By Club Admin',
description: 'New route description',
participantLimit: 20,
meetingPoint: {
latitude: 34.7,
longitude: -83.8,
displayName: 'New Meetup Point',
locationType: 'meetup',
},
updatedAt: '2026-09-10T10:00:00Z',
}));
// 4b. Ordinary Member Charlie CANNOT edit scheduled metadata
await assertFails(updateDoc(doc(charlie, 'group_rides/ride_admin_scheduled'), {
title: 'Hacked by Member',
}));
// 4c. Admin from another Club (Frank) CANNOT edit scheduled metadata
await assertFails(updateDoc(doc(frank, 'group_rides/ride_admin_scheduled'), {
title: 'Hacked by Foreign Admin',
}));
// 4d. Bob CANNOT mutate accessUids
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
accessUids: ['alice', 'bob'],
}));
// 4e. Bob CANNOT mutate participantCount
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
participantCount: 5,
}));
// 4f. Bob CANNOT change organizerUid
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
organizerUid: 'bob',
}));
// 4g. Bob CANNOT perform arbitrary status transition to 'underway'
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
status: 'underway',
}));
// 4h. Assembling ride rejects arbitrary admin metadata edit
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_assembling'), {
title: 'Attempted Assembling Edit',
}));
// 4i. Scheduled ride: dedicated admin cancellation succeeds
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
status: 'canceled',
canceledAt: '2026-09-10T11:00:00Z',
canceledReason: 'Rain predicted',
updatedAt: '2026-09-10T11:00:00Z',
}));
// 4j. Assembling ride: dedicated admin cancellation succeeds
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_admin_assembling'), {
status: 'canceled',
canceledAt: '2026-09-10T11:00:00Z',
canceledReason: 'Club event canceled',
updatedAt: '2026-09-10T11:00:00Z',
}));
// 4k. Underway ride: non-organizer Club admin cancellation FAILS
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_underway'), {
status: 'canceled',
canceledAt: '2026-09-10T11:00:00Z',
canceledReason: 'Admin stopping ride',
updatedAt: '2026-09-10T11:00:00Z',
}));
// 4l. Underway ride: Organizer Alice CAN cancel
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_admin_underway'), {
status: 'canceled',
canceledAt: '2026-09-10T11:00:00Z',
canceledReason: 'Mechanical issue',
updatedAt: '2026-09-10T11:00:00Z',
}));
});
// 5. TERMINAL-RIDE SECURITY HARDENING
test('5. Terminal-ride security hardening: completed and canceled parent rides cannot be mutated', async () => {
await seedClubGroupRideEnv();
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
// Completed personal ride
await setDoc(doc(db, 'group_rides/ride_term_personal_comp'), {
...baseRideData('ride_term_personal_comp', 'alice'),
status: 'completed',
});
// Canceled personal ride
await setDoc(doc(db, 'group_rides/ride_term_personal_canc'), {
...baseRideData('ride_term_personal_canc', 'alice'),
status: 'canceled',
});
// Completed club ride
await setDoc(doc(db, 'group_rides/ride_term_club_comp'), {
...baseRideData('ride_term_club_comp', 'alice', 'club_alpha'),
status: 'completed',
});
// Canceled club ride
await setDoc(doc(db, 'group_rides/ride_term_club_canc'), {
...baseRideData('ride_term_club_canc', 'alice', 'club_alpha'),
status: 'canceled',
});
});
// 5a. Completed personal ride cannot be mutated by organizer (even with same status)
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_comp'), {
title: 'New Title Post Completion',
}));
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_comp'), {
status: 'completed',
title: 'Same-status mutate attempt',
}));
// 5b. Canceled personal ride cannot be mutated by organizer
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_canc'), {
title: 'New Title Post Cancellation',
}));
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_canc'), {
status: 'canceled',
title: 'Same-status mutate attempt',
}));
// 5c. Completed club ride cannot be mutated by organizer or club admin
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_club_comp'), {
title: 'Organizer Mutate Attempt',
}));
await assertFails(updateDoc(doc(bob, 'group_rides/ride_term_club_comp'), {
title: 'Admin Mutate Attempt',
}));
// 5d. Canceled club ride cannot be mutated by organizer or club admin
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_club_canc'), {
title: 'Organizer Mutate Attempt',
}));
await assertFails(updateDoc(doc(bob, 'group_rides/ride_term_club_canc'), {
title: 'Admin Mutate Attempt',
}));
});
// 6. LIVE-LOCATION PRIVACY (CLUBMEMBER != GROUPRIDEPARTICIPANT)
test('6. Live-location privacy: Club membership or admin role NEVER grants live_presence access', async () => {
await seedClubGroupRideEnv();
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
const charlie = testEnv.authenticatedContext('charlie').firestore();
const validAlicePresence = {
userId: 'alice',
groupRideId: 'ride_privacy_live',
latitude: 34.5378,
longitude: -83.9849,
heading: 90.0,
speedMph: 45.0,
accuracyMeters: 5.0,
updatedAt: '2026-09-10T09:35:00Z',
expiresAt: '2026-09-10T09:37:00Z',
sharingEnabled: true,
status: 'riding',
};
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
// Club-affiliated ride that is underway
await setDoc(doc(db, 'group_rides/ride_privacy_live'), {
...baseRideData('ride_privacy_live', 'alice', 'club_alpha'),
status: 'underway',
visibility: 'public',
});
// Alice is accepted participant
await setDoc(doc(db, 'group_rides/ride_privacy_live/participants/alice'), {
userId: 'alice',
role: 'organizer',
status: 'accepted',
riderHandle: 'alice_rider',
riderDisplayName: 'Alice',
});
// Alice's live presence
await setDoc(doc(db, 'group_rides/ride_privacy_live/live_presence/alice'), validAlicePresence);
});
// 6a. Alice can read her own live presence
await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_privacy_live/live_presence/alice')));
// 6b. Bob is Club Admin, but NOT an accepted participant -> CANNOT read Alice's live presence
await assertFails(getDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/alice')));
// 6c. Charlie is Club Member, but NOT an accepted participant -> CANNOT read Alice's live presence
await assertFails(getDoc(doc(charlie, 'group_rides/ride_privacy_live/live_presence/alice')));
// 6d. Bob cannot write live presence if not an accepted participant
await assertFails(setDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/bob'), {
...validAlicePresence,
userId: 'bob',
}));
// 6e. When Bob is added as accepted participant, Bob CAN read Alice's live presence
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
await setDoc(doc(db, 'group_rides/ride_privacy_live/participants/bob'), {
userId: 'bob',
role: 'participant',
status: 'accepted',
riderHandle: 'bob_rider',
riderDisplayName: 'Bob',
});
});
await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/alice')));
// 6f. If Alice blocks Bob, Bob CANNOT read Alice's live presence even though both are accepted participants and in the same Club
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
await setDoc(doc(db, 'users/alice/blocks/bob'), { blockedUid: 'bob' });
});
await assertFails(getDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/alice')));
});
// 7. READ & LIST PRIVACY
test('7. Point-read authorization and list privacy: active club members can point-read; blocks deny point-read; broad club list query is denied', async () => {
await seedClubGroupRideEnv();
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
const charlie = testEnv.authenticatedContext('charlie').firestore();
const dave = testEnv.authenticatedContext('dave').firestore();
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
// Private club ride (not public, not friendsOnly)
await setDoc(doc(db, 'group_rides/ride_discovery_private'), {
...baseRideData('ride_discovery_private', 'alice', 'club_alpha'),
visibility: 'private',
});
});
// 7a. Active Club Member Charlie can point-read private club ride
await assertSucceeds(getDoc(doc(charlie, 'group_rides/ride_discovery_private')));
// 7b. Active Club Admin Bob can point-read private club ride
await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_discovery_private')));
// 7c. Non-member Dave CANNOT read private club ride
await assertFails(getDoc(doc(dave, 'group_rides/ride_discovery_private')));
// 7d-1. Block privacy: If Alice blocks Charlie, Charlie CANNOT read the ride
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
await setDoc(doc(db, 'users/alice/blocks/charlie'), { blockedUid: 'charlie' });
});
await assertFails(getDoc(doc(charlie, 'group_rides/ride_discovery_private')));
// Clean up Alice's block
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
await deleteDoc(doc(db, 'users/alice/blocks/charlie'));
});
// 7d-2. Block privacy: If Charlie blocks Alice (reverse), Charlie CANNOT read the ride
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
await setDoc(doc(db, 'users/charlie/blocks/alice'), { blockedUid: 'alice' });
});
await assertFails(getDoc(doc(charlie, 'group_rides/ride_discovery_private')));
// Clean up Charlie's block
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
await deleteDoc(doc(db, 'users/charlie/blocks/alice'));
});
// 7e. Unsafe broad Club-member list query is NOT authorized (rule branch removed)
const charlieClubQuery = query(
collection(charlie, 'group_rides'),
where('clubId', '==', 'club_alpha'),
);
await assertFails(getDocs(charlieClubQuery));
// 7f. Non-member Dave CANNOT list club rides
const daveClubQuery = query(
collection(dave, 'group_rides'),
where('clubId', '==', 'club_alpha'),
);
await assertFails(getDocs(daveClubQuery));
// 7g. Ordered broad query is also NOT authorized
const charlieOrderedQuery = query(
collection(charlie, 'group_rides'),
where('clubId', '==', 'club_alpha'),
orderBy('scheduledStartAt', 'desc'),
limit(30),
);
await assertFails(getDocs(charlieOrderedQuery));
});
});
});

View File

@ -198,7 +198,7 @@ service cloud.firestore {
// Top-level social object distinct from personal rides, accounts, and telemetry. // Top-level social object distinct from personal rides, accounts, and telemetry.
match /group_rides/{groupRideId} { match /group_rides/{groupRideId} {
function isOrganizer() { function isOrganizer() {
return signedIn() && resource.data.organizerUid == request.auth.uid; return signedIn() && 'organizerUid' in resource.data && resource.data.organizerUid == request.auth.uid;
} }
function hasParticipantAccess(uid) { function hasParticipantAccess(uid) {
@ -210,6 +210,55 @@ service cloud.firestore {
return signedIn() && exists(/databases/$(database)/documents/users/$(resource.data.organizerUid)/friends/$(request.auth.uid)); return signedIn() && exists(/databases/$(database)/documents/users/$(resource.data.organizerUid)/friends/$(request.auth.uid));
} }
function hasClubAffiliation(data) {
return 'clubId' in data && data.clubId != null;
}
function validClubId(clubId) {
return clubId is string && clubId.size() >= 1 && clubId.size() <= 64 && clubId.matches('^\\S+$');
}
function clubExists(clubId) {
return exists(/databases/$(database)/documents/clubs/$(clubId));
}
function clubIsActive(clubId) {
return clubExists(clubId) &&
(!('archivedAt' in get(/databases/$(database)/documents/clubs/$(clubId)).data) ||
get(/databases/$(database)/documents/clubs/$(clubId)).data.archivedAt == null);
}
function hasClubMemberDoc(clubId, uid) {
return exists(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid));
}
function getClubMemberData(clubId, uid) {
return get(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)).data;
}
function isActiveClubMember(clubId, uid) {
return isRegisteredUser() &&
hasClubMemberDoc(clubId, uid) &&
getClubMemberData(clubId, uid).status == 'active';
}
function isActiveClubAdmin(clubId, uid) {
return isActiveClubMember(clubId, uid) &&
getClubMemberData(clubId, uid).role in ['owner', 'admin'];
}
function sameClubAffiliation(prevData, nextData) {
let prevHas = hasClubAffiliation(prevData);
let nextHas = hasClubAffiliation(nextData);
return (!prevHas && !nextHas) ||
(prevHas && nextHas && prevData.clubId == nextData.clubId);
}
function isClubRideMember() {
return hasClubAffiliation(resource.data) &&
isActiveClubMember(resource.data.clubId, request.auth.uid);
}
function canReadRide() { function canReadRide() {
return signedIn() && ( return signedIn() && (
isOrganizer() || isOrganizer() ||
@ -217,7 +266,8 @@ service cloud.firestore {
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && ( !areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
hasParticipantAccess(request.auth.uid) || hasParticipantAccess(request.auth.uid) ||
resource.data.visibility == 'public' || resource.data.visibility == 'public' ||
(resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer()) (resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer()) ||
isClubRideMember()
) )
) )
); );
@ -226,7 +276,10 @@ service cloud.firestore {
allow get: if canReadRide(); allow get: if canReadRide();
allow list: if signedIn() && ( allow list: if signedIn() && (
resource.data.organizerUid == request.auth.uid || (
'organizerUid' in resource.data &&
resource.data.organizerUid == request.auth.uid
) ||
( (
'accessUids' in resource.data && 'accessUids' in resource.data &&
request.auth.uid in resource.data.accessUids request.auth.uid in resource.data.accessUids
@ -246,15 +299,25 @@ service cloud.firestore {
exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) && exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) &&
get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid
) )
)
&& (
!hasClubAffiliation(request.resource.data) ||
(
validClubId(request.resource.data.clubId) &&
clubIsActive(request.resource.data.clubId) &&
isActiveClubAdmin(request.resource.data.clubId, request.auth.uid)
)
); );
function validStatusTransition() { function validStatusTransition() {
let prev = resource.data.status; let prev = 'status' in resource.data ? resource.data.status : 'scheduled';
let next = request.resource.data.status; let next = 'status' in request.resource.data ? request.resource.data.status : prev;
return prev == next || return prev in ['scheduled', 'assembling', 'underway'] && (
prev == next ||
(prev == 'scheduled' && next in ['assembling', 'underway', 'canceled']) || (prev == 'scheduled' && next in ['assembling', 'underway', 'canceled']) ||
(prev == 'assembling' && next in ['underway', 'canceled']) || (prev == 'assembling' && next in ['underway', 'canceled']) ||
(prev == 'underway' && next in ['completed', 'canceled']); (prev == 'underway' && next in ['completed', 'canceled'])
);
} }
function validRouteAttachment() { function validRouteAttachment() {
@ -330,9 +393,42 @@ service cloud.firestore {
); );
} }
allow update: if signedIn() && ( function validClubAdminRideUpdate() {
let clubId = resource.data.clubId;
let prevStatus = 'status' in resource.data ? resource.data.status : 'scheduled';
let nextStatus = 'status' in request.resource.data ? request.resource.data.status : prevStatus;
return isActiveClubAdmin(clubId, request.auth.uid) && (
// Sub-case A: Scheduled metadata edit
(
prevStatus == 'scheduled' &&
nextStatus == 'scheduled' &&
request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'title', 'description', 'visibility', 'joinPolicy',
'scheduledStartAt', 'departureAt', 'participantLimit',
'meetingPoint', 'destination', 'stops',
'routeId', 'routeTitle', 'routeDistanceMeters', 'routeStopCount',
'updatedAt'
]) &&
validRouteAttachment()
) ||
// Sub-case B: Dedicated narrow admin cancellation (scheduled or assembling only)
(
prevStatus in ['scheduled', 'assembling'] &&
nextStatus == 'canceled' &&
request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'status', 'canceledAt', 'canceledReason', 'updatedAt'
])
)
);
}
allow update: if signedIn() &&
(!('status' in resource.data) || !(resource.data.status in ['completed', 'canceled'])) &&
sameClubAffiliation(resource.data, request.resource.data) &&
(
(isOrganizer() && validOrganizerRideUpdate()) || (isOrganizer() && validOrganizerRideUpdate()) ||
validParticipantRideUpdate() validParticipantRideUpdate() ||
(hasClubAffiliation(resource.data) && validClubAdminRideUpdate())
); );
allow delete: if isOrganizer(); allow delete: if isOrganizer();

View File

@ -224,7 +224,10 @@ final appRouter = GoRouter(
routes: [ routes: [
GoRoute( GoRoute(
path: 'create', path: 'create',
builder: (context, state) => const CreateGroupRideScreen(), builder: (context, state) {
final clubId = state.uri.queryParameters['clubId'];
return CreateGroupRideScreen(clubId: clubId);
},
), ),
GoRoute( GoRoute(
path: ':id', path: ':id',

View File

@ -14,10 +14,15 @@ import '../../services/routes/route_models.dart';
import '../../services/routes/route_provider.dart'; import '../../services/routes/route_provider.dart';
import '../../services/routes/route_providers.dart'; import '../../services/routes/route_providers.dart';
import '../../services/routes/route_service.dart'; import '../../services/routes/route_service.dart';
import '../../services/clubs/club_models.dart';
import '../../services/clubs/club_providers.dart';
import '../routes/widgets/address_autocomplete_field.dart'; import '../routes/widgets/address_autocomplete_field.dart';
import '../social/clubs/widgets/club_monogram.dart';
class CreateGroupRideScreen extends ConsumerStatefulWidget { class CreateGroupRideScreen extends ConsumerStatefulWidget {
const CreateGroupRideScreen({super.key}); const CreateGroupRideScreen({super.key, this.clubId});
final String? clubId;
@override @override
ConsumerState<CreateGroupRideScreen> createState() => ConsumerState<CreateGroupRideScreen> createState() =>
@ -223,6 +228,15 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
return; return;
} }
if (widget.clubId != null) {
final club = ref.read(clubDetailProvider(widget.clubId!)).valueOrNull;
final member = ref.read(currentMemberProvider(widget.clubId!)).valueOrNull;
if (club == null || club.isArchived || member == null || !member.status.isActive || !member.role.isAtLeast(ClubRole.admin)) {
setState(() => _errorMessage = 'Cannot create club ride: leadership permission required.');
return;
}
}
setState(() => _isSaving = true); setState(() => _isSaving = true);
try { try {
final meetingPoint = GroupRideLocation( final meetingPoint = GroupRideLocation(
@ -265,6 +279,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
routeTitle: _selectedRouteTitle, routeTitle: _selectedRouteTitle,
routeDistanceMeters: _selectedRouteDistanceMeters, routeDistanceMeters: _selectedRouteDistanceMeters,
routeStopCount: _selectedRouteStopCount, routeStopCount: _selectedRouteStopCount,
clubId: widget.clubId,
); );
if (mounted) { if (mounted) {
@ -274,7 +289,11 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', ''); _errorMessage = e
.toString()
.replaceAll('Exception: ', '')
.replaceAll('StateError: ', '')
.replaceAll('ArgumentError: ', '');
_isSaving = false; _isSaving = false;
}); });
} }
@ -633,14 +652,52 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
final colors = context.motorColors; final colors = context.motorColors;
ref.watch(myRiderProfileProvider); ref.watch(myRiderProfileProvider);
final clubAsync = widget.clubId != null
? ref.watch(clubDetailProvider(widget.clubId!))
: null;
final memberAsync = widget.clubId != null
? ref.watch(currentMemberProvider(widget.clubId!))
: null;
bool canSubmit = !_isSaving;
String? clubValidationError;
if (widget.clubId != null) {
if (clubAsync == null || memberAsync == null || clubAsync.isLoading || memberAsync.isLoading) {
canSubmit = false;
} else if (clubAsync.hasError) {
canSubmit = false;
clubValidationError = 'Failed to load club: ${clubAsync.error}';
} else if (memberAsync.hasError) {
canSubmit = false;
clubValidationError = 'Failed to verify club membership: ${memberAsync.error}';
} else {
final club = clubAsync.value;
final member = memberAsync.value;
if (club == null) {
canSubmit = false;
clubValidationError = 'Club not found or does not exist.';
} else if (club.isArchived) {
canSubmit = false;
clubValidationError = 'This club has been archived and cannot organize rides.';
} else if (member == null || !member.status.isActive) {
canSubmit = false;
clubValidationError = 'You are not an active member of this club.';
} else if (!member.role.isAtLeast(ClubRole.admin)) {
canSubmit = false;
clubValidationError = 'Only club owners and administrators can create club rides.';
}
}
}
return Scaffold( return Scaffold(
backgroundColor: colors.surfaceContainer, backgroundColor: colors.surfaceContainer,
appBar: AppBar( appBar: AppBar(
backgroundColor: colors.surfaceContainerHigh, backgroundColor: colors.surfaceContainerHigh,
elevation: 0, elevation: 0,
title: const Text( title: Text(
'CREATE GROUP RIDE', widget.clubId != null ? 'CREATE CLUB RIDE' : 'CREATE GROUP RIDE',
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 1.0, letterSpacing: 1.0,
fontSize: 15, fontSize: 15,
@ -648,7 +705,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: _isSaving ? null : _submit, onPressed: (_isSaving || !canSubmit) ? null : _submit,
child: _isSaving child: _isSaving
? SizedBox( ? SizedBox(
width: 16, width: 16,
@ -661,7 +718,12 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
: Text( : Text(
'PUBLISH', 'PUBLISH',
style: TextStyle( style: TextStyle(
color: colors.cyberCyan, color: canSubmit
? colors.cyberCyan
: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.5),
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@ -676,9 +738,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
(snapshot.isLandscape && snapshot.width >= 720); (snapshot.isLandscape && snapshot.width >= 720);
if (isWide) { if (isWide) {
return _buildWideForm(context, colors, snapshot); return _buildWideForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
} }
return _buildCompactForm(context, colors, snapshot); return _buildCompactForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
}, },
), ),
), ),
@ -689,12 +751,18 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
BuildContext context, BuildContext context,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
ResponsiveSnapshot snapshot, ResponsiveSnapshot snapshot,
bool canSubmit,
AsyncValue<Club?>? clubAsync,
AsyncValue<ClubMember?>? memberAsync,
String? clubValidationError,
) { ) {
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (widget.clubId != null)
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
if (_errorMessage != null) _buildErrorBanner(colors), if (_errorMessage != null) _buildErrorBanner(colors),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -726,7 +794,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
_buildMapPreviewSection(colors, snapshot), _buildMapPreviewSection(colors, snapshot),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildSubmitButton(colors), _buildSubmitButton(colors, canSubmit),
], ],
), ),
), ),
@ -742,10 +810,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
BuildContext context, BuildContext context,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
ResponsiveSnapshot snapshot, ResponsiveSnapshot snapshot,
bool canSubmit,
AsyncValue<Club?>? clubAsync,
AsyncValue<ClubMember?>? memberAsync,
String? clubValidationError,
) { ) {
return ListView( return ListView(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
children: [ children: [
if (widget.clubId != null)
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
if (_errorMessage != null) _buildErrorBanner(colors), if (_errorMessage != null) _buildErrorBanner(colors),
_buildRideDetailsSection(colors), _buildRideDetailsSection(colors),
const SizedBox(height: 24), const SizedBox(height: 24),
@ -759,12 +833,180 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
_buildPrivacySection(colors), _buildPrivacySection(colors),
const SizedBox(height: 32), const SizedBox(height: 32),
_buildSubmitButton(colors), _buildSubmitButton(colors, canSubmit),
const SizedBox(height: 32), const SizedBox(height: 32),
], ],
); );
} }
Widget _buildClubAffiliationHeader(
MotorSignalThemeColors colors,
AsyncValue<Club?>? clubAsync,
AsyncValue<ClubMember?>? memberAsync,
String? validationError,
) {
if (widget.clubId == null) return const SizedBox.shrink();
final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant;
if (clubAsync == null || memberAsync == null || clubAsync.isLoading || memberAsync.isLoading) {
return Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant),
),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.cyberCyan,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Verifying club leadership permissions...',
style: TextStyle(
color: onSurfaceVariant,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
if (validationError != null) {
return Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.raceRed, width: 1),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, color: colors.raceRed, size: 22),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Club Affiliation Error',
style: TextStyle(
color: colors.raceRed,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
validationError,
style: TextStyle(
color: colors.raceRed,
fontSize: 12,
),
),
],
),
),
],
),
);
}
final club = clubAsync.value;
final member = memberAsync.value;
return Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.cyberCyan.withValues(alpha: 0.5), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ClubMonogram(name: club?.name ?? '', size: 40, fontSize: 16),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: colors.cyberCyan, width: 0.6),
),
child: Text(
'CLUB RIDE',
style: TextStyle(
color: colors.cyberCyan,
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
),
const SizedBox(width: 8),
if (member != null)
Text(
member.role.label.toUpperCase(),
style: TextStyle(
color: onSurfaceVariant,
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
],
),
const SizedBox(height: 4),
Text(
'Planning for ${club?.name ?? 'Club'}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
),
],
),
const SizedBox(height: 10),
Text(
'This ride will be affiliated with ${club?.name ?? 'the club'}. Club members can discover it on the club page. Note: Club members are not automatically added as participants.',
style: TextStyle(
color: onSurfaceVariant,
fontSize: 11.5,
height: 1.3,
),
),
],
),
);
}
Widget _buildErrorBanner(MotorSignalThemeColors colors) { Widget _buildErrorBanner(MotorSignalThemeColors colors) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
@ -1488,24 +1730,24 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
); );
} }
Widget _buildSubmitButton(MotorSignalThemeColors colors) { Widget _buildSubmitButton(MotorSignalThemeColors colors, bool canSubmit) {
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
height: 48, height: 48,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan, backgroundColor: canSubmit ? colors.cyberCyan : colors.surfaceContainerHighest,
foregroundColor: Colors.black, foregroundColor: canSubmit ? Colors.black : Theme.of(context).colorScheme.onSurfaceVariant,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
onPressed: _isSaving ? null : _submit, onPressed: (_isSaving || !canSubmit) ? null : _submit,
child: _isSaving child: _isSaving
? const CircularProgressIndicator(color: Colors.black) ? const CircularProgressIndicator(color: Colors.black)
: const Text( : Text(
'PUBLISH GROUP RIDE', widget.clubId != null ? 'PUBLISH CLUB RIDE' : 'PUBLISH GROUP RIDE',
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 1.0, letterSpacing: 1.0,
), ),

View File

@ -10,6 +10,8 @@ import '../../services/group_rides/group_ride_models.dart';
import '../../services/group_rides/group_ride_providers.dart'; import '../../services/group_rides/group_ride_providers.dart';
import '../../services/social/social_providers.dart'; import '../../services/social/social_providers.dart';
import '../../services/routes/route_providers.dart'; import '../../services/routes/route_providers.dart';
import '../../services/clubs/club_models.dart';
import '../../services/clubs/club_providers.dart';
class GroupRideDetailScreen extends ConsumerStatefulWidget { class GroupRideDetailScreen extends ConsumerStatefulWidget {
final String rideId; final String rideId;
@ -201,6 +203,12 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
final isOrganizer = final isOrganizer =
currentUid != null && ride.organizerUid == currentUid; currentUid != null && ride.organizerUid == currentUid;
final clubMember = ride.clubId != null
? ref.watch(currentMemberProvider(ride.clubId!)).asData?.value
: null;
final isClubAdmin = clubMember != null &&
clubMember.status.isActive &&
clubMember.role.isAtLeast(ClubRole.admin);
return participantsAsync.when( return participantsAsync.when(
data: (participants) { data: (participants) {
@ -224,6 +232,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
participants, participants,
myParticipant, myParticipant,
isOrganizer, isOrganizer,
isClubAdmin,
currentUid, currentUid,
colors, colors,
); );
@ -235,6 +244,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
participants, participants,
myParticipant, myParticipant,
isOrganizer, isOrganizer,
isClubAdmin,
currentUid, currentUid,
colors, colors,
); );
@ -257,7 +267,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
), ),
error: (e, _) => Center( error: (e, _) => Center(
child: Text( child: Text(
'Error loading ride: $e', 'Error loading group ride: $e',
style: TextStyle(color: colors.raceRed), style: TextStyle(color: colors.raceRed),
), ),
), ),
@ -271,6 +281,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
List<GroupRideParticipant> participants, List<GroupRideParticipant> participants,
GroupRideParticipant? myParticipant, GroupRideParticipant? myParticipant,
bool isOrganizer, bool isOrganizer,
bool isClubAdmin,
String? currentUid, String? currentUid,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
) { ) {
@ -289,7 +300,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
_buildStatusMessageBanner(colors), _buildStatusMessageBanner(colors),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
_buildHeaderCard(context, ride, isOrganizer, colors), _buildHeaderCard(context, ride, isOrganizer, isClubAdmin, colors),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildActionBanner( _buildActionBanner(
context, context,
@ -330,6 +341,13 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[ if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[
_buildOrganizerControlCard(context, ride, colors), _buildOrganizerControlCard(context, ride, colors),
const SizedBox(height: 16), const SizedBox(height: 16),
] else if (isClubAdmin &&
!ride.isCanceled &&
!ride.isCompleted &&
(ride.status == GroupRideStatus.scheduled ||
ride.status == GroupRideStatus.assembling)) ...[
_buildClubAdminControlCard(context, ride, colors),
const SizedBox(height: 16),
], ],
_buildParticipantsCard( _buildParticipantsCard(
ride, ride,
@ -356,6 +374,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
List<GroupRideParticipant> participants, List<GroupRideParticipant> participants,
GroupRideParticipant? myParticipant, GroupRideParticipant? myParticipant,
bool isOrganizer, bool isOrganizer,
bool isClubAdmin,
String? currentUid, String? currentUid,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
) { ) {
@ -366,11 +385,18 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
_buildStatusMessageBanner(colors), _buildStatusMessageBanner(colors),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
_buildHeaderCard(context, ride, isOrganizer, colors), _buildHeaderCard(context, ride, isOrganizer, isClubAdmin, colors),
const SizedBox(height: 16), const SizedBox(height: 16),
if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[ if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[
_buildOrganizerControlCard(context, ride, colors), _buildOrganizerControlCard(context, ride, colors),
const SizedBox(height: 16), const SizedBox(height: 16),
] else if (isClubAdmin &&
!ride.isCanceled &&
!ride.isCompleted &&
(ride.status == GroupRideStatus.scheduled ||
ride.status == GroupRideStatus.assembling)) ...[
_buildClubAdminControlCard(context, ride, colors),
const SizedBox(height: 16),
], ],
_buildActionBanner( _buildActionBanner(
context, context,
@ -437,6 +463,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
BuildContext context, BuildContext context,
GroupRide ride, GroupRide ride,
bool isOrganizer, bool isOrganizer,
bool isClubAdmin,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
) { ) {
return Card( return Card(
@ -501,18 +528,25 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
], ],
), ),
), ),
if (isOrganizer && !ride.isCanceled) if ((isOrganizer && !ride.isCanceled) ||
(isClubAdmin &&
!ride.isCanceled &&
(ride.status == GroupRideStatus.scheduled ||
ride.status == GroupRideStatus.assembling)))
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white70), icon: const Icon(Icons.more_vert, color: Colors.white70),
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
onSelected: (val) async { onSelected: (val) async {
if (val == 'invite') { if (val == 'invite') {
_showInviteFriendsSheet(context, ride, colors); _showInviteFriendsSheet(context, ride, colors);
} else if (val == 'edit') {
_showEditDetailsDialog(context, ride, colors);
} else if (val == 'cancel') { } else if (val == 'cancel') {
_confirmCancelRide(ride, colors); _confirmCancelRide(ride, colors);
} }
}, },
itemBuilder: (ctx) => [ itemBuilder: (ctx) => [
if (isOrganizer)
const PopupMenuItem( const PopupMenuItem(
value: 'invite', value: 'invite',
child: Text( child: Text(
@ -520,6 +554,14 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
style: TextStyle(color: Colors.white), style: TextStyle(color: Colors.white),
), ),
), ),
if (ride.status == GroupRideStatus.scheduled)
const PopupMenuItem(
value: 'edit',
child: Text(
'Edit Details',
style: TextStyle(color: Colors.white),
),
),
PopupMenuItem( PopupMenuItem(
value: 'cancel', value: 'cancel',
child: Text( child: Text(
@ -2030,6 +2072,181 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
); );
} }
Widget _buildClubAdminControlCard(
BuildContext context,
GroupRide ride,
MotorSignalThemeColors colors,
) {
return Card(
color: colors.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.warningAmber, width: 0.8),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.shield,
color: colors.warningAmber,
size: 18,
),
const SizedBox(width: 8),
Text(
'CLUB ADMIN CONTROLS',
style: TextStyle(
color: colors.warningAmber,
fontWeight: FontWeight.bold,
letterSpacing: 1.0,
fontSize: 12,
),
),
],
),
const SizedBox(height: 10),
Text(
'Current Status: ${ride.status.label.toUpperCase()}',
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (ride.status == GroupRideStatus.scheduled)
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan),
),
icon: const Icon(Icons.edit, size: 16),
label: const Text('EDIT DETAILS'),
onPressed: () =>
_showEditDetailsDialog(context, ride, colors),
),
if (ride.status == GroupRideStatus.scheduled ||
ride.status == GroupRideStatus.assembling)
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: colors.raceRed.withValues(alpha: 0.2),
foregroundColor: colors.raceRed,
side: BorderSide(color: colors.raceRed),
),
icon: const Icon(Icons.cancel, size: 16),
label: const Text('CANCEL RIDE'),
onPressed: () => _confirmCancelRide(ride, colors),
),
],
),
],
),
),
);
}
void _showEditDetailsDialog(
BuildContext context,
GroupRide ride,
MotorSignalThemeColors colors,
) {
final titleController = TextEditingController(text: ride.title);
final descController = TextEditingController(text: ride.description);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant),
),
title: const Text(
'Edit Ride Details',
style: TextStyle(color: Colors.white),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Title',
labelStyle: TextStyle(color: colors.cyberCyan),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: colors.outlineVariant),
),
),
),
const SizedBox(height: 12),
TextField(
controller: descController,
style: const TextStyle(color: Colors.white),
maxLines: 3,
decoration: InputDecoration(
labelText: 'Description',
labelStyle: TextStyle(color: colors.cyberCyan),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: colors.outlineVariant),
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('CANCEL', style: TextStyle(color: Colors.white60)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
),
onPressed: () async {
final newTitle = titleController.text.trim();
if (newTitle.length < 3) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Title must be at least 3 characters.'),
backgroundColor: colors.raceRed,
),
);
return;
}
final messenger = ScaffoldMessenger.of(context);
Navigator.pop(ctx);
final currentUid = ref.read(currentUserProvider)?.uid;
if (currentUid == null) return;
try {
await ref.read(groupRideServiceProvider).updateGroupRide(
callerUid: currentUid,
groupRideId: ride.id,
title: newTitle,
description: descController.text.trim(),
);
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text('Failed to update ride: $e'),
backgroundColor: colors.raceRed,
),
);
}
}
},
child: const Text('SAVE'),
),
],
),
);
}
Widget _buildLivePresenceSection( Widget _buildLivePresenceSection(
BuildContext context, BuildContext context,
GroupRide ride, GroupRide ride,

View File

@ -194,6 +194,17 @@ class ClubDetailScreen extends ConsumerWidget {
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
// Club Rides Card
_buildClubRidesCard(
context,
ref,
club,
myMembership,
colors,
theme,
),
const SizedBox(height: 18),
// Description Card // Description Card
_buildDescriptionCard(context, club, colors, theme), _buildDescriptionCard(context, club, colors, theme),
const SizedBox(height: 18), const SizedBox(height: 18),
@ -936,4 +947,79 @@ class ClubDetailScreen extends ConsumerWidget {
), ),
); );
} }
Widget _buildClubRidesCard(
BuildContext context,
WidgetRef ref,
Club club,
ClubMember? member,
MotorSignalThemeColors colors,
ThemeData theme,
) {
final isActiveMember = member != null && member.status.isActive;
final canPlanRides =
isActiveMember && member.role.isAtLeast(ClubRole.admin);
return Container(
padding: const EdgeInsets.all(18.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outlineVariant, width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'CLUB RIDES',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: colors.cyberCyan,
),
),
if (canPlanRides)
ElevatedButton.icon(
onPressed: () => context.push('/group-rides/create?clubId=${club.id}'),
icon: const Icon(Icons.add_road, size: 14),
label: const Text('Plan Club Ride'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
textStyle: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Text(
isActiveMember
? 'Club ride discovery is coming soon.'
: 'Club rides are visible to active members only.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
} }

View File

@ -369,7 +369,12 @@ class GroupRide {
final DateTime? canceledAt; final DateTime? canceledAt;
final String? canceledReason; final String? canceledReason;
const GroupRide({ /// Club affiliation identifier if created for an explicit Club.
/// Null for personal/friend rides. Blank/whitespace-only is invalid.
/// Immutable after creation.
final String? clubId;
GroupRide({
required this.id, required this.id,
required this.organizerUid, required this.organizerUid,
required this.organizerHandle, required this.organizerHandle,
@ -395,7 +400,18 @@ class GroupRide {
required this.updatedAt, required this.updatedAt,
this.canceledAt, this.canceledAt,
this.canceledReason, this.canceledReason,
}); this.clubId,
}) {
if (clubId != null) {
if (clubId!.trim().isEmpty || clubId!.contains(RegExp(r'\s')) || clubId!.length > 64) {
throw ArgumentError.value(
clubId,
'clubId',
'Club ID cannot be blank, contain whitespace, or exceed 64 characters.',
);
}
}
}
bool get isCanceled => status == GroupRideStatus.canceled; bool get isCanceled => status == GroupRideStatus.canceled;
bool get isCompleted => status == GroupRideStatus.completed; bool get isCompleted => status == GroupRideStatus.completed;
@ -434,6 +450,7 @@ class GroupRide {
'updatedAt': updatedAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(),
if (canceledAt != null) 'canceledAt': canceledAt!.toIso8601String(), if (canceledAt != null) 'canceledAt': canceledAt!.toIso8601String(),
if (canceledReason != null) 'canceledReason': canceledReason, if (canceledReason != null) 'canceledReason': canceledReason,
if (clubId != null) 'clubId': clubId,
}; };
factory GroupRide.fromJson(Map<String, dynamic> json) { factory GroupRide.fromJson(Map<String, dynamic> json) {
@ -508,6 +525,7 @@ class GroupRide {
updatedAt: updated, updatedAt: updated,
canceledAt: canceled, canceledAt: canceled,
canceledReason: json['canceledReason'] as String?, canceledReason: json['canceledReason'] as String?,
clubId: json['clubId'] as String?,
); );
} }
@ -564,6 +582,7 @@ class GroupRide {
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
canceledAt: canceledAt ?? this.canceledAt, canceledAt: canceledAt ?? this.canceledAt,
canceledReason: canceledReason ?? this.canceledReason, canceledReason: canceledReason ?? this.canceledReason,
clubId: clubId,
); );
} }
@ -580,7 +599,8 @@ class GroupRide {
departureAt == other.departureAt && departureAt == other.departureAt &&
participantCount == other.participantCount && participantCount == other.participantCount &&
participantLimit == other.participantLimit && participantLimit == other.participantLimit &&
routeId == other.routeId; routeId == other.routeId &&
clubId == other.clubId;
@override @override
int get hashCode => int get hashCode =>
@ -592,7 +612,8 @@ class GroupRide {
departureAt.hashCode ^ departureAt.hashCode ^
participantCount.hashCode ^ participantCount.hashCode ^
participantLimit.hashCode ^ participantLimit.hashCode ^
routeId.hashCode; routeId.hashCode ^
clubId.hashCode;
} }
/// Status of rider in live group ride presence. /// Status of rider in live group ride presence.

View File

@ -129,6 +129,7 @@ final myGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
return service.myRidesStream(callerUid: user.uid); return service.myRidesStream(callerUid: user.uid);
}); });
/// Stream of live presence records for all accepted participants in a specific Group Ride. /// Stream of live presence records for all accepted participants in a specific Group Ride.
/// Uses bounded point-document listeners per candidate UID discovered via parent GroupRide.accessUids. /// Uses bounded point-document listeners per candidate UID discovered via parent GroupRide.accessUids.
/// Isolates peer permission-denied / sharing-off events to the specific peer and recovers cleanly. /// Isolates peer permission-denied / sharing-off events to the specific peer and recovers cleanly.

View File

@ -39,6 +39,7 @@ class GroupRideService {
String? routeTitle, String? routeTitle,
double? routeDistanceMeters, double? routeDistanceMeters,
int? routeStopCount, int? routeStopCount,
String? clubId,
}) async { }) async {
final cleanTitle = title.trim(); final cleanTitle = title.trim();
if (cleanTitle.length < 3) { if (cleanTitle.length < 3) {
@ -54,6 +55,44 @@ class GroupRideService {
throw ArgumentError('Participant limit must be at least 2 riders.'); throw ArgumentError('Participant limit must be at least 2 riders.');
} }
if (clubId != null) {
if (clubId.trim().isEmpty || clubId.contains(RegExp(r'\s')) || clubId.length > 64) {
throw ArgumentError('Club ID cannot be blank, contain whitespace, or exceed 64 characters.');
}
final firestore = _firestore;
if (firestore == null) {
throw StateError('Cannot create a club ride while offline.');
}
final clubDoc = await firestore.collection('clubs').doc(clubId).get();
if (!clubDoc.exists || clubDoc.data() == null) {
throw StateError('Club not found.');
}
final clubData = clubDoc.data()!;
if (clubData['archivedAt'] != null) {
throw StateError('Cannot create a ride for an archived club.');
}
final memberDoc = await firestore
.collection('clubs')
.doc(clubId)
.collection('members')
.doc(organizer.uid)
.get();
if (!memberDoc.exists || memberDoc.data() == null) {
throw StateError('You are not a member of this club.');
}
final memberData = memberDoc.data()!;
final memberStatus = memberData['status'] as String?;
if (memberStatus != 'active') {
throw StateError('Active club membership is required to organize club rides.');
}
final memberRole = memberData['role'] as String?;
if (memberRole != 'owner' && memberRole != 'admin') {
throw StateError('Only club owners or administrators can organize club rides.');
}
}
final now = DateTime.now(); final now = DateTime.now();
final rideId = 'ride_${now.millisecondsSinceEpoch}_${organizer.uid.substring(0, organizer.uid.length.clamp(0, 6))}'; final rideId = 'ride_${now.millisecondsSinceEpoch}_${organizer.uid.substring(0, organizer.uid.length.clamp(0, 6))}';
@ -81,6 +120,7 @@ class GroupRideService {
routeStopCount: routeStopCount, routeStopCount: routeStopCount,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
clubId: clubId,
); );
final organizerParticipant = GroupRideParticipant( final organizerParticipant = GroupRideParticipant(
@ -112,7 +152,32 @@ class GroupRideService {
return groupRide; return groupRide;
} }
/// Updates editable Group Ride details (organizer only). Future<bool> _isActiveClubAdmin({
required String clubId,
required String uid,
}) async {
final firestore = _firestore;
if (firestore == null) return false;
try {
final memberDoc = await firestore
.collection('clubs')
.doc(clubId)
.collection('members')
.doc(uid)
.get();
if (!memberDoc.exists || memberDoc.data() == null) return false;
final memberData = memberDoc.data()!;
final memberStatus = memberData['status'] as String?;
final memberRole = memberData['role'] as String?;
return memberStatus == 'active' &&
(memberRole == 'owner' || memberRole == 'admin');
} catch (_) {
return false;
}
}
/// Updates editable Group Ride details (organizer or club admin).
Future<void> updateGroupRide({ Future<void> updateGroupRide({
required String callerUid, required String callerUid,
required String groupRideId, required String groupRideId,
@ -137,12 +202,20 @@ class GroupRideService {
} }
final current = GroupRide.fromJson(snapshot.data()!); final current = GroupRide.fromJson(snapshot.data()!);
if (current.organizerUid != callerUid) { final isOrganizer = current.organizerUid == callerUid;
throw StateError('Only the organizer can modify this ride.'); final isClubAdmin = !isOrganizer &&
current.clubId != null &&
await _isActiveClubAdmin(clubId: current.clubId!, uid: callerUid);
if (!isOrganizer && !isClubAdmin) {
throw StateError('Only the organizer or a club administrator can modify this ride.');
} }
if (current.isCanceled || current.isCompleted) { if (current.isCanceled || current.isCompleted) {
throw StateError('Cannot modify a canceled or completed ride.'); throw StateError('Cannot modify a canceled or completed ride.');
} }
if (isClubAdmin && current.status != GroupRideStatus.scheduled) {
throw StateError('Club administrators can only modify scheduled rides.');
}
final newStart = scheduledStartAt ?? current.scheduledStartAt; final newStart = scheduledStartAt ?? current.scheduledStartAt;
final newDep = departureAt ?? current.departureAt; final newDep = departureAt ?? current.departureAt;
@ -169,7 +242,7 @@ class GroupRideService {
await rideRef.update(updates); await rideRef.update(updates);
} }
/// Cancels a Group Ride with an optional reason (organizer only). /// Cancels a Group Ride with an optional reason (organizer or club admin).
Future<void> cancelGroupRide({ Future<void> cancelGroupRide({
required String callerUid, required String callerUid,
required String groupRideId, required String groupRideId,
@ -185,10 +258,25 @@ class GroupRideService {
} }
final current = GroupRide.fromJson(snapshot.data()!); final current = GroupRide.fromJson(snapshot.data()!);
if (current.organizerUid != callerUid) { final isOrganizer = current.organizerUid == callerUid;
throw StateError('Only the organizer can cancel this ride.'); final isClubAdmin = !isOrganizer &&
current.clubId != null &&
await _isActiveClubAdmin(clubId: current.clubId!, uid: callerUid);
if (!isOrganizer && !isClubAdmin) {
throw StateError('Only the organizer or a club administrator can cancel this ride.');
} }
if (current.isCanceled) return; if (current.isCanceled) return;
if (current.isCompleted) {
throw StateError('Cannot cancel a completed ride.');
}
if (isClubAdmin &&
current.status != GroupRideStatus.scheduled &&
current.status != GroupRideStatus.assembling) {
throw StateError(
'Club administrators can only cancel scheduled or assembling rides.',
);
}
final now = DateTime.now(); final now = DateTime.now();
await rideRef.update({ await rideRef.update({

View File

@ -498,5 +498,95 @@ void main() {
expect(find.text('Leave Open Track Riders?'), findsOneWidget); expect(find.text('Leave Open Track Riders?'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('non-member sees member-only informational card for club rides', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(null)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('Club rides are visible to active members only.'), findsOneWidget);
expect(find.text('Plan Club Ride'), findsNothing);
});
testWidgets('active member sees discovery coming soon and admin sees Plan Club Ride button', (tester) async {
final activeAdmin = ClubMember(
uid: 'rider_1',
clubId: 'club_open',
status: ClubMemberStatus.active,
role: ClubRole.admin,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(activeAdmin)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('Club ride discovery is coming soon.'), findsOneWidget);
expect(find.text('Plan Club Ride'), findsOneWidget);
});
testWidgets('active ordinary member sees discovery coming soon and no Plan button', (tester) async {
final activeMember = ClubMember(
uid: 'rider_2',
clubId: 'club_open',
status: ClubMemberStatus.active,
role: ClubRole.member,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(activeMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('Club ride discovery is coming soon.'), findsOneWidget);
expect(find.text('Plan Club Ride'), findsNothing);
});
}); });
} }

View File

@ -15,6 +15,8 @@ import 'package:motorcycle_dash/services/routes/route_providers.dart';
import 'package:motorcycle_dash/services/social/rider_profile_models.dart'; import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
import 'package:motorcycle_dash/services/social/social_providers.dart'; import 'package:motorcycle_dash/services/social/social_providers.dart';
import 'package:motorcycle_dash/features/routes/widgets/address_autocomplete_field.dart'; import 'package:motorcycle_dash/features/routes/widgets/address_autocomplete_field.dart';
import 'package:motorcycle_dash/services/clubs/club_models.dart';
import 'package:motorcycle_dash/services/clubs/club_providers.dart';
import 'fake_route_provider.dart'; import 'fake_route_provider.dart';
class _MockGroupRideService extends GroupRideService { class _MockGroupRideService extends GroupRideService {
@ -38,6 +40,7 @@ class _MockGroupRideService extends GroupRideService {
String? routeTitle, String? routeTitle,
double? routeDistanceMeters, double? routeDistanceMeters,
int? routeStopCount, int? routeStopCount,
String? clubId,
}) async { }) async {
final now = DateTime.now(); final now = DateTime.now();
createdRide = GroupRide( createdRide = GroupRide(
@ -57,6 +60,7 @@ class _MockGroupRideService extends GroupRideService {
stops: stops, stops: stops,
routeId: routeId, routeId: routeId,
routeTitle: routeTitle, routeTitle: routeTitle,
clubId: clubId,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
); );
@ -951,13 +955,180 @@ void main() {
expect(find.textContaining('miles'), findsNothing); expect(find.textContaining('miles'), findsNothing);
expect(find.textContaining('km'), findsNothing); expect(find.textContaining('km'), findsNothing);
}); });
});
group('CreateGroupRideScreen Club Ride Creation Tests', () {
late RiderProfile profile;
late AuthUser user;
late Club testClub;
late ClubMember adminMember;
late ClubMember regularMember;
setUp(() {
final now = DateTime.now();
profile = RiderProfile(
uid: 'alice',
handle: 'alice_apex',
displayName: 'Alice Apex',
createdAt: now,
updatedAt: now,
);
user = const AuthUser(
uid: 'alice',
email: 'alice@example.com',
);
testClub = Club(
id: 'club_alpha',
name: 'Alpha Moto Club',
slug: 'alpha-moto',
description: 'Elite riders club',
ownerUid: 'alice',
createdAt: now,
updatedAt: now,
);
adminMember = ClubMember(
clubId: 'club_alpha',
uid: 'alice',
role: ClubRole.admin,
status: ClubMemberStatus.active,
joinedAt: now,
updatedAt: now,
);
regularMember = ClubMember(
clubId: 'club_alpha',
uid: 'alice',
role: ClubRole.member,
status: ClubMemberStatus.active,
joinedAt: now,
updatedAt: now,
);
});
testWidgets('Personal ride creation flow: no club header and creates with null clubId', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final mockService = _MockGroupRideService();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
groupRideServiceProvider.overrideWithValue(mockService),
],
child: const MaterialApp(
debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CREATE GROUP RIDE'), findsOneWidget);
expect(find.text('CLUB RIDE'), findsNothing);
expect(find.textContaining('Planning for'), findsNothing);
expect(find.text('PUBLISH GROUP RIDE'), findsOneWidget);
});
testWidgets('Club ride creation: active admin sees club header and creates with clubId', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final mockService = _MockGroupRideService();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
groupRideServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_alpha').overrideWith((ref) => Stream.value(testClub)),
currentMemberProvider('club_alpha').overrideWith((ref) => Stream.value(adminMember)),
],
child: const MaterialApp(
debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(clubId: 'club_alpha'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CREATE CLUB RIDE'), findsOneWidget);
expect(find.text('CLUB RIDE'), findsOneWidget);
expect(find.text('Planning for Alpha Moto Club'), findsOneWidget);
expect(find.text('PUBLISH CLUB RIDE'), findsOneWidget);
});
testWidgets('Club ride creation: ordinary member is blocked with permission error', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final mockService = _MockGroupRideService();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
groupRideServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_alpha').overrideWith((ref) => Stream.value(testClub)),
currentMemberProvider('club_alpha').overrideWith((ref) => Stream.value(regularMember)),
],
child: const MaterialApp(
debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(clubId: 'club_alpha'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CREATE CLUB RIDE'), findsOneWidget);
expect(find.text('Club Affiliation Error'), findsOneWidget);
expect(find.text('Only club owners and administrators can create club rides.'), findsOneWidget);
final publishBtn = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE'));
expect(publishBtn.onPressed, isNull);
});
testWidgets('Club ride creation: archived club is blocked with error', (tester) async {
await tester.binding.setSurfaceSize(const Size(800, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final mockService = _MockGroupRideService();
final archivedClub = Club(
id: 'club_alpha',
name: 'Archived Moto Club',
slug: 'archived-moto',
ownerUid: 'alice',
archivedAt: DateTime.now(),
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
groupRideServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_alpha').overrideWith((ref) => Stream.value(archivedClub)),
currentMemberProvider('club_alpha').overrideWith((ref) => Stream.value(adminMember)),
],
child: const MaterialApp(
debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(clubId: 'club_alpha'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Club Affiliation Error'), findsOneWidget);
expect(find.text('This club has been archived and cannot organize rides.'), findsOneWidget);
final publishBtn = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE'));
expect(publishBtn.onPressed, isNull);
});
}); });
} }

View File

@ -121,6 +121,305 @@ void main() {
}); });
}); });
group('GroupRide clubId Domain Tests', () {
final now = DateTime(2026, 9, 20, 10, 0);
final baseRide = GroupRide(
id: 'ride_base',
organizerUid: 'organizer_1',
organizerHandle: 'organizer',
organizerDisplayName: 'Organizer',
title: 'Sunday Morning Run',
scheduledStartAt: now,
departureAt: now.add(const Duration(minutes: 30)),
meetingPoint: const GroupRideLocation(
latitude: 34.5,
longitude: -84.0,
displayName: 'Base Meetup',
),
createdAt: now,
updatedAt: now,
);
test('1. missing clubId deserializes to null', () {
final json = baseRide.toJson();
expect(json.containsKey('clubId'), isFalse);
final restored = GroupRide.fromJson(json);
expect(restored.clubId, isNull);
});
test('2. explicit null clubId deserializes to null', () {
final json = baseRide.toJson();
json['clubId'] = null;
final restored = GroupRide.fromJson(json);
expect(restored.clubId, isNull);
});
test('3. non-null clubId round-trips exactly', () {
final clubRide = GroupRide(
id: 'ride_club_1',
organizerUid: 'organizer_1',
organizerHandle: 'organizer',
organizerDisplayName: 'Organizer',
title: 'Club Mountain Loop',
scheduledStartAt: now,
departureAt: now.add(const Duration(minutes: 30)),
meetingPoint: const GroupRideLocation(
latitude: 34.5,
longitude: -84.0,
displayName: 'Club Meetup',
),
createdAt: now,
updatedAt: now,
clubId: 'club_north_georgia_riders',
);
final json = clubRide.toJson();
expect(json['clubId'], equals('club_north_georgia_riders'));
final restored = GroupRide.fromJson(json);
expect(restored.clubId, equals('club_north_georgia_riders'));
expect(restored, equals(clubRide));
});
test('4. null clubId serialization preserves legacy document shape by omitting clubId', () {
expect(baseRide.clubId, isNull);
final json = baseRide.toJson();
expect(json.containsKey('clubId'), isFalse);
});
test('5. blank or whitespace-only clubId is rejected at domain boundary', () {
// Direct constructor validation
expect(
() => GroupRide(
id: 'r1',
organizerUid: 'u1',
organizerHandle: 'h1',
organizerDisplayName: 'd1',
title: 'Ride',
scheduledStartAt: now,
departureAt: now,
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
createdAt: now,
updatedAt: now,
clubId: '',
),
throwsA(isA<ArgumentError>()),
);
expect(
() => GroupRide(
id: 'r2',
organizerUid: 'u1',
organizerHandle: 'h1',
organizerDisplayName: 'd1',
title: 'Ride',
scheduledStartAt: now,
departureAt: now,
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
createdAt: now,
updatedAt: now,
clubId: ' ',
),
throwsA(isA<ArgumentError>()),
);
// Internal, leading, or trailing whitespace rejected
expect(
() => GroupRide(
id: 'r3',
organizerUid: 'u1',
organizerHandle: 'h1',
organizerDisplayName: 'd1',
title: 'Ride',
scheduledStartAt: now,
departureAt: now,
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
createdAt: now,
updatedAt: now,
clubId: 'club 123',
),
throwsA(isA<ArgumentError>()),
);
expect(
() => GroupRide(
id: 'r4',
organizerUid: 'u1',
organizerHandle: 'h1',
organizerDisplayName: 'd1',
title: 'Ride',
scheduledStartAt: now,
departureAt: now,
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
createdAt: now,
updatedAt: now,
clubId: ' club_123',
),
throwsA(isA<ArgumentError>()),
);
// Exceeds 64 characters rejected
expect(
() => GroupRide(
id: 'r5',
organizerUid: 'u1',
organizerHandle: 'h1',
organizerDisplayName: 'd1',
title: 'Ride',
scheduledStartAt: now,
departureAt: now,
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
createdAt: now,
updatedAt: now,
clubId: 'c' * 65,
),
throwsA(isA<ArgumentError>()),
);
// Deserialization validation
final jsonEmpty = baseRide.toJson()..['clubId'] = '';
expect(
() => GroupRide.fromJson(jsonEmpty),
throwsA(isA<ArgumentError>()),
);
final jsonWhitespace = baseRide.toJson()..['clubId'] = ' \t\n ';
expect(
() => GroupRide.fromJson(jsonWhitespace),
throwsA(isA<ArgumentError>()),
);
final jsonInternalSpace = baseRide.toJson()..['clubId'] = 'club alpha';
expect(
() => GroupRide.fromJson(jsonInternalSpace),
throwsA(isA<ArgumentError>()),
);
final jsonTooLong = baseRide.toJson()..['clubId'] = 'c' * 65;
expect(
() => GroupRide.fromJson(jsonTooLong),
throwsA(isA<ArgumentError>()),
);
});
test('7. copyWith and object reconstruction preserves clubId affiliation immutably', () {
final clubRide = GroupRide(
id: 'ride_club_1',
organizerUid: 'organizer_1',
organizerHandle: 'organizer',
organizerDisplayName: 'Organizer',
title: 'Club Mountain Loop',
scheduledStartAt: now,
departureAt: now.add(const Duration(minutes: 30)),
meetingPoint: const GroupRideLocation(
latitude: 34.5,
longitude: -84.0,
displayName: 'Club Meetup',
),
createdAt: now,
updatedAt: now,
clubId: 'club_north_georgia_riders',
);
final updated = clubRide.copyWith(title: 'Updated Loop Title', status: GroupRideStatus.underway);
expect(updated.clubId, equals('club_north_georgia_riders'));
expect(updated.title, equals('Updated Loop Title'));
expect(updated.status, equals(GroupRideStatus.underway));
// Personal ride copyWith retains null clubId
final personalCopy = baseRide.copyWith(title: 'Updated Personal');
expect(personalCopy.clubId, isNull);
});
test('8. equality and hashCode include clubId', () {
final ridePersonal1 = GroupRide(
id: 'r1',
organizerUid: 'u1',
organizerHandle: 'h1',
organizerDisplayName: 'd1',
title: 'Ride',
scheduledStartAt: now,
departureAt: now,
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
createdAt: now,
updatedAt: now,
clubId: null,
);
final ridePersonal2 = GroupRide(
id: 'r1',
organizerUid: 'u1',
organizerHandle: 'h1',
organizerDisplayName: 'd1',
title: 'Ride',
scheduledStartAt: now,
departureAt: now,
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
createdAt: now,
updatedAt: now,
clubId: null,
);
final rideClubA = GroupRide(
id: 'r1',
organizerUid: 'u1',
organizerHandle: 'h1',
organizerDisplayName: 'd1',
title: 'Ride',
scheduledStartAt: now,
departureAt: now,
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
createdAt: now,
updatedAt: now,
clubId: 'club_alpha',
);
final rideClubA2 = GroupRide(
id: 'r1',
organizerUid: 'u1',
organizerHandle: 'h1',
organizerDisplayName: 'd1',
title: 'Ride',
scheduledStartAt: now,
departureAt: now,
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
createdAt: now,
updatedAt: now,
clubId: 'club_alpha',
);
final rideClubB = GroupRide(
id: 'r1',
organizerUid: 'u1',
organizerHandle: 'h1',
organizerDisplayName: 'd1',
title: 'Ride',
scheduledStartAt: now,
departureAt: now,
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
createdAt: now,
updatedAt: now,
clubId: 'club_bravo',
);
expect(ridePersonal1, equals(ridePersonal2));
expect(ridePersonal1.hashCode, equals(ridePersonal2.hashCode));
expect(rideClubA, equals(rideClubA2));
expect(rideClubA.hashCode, equals(rideClubA2.hashCode));
// Personal vs Club are not equal
expect(ridePersonal1 == rideClubA, isFalse);
expect(ridePersonal1.hashCode == rideClubA.hashCode, isFalse);
// Club A vs Club B are not equal
expect(rideClubA == rideClubB, isFalse);
expect(rideClubA.hashCode == rideClubB.hashCode, isFalse);
});
});
group('GroupRideParticipant Model Tests', () { group('GroupRideParticipant Model Tests', () {
test('serializes and deserializes participant', () { test('serializes and deserializes participant', () {
final now = DateTime(2026, 9, 3, 12, 0); final now = DateTime(2026, 9, 3, 12, 0);

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/core/theme/app_colors.dart'; import 'package:motorcycle_dash/core/theme/app_colors.dart';
import 'package:motorcycle_dash/core/theme/theme.dart'; import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart'; import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart'; import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart'; import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
@ -478,6 +479,579 @@ void main() {
expect(lightTheme.appBarTheme.systemOverlayStyle?.statusBarIconBrightness, Brightness.dark); expect(lightTheme.appBarTheme.systemOverlayStyle?.statusBarIconBrightness, Brightness.dark);
}); });
}); });
group('GroupRideService Club Affiliation & Discovery Tests', () {
late FakeFirebaseFirestore fakeFirestore;
late GroupRideService service;
late RiderProfile organizer;
const meetup = GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Starting Spot',
);
setUp(() {
fakeFirestore = FakeFirebaseFirestore();
service = GroupRideService(firestore: fakeFirestore);
organizer = RiderProfile(
uid: 'user_alice',
handle: 'alice_apex',
displayName: 'Alice Apex',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
});
test('personal ride creation omits clubId and does not perform club reads', () async {
final ride = await service.createGroupRide(
organizer: organizer,
title: 'Sunday Morning Cruise',
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: null,
);
expect(ride.clubId, isNull);
final doc = await fakeFirestore.collection('group_rides').doc(ride.id).get();
expect(doc.exists, isTrue);
expect(doc.data()!.containsKey('clubId'), isFalse);
});
test('active club admin creates club-affiliated ride with persisted clubId', () async {
final now = DateTime.now();
// Seed club document
await fakeFirestore.collection('clubs').doc('club_123').set({
'id': 'club_123',
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_bob',
'visibility': 'public',
'admissionPolicy': 'open',
'memberCount': 2,
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
// Seed membership document for organizer as admin
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
'clubId': 'club_123',
'uid': 'user_alice',
'role': 'admin',
'status': 'active',
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
final ride = await service.createGroupRide(
organizer: organizer,
title: 'Alpha Club Mountain Run',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_123',
);
expect(ride.clubId, 'club_123');
final doc = await fakeFirestore.collection('group_rides').doc(ride.id).get();
expect(doc.exists, isTrue);
expect(doc.data()!['clubId'], 'club_123');
});
test('active club owner creates club-affiliated ride with persisted clubId', () async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc('club_123').set({
'id': 'club_123',
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_alice',
'visibility': 'public',
'admissionPolicy': 'open',
'memberCount': 1,
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
'clubId': 'club_123',
'uid': 'user_alice',
'role': 'owner',
'status': 'active',
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
final ride = await service.createGroupRide(
organizer: organizer,
title: 'Alpha Club Mountain Run',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_123',
);
expect(ride.clubId, 'club_123');
});
test('rejects malformed clubId with ArgumentError', () async {
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Malformed Club Ride',
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club with spaces',
),
throwsA(isA<ArgumentError>()),
);
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Empty Club Ride',
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: ' ',
),
throwsA(isA<ArgumentError>()),
);
});
test('rejects creation when club does not exist (StateError)', () async {
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Nonexistent Club Ride',
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'nonexistent_club',
),
throwsA(isA<StateError>()),
);
});
test('rejects creation when club is archived (StateError)', () async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc('club_archived').set({
'id': 'club_archived',
'name': 'Archived Club',
'slug': 'archived-club',
'ownerUid': 'user_alice',
'archivedAt': now.toIso8601String(),
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore.collection('clubs').doc('club_archived').collection('members').doc('user_alice').set({
'clubId': 'club_archived',
'uid': 'user_alice',
'role': 'owner',
'status': 'active',
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Archived Club Ride',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_archived',
),
throwsA(isA<StateError>()),
);
});
test('rejects creation when organizer is ordinary member (StateError)', () async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc('club_123').set({
'id': 'club_123',
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_bob',
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
'clubId': 'club_123',
'uid': 'user_alice',
'role': 'member',
'status': 'active',
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Ordinary Member Club Ride',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_123',
),
throwsA(isA<StateError>()),
);
});
test('rejects creation when organizer membership is not active (StateError)', () async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc('club_123').set({
'id': 'club_123',
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_bob',
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
'clubId': 'club_123',
'uid': 'user_alice',
'role': 'admin',
'status': 'invited',
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Invited Member Club Ride',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_123',
),
throwsA(isA<StateError>()),
);
});
test('rejects creation when organizer is not a member (StateError)', () async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc('club_123').set({
'id': 'club_123',
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_bob',
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Non-Member Club Ride',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_123',
),
throwsA(isA<StateError>()),
);
});
});
group('Club Admin Group Ride Management Tests', () {
late FakeFirebaseFirestore fakeFirestore;
late GroupRideService service;
late GroupRideLocation meetup;
setUp(() {
fakeFirestore = FakeFirebaseFirestore();
service = GroupRideService(firestore: fakeFirestore);
meetup = const GroupRideLocation(
latitude: 37.7749,
longitude: -122.4194,
displayName: 'Twin Peaks SF',
);
});
Future<void> seedClubAndMember({
required String clubId,
required String uid,
required String role,
required String status,
}) async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc(clubId).set({
'id': clubId,
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_owner',
'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(),
});
}
Future<void> seedRide({
required String rideId,
required String organizerUid,
String? clubId,
GroupRideStatus status = GroupRideStatus.scheduled,
}) async {
final now = DateTime.now();
await fakeFirestore.collection('group_rides').doc(rideId).set({
'id': rideId,
'organizerUid': organizerUid,
'title': 'Original Ride Title',
'description': 'Original description',
'status': status.name,
'visibility': 'public',
'joinPolicy': 'open',
'scheduledStartAt': now.add(const Duration(hours: 2)).toIso8601String(),
'departureAt': now.add(const Duration(hours: 2, minutes: 30)).toIso8601String(),
'meetingPoint': meetup.toJson(),
'stops': [],
'accessUids': [organizerUid],
'clubId': ?clubId,
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
}
test('active club admin can update scheduled club ride metadata', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_admin',
role: 'admin',
status: 'active',
);
await seedRide(
rideId: 'ride_1',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.scheduled,
);
await service.updateGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_1',
title: 'Updated Title by Club Admin',
description: 'Updated description by Club Admin',
);
final doc = await fakeFirestore.collection('group_rides').doc('ride_1').get();
expect(doc.data()!['title'], 'Updated Title by Club Admin');
expect(doc.data()!['description'], 'Updated description by Club Admin');
});
test('active club owner can update scheduled club ride metadata', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_owner',
role: 'owner',
status: 'active',
);
await seedRide(
rideId: 'ride_1',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.scheduled,
);
await service.updateGroupRide(
callerUid: 'user_owner',
groupRideId: 'ride_1',
title: 'Updated Title by Club Owner',
);
final doc = await fakeFirestore.collection('group_rides').doc('ride_1').get();
expect(doc.data()!['title'], 'Updated Title by Club Owner');
});
test('club admin cannot update assembling, underway, completed, or canceled club ride', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_admin',
role: 'admin',
status: 'active',
);
for (final s in [
GroupRideStatus.assembling,
GroupRideStatus.underway,
GroupRideStatus.completed,
GroupRideStatus.canceled,
]) {
await seedRide(
rideId: 'ride_${s.name}',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: s,
);
expect(
() => service.updateGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_${s.name}',
title: 'Attempted Update',
),
throwsA(isA<StateError>()),
);
}
});
test('active club admin can cancel scheduled or assembling club ride', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_admin',
role: 'admin',
status: 'active',
);
// Scheduled
await seedRide(
rideId: 'ride_sched',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.scheduled,
);
await service.cancelGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_sched',
reason: 'Weather cancellation by Club Admin',
);
final docSched = await fakeFirestore.collection('group_rides').doc('ride_sched').get();
expect(docSched.data()!['status'], 'canceled');
expect(docSched.data()!['canceledReason'], 'Weather cancellation by Club Admin');
// Assembling
await seedRide(
rideId: 'ride_assemb',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.assembling,
);
await service.cancelGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_assemb',
reason: 'Emergency cancellation by Club Admin',
);
final docAssemb = await fakeFirestore.collection('group_rides').doc('ride_assemb').get();
expect(docAssemb.data()!['status'], 'canceled');
});
test('club admin cannot cancel underway or completed club ride', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_admin',
role: 'admin',
status: 'active',
);
await seedRide(
rideId: 'ride_underway',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.underway,
);
expect(
() => service.cancelGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_underway',
),
throwsA(isA<StateError>()),
);
await seedRide(
rideId: 'ride_completed',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.completed,
);
expect(
() => service.cancelGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_completed',
),
throwsA(isA<StateError>()),
);
});
test('non-admin club member cannot update or cancel club ride', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_member',
role: 'member',
status: 'active',
);
await seedRide(
rideId: 'ride_sched',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.scheduled,
);
expect(
() => service.updateGroupRide(
callerUid: 'user_member',
groupRideId: 'ride_sched',
title: 'Unauthorized Member Update',
),
throwsA(isA<StateError>()),
);
expect(
() => service.cancelGroupRide(
callerUid: 'user_member',
groupRideId: 'ride_sched',
),
throwsA(isA<StateError>()),
);
});
test('admin from different club cannot update or cancel club ride', () async {
await seedClubAndMember(
clubId: 'club_456',
uid: 'user_other_admin',
role: 'admin',
status: 'active',
);
await seedRide(
rideId: 'ride_sched',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.scheduled,
);
expect(
() => service.updateGroupRide(
callerUid: 'user_other_admin',
groupRideId: 'ride_sched',
title: 'Cross-Club Update',
),
throwsA(isA<StateError>()),
);
expect(
() => service.cancelGroupRide(
callerUid: 'user_other_admin',
groupRideId: 'ride_sched',
),
throwsA(isA<StateError>()),
);
});
});
} }
class _FakeGroupRideLifecycleService implements GroupRideService { class _FakeGroupRideLifecycleService implements GroupRideService {