From 68d4539f90199e2428fafbb7de779ff2dfb22b4e Mon Sep 17 00:00:00 2001 From: Billie Ponder Date: Sun, 20 Sep 2026 07:03:48 -0400 Subject: [PATCH] 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. --- .../emulator-tests/firestore.rules.test.js | 587 +++++++++++++++++- firestore.rules | 118 +++- lib/core/router.dart | 5 +- .../group_rides/create_group_ride_screen.dart | 278 ++++++++- .../group_rides/group_ride_detail_screen.dart | 237 ++++++- .../social/clubs/club_detail_screen.dart | 86 +++ .../group_rides/group_ride_models.dart | 29 +- .../group_rides/group_ride_providers.dart | 1 + .../group_rides/group_ride_service.dart | 100 ++- test/club_detail_screen_test.dart | 90 +++ test/create_group_ride_screen_test.dart | 171 +++++ test/group_ride_models_test.dart | 299 +++++++++ test/group_ride_service_test.dart | 574 +++++++++++++++++ 13 files changed, 2523 insertions(+), 52 deletions(-) diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index 2ba59c7..6f04abf 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -4,7 +4,7 @@ import { assertSucceeds, initializeTestEnvironment, } 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 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)); + }); + }); +}); diff --git a/firestore.rules b/firestore.rules index 17bd313..0ad5710 100644 --- a/firestore.rules +++ b/firestore.rules @@ -198,7 +198,7 @@ service cloud.firestore { // Top-level social object distinct from personal rides, accounts, and telemetry. match /group_rides/{groupRideId} { 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) { @@ -210,6 +210,55 @@ service cloud.firestore { 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() { return signedIn() && ( isOrganizer() || @@ -217,7 +266,8 @@ service cloud.firestore { !areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && ( hasParticipantAccess(request.auth.uid) || 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 list: if signedIn() && ( - resource.data.organizerUid == request.auth.uid || + ( + 'organizerUid' in resource.data && + resource.data.organizerUid == request.auth.uid + ) || ( 'accessUids' in resource.data && request.auth.uid in resource.data.accessUids @@ -246,15 +299,25 @@ service cloud.firestore { exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) && 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() { - let prev = resource.data.status; - let next = request.resource.data.status; - return prev == next || + let prev = 'status' in resource.data ? resource.data.status : 'scheduled'; + let next = 'status' in request.resource.data ? request.resource.data.status : prev; + return prev in ['scheduled', 'assembling', 'underway'] && ( + prev == next || (prev == 'scheduled' && next in ['assembling', 'underway', 'canceled']) || (prev == 'assembling' && next in ['underway', 'canceled']) || - (prev == 'underway' && next in ['completed', 'canceled']); + (prev == 'underway' && next in ['completed', 'canceled']) + ); } function validRouteAttachment() { @@ -330,10 +393,43 @@ service cloud.firestore { ); } - allow update: if signedIn() && ( - (isOrganizer() && validOrganizerRideUpdate()) || - validParticipantRideUpdate() - ); + 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()) || + validParticipantRideUpdate() || + (hasClubAffiliation(resource.data) && validClubAdminRideUpdate()) + ); allow delete: if isOrganizer(); diff --git a/lib/core/router.dart b/lib/core/router.dart index b311d3a..0d983dd 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -224,7 +224,10 @@ final appRouter = GoRouter( routes: [ GoRoute( path: 'create', - builder: (context, state) => const CreateGroupRideScreen(), + builder: (context, state) { + final clubId = state.uri.queryParameters['clubId']; + return CreateGroupRideScreen(clubId: clubId); + }, ), GoRoute( path: ':id', diff --git a/lib/features/group_rides/create_group_ride_screen.dart b/lib/features/group_rides/create_group_ride_screen.dart index 8d8e06a..fbbef79 100644 --- a/lib/features/group_rides/create_group_ride_screen.dart +++ b/lib/features/group_rides/create_group_ride_screen.dart @@ -14,10 +14,15 @@ import '../../services/routes/route_models.dart'; import '../../services/routes/route_provider.dart'; import '../../services/routes/route_providers.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 '../social/clubs/widgets/club_monogram.dart'; class CreateGroupRideScreen extends ConsumerStatefulWidget { - const CreateGroupRideScreen({super.key}); + const CreateGroupRideScreen({super.key, this.clubId}); + + final String? clubId; @override ConsumerState createState() => @@ -223,6 +228,15 @@ class _CreateGroupRideScreenState extends ConsumerState { 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); try { final meetingPoint = GroupRideLocation( @@ -265,6 +279,7 @@ class _CreateGroupRideScreenState extends ConsumerState { routeTitle: _selectedRouteTitle, routeDistanceMeters: _selectedRouteDistanceMeters, routeStopCount: _selectedRouteStopCount, + clubId: widget.clubId, ); if (mounted) { @@ -274,7 +289,11 @@ class _CreateGroupRideScreenState extends ConsumerState { } catch (e) { if (mounted) { setState(() { - _errorMessage = e.toString().replaceAll('Exception: ', ''); + _errorMessage = e + .toString() + .replaceAll('Exception: ', '') + .replaceAll('StateError: ', '') + .replaceAll('ArgumentError: ', ''); _isSaving = false; }); } @@ -633,14 +652,52 @@ class _CreateGroupRideScreenState extends ConsumerState { final colors = context.motorColors; 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( backgroundColor: colors.surfaceContainer, appBar: AppBar( backgroundColor: colors.surfaceContainerHigh, elevation: 0, - title: const Text( - 'CREATE GROUP RIDE', - style: TextStyle( + title: Text( + widget.clubId != null ? 'CREATE CLUB RIDE' : 'CREATE GROUP RIDE', + style: const TextStyle( fontWeight: FontWeight.bold, letterSpacing: 1.0, fontSize: 15, @@ -648,7 +705,7 @@ class _CreateGroupRideScreenState extends ConsumerState { ), actions: [ TextButton( - onPressed: _isSaving ? null : _submit, + onPressed: (_isSaving || !canSubmit) ? null : _submit, child: _isSaving ? SizedBox( width: 16, @@ -661,7 +718,12 @@ class _CreateGroupRideScreenState extends ConsumerState { : Text( 'PUBLISH', style: TextStyle( - color: colors.cyberCyan, + color: canSubmit + ? colors.cyberCyan + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), fontWeight: FontWeight.bold, ), ), @@ -676,9 +738,9 @@ class _CreateGroupRideScreenState extends ConsumerState { (snapshot.isLandscape && snapshot.width >= 720); 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 { BuildContext context, MotorSignalThemeColors colors, ResponsiveSnapshot snapshot, + bool canSubmit, + AsyncValue? clubAsync, + AsyncValue? memberAsync, + String? clubValidationError, ) { return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (widget.clubId != null) + _buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError), if (_errorMessage != null) _buildErrorBanner(colors), Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -726,7 +794,7 @@ class _CreateGroupRideScreenState extends ConsumerState { const SizedBox(height: 24), _buildMapPreviewSection(colors, snapshot), const SizedBox(height: 24), - _buildSubmitButton(colors), + _buildSubmitButton(colors, canSubmit), ], ), ), @@ -742,10 +810,16 @@ class _CreateGroupRideScreenState extends ConsumerState { BuildContext context, MotorSignalThemeColors colors, ResponsiveSnapshot snapshot, + bool canSubmit, + AsyncValue? clubAsync, + AsyncValue? memberAsync, + String? clubValidationError, ) { return ListView( padding: const EdgeInsets.all(20), children: [ + if (widget.clubId != null) + _buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError), if (_errorMessage != null) _buildErrorBanner(colors), _buildRideDetailsSection(colors), const SizedBox(height: 24), @@ -759,12 +833,180 @@ class _CreateGroupRideScreenState extends ConsumerState { const SizedBox(height: 24), _buildPrivacySection(colors), const SizedBox(height: 32), - _buildSubmitButton(colors), + _buildSubmitButton(colors, canSubmit), const SizedBox(height: 32), ], ); } + Widget _buildClubAffiliationHeader( + MotorSignalThemeColors colors, + AsyncValue? clubAsync, + AsyncValue? 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) { return Container( margin: const EdgeInsets.only(bottom: 16), @@ -1488,24 +1730,24 @@ class _CreateGroupRideScreenState extends ConsumerState { ); } - Widget _buildSubmitButton(MotorSignalThemeColors colors) { + Widget _buildSubmitButton(MotorSignalThemeColors colors, bool canSubmit) { return SizedBox( width: double.infinity, height: 48, child: ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + backgroundColor: canSubmit ? colors.cyberCyan : colors.surfaceContainerHighest, + foregroundColor: canSubmit ? Colors.black : Theme.of(context).colorScheme.onSurfaceVariant, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), - onPressed: _isSaving ? null : _submit, + onPressed: (_isSaving || !canSubmit) ? null : _submit, child: _isSaving ? const CircularProgressIndicator(color: Colors.black) - : const Text( - 'PUBLISH GROUP RIDE', - style: TextStyle( + : Text( + widget.clubId != null ? 'PUBLISH CLUB RIDE' : 'PUBLISH GROUP RIDE', + style: const TextStyle( fontWeight: FontWeight.bold, letterSpacing: 1.0, ), diff --git a/lib/features/group_rides/group_ride_detail_screen.dart b/lib/features/group_rides/group_ride_detail_screen.dart index 028cf83..50d8ccc 100644 --- a/lib/features/group_rides/group_ride_detail_screen.dart +++ b/lib/features/group_rides/group_ride_detail_screen.dart @@ -10,6 +10,8 @@ import '../../services/group_rides/group_ride_models.dart'; import '../../services/group_rides/group_ride_providers.dart'; import '../../services/social/social_providers.dart'; import '../../services/routes/route_providers.dart'; +import '../../services/clubs/club_models.dart'; +import '../../services/clubs/club_providers.dart'; class GroupRideDetailScreen extends ConsumerStatefulWidget { final String rideId; @@ -201,6 +203,12 @@ class _GroupRideDetailScreenState extends ConsumerState { final isOrganizer = 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( data: (participants) { @@ -224,6 +232,7 @@ class _GroupRideDetailScreenState extends ConsumerState { participants, myParticipant, isOrganizer, + isClubAdmin, currentUid, colors, ); @@ -235,6 +244,7 @@ class _GroupRideDetailScreenState extends ConsumerState { participants, myParticipant, isOrganizer, + isClubAdmin, currentUid, colors, ); @@ -257,7 +267,7 @@ class _GroupRideDetailScreenState extends ConsumerState { ), error: (e, _) => Center( child: Text( - 'Error loading ride: $e', + 'Error loading group ride: $e', style: TextStyle(color: colors.raceRed), ), ), @@ -271,6 +281,7 @@ class _GroupRideDetailScreenState extends ConsumerState { List participants, GroupRideParticipant? myParticipant, bool isOrganizer, + bool isClubAdmin, String? currentUid, MotorSignalThemeColors colors, ) { @@ -289,7 +300,7 @@ class _GroupRideDetailScreenState extends ConsumerState { _buildStatusMessageBanner(colors), const SizedBox(height: 16), ], - _buildHeaderCard(context, ride, isOrganizer, colors), + _buildHeaderCard(context, ride, isOrganizer, isClubAdmin, colors), const SizedBox(height: 16), _buildActionBanner( context, @@ -330,6 +341,13 @@ class _GroupRideDetailScreenState extends ConsumerState { if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[ _buildOrganizerControlCard(context, ride, colors), 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( ride, @@ -356,6 +374,7 @@ class _GroupRideDetailScreenState extends ConsumerState { List participants, GroupRideParticipant? myParticipant, bool isOrganizer, + bool isClubAdmin, String? currentUid, MotorSignalThemeColors colors, ) { @@ -366,11 +385,18 @@ class _GroupRideDetailScreenState extends ConsumerState { _buildStatusMessageBanner(colors), const SizedBox(height: 16), ], - _buildHeaderCard(context, ride, isOrganizer, colors), + _buildHeaderCard(context, ride, isOrganizer, isClubAdmin, colors), const SizedBox(height: 16), if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[ _buildOrganizerControlCard(context, ride, colors), 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( context, @@ -437,6 +463,7 @@ class _GroupRideDetailScreenState extends ConsumerState { BuildContext context, GroupRide ride, bool isOrganizer, + bool isClubAdmin, MotorSignalThemeColors colors, ) { return Card( @@ -501,25 +528,40 @@ class _GroupRideDetailScreenState extends ConsumerState { ], ), ), - if (isOrganizer && !ride.isCanceled) + if ((isOrganizer && !ride.isCanceled) || + (isClubAdmin && + !ride.isCanceled && + (ride.status == GroupRideStatus.scheduled || + ride.status == GroupRideStatus.assembling))) PopupMenuButton( icon: const Icon(Icons.more_vert, color: Colors.white70), color: colors.surfaceContainerHigh, onSelected: (val) async { if (val == 'invite') { _showInviteFriendsSheet(context, ride, colors); + } else if (val == 'edit') { + _showEditDetailsDialog(context, ride, colors); } else if (val == 'cancel') { _confirmCancelRide(ride, colors); } }, itemBuilder: (ctx) => [ - const PopupMenuItem( - value: 'invite', - child: Text( - 'Invite Friends', - style: TextStyle(color: Colors.white), + if (isOrganizer) + const PopupMenuItem( + value: 'invite', + child: Text( + 'Invite Friends', + style: TextStyle(color: Colors.white), + ), + ), + if (ride.status == GroupRideStatus.scheduled) + const PopupMenuItem( + value: 'edit', + child: Text( + 'Edit Details', + style: TextStyle(color: Colors.white), + ), ), - ), PopupMenuItem( value: 'cancel', child: Text( @@ -2030,6 +2072,181 @@ class _GroupRideDetailScreenState extends ConsumerState { ); } + 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( BuildContext context, GroupRide ride, diff --git a/lib/features/social/clubs/club_detail_screen.dart b/lib/features/social/clubs/club_detail_screen.dart index d91eb27..64b547a 100644 --- a/lib/features/social/clubs/club_detail_screen.dart +++ b/lib/features/social/clubs/club_detail_screen.dart @@ -194,6 +194,17 @@ class ClubDetailScreen extends ConsumerWidget { ), const SizedBox(height: 18), + // Club Rides Card + _buildClubRidesCard( + context, + ref, + club, + myMembership, + colors, + theme, + ), + const SizedBox(height: 18), + // Description Card _buildDescriptionCard(context, club, colors, theme), 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, + ), + ), + ), + ], + ), + ); + } } diff --git a/lib/services/group_rides/group_ride_models.dart b/lib/services/group_rides/group_ride_models.dart index d46e12e..ea15c92 100644 --- a/lib/services/group_rides/group_ride_models.dart +++ b/lib/services/group_rides/group_ride_models.dart @@ -369,7 +369,12 @@ class GroupRide { final DateTime? canceledAt; 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.organizerUid, required this.organizerHandle, @@ -395,7 +400,18 @@ class GroupRide { required this.updatedAt, this.canceledAt, 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 isCompleted => status == GroupRideStatus.completed; @@ -434,6 +450,7 @@ class GroupRide { 'updatedAt': updatedAt.toIso8601String(), if (canceledAt != null) 'canceledAt': canceledAt!.toIso8601String(), if (canceledReason != null) 'canceledReason': canceledReason, + if (clubId != null) 'clubId': clubId, }; factory GroupRide.fromJson(Map json) { @@ -508,6 +525,7 @@ class GroupRide { updatedAt: updated, canceledAt: canceled, canceledReason: json['canceledReason'] as String?, + clubId: json['clubId'] as String?, ); } @@ -564,6 +582,7 @@ class GroupRide { updatedAt: updatedAt ?? this.updatedAt, canceledAt: canceledAt ?? this.canceledAt, canceledReason: canceledReason ?? this.canceledReason, + clubId: clubId, ); } @@ -580,7 +599,8 @@ class GroupRide { departureAt == other.departureAt && participantCount == other.participantCount && participantLimit == other.participantLimit && - routeId == other.routeId; + routeId == other.routeId && + clubId == other.clubId; @override int get hashCode => @@ -592,7 +612,8 @@ class GroupRide { departureAt.hashCode ^ participantCount.hashCode ^ participantLimit.hashCode ^ - routeId.hashCode; + routeId.hashCode ^ + clubId.hashCode; } /// Status of rider in live group ride presence. diff --git a/lib/services/group_rides/group_ride_providers.dart b/lib/services/group_rides/group_ride_providers.dart index f9b9a77..e215948 100644 --- a/lib/services/group_rides/group_ride_providers.dart +++ b/lib/services/group_rides/group_ride_providers.dart @@ -129,6 +129,7 @@ final myGroupRidesProvider = StreamProvider>((ref) { return service.myRidesStream(callerUid: user.uid); }); + /// 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. /// Isolates peer permission-denied / sharing-off events to the specific peer and recovers cleanly. diff --git a/lib/services/group_rides/group_ride_service.dart b/lib/services/group_rides/group_ride_service.dart index 33bb4c3..beaf755 100644 --- a/lib/services/group_rides/group_ride_service.dart +++ b/lib/services/group_rides/group_ride_service.dart @@ -39,6 +39,7 @@ class GroupRideService { String? routeTitle, double? routeDistanceMeters, int? routeStopCount, + String? clubId, }) async { final cleanTitle = title.trim(); if (cleanTitle.length < 3) { @@ -54,6 +55,44 @@ class GroupRideService { 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 rideId = 'ride_${now.millisecondsSinceEpoch}_${organizer.uid.substring(0, organizer.uid.length.clamp(0, 6))}'; @@ -81,6 +120,7 @@ class GroupRideService { routeStopCount: routeStopCount, createdAt: now, updatedAt: now, + clubId: clubId, ); final organizerParticipant = GroupRideParticipant( @@ -112,7 +152,32 @@ class GroupRideService { return groupRide; } - /// Updates editable Group Ride details (organizer only). + Future _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 updateGroupRide({ required String callerUid, required String groupRideId, @@ -137,12 +202,20 @@ class GroupRideService { } final current = GroupRide.fromJson(snapshot.data()!); - if (current.organizerUid != callerUid) { - throw StateError('Only the organizer can modify this ride.'); + final isOrganizer = current.organizerUid == callerUid; + 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) { 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 newDep = departureAt ?? current.departureAt; @@ -169,7 +242,7 @@ class GroupRideService { 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 cancelGroupRide({ required String callerUid, required String groupRideId, @@ -185,10 +258,25 @@ class GroupRideService { } final current = GroupRide.fromJson(snapshot.data()!); - if (current.organizerUid != callerUid) { - throw StateError('Only the organizer can cancel this ride.'); + final isOrganizer = current.organizerUid == callerUid; + 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.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(); await rideRef.update({ diff --git a/test/club_detail_screen_test.dart b/test/club_detail_screen_test.dart index 93e3f2a..2317143 100644 --- a/test/club_detail_screen_test.dart +++ b/test/club_detail_screen_test.dart @@ -498,5 +498,95 @@ void main() { expect(find.text('Leave Open Track Riders?'), findsOneWidget); 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); + }); }); } diff --git a/test/create_group_ride_screen_test.dart b/test/create_group_ride_screen_test.dart index 0302c2b..09273ff 100644 --- a/test/create_group_ride_screen_test.dart +++ b/test/create_group_ride_screen_test.dart @@ -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/social_providers.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'; class _MockGroupRideService extends GroupRideService { @@ -38,6 +40,7 @@ class _MockGroupRideService extends GroupRideService { String? routeTitle, double? routeDistanceMeters, int? routeStopCount, + String? clubId, }) async { final now = DateTime.now(); createdRide = GroupRide( @@ -57,6 +60,7 @@ class _MockGroupRideService extends GroupRideService { stops: stops, routeId: routeId, routeTitle: routeTitle, + clubId: clubId, createdAt: now, updatedAt: now, ); @@ -951,13 +955,180 @@ void main() { expect(find.textContaining('miles'), 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(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(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE')); + expect(publishBtn.onPressed, isNull); + }); }); } diff --git a/test/group_ride_models_test.dart b/test/group_ride_models_test.dart index 921fe9d..5fcd909 100644 --- a/test/group_ride_models_test.dart +++ b/test/group_ride_models_test.dart @@ -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()), + ); + + 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()), + ); + + // 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()), + ); + + 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()), + ); + + // 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()), + ); + + // Deserialization validation + final jsonEmpty = baseRide.toJson()..['clubId'] = ''; + expect( + () => GroupRide.fromJson(jsonEmpty), + throwsA(isA()), + ); + + final jsonWhitespace = baseRide.toJson()..['clubId'] = ' \t\n '; + expect( + () => GroupRide.fromJson(jsonWhitespace), + throwsA(isA()), + ); + + final jsonInternalSpace = baseRide.toJson()..['clubId'] = 'club alpha'; + expect( + () => GroupRide.fromJson(jsonInternalSpace), + throwsA(isA()), + ); + + final jsonTooLong = baseRide.toJson()..['clubId'] = 'c' * 65; + expect( + () => GroupRide.fromJson(jsonTooLong), + throwsA(isA()), + ); + }); + + 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', () { test('serializes and deserializes participant', () { final now = DateTime(2026, 9, 3, 12, 0); diff --git a/test/group_ride_service_test.dart b/test/group_ride_service_test.dart index 08b6182..51c77da 100644 --- a/test/group_ride_service_test.dart +++ b/test/group_ride_service_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:motorcycle_dash/core/theme/app_colors.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_providers.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); }); }); + + 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()), + ); + + 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + }); + + 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 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 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()), + ); + } + }); + + 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()), + ); + + 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()), + ); + }); + + 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()), + ); + + expect( + () => service.cancelGroupRide( + callerUid: 'user_member', + groupRideId: 'ride_sched', + ), + throwsA(isA()), + ); + }); + + 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()), + ); + + expect( + () => service.cancelGroupRide( + callerUid: 'user_other_admin', + groupRideId: 'ride_sched', + ), + throwsA(isA()), + ); + }); + }); } class _FakeGroupRideLifecycleService implements GroupRideService {