448 lines
14 KiB
Dart
448 lines
14 KiB
Dart
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 bool approve,
|
|
required String requesterUid,
|
|
}) async {
|
|
lastModeratedCallerUid = callerUid;
|
|
lastModeratedGroupRideId = groupRideId;
|
|
lastModeratedRequesterUid = requesterUid;
|
|
lastModeratedApprove = approve;
|
|
}
|
|
|
|
@override
|
|
Stream<GroupRideParticipant?> participantDocStream({
|
|
required String groupRideId,
|
|
required String participantUid,
|
|
}) =>
|
|
Stream.value(null);
|
|
|
|
@override
|
|
Stream<GroupRide?> groupRideStream(String groupRideId) => Stream.value(null);
|
|
|
|
@override
|
|
Stream<LiveGroupPresence?> livePresenceDocStream({
|
|
required String groupRideId,
|
|
required String targetUid,
|
|
}) =>
|
|
Stream.value(null);
|
|
|
|
@override
|
|
Future<void> publishLivePresence({
|
|
required String callerUid,
|
|
required String groupRideId,
|
|
required double latitude,
|
|
required double longitude,
|
|
double? heading,
|
|
double? speedMph,
|
|
double? accuracyMeters,
|
|
LivePresenceStatus status = LivePresenceStatus.riding,
|
|
bool sharingEnabled = true,
|
|
}) async {}
|
|
|
|
@override
|
|
Future<void> clearLivePresence({
|
|
required String callerUid,
|
|
required String groupRideId,
|
|
}) async {}
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|