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).
This commit is contained in:
parent
c1aa68ac43
commit
46ce052e0c
|
|
@ -26,6 +26,7 @@ class GroupRideDetailScreen extends ConsumerStatefulWidget {
|
|||
class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||
bool _isProcessing = false;
|
||||
String? _statusMessage;
|
||||
final Set<String> _moderatingUids = {};
|
||||
|
||||
Future<void> _launchMapNavigation(GroupRideLocation loc) async {
|
||||
final query = Uri.encodeComponent(
|
||||
|
|
@ -353,6 +354,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
ride,
|
||||
participants,
|
||||
isOrganizer,
|
||||
isClubAdmin,
|
||||
currentUid,
|
||||
colors,
|
||||
),
|
||||
|
|
@ -433,6 +435,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
ride,
|
||||
participants,
|
||||
isOrganizer,
|
||||
isClubAdmin,
|
||||
currentUid,
|
||||
colors,
|
||||
),
|
||||
|
|
@ -1597,19 +1600,16 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
GroupRide ride,
|
||||
List<GroupRideParticipant> 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<GroupRideDetailScreen> {
|
|||
),
|
||||
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<GroupRideDetailScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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<String, dynamic> 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 {
|
||||
|
|
|
|||
|
|
@ -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<List<GroupRideJoinRequest>, 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<List<GroupRide>>((ref) {
|
||||
|
|
|
|||
|
|
@ -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<List<GroupRideJoinRequest>> 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<String, dynamic>) {
|
||||
throw const FormatException('Malformed response from server.');
|
||||
}
|
||||
final requestsList = decoded['requests'];
|
||||
if (requestsList is! List) {
|
||||
return const [];
|
||||
}
|
||||
return requestsList
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.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<void> respondToJoinRequest({
|
||||
required String callerUid,
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AuthState> 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<String?> getIdToken([bool forceRefresh = false]) async => 'mock-id-token';
|
||||
|
||||
@override
|
||||
Future<void> signInWithEmailAndPassword(String email, String password) async {}
|
||||
|
||||
@override
|
||||
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
|
||||
|
||||
@override
|
||||
Future<void> signInWithGoogle() async {}
|
||||
|
||||
@override
|
||||
Future<void> signInAnonymously() async {}
|
||||
|
||||
@override
|
||||
Future<void> ensureBackendIdentity() async {}
|
||||
|
||||
@override
|
||||
Future<void> sendPasswordResetEmail(String email) async {}
|
||||
|
||||
@override
|
||||
Future<void> 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<void> 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<List<GroupRideJoinRequest>>? 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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<ArgumentError>()),
|
||||
);
|
||||
expect(
|
||||
() => service.fetchJoinRequests(groupRideId: ' '),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws StateError when unauthenticated (no token provided and no Firebase user)', () async {
|
||||
final service = GroupRideService();
|
||||
expect(
|
||||
() => service.fetchJoinRequests(groupRideId: 'ride_1'),
|
||||
throwsA(isA<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('Network connection error'),
|
||||
)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeGroupRideLifecycleService implements GroupRideService {
|
||||
|
|
|
|||
Loading…
Reference in New Issue