import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:motorcycle_dash/core/theme/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'; import 'package:motorcycle_dash/services/social/rider_profile_models.dart'; void main() { group('GroupRideService Offline & Validation Tests', () { final organizer = RiderProfile( uid: 'user_alice', handle: 'alice_apex', displayName: 'Alice Apex', createdAt: DateTime.now(), updatedAt: DateTime.now(), ); test('rejects ride creation with invalid title', () async { final service = GroupRideService(); const meetup = GroupRideLocation( latitude: 34.0, longitude: -84.0, displayName: 'Meetup', ); expect( () => service.createGroupRide( organizer: organizer, title: 'Hi', // too short (< 3) scheduledStartAt: DateTime.now(), departureAt: DateTime.now().add(const Duration(minutes: 30)), meetingPoint: meetup, ), throwsA(isA()), ); }); test('rejects departure before arrival time', () async { final service = GroupRideService(); const meetup = GroupRideLocation( latitude: 34.0, longitude: -84.0, displayName: 'Meetup', ); final now = DateTime.now(); expect( () => service.createGroupRide( organizer: organizer, title: 'Morning Cruise', scheduledStartAt: now.add(const Duration(hours: 2)), departureAt: now.add(const Duration(hours: 1)), // earlier! meetingPoint: meetup, ), throwsA(isA()), ); }); test('rejects participant limit less than 2', () async { final service = GroupRideService(); const meetup = GroupRideLocation( latitude: 34.0, longitude: -84.0, displayName: 'Meetup', ); final now = DateTime.now(); expect( () => service.createGroupRide( organizer: organizer, title: 'Solo Ride', scheduledStartAt: now, departureAt: now.add(const Duration(minutes: 30)), participantLimit: 1, // Invalid! meetingPoint: meetup, ), throwsA(isA()), ); }); test('creates offline fallback ride safely when unconfigured', () async { final service = GroupRideService(); const meetup = GroupRideLocation( latitude: 34.5, longitude: -83.9, displayName: 'Dahlonega', ); final start = DateTime.now().add(const Duration(days: 1)); final dep = start.add(const Duration(minutes: 30)); final ride = await service.createGroupRide( organizer: organizer, title: 'Sunday Morning Run', description: 'Twisty mountain roads', visibility: GroupRideVisibility.friendsOnly, scheduledStartAt: start, departureAt: dep, meetingPoint: meetup, ); expect(ride.title, equals('Sunday Morning Run')); expect(ride.organizerUid, equals('user_alice')); expect(ride.meetingPoint.displayName, equals('Dahlonega')); expect(ride.participantCount, equals(1)); }); test('cannot invite self to group ride', () async { final service = GroupRideService(); expect( () => service.inviteFriend( callerUid: 'user_alice', groupRideId: 'ride_1', friendProfile: organizer, // same UID! ), throwsA(isA()), ); }); test('publishLivePresence validates physical coordinates', () async { final service = GroupRideService(); expect( () => service.publishLivePresence( callerUid: 'user_alice', groupRideId: 'ride_1', latitude: 105.0, // out of range longitude: -84.0, ), throwsA(isA()), ); }); test('offline live presence and upcoming streams return safely', () async { final service = GroupRideService(); final stream = service.livePresenceDocStream(groupRideId: 'ride_offline', targetUid: 'user_alice'); expect(await stream.first, isNull); final pStream = service.participantDocStream(groupRideId: 'ride_offline', participantUid: 'user_alice'); expect(await pStream.first, isNull); final upcoming = service.upcomingRidesStream(callerUid: 'user_alice'); expect(await upcoming.first, isEmpty); }); }); group('Live Presence Point Listener Lifecycle Tests', () { test('reconciles candidates from accessUids and only subscribes to accepted participants', () async { final fakeService = _FakeGroupRideLifecycleService(); final rideStreamController = StreamController.broadcast(); final rideDoc = GroupRide( id: 'ride_123', organizerUid: 'organizer_1', organizerHandle: 'organizer', organizerDisplayName: 'Organizer', title: 'Sunday Morning Twisties', status: GroupRideStatus.scheduled, scheduledStartAt: DateTime.now(), departureAt: DateTime.now().add(const Duration(minutes: 15)), meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'), joinPolicy: GroupRideJoinPolicy.open, createdAt: DateTime.now(), updatedAt: DateTime.now(), accessUids: const ['user_accepted', 'user_invited', 'user_declined'], ); final container = ProviderContainer( overrides: [ groupRideServiceProvider.overrideWithValue(fakeService), groupRideStreamProvider('ride_123').overrideWith((ref) => rideStreamController.stream), ], ); addTearDown(container.dispose); final states = >[]; container.listen( groupRideLivePresenceProvider('ride_123'), (previous, next) { next.whenData((data) => states.add(data)); }, ); rideStreamController.add(rideDoc); await Future.delayed(const Duration(milliseconds: 20)); fakeService.getParticipantController('user_accepted').add( GroupRideParticipant( userId: 'user_accepted', role: GroupRideRole.participant, status: ParticipantStatus.accepted, riderHandle: 'alice', riderDisplayName: 'Alice', createdAt: DateTime.now(), updatedAt: DateTime.now(), ), ); fakeService.getParticipantController('user_invited').add( GroupRideParticipant( userId: 'user_invited', role: GroupRideRole.participant, status: ParticipantStatus.invited, riderHandle: 'bob', riderDisplayName: 'Bob', createdAt: DateTime.now(), updatedAt: DateTime.now(), ), ); fakeService.getParticipantController('user_declined').add( GroupRideParticipant( userId: 'user_declined', role: GroupRideRole.participant, status: ParticipantStatus.declined, riderHandle: 'charlie', riderDisplayName: 'Charlie', createdAt: DateTime.now(), updatedAt: DateTime.now(), ), ); await Future.delayed(const Duration(milliseconds: 20)); final presenceAlice = LiveGroupPresence( userId: 'user_accepted', groupRideId: 'ride_123', latitude: 37.7749, longitude: -122.4194, speedMph: 45.0, sharingEnabled: true, updatedAt: DateTime.now(), expiresAt: DateTime.now().add(const Duration(minutes: 5)), ); fakeService.getPresenceController('user_accepted').add(presenceAlice); await Future.delayed(const Duration(milliseconds: 20)); expect(states.isNotEmpty, isTrue); expect(states.last.any((p) => p.userId == 'user_accepted'), isTrue); expect(states.last.any((p) => p.userId == 'user_invited'), isFalse); expect(states.last.any((p) => p.userId == 'user_declined'), isFalse); }); test('peer error isolation: peer permission-denied does not drop other peer presence', () async { final fakeService = _FakeGroupRideLifecycleService(); final rideStreamController = StreamController.broadcast(); final rideDoc = GroupRide( id: 'ride_error_isolation', organizerUid: 'organizer_1', organizerHandle: 'organizer', organizerDisplayName: 'Organizer', title: 'Ride', status: GroupRideStatus.scheduled, scheduledStartAt: DateTime.now(), departureAt: DateTime.now().add(const Duration(minutes: 15)), meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'), joinPolicy: GroupRideJoinPolicy.open, createdAt: DateTime.now(), updatedAt: DateTime.now(), accessUids: const ['user_alice', 'user_bob'], ); final container = ProviderContainer( overrides: [ groupRideServiceProvider.overrideWithValue(fakeService), groupRideStreamProvider('ride_error_isolation').overrideWith((ref) => rideStreamController.stream), ], ); addTearDown(container.dispose); final states = >[]; container.listen( groupRideLivePresenceProvider('ride_error_isolation'), (previous, next) { next.whenData((data) => states.add(data)); }, ); rideStreamController.add(rideDoc); await Future.delayed(const Duration(milliseconds: 20)); fakeService.getParticipantController('user_alice').add( GroupRideParticipant( userId: 'user_alice', role: GroupRideRole.participant, status: ParticipantStatus.accepted, riderHandle: 'alice', riderDisplayName: 'Alice', createdAt: DateTime.now(), updatedAt: DateTime.now(), ), ); fakeService.getParticipantController('user_bob').add( GroupRideParticipant( userId: 'user_bob', role: GroupRideRole.participant, status: ParticipantStatus.accepted, riderHandle: 'bob', riderDisplayName: 'Bob', createdAt: DateTime.now(), updatedAt: DateTime.now(), ), ); await Future.delayed(const Duration(milliseconds: 20)); final presenceAlice = LiveGroupPresence( userId: 'user_alice', groupRideId: 'ride_error_isolation', latitude: 37.7749, longitude: -122.4194, speedMph: 45.0, sharingEnabled: true, updatedAt: DateTime.now(), expiresAt: DateTime.now().add(const Duration(minutes: 5)), ); fakeService.getPresenceController('user_alice').add(presenceAlice); fakeService.getPresenceController('user_bob').addError('permission-denied'); await Future.delayed(const Duration(milliseconds: 20)); expect(states.isNotEmpty, isTrue); expect(states.last.length, 1); expect(states.last.first.userId, 'user_alice'); }); test('participants provider reconciles candidates from accessUids and callerUid without broad collection queries', () async { final fakeService = _FakeGroupRideLifecycleService(); final rideStreamController = StreamController.broadcast(); final rideDoc = GroupRide( id: 'ride_participants_test', organizerUid: 'user_alice', organizerHandle: 'alice', organizerDisplayName: 'Alice', title: 'Ride', status: GroupRideStatus.scheduled, scheduledStartAt: DateTime.now(), departureAt: DateTime.now().add(const Duration(minutes: 15)), meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'), joinPolicy: GroupRideJoinPolicy.open, createdAt: DateTime.now(), updatedAt: DateTime.now(), accessUids: const ['user_alice', 'user_bob'], ); final container = ProviderContainer( overrides: [ groupRideServiceProvider.overrideWithValue(fakeService), groupRideStreamProvider('ride_participants_test').overrideWith((ref) => rideStreamController.stream), ], ); addTearDown(container.dispose); final states = >[]; container.listen( groupRideParticipantsProvider('ride_participants_test'), (previous, next) { next.whenData((data) => states.add(data)); }, ); rideStreamController.add(rideDoc); await Future.delayed(const Duration(milliseconds: 20)); fakeService.getParticipantController('user_alice').add( GroupRideParticipant( userId: 'user_alice', role: GroupRideRole.organizer, status: ParticipantStatus.accepted, riderHandle: 'alice', riderDisplayName: 'Alice', createdAt: DateTime.now(), updatedAt: DateTime.now(), ), ); fakeService.getParticipantController('user_bob').add( GroupRideParticipant( userId: 'user_bob', role: GroupRideRole.participant, status: ParticipantStatus.accepted, riderHandle: 'bob', riderDisplayName: 'Bob', createdAt: DateTime.now(), updatedAt: DateTime.now(), ), ); await Future.delayed(const Duration(milliseconds: 20)); expect(states.isNotEmpty, isTrue); expect(states.last.length, 2); expect(states.last.first.userId, 'user_alice'); expect(states.last.last.userId, 'user_bob'); }); }); group('UI Layout and Styling Regression Tests', () { testWidgets('Live presence header does not overflow on 393px width constraint', (tester) async { tester.view.physicalSize = const Size(393, 852); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); await tester.pumpWidget( MaterialApp( theme: AppTheme.darkTheme, home: Scaffold( body: Center( child: SizedBox( width: 393, child: Row( children: [ const Icon(Icons.sensors, color: AppColors.brandBlue, size: 20), const SizedBox(width: 8), const Expanded( child: Text( 'LIVE RIDE PRESENCE', overflow: TextOverflow.ellipsis, maxLines: 1, style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, letterSpacing: 1.2, color: Colors.white, ), ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppColors.brandBlue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 6, height: 6, decoration: const BoxDecoration( color: AppColors.brandBlue, shape: BoxShape.circle, ), ), const SizedBox(width: 4), const Text( 'BROADCASTING', style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.brandBlue, ), ), ], ), ), ], ), ), ), ), ), ); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect(find.text('LIVE RIDE PRESENCE'), findsOneWidget); expect(find.text('BROADCASTING'), findsOneWidget); }); test('Light theme AppBar has dark status bar icons and high contrast title', () { final lightTheme = AppTheme.lightTheme; expect(lightTheme.appBarTheme.titleTextStyle?.color, AppColors.lightOnSurface); expect(lightTheme.appBarTheme.iconTheme?.color, AppColors.lightOnSurface); 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 { final Map> participantControllers = {}; final Map> presenceControllers = {}; StreamController getParticipantController(String uid) { return participantControllers.putIfAbsent( uid, () => StreamController.broadcast(), ); } StreamController getPresenceController(String uid) { return presenceControllers.putIfAbsent( uid, () => StreamController.broadcast(), ); } @override Stream participantDocStream({ required String groupRideId, required String participantUid, }) { return getParticipantController(participantUid).stream; } @override Stream livePresenceDocStream({ required String groupRideId, required String targetUid, }) { return getPresenceController(targetUid).stream; } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }