From 46ce052e0c67e08bd27ae7ff9454f2105339cc23 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 24 Sep 2026 14:17:19 -0400 Subject: [PATCH] feat(group-rides): add mobile join request moderation UI and service (Phase 3B) Connects mobile group ride details to the Phase 3A moderation server endpoint (GET /api/group-rides/[rideId]/join-requests). --- .../group_rides/group_ride_detail_screen.dart | 286 +++++++++--- .../group_rides/group_ride_models.dart | 87 ++++ .../group_rides/group_ride_providers.dart | 12 + .../group_rides/group_ride_service.dart | 102 ++++- test/group_ride_models_test.dart | 92 ++++ test/group_ride_moderation_widget_test.dart | 411 ++++++++++++++++++ test/group_ride_service_test.dart | 227 ++++++++++ 7 files changed, 1160 insertions(+), 57 deletions(-) create mode 100644 test/group_ride_moderation_widget_test.dart diff --git a/lib/features/group_rides/group_ride_detail_screen.dart b/lib/features/group_rides/group_ride_detail_screen.dart index 50d8ccc..983743e 100644 --- a/lib/features/group_rides/group_ride_detail_screen.dart +++ b/lib/features/group_rides/group_ride_detail_screen.dart @@ -26,6 +26,7 @@ class GroupRideDetailScreen extends ConsumerStatefulWidget { class _GroupRideDetailScreenState extends ConsumerState { bool _isProcessing = false; String? _statusMessage; + final Set _moderatingUids = {}; Future _launchMapNavigation(GroupRideLocation loc) async { final query = Uri.encodeComponent( @@ -353,6 +354,7 @@ class _GroupRideDetailScreenState extends ConsumerState { ride, participants, isOrganizer, + isClubAdmin, currentUid, colors, ), @@ -433,6 +435,7 @@ class _GroupRideDetailScreenState extends ConsumerState { ride, participants, isOrganizer, + isClubAdmin, currentUid, colors, ), @@ -1597,19 +1600,16 @@ class _GroupRideDetailScreenState extends ConsumerState { GroupRide ride, List participants, bool isOrganizer, + bool isClubAdmin, String? currentUid, MotorSignalThemeColors colors, ) { final active = participants .where((p) => p.status == ParticipantStatus.accepted) .toList(); - final pendingRequests = participants - .where((p) => p.status == ParticipantStatus.requested) - .toList(); final invited = participants .where((p) => p.status == ParticipantStatus.invited) .toList(); - final service = ref.read(groupRideServiceProvider); return Card( @@ -1665,57 +1665,101 @@ class _GroupRideDetailScreenState extends ConsumerState { ), const SizedBox(height: 12), - // Pending join requests (visible to organizer) - if (isOrganizer && pendingRequests.isNotEmpty) ...[ - Text( - 'JOIN REQUESTS', - style: TextStyle( - color: colors.warningAmber, - fontWeight: FontWeight.bold, - fontSize: 11, - ), + // Pending join requests (visible to organizer and club leadership) + if ((isOrganizer || isClubAdmin) && + !ride.isCanceled && + !ride.isCompleted) ...[ + Builder( + builder: (context) { + final joinRequestsAsync = ref.watch(groupRideJoinRequestsProvider(ride.id)); + return joinRequestsAsync.when( + data: (requests) { + if (requests.isEmpty) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'JOIN REQUESTS (${requests.length})', + style: TextStyle( + color: colors.warningAmber, + fontWeight: FontWeight.bold, + fontSize: 11, + letterSpacing: 0.8, + ), + ), + IconButton( + icon: const Icon(Icons.refresh, size: 14), + tooltip: 'Refresh join requests', + color: colors.warningAmber, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => + ref.invalidate(groupRideJoinRequestsProvider(ride.id)), + ), + ], + ), + const SizedBox(height: 6), + for (final req in requests) + _buildJoinRequestTile(ride, req, currentUid, colors), + const Divider(color: Colors.white12), + ], + ); + }, + loading: () => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: colors.warningAmber, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Checking for pending join requests...', + style: TextStyle(color: colors.warningAmber, fontSize: 11), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + error: (err, _) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 4, + children: [ + Text( + 'Unable to load join requests.', + style: TextStyle(color: colors.raceRed, fontSize: 11), + ), + TextButton( + onPressed: () => + ref.invalidate(groupRideJoinRequestsProvider(ride.id)), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + child: Text('Retry', + style: TextStyle(color: colors.cyberCyan, fontSize: 11)), + ), + ], + ), + ), + ); + }, ), - const SizedBox(height: 6), - for (final req in pendingRequests) - ListTile( - contentPadding: EdgeInsets.zero, - title: Text( - req.riderDisplayName, - style: const TextStyle(color: Colors.white, fontSize: 13), - ), - subtitle: Text( - req.displayHandle, - style: const TextStyle(color: Colors.white54, fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(Icons.close, color: colors.raceRed), - onPressed: () async { - await service.respondToJoinRequest( - callerUid: currentUid!, - groupRideId: ride.id, - requesterUid: req.userId, - approve: false, - ); - }, - ), - IconButton( - icon: Icon(Icons.check, color: colors.cyberCyan), - onPressed: () async { - await service.respondToJoinRequest( - callerUid: currentUid!, - groupRideId: ride.id, - requesterUid: req.userId, - approve: true, - ); - }, - ), - ], - ), - ), - const Divider(color: Colors.white12), ], // Active members @@ -1821,6 +1865,140 @@ class _GroupRideDetailScreenState extends ConsumerState { ); } + Widget _buildJoinRequestTile( + GroupRide ride, + GroupRideJoinRequest req, + String? currentUid, + MotorSignalThemeColors colors, + ) { + final service = ref.read(groupRideServiceProvider); + final isModerating = _moderatingUids.contains(req.userId); + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + radius: 16, + backgroundColor: colors.surfaceContainer, + child: Text( + req.displayName.isNotEmpty ? req.displayName[0].toUpperCase() : '?', + style: TextStyle( + color: colors.cyberCyan, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + title: Text( + req.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + req.displayHandle, + style: const TextStyle(color: Colors.white54, fontSize: 11), + ), + trailing: isModerating + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.cyberCyan, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.close, color: colors.raceRed), + tooltip: 'Decline request', + onPressed: () async { + if (currentUid == null) return; + setState(() => _moderatingUids.add(req.userId)); + try { + await service.respondToJoinRequest( + callerUid: currentUid, + groupRideId: ride.id, + requesterUid: req.userId, + approve: false, + ); + if (mounted) { + ref.invalidate(groupRideJoinRequestsProvider(ride.id)); + ref.invalidate(groupRideStreamProvider(ride.id)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Declined join request from ${req.displayName}.', + ), + backgroundColor: colors.raceRed, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to decline request: $e'), + backgroundColor: colors.raceRed, + ), + ); + } + } finally { + if (mounted) { + setState(() => _moderatingUids.remove(req.userId)); + } + } + }, + ), + IconButton( + icon: Icon(Icons.check, color: colors.cyberCyan), + tooltip: 'Approve request', + onPressed: () async { + if (currentUid == null) return; + setState(() => _moderatingUids.add(req.userId)); + try { + await service.respondToJoinRequest( + callerUid: currentUid, + groupRideId: ride.id, + requesterUid: req.userId, + approve: true, + ); + if (mounted) { + ref.invalidate(groupRideJoinRequestsProvider(ride.id)); + ref.invalidate(groupRideStreamProvider(ride.id)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Approved ${req.displayName} to join the ride.', + ), + backgroundColor: colors.cyberCyan, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to approve request: $e'), + backgroundColor: colors.raceRed, + ), + ); + } + } finally { + if (mounted) { + setState(() => _moderatingUids.remove(req.userId)); + } + } + }, + ), + ], + ), + ); + } + void _confirmCancelRide(GroupRide ride, MotorSignalThemeColors colors) { showDialog( context: context, diff --git a/lib/services/group_rides/group_ride_models.dart b/lib/services/group_rides/group_ride_models.dart index ea15c92..24e972a 100644 --- a/lib/services/group_rides/group_ride_models.dart +++ b/lib/services/group_rides/group_ride_models.dart @@ -340,6 +340,93 @@ class GroupRideParticipant { invitedBy.hashCode; } +/// Pending join request for a Group Ride returned by the moderation endpoint. +@immutable +class GroupRideJoinRequest { + final String userId; + final String? riderDisplayName; + final String? riderHandle; + final String? riderAvatarPreset; + final String? riderAvatarUrl; + final DateTime createdAt; + + const GroupRideJoinRequest({ + required this.userId, + this.riderDisplayName, + this.riderHandle, + this.riderAvatarPreset, + this.riderAvatarUrl, + required this.createdAt, + }); + + String get displayName => + (riderDisplayName != null && riderDisplayName!.trim().isNotEmpty) + ? riderDisplayName!.trim() + : 'Rider'; + + String get displayHandle => + (riderHandle != null && riderHandle!.trim().isNotEmpty) + ? '@${riderHandle!.trim()}' + : '@rider'; + + Map toJson() => { + 'userId': userId, + if (riderDisplayName != null) 'riderDisplayName': riderDisplayName, + if (riderHandle != null) 'riderHandle': riderHandle, + if (riderAvatarPreset != null) 'riderAvatarPreset': riderAvatarPreset, + if (riderAvatarUrl != null) 'riderAvatarUrl': riderAvatarUrl, + 'createdAt': createdAt.toIso8601String(), + }; + + factory GroupRideJoinRequest.fromJson(Map json) { + return GroupRideJoinRequest( + userId: json['userId'] as String? ?? '', + riderDisplayName: json['riderDisplayName'] as String?, + riderHandle: json['riderHandle'] as String?, + riderAvatarPreset: json['riderAvatarPreset'] as String?, + riderAvatarUrl: json['riderAvatarUrl'] as String?, + createdAt: json['createdAt'] != null + ? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now() + : DateTime.now(), + ); + } + + GroupRideParticipant toParticipant() { + return GroupRideParticipant( + userId: userId, + role: GroupRideRole.participant, + status: ParticipantStatus.requested, + riderHandle: riderHandle ?? '', + riderDisplayName: displayName, + riderAvatarPreset: riderAvatarPreset, + riderAvatarUrl: riderAvatarUrl, + createdAt: createdAt, + updatedAt: createdAt, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupRideJoinRequest && + runtimeType == other.runtimeType && + userId == other.userId && + riderDisplayName == other.riderDisplayName && + riderHandle == other.riderHandle && + riderAvatarPreset == other.riderAvatarPreset && + riderAvatarUrl == other.riderAvatarUrl && + createdAt == other.createdAt; + + @override + int get hashCode => + userId.hashCode ^ + riderDisplayName.hashCode ^ + riderHandle.hashCode ^ + riderAvatarPreset.hashCode ^ + riderAvatarUrl.hashCode ^ + createdAt.hashCode; +} + /// Core Group Ride model. @immutable class GroupRide { diff --git a/lib/services/group_rides/group_ride_providers.dart b/lib/services/group_rides/group_ride_providers.dart index e215948..f95653a 100644 --- a/lib/services/group_rides/group_ride_providers.dart +++ b/lib/services/group_rides/group_ride_providers.dart @@ -108,6 +108,18 @@ final groupRideParticipantsProvider = return controller.stream; }); +/// FutureProvider fetching pending join requests for a GroupRide (organizer or club leadership only). +final groupRideJoinRequestsProvider = + FutureProvider.family.autoDispose, String>((ref, rideId) async { + final user = ref.watch(currentUserProvider); + if (user == null || user.isAnonymous) { + return const []; + } + final authService = ref.watch(authServiceProvider); + final token = await authService.getIdToken(); + final service = ref.watch(groupRideServiceProvider); + return service.fetchJoinRequests(groupRideId: rideId, idToken: token); +}); /// Stream of upcoming Group Rides for the current user. final upcomingGroupRidesProvider = StreamProvider>((ref) { diff --git a/lib/services/group_rides/group_ride_service.dart b/lib/services/group_rides/group_ride_service.dart index bea0991..ccc30af 100644 --- a/lib/services/group_rides/group_ride_service.dart +++ b/lib/services/group_rides/group_ride_service.dart @@ -1,16 +1,25 @@ import 'dart:async'; +import 'dart:convert'; import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import '../social/rider_profile_models.dart'; import 'group_ride_models.dart'; /// Service managing Group Rides, meetup locations, stops, invitations, join requests, and memberships. class GroupRideService { - final FirebaseFirestore? _firestore; + static const String defaultApiBaseUrl = 'https://motorsignal.app'; - GroupRideService({FirebaseFirestore? firestore}) - : _firestore = firestore ?? _resolveFirestore(); + final FirebaseFirestore? _firestore; + final FirebaseAuth? _auth; + + GroupRideService({ + FirebaseFirestore? firestore, + FirebaseAuth? auth, + }) : _firestore = firestore ?? _resolveFirestore(), + _auth = auth ?? _resolveAuth(); static FirebaseFirestore? _resolveFirestore() { try { @@ -19,6 +28,13 @@ class GroupRideService { return null; } + static FirebaseAuth? _resolveAuth() { + try { + if (Firebase.apps.isNotEmpty) return FirebaseAuth.instance; + } catch (_) {} + return null; + } + bool get isAvailable => _firestore != null; /// Creates a new Group Ride and establishes the organizer participant record. @@ -531,6 +547,86 @@ class GroupRideService { }); } + /// Fetches pending join requests for a group ride via the authenticated moderation API. + /// Requires caller to be the organizer or an active club administrator. + Future> fetchJoinRequests({ + required String groupRideId, + String? idToken, + String? baseUrl, + http.Client? client, + }) async { + final cleanRideId = groupRideId.trim(); + if (cleanRideId.isEmpty) { + throw ArgumentError('Group ride ID cannot be empty.'); + } + + String? token = idToken; + if (token == null || token.isEmpty) { + final user = _auth?.currentUser; + if (user == null || user.isAnonymous) { + throw StateError('A registered account is required to view join requests.'); + } + + token = await user.getIdToken(); + if (token == null || token.isEmpty) { + throw StateError('Unable to retrieve authentication token.'); + } + } + + final rootUrl = (baseUrl ?? defaultApiBaseUrl).replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$rootUrl/api/group-rides/${Uri.encodeComponent(cleanRideId)}/join-requests'); + + final httpClient = client ?? http.Client(); + final shouldCloseClient = client == null; + + try { + final response = await httpClient.get( + uri, + headers: { + 'Authorization': 'Bearer $idToken', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 15)); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const FormatException('Malformed response from server.'); + } + final requestsList = decoded['requests']; + if (requestsList is! List) { + return const []; + } + return requestsList + .whereType>() + .map((item) => GroupRideJoinRequest.fromJson(item)) + .toList(); + } + + if (response.statusCode == 401) { + throw StateError('Your session has expired. Please sign in again.'); + } + + if (response.statusCode == 403) { + throw StateError('Only the organizer or club leadership can view join requests.'); + } + + if (response.statusCode == 404) { + throw StateError('Group ride not found.'); + } + + throw StateError('Unable to load join requests (HTTP ${response.statusCode}).'); + } on TimeoutException { + throw TimeoutException('Network timeout while loading join requests. Please try again.'); + } on http.ClientException catch (e) { + throw StateError('Network connection error: ${e.message}'); + } finally { + if (shouldCloseClient) { + httpClient.close(); + } + } + } + /// Responds to a join request (organizer or authorized club leadership). Future respondToJoinRequest({ required String callerUid, diff --git a/test/group_ride_models_test.dart b/test/group_ride_models_test.dart index 5fcd909..d0d3560 100644 --- a/test/group_ride_models_test.dart +++ b/test/group_ride_models_test.dart @@ -577,4 +577,96 @@ void main() { expect(disabled.isStale, isTrue); }); }); + + group('GroupRideJoinRequest Model Tests', () { + test('serializes and deserializes correctly', () { + final now = DateTime(2026, 9, 24, 12, 0, 0); + final req = GroupRideJoinRequest( + userId: 'rider_99', + riderDisplayName: 'Mountain Rider', + riderHandle: 'mountain_rider', + riderAvatarPreset: 'helmet_red', + riderAvatarUrl: 'https://example.com/avatar.jpg', + createdAt: now, + ); + + final json = req.toJson(); + expect(json['userId'], equals('rider_99')); + expect(json['riderDisplayName'], equals('Mountain Rider')); + expect(json['riderHandle'], equals('mountain_rider')); + expect(json['riderAvatarPreset'], equals('helmet_red')); + expect(json['riderAvatarUrl'], equals('https://example.com/avatar.jpg')); + expect(json['createdAt'], equals(now.toIso8601String())); + + final restored = GroupRideJoinRequest.fromJson(json); + expect(restored, equals(req)); + expect(restored.displayName, equals('Mountain Rider')); + expect(restored.displayHandle, equals('@mountain_rider')); + }); + + test('falls back gracefully on empty or null values', () { + final req = GroupRideJoinRequest( + userId: 'u1', + riderDisplayName: ' ', + riderHandle: null, + createdAt: DateTime(2026, 1, 1), + ); + + expect(req.displayName, equals('Rider')); + expect(req.displayHandle, equals('@rider')); + + final fromEmptyJson = GroupRideJoinRequest.fromJson(const {}); + expect(fromEmptyJson.userId, isEmpty); + expect(fromEmptyJson.displayName, equals('Rider')); + expect(fromEmptyJson.displayHandle, equals('@rider')); + }); + + test('converts to GroupRideParticipant accurately', () { + final now = DateTime(2026, 9, 24, 10, 0, 0); + final req = GroupRideJoinRequest( + userId: 'u42', + riderDisplayName: 'Speed Demon', + riderHandle: 'speedy', + riderAvatarPreset: 'visor_dark', + createdAt: now, + ); + + final participant = req.toParticipant(); + expect(participant.userId, equals('u42')); + expect(participant.role, equals(GroupRideRole.participant)); + expect(participant.status, equals(ParticipantStatus.requested)); + expect(participant.riderDisplayName, equals('Speed Demon')); + expect(participant.riderHandle, equals('speedy')); + expect(participant.riderAvatarPreset, equals('visor_dark')); + expect(participant.createdAt, equals(now)); + expect(participant.updatedAt, equals(now)); + }); + + test('equality and hashCode contracts hold', () { + final now = DateTime(2026, 9, 24, 10, 0, 0); + final r1 = GroupRideJoinRequest( + userId: 'u1', + riderDisplayName: 'Alice', + riderHandle: 'alice', + createdAt: now, + ); + final r2 = GroupRideJoinRequest( + userId: 'u1', + riderDisplayName: 'Alice', + riderHandle: 'alice', + createdAt: now, + ); + final r3 = GroupRideJoinRequest( + userId: 'u2', + riderDisplayName: 'Bob', + riderHandle: 'bob', + createdAt: now, + ); + + expect(r1, equals(r2)); + expect(r1.hashCode, equals(r2.hashCode)); + expect(r1, isNot(equals(r3))); + }); + }); } + diff --git a/test/group_ride_moderation_widget_test.dart b/test/group_ride_moderation_widget_test.dart new file mode 100644 index 0000000..065f501 --- /dev/null +++ b/test/group_ride_moderation_widget_test.dart @@ -0,0 +1,411 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/core/theme/theme.dart'; +import 'package:motorcycle_dash/features/group_rides/group_ride_detail_screen.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_state.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/clubs/club_models.dart'; +import 'package:motorcycle_dash/services/clubs/club_providers.dart'; +import 'package:motorcycle_dash/services/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'; +import 'package:motorcycle_dash/services/social/social_providers.dart'; + +class _FakeAuth implements AuthService { + final AuthUser? user; + _FakeAuth({this.user}); + + @override + Stream get authStateChanges => Stream.value( + user != null + ? AuthState.authenticated(user!, isFirebaseConfigured: true) + : const AuthState.unauthenticated(isFirebaseConfigured: true), + ); + + @override + AuthState get currentState => user != null + ? AuthState.authenticated(user!, isFirebaseConfigured: true) + : const AuthState.unauthenticated(isFirebaseConfigured: true); + + @override + AuthUser? get currentUser => user; + + @override + bool get isConfigured => true; + + @override + bool get isEmailVerified => true; + + @override + Future getIdToken([bool forceRefresh = false]) async => 'mock-id-token'; + + @override + Future signInWithEmailAndPassword(String email, String password) async {} + + @override + Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + + @override + Future signInWithGoogle() async {} + + @override + Future signInAnonymously() async {} + + @override + Future ensureBackendIdentity() async {} + + @override + Future sendPasswordResetEmail(String email) async {} + + @override + Future signOut() async {} + + @override + void dispose() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _RecordingGroupRideService extends Fake implements GroupRideService { + String? lastModeratedCallerUid; + String? lastModeratedGroupRideId; + String? lastModeratedRequesterUid; + bool? lastModeratedApprove; + + @override + Future respondToJoinRequest({ + required String callerUid, + required String groupRideId, + required String requesterUid, + required bool approve, + }) async { + lastModeratedCallerUid = callerUid; + lastModeratedGroupRideId = groupRideId; + lastModeratedRequesterUid = requesterUid; + lastModeratedApprove = approve; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late SharedPreferences prefs; + final now = DateTime(2026, 9, 24, 10, 0); + + final organizerUser = const AuthUser( + uid: 'organizer_1', + email: 'organizer@example.com', + displayName: 'Alice Apex', + isAnonymous: false, + ); + + final adminUser = const AuthUser( + uid: 'admin_1', + email: 'admin@example.com', + displayName: 'Charlie Admin', + isAnonymous: false, + ); + + final regularUser = const AuthUser( + uid: 'rider_1', + email: 'rider@example.com', + displayName: 'Bob Rider', + isAnonymous: false, + ); + + final testRide = GroupRide( + id: 'ride_101', + organizerUid: 'organizer_1', + organizerHandle: 'alice_apex', + organizerDisplayName: 'Alice Apex', + title: 'Blue Ridge Twisties', + description: 'Scenic group ride through Blood Mountain.', + status: GroupRideStatus.scheduled, + joinPolicy: GroupRideJoinPolicy.requestToJoin, + scheduledStartAt: now.add(const Duration(days: 1)), + departureAt: now.add(const Duration(days: 1, minutes: 30)), + participantLimit: 10, + participantCount: 1, + accessUids: const ['organizer_1'], + meetingPoint: const GroupRideLocation( + latitude: 34.5, + longitude: -84.0, + displayName: 'Dahlonega Square', + ), + clubId: 'club_77', + createdAt: now, + updatedAt: now, + ); + + final organizerParticipant = GroupRideParticipant( + userId: 'organizer_1', + role: GroupRideRole.organizer, + status: ParticipantStatus.accepted, + riderDisplayName: 'Alice Apex', + riderHandle: 'alice_apex', + createdAt: now, + updatedAt: now, + ); + + final testProfile = RiderProfile( + uid: 'organizer_1', + handle: 'alice_apex', + displayName: 'Alice Apex', + createdAt: now, + updatedAt: now, + ); + + final pendingRequest = GroupRideJoinRequest( + userId: 'requester_42', + riderDisplayName: 'Dave Curve', + riderHandle: 'dave_curves', + createdAt: now, + ); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + Widget buildScreen({ + required AuthUser user, + required GroupRide ride, + required _RecordingGroupRideService mockService, + AsyncValue>? joinRequestsValue, + ClubMember? clubMember, + }) { + return ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + authServiceProvider.overrideWithValue(_FakeAuth(user: user)), + currentUserProvider.overrideWithValue(user), + groupRideServiceProvider.overrideWithValue(mockService), + groupRideStreamProvider(ride.id).overrideWith((ref) => Stream.value(ride)), + groupRideParticipantsProvider(ride.id).overrideWith((ref) => Stream.value([organizerParticipant])), + groupRideLivePresenceProvider(ride.id).overrideWith((ref) => Stream.value(const [])), + myRiderProfileProvider.overrideWith((ref) => Stream.value(testProfile)), + myFriendsProvider.overrideWith((ref) => Stream.value(const [])), + if (ride.clubId != null) + currentMemberProvider(ride.clubId!).overrideWith((ref) => Stream.value(clubMember)), + if (joinRequestsValue != null) + groupRideJoinRequestsProvider(ride.id).overrideWith((ref) => joinRequestsValue.value ?? const []) + else + groupRideJoinRequestsProvider(ride.id).overrideWith((ref) => [pendingRequest]), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: GroupRideDetailScreen(rideId: ride.id), + ), + ); + } + + group('GroupRideDetailScreen Join Request Moderation UI Tests', () { + testWidgets('organizer sees JOIN REQUESTS section and pending request details', (tester) async { + final mockService = _RecordingGroupRideService(); + + await tester.pumpWidget( + buildScreen( + user: organizerUser, + ride: testRide, + mockService: mockService, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('JOIN REQUESTS (1)'), findsOneWidget); + expect(find.text('Dave Curve'), findsOneWidget); + expect(find.text('@dave_curves'), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(Icons.refresh), findsOneWidget); + }); + + testWidgets('organizer approves join request invokes service and triggers feedback', (tester) async { + final mockService = _RecordingGroupRideService(); + + await tester.pumpWidget( + buildScreen( + user: organizerUser, + ride: testRide, + mockService: mockService, + ), + ); + + await tester.pumpAndSettle(); + + final approveButton = find.byTooltip('Approve request'); + expect(approveButton, findsOneWidget); + + await tester.tap(approveButton); + await tester.pump(); + + expect(mockService.lastModeratedCallerUid, equals('organizer_1')); + expect(mockService.lastModeratedGroupRideId, equals('ride_101')); + expect(mockService.lastModeratedRequesterUid, equals('requester_42')); + expect(mockService.lastModeratedApprove, isTrue); + + await tester.pumpAndSettle(); + expect(find.text('Approved Dave Curve to join the ride.'), findsOneWidget); + }); + + testWidgets('organizer declines join request invokes service with approve: false', (tester) async { + final mockService = _RecordingGroupRideService(); + + await tester.pumpWidget( + buildScreen( + user: organizerUser, + ride: testRide, + mockService: mockService, + ), + ); + + await tester.pumpAndSettle(); + + final declineButton = find.byTooltip('Decline request'); + expect(declineButton, findsOneWidget); + + await tester.tap(declineButton); + await tester.pump(); + + expect(mockService.lastModeratedCallerUid, equals('organizer_1')); + expect(mockService.lastModeratedGroupRideId, equals('ride_101')); + expect(mockService.lastModeratedRequesterUid, equals('requester_42')); + expect(mockService.lastModeratedApprove, isFalse); + + await tester.pumpAndSettle(); + expect(find.text('Declined join request from Dave Curve.'), findsOneWidget); + }); + + testWidgets('club admin who is not organizer sees moderation controls', (tester) async { + final mockService = _RecordingGroupRideService(); + + final adminMember = ClubMember( + uid: adminUser.uid, + clubId: testRide.clubId!, + status: ClubMemberStatus.active, + role: ClubRole.admin, + joinedAt: now, + updatedAt: now, + ); + + await tester.pumpWidget( + buildScreen( + user: adminUser, + ride: testRide, + mockService: mockService, + clubMember: adminMember, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('JOIN REQUESTS (1)'), findsOneWidget); + expect(find.text('Dave Curve'), findsOneWidget); + expect(find.byTooltip('Approve request'), findsOneWidget); + expect(find.byTooltip('Decline request'), findsOneWidget); + }); + + testWidgets('regular rider does not see JOIN REQUESTS section', (tester) async { + final mockService = _RecordingGroupRideService(); + + final regularMember = ClubMember( + uid: regularUser.uid, + clubId: testRide.clubId!, + status: ClubMemberStatus.active, + role: ClubRole.member, + joinedAt: now, + updatedAt: now, + ); + + await tester.pumpWidget( + buildScreen( + user: regularUser, + ride: testRide, + mockService: mockService, + clubMember: regularMember, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.textContaining('JOIN REQUESTS'), findsNothing); + expect(find.byTooltip('Approve request'), findsNothing); + expect(find.byTooltip('Decline request'), findsNothing); + }); + + testWidgets('empty requests list does not render section header', (tester) async { + final mockService = _RecordingGroupRideService(); + + await tester.pumpWidget( + buildScreen( + user: organizerUser, + ride: testRide, + mockService: mockService, + joinRequestsValue: const AsyncValue.data([]), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.textContaining('JOIN REQUESTS'), findsNothing); + }); + + testWidgets('displays error message and retry button on fetch failure', (tester) async { + final mockService = _RecordingGroupRideService(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + authServiceProvider.overrideWithValue(_FakeAuth(user: organizerUser)), + currentUserProvider.overrideWithValue(organizerUser), + groupRideServiceProvider.overrideWithValue(mockService), + groupRideStreamProvider(testRide.id).overrideWith((ref) => Stream.value(testRide)), + groupRideParticipantsProvider(testRide.id).overrideWith((ref) => Stream.value([organizerParticipant])), + groupRideLivePresenceProvider(testRide.id).overrideWith((ref) => Stream.value(const [])), + myRiderProfileProvider.overrideWith((ref) => Stream.value(testProfile)), + myFriendsProvider.overrideWith((ref) => Stream.value(const [])), + groupRideJoinRequestsProvider(testRide.id).overrideWith( + (ref) => Future.error(StateError('Server connection error')), + ), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + home: GroupRideDetailScreen(rideId: testRide.id), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Unable to load join requests.'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + }); + + testWidgets('canceled ride hides JOIN REQUESTS section even for organizer', (tester) async { + final mockService = _RecordingGroupRideService(); + final canceledRide = testRide.copyWith(status: GroupRideStatus.canceled); + + await tester.pumpWidget( + buildScreen( + user: organizerUser, + ride: canceledRide, + mockService: mockService, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.textContaining('JOIN REQUESTS'), findsNothing); + }); + }); +} diff --git a/test/group_ride_service_test.dart b/test/group_ride_service_test.dart index af4f757..09103e7 100644 --- a/test/group_ride_service_test.dart +++ b/test/group_ride_service_test.dart @@ -1,7 +1,10 @@ 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'; @@ -1684,6 +1687,230 @@ void main() { 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 {