import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:motorcycle_dash/core/theme/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: 50)); 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: 50)); 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: 50)); 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: 50)); 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: 50)); 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: 50)); 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: 50)); 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: 50)); 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()), ); }); }); group('GroupRide Join Workflow & Participant Moderation Tests', () { late FakeFirebaseFirestore fakeFirestore; late GroupRideService service; const meetup = GroupRideLocation( latitude: 34.0, longitude: -84.0, displayName: 'Atlanta Base', ); final organizer = RiderProfile( uid: 'user_org', handle: 'alice_org', displayName: 'Alice Org', createdAt: DateTime.now(), updatedAt: DateTime.now(), ); final requester = RiderProfile( uid: 'user_req', handle: 'charlie_rider', displayName: 'Charlie Rider', createdAt: DateTime.now(), updatedAt: DateTime.now(), ); setUp(() { fakeFirestore = FakeFirebaseFirestore(); service = GroupRideService(firestore: fakeFirestore); }); Future seedRide({ required String rideId, required String organizerUid, String? clubId, GroupRideStatus status = GroupRideStatus.scheduled, GroupRideJoinPolicy joinPolicy = GroupRideJoinPolicy.requestToJoin, int? participantLimit, int participantCount = 1, List accessUids = const ['user_org'], }) async { final now = DateTime.now(); await fakeFirestore.collection('group_rides').doc(rideId).set({ 'id': rideId, 'organizerUid': organizerUid, 'title': 'Saturday Morning Run', 'status': status.name, 'visibility': 'public', 'joinPolicy': joinPolicy.name, 'participantLimit': ?participantLimit, 'participantCount': participantCount, 'scheduledStartAt': now.add(const Duration(hours: 2)).toIso8601String(), 'departureAt': now.add(const Duration(hours: 2, minutes: 30)).toIso8601String(), 'meetingPoint': meetup.toJson(), 'accessUids': accessUids, 'clubId': ?clubId, 'createdAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(), }); await fakeFirestore .collection('group_rides') .doc(rideId) .collection('participants') .doc(organizerUid) .set({ 'userId': organizerUid, 'role': 'organizer', 'status': 'accepted', 'createdAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(), }); } Future seedClubMember({ required String clubId, required String uid, required String role, String status = 'active', }) async { final now = DateTime.now(); await fakeFirestore.collection('clubs').doc(clubId).set({ 'id': clubId, 'name': 'Alpha Club', 'slug': 'alpha-club', 'ownerUid': 'user_org', 'visibility': 'public', 'admissionPolicy': 'open', 'memberCount': 2, 'createdAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(), }); await fakeFirestore .collection('clubs') .doc(clubId) .collection('members') .doc(uid) .set({ 'clubId': clubId, 'uid': uid, 'role': role, 'status': status, 'joinedAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(), }); } test('requestToJoin creates participant doc without mutating parent ride', () async { await seedRide(rideId: 'ride_1', organizerUid: organizer.uid); await service.requestToJoin(caller: requester, groupRideId: 'ride_1'); final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_1').get(); final ride = GroupRide.fromJson(rideSnap.data()!); expect(ride.accessUids, isNot(contains(requester.uid))); expect(ride.participantCount, equals(1)); expect(rideSnap.data()!.containsKey('pendingUids'), isFalse); final partSnap = await fakeFirestore .collection('group_rides') .doc('ride_1') .collection('participants') .doc(requester.uid) .get(); expect(partSnap.exists, isTrue); expect(partSnap.data()!['status'], equals('requested')); }); test('requestToJoin succeeds even when ride is currently at capacity (enforced at approval)', () async { await seedRide( rideId: 'ride_full', organizerUid: organizer.uid, participantLimit: 2, participantCount: 2, accessUids: [organizer.uid, 'user_other'], ); await service.requestToJoin(caller: requester, groupRideId: 'ride_full'); final partSnap = await fakeFirestore .collection('group_rides') .doc('ride_full') .collection('participants') .doc(requester.uid) .get(); expect(partSnap.exists, isTrue); expect(partSnap.data()!['status'], equals('requested')); }); test('requestToJoin rejects when ride is underway, completed, or canceled', () async { for (final s in [ GroupRideStatus.underway, GroupRideStatus.completed, GroupRideStatus.canceled, ]) { await seedRide( rideId: 'ride_${s.name}', organizerUid: organizer.uid, status: s, ); expect( () => service.requestToJoin(caller: requester, groupRideId: 'ride_${s.name}'), throwsA(isA()), ); } }); test('requestToJoin rejects when caller is mutually blocked with organizer', () async { await seedRide(rideId: 'ride_blocked', organizerUid: organizer.uid); await fakeFirestore .collection('users') .doc(organizer.uid) .collection('blocks') .doc(requester.uid) .set({'blockedUid': requester.uid}); expect( () => service.requestToJoin(caller: requester, groupRideId: 'ride_blocked'), throwsA(isA()), ); }); test('requestToJoin on club ride requires active club membership', () async { await seedRide( rideId: 'ride_club', organizerUid: organizer.uid, clubId: 'club_1', ); // Non-member fails expect( () => service.requestToJoin(caller: requester, groupRideId: 'ride_club'), throwsA(isA()), ); // Active member succeeds await seedClubMember(clubId: 'club_1', uid: requester.uid, role: 'member', status: 'active'); await service.requestToJoin(caller: requester, groupRideId: 'ride_club'); final partSnap = await fakeFirestore .collection('group_rides') .doc('ride_club') .collection('participants') .doc(requester.uid) .get(); expect(partSnap.exists, isTrue); expect(partSnap.data()!['status'], equals('requested')); }); test('respondToJoinRequest approval moves participant to accepted, adds accessUid, and increments count', () async { await seedRide( rideId: 'ride_approve', organizerUid: organizer.uid, ); await fakeFirestore .collection('group_rides') .doc('ride_approve') .collection('participants') .doc(requester.uid) .set({ 'userId': requester.uid, 'role': 'participant', 'status': 'requested', 'createdAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); await service.respondToJoinRequest( callerUid: organizer.uid, groupRideId: 'ride_approve', requesterUid: requester.uid, approve: true, ); final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_approve').get(); final ride = GroupRide.fromJson(rideSnap.data()!); expect(ride.accessUids, contains(requester.uid)); expect(ride.participantCount, equals(2)); final partSnap = await fakeFirestore .collection('group_rides') .doc('ride_approve') .collection('participants') .doc(requester.uid) .get(); expect(partSnap.data()!['status'], equals('accepted')); }); test('respondToJoinRequest decline marks participant declined with zero parent ride mutations', () async { await seedRide( rideId: 'ride_decline', organizerUid: organizer.uid, ); await fakeFirestore .collection('group_rides') .doc('ride_decline') .collection('participants') .doc(requester.uid) .set({ 'userId': requester.uid, 'role': 'participant', 'status': 'requested', 'createdAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); await service.respondToJoinRequest( callerUid: organizer.uid, groupRideId: 'ride_decline', requesterUid: requester.uid, approve: false, ); final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_decline').get(); final ride = GroupRide.fromJson(rideSnap.data()!); expect(ride.accessUids, isNot(contains(requester.uid))); expect(ride.participantCount, equals(1)); final partSnap = await fakeFirestore .collection('group_rides') .doc('ride_decline') .collection('participants') .doc(requester.uid) .get(); expect(partSnap.data()!['status'], equals('declined')); }); test('active club admin can approve requests on official club ride', () async { await seedClubMember(clubId: 'club_mod', uid: 'admin_user', role: 'admin'); await seedClubMember(clubId: 'club_mod', uid: requester.uid, role: 'member'); await seedRide( rideId: 'ride_mod', organizerUid: organizer.uid, clubId: 'club_mod', ); await fakeFirestore .collection('group_rides') .doc('ride_mod') .collection('participants') .doc(requester.uid) .set({ 'userId': requester.uid, 'role': 'participant', 'status': 'requested', 'createdAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); await service.respondToJoinRequest( callerUid: 'admin_user', groupRideId: 'ride_mod', requesterUid: requester.uid, approve: true, ); final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_mod').get(); expect(rideSnap.data()!['accessUids'], contains(requester.uid)); }); test('non-admin club member cannot approve requests', () async { await seedClubMember(clubId: 'club_mod2', uid: 'normal_member', role: 'member'); await seedClubMember(clubId: 'club_mod2', uid: requester.uid, role: 'member'); await seedRide( rideId: 'ride_mod2', organizerUid: organizer.uid, clubId: 'club_mod2', ); await fakeFirestore .collection('group_rides') .doc('ride_mod2') .collection('participants') .doc(requester.uid) .set({ 'userId': requester.uid, 'role': 'participant', 'status': 'requested', 'createdAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); expect( () => service.respondToJoinRequest( callerUid: 'normal_member', groupRideId: 'ride_mod2', requesterUid: requester.uid, approve: true, ), throwsA(isA()), ); }); test('approval rejected when ride is underway or at capacity', () async { // Underway await seedRide( rideId: 'ride_underway_app', organizerUid: organizer.uid, status: GroupRideStatus.underway, ); await fakeFirestore .collection('group_rides') .doc('ride_underway_app') .collection('participants') .doc(requester.uid) .set({ 'userId': requester.uid, 'role': 'participant', 'status': 'requested', 'createdAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); expect( () => service.respondToJoinRequest( callerUid: organizer.uid, groupRideId: 'ride_underway_app', requesterUid: requester.uid, approve: true, ), throwsA(isA()), ); // At capacity await seedRide( rideId: 'ride_cap_app', organizerUid: organizer.uid, participantLimit: 2, participantCount: 2, accessUids: [organizer.uid, 'user_other'], ); await fakeFirestore .collection('group_rides') .doc('ride_cap_app') .collection('participants') .doc(requester.uid) .set({ 'userId': requester.uid, 'role': 'participant', 'status': 'requested', 'createdAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); expect( () => service.respondToJoinRequest( callerUid: organizer.uid, groupRideId: 'ride_cap_app', requesterUid: requester.uid, approve: true, ), throwsA(isA()), ); }); test('leaveRide cancels pending request without mutating parent ride', () async { await seedRide( rideId: 'ride_leave_pending', organizerUid: organizer.uid, ); await fakeFirestore .collection('group_rides') .doc('ride_leave_pending') .collection('participants') .doc(requester.uid) .set({ 'userId': requester.uid, 'role': 'participant', 'status': 'requested', 'createdAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); await service.leaveRide(callerUid: requester.uid, groupRideId: 'ride_leave_pending'); final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_leave_pending').get(); expect(rideSnap.data()!['accessUids'], isNot(contains(requester.uid))); expect(rideSnap.data()!['participantCount'], equals(1)); final partSnap = await fakeFirestore .collection('group_rides') .doc('ride_leave_pending') .collection('participants') .doc(requester.uid) .get(); expect(partSnap.data()!['status'], equals('left')); }); test('leaveRide removes accepted rider from accessUids and decrements count', () async { await seedRide( rideId: 'ride_leave_accepted', organizerUid: organizer.uid, participantCount: 2, accessUids: [organizer.uid, requester.uid], ); await fakeFirestore .collection('group_rides') .doc('ride_leave_accepted') .collection('participants') .doc(requester.uid) .set({ 'userId': requester.uid, 'role': 'participant', 'status': 'accepted', 'createdAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); await service.leaveRide(callerUid: requester.uid, groupRideId: 'ride_leave_accepted'); final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_leave_accepted').get(); expect(rideSnap.data()!['accessUids'], isNot(contains(requester.uid))); expect(rideSnap.data()!['participantCount'], equals(1)); }); test('respondToInvitation accept: true marks participant accepted and increments count', () async { final invitee = RiderProfile( uid: 'user_bob', handle: 'bob_trail', displayName: 'Bob Trail', createdAt: DateTime.now(), updatedAt: DateTime.now(), ); await seedRide( rideId: 'ride_inv_accept', organizerUid: organizer.uid, participantCount: 1, accessUids: [organizer.uid, invitee.uid], ); await fakeFirestore .collection('group_rides') .doc('ride_inv_accept') .collection('participants') .doc(invitee.uid) .set({ 'userId': invitee.uid, 'role': 'participant', 'status': 'invited', 'invitedBy': organizer.uid, 'createdAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); await service.respondToInvitation( callerUid: invitee.uid, groupRideId: 'ride_inv_accept', accept: true, ); final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_inv_accept').get(); expect(rideSnap.data()!['participantCount'], equals(2)); final partSnap = await fakeFirestore .collection('group_rides') .doc('ride_inv_accept') .collection('participants') .doc(invitee.uid) .get(); expect(partSnap.data()!['status'], equals('accepted')); }); test('respondToInvitation accept: false marks participant declined and removes accessUid with count unchanged', () async { final invitee = RiderProfile( uid: 'user_bob', handle: 'bob_trail', displayName: 'Bob Trail', createdAt: DateTime.now(), updatedAt: DateTime.now(), ); await seedRide( rideId: 'ride_inv_decline', organizerUid: organizer.uid, participantCount: 1, accessUids: [organizer.uid, invitee.uid], ); await fakeFirestore .collection('group_rides') .doc('ride_inv_decline') .collection('participants') .doc(invitee.uid) .set({ 'userId': invitee.uid, 'role': 'participant', 'status': 'invited', 'invitedBy': organizer.uid, 'createdAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); await service.respondToInvitation( callerUid: invitee.uid, groupRideId: 'ride_inv_decline', accept: false, ); final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_inv_decline').get(); expect(rideSnap.data()!['participantCount'], equals(1)); expect(rideSnap.data()!['accessUids'], isNot(contains(invitee.uid))); final partSnap = await fakeFirestore .collection('group_rides') .doc('ride_inv_decline') .collection('participants') .doc(invitee.uid) .get(); expect(partSnap.data()!['status'], equals('declined')); }); test('respondToJoinRequest decline succeeds even when organizer and requester are mutually blocked', () async { await seedRide( rideId: 'ride_block_decline', organizerUid: organizer.uid, ); // Seed mutual block await fakeFirestore .collection('users') .doc(organizer.uid) .collection('blocks') .doc(requester.uid) .set({'blockedUid': requester.uid}); await fakeFirestore .collection('users') .doc(requester.uid) .collection('blocks') .doc(organizer.uid) .set({'blockedUid': organizer.uid}); await fakeFirestore .collection('group_rides') .doc('ride_block_decline') .collection('participants') .doc(requester.uid) .set({ 'userId': requester.uid, 'role': 'participant', 'status': 'requested', 'createdAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }); // 1. Approve fails expect( () => service.respondToJoinRequest( callerUid: organizer.uid, groupRideId: 'ride_block_decline', requesterUid: requester.uid, approve: true, ), throwsA(isA()), ); // 2. Decline SUCCEEDS await service.respondToJoinRequest( callerUid: organizer.uid, groupRideId: 'ride_block_decline', requesterUid: requester.uid, approve: false, ); final partSnap = await fakeFirestore .collection('group_rides') .doc('ride_block_decline') .collection('participants') .doc(requester.uid) .get(); expect(partSnap.data()!['status'], equals('declined')); }); }); group('fetchJoinRequests Moderation API Tests', () { test('rejects empty or whitespace groupRideId', () async { final service = GroupRideService(); expect( () => service.fetchJoinRequests(groupRideId: ''), throwsA(isA()), ); expect( () => service.fetchJoinRequests(groupRideId: ' '), throwsA(isA()), ); }); test('throws StateError when unauthenticated (no token provided and no Firebase user)', () async { final service = GroupRideService(); expect( () => service.fetchJoinRequests(groupRideId: 'ride_1'), throwsA(isA().having( (e) => e.message, 'message', contains('registered account is required'), )), ); }); test('fetches and parses join requests successfully (HTTP 200)', () async { final mockClient = MockClient((request) async { expect(request.method, equals('GET')); expect( request.url.toString(), equals('https://motorsignal.app/api/group-rides/ride_123/join-requests'), ); expect(request.headers['Authorization'], equals('Bearer valid-token')); expect(request.headers['Accept'], equals('application/json')); final responseBody = jsonEncode({ 'requests': [ { 'userId': 'rider_1', 'riderDisplayName': 'Mountain Explorer', 'riderHandle': 'mtn_rider', 'riderAvatarPreset': 'helmet_red', 'riderAvatarUrl': 'https://example.com/avatar.jpg', 'createdAt': '2026-09-24T12:00:00.000Z', }, { 'userId': 'rider_2', 'riderDisplayName': 'Track Master', 'riderHandle': 'track_pro', 'createdAt': '2026-09-24T12:05:00.000Z', }, ] }); return http.Response(responseBody, 200, headers: { 'content-type': 'application/json', }); }); final service = GroupRideService(); final result = await service.fetchJoinRequests( groupRideId: 'ride_123', idToken: 'valid-token', client: mockClient, ); expect(result.length, equals(2)); expect(result[0].userId, equals('rider_1')); expect(result[0].displayName, equals('Mountain Explorer')); expect(result[0].displayHandle, equals('@mtn_rider')); expect(result[0].riderAvatarPreset, equals('helmet_red')); expect(result[0].riderAvatarUrl, equals('https://example.com/avatar.jpg')); expect(result[0].createdAt, equals(DateTime.parse('2026-09-24T12:00:00.000Z'))); expect(result[1].userId, equals('rider_2')); expect(result[1].displayName, equals('Track Master')); expect(result[1].displayHandle, equals('@track_pro')); expect(result[1].riderAvatarPreset, isNull); }); test('returns empty list when requests array is empty or missing (HTTP 200)', () async { final mockClient = MockClient((request) async { return http.Response(jsonEncode({'requests': []}), 200); }); final service = GroupRideService(); final result = await service.fetchJoinRequests( groupRideId: 'ride_empty', idToken: 'token', client: mockClient, ); expect(result, isEmpty); final mockClient2 = MockClient((request) async { return http.Response(jsonEncode({}), 200); }); final result2 = await service.fetchJoinRequests( groupRideId: 'ride_empty', idToken: 'token', client: mockClient2, ); expect(result2, isEmpty); }); test('respects custom baseUrl and properly encodes groupRideId', () async { final mockClient = MockClient((request) async { expect( request.url.toString(), equals('https://staging.motorsignal.app/api/group-rides/ride%2Fspecial%231/join-requests'), ); return http.Response(jsonEncode({'requests': []}), 200); }); final service = GroupRideService(); final result = await service.fetchJoinRequests( groupRideId: 'ride/special#1', baseUrl: 'https://staging.motorsignal.app/', idToken: 'token', client: mockClient, ); expect(result, isEmpty); }); test('throws StateError on session expired (HTTP 401)', () async { final mockClient = MockClient((request) async { return http.Response(jsonEncode({'error': 'Unauthorized'}), 401); }); final service = GroupRideService(); expect( () => service.fetchJoinRequests( groupRideId: 'ride_1', idToken: 'expired-token', client: mockClient, ), throwsA(isA().having( (e) => e.message, 'message', contains('session has expired'), )), ); }); test('throws StateError on forbidden (HTTP 403)', () async { final mockClient = MockClient((request) async { return http.Response(jsonEncode({'error': 'Forbidden'}), 403); }); final service = GroupRideService(); expect( () => service.fetchJoinRequests( groupRideId: 'ride_1', idToken: 'rider-token', client: mockClient, ), throwsA(isA().having( (e) => e.message, 'message', contains('organizer or club leadership'), )), ); }); test('throws StateError on ride not found (HTTP 404)', () async { final mockClient = MockClient((request) async { return http.Response(jsonEncode({'error': 'Not Found'}), 404); }); final service = GroupRideService(); expect( () => service.fetchJoinRequests( groupRideId: 'nonexistent', idToken: 'token', client: mockClient, ), throwsA(isA().having( (e) => e.message, 'message', contains('Group ride not found'), )), ); }); test('throws StateError on generic server error (HTTP 500)', () async { final mockClient = MockClient((request) async { return http.Response('Internal Server Error', 500); }); final service = GroupRideService(); expect( () => service.fetchJoinRequests( groupRideId: 'ride_1', idToken: 'token', client: mockClient, ), throwsA(isA().having( (e) => e.message, 'message', contains('HTTP 500'), )), ); }); test('handles client exception gracefully', () async { final mockClient = MockClient((request) async { throw http.ClientException('Connection failed'); }); final service = GroupRideService(); expect( () => service.fetchJoinRequests( groupRideId: 'ride_1', idToken: 'token', client: mockClient, ), throwsA(isA().having( (e) => e.message, 'message', contains('Network connection error'), )), ); }); }); } 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); }