MotorSignal-App/test/group_ride_service_test.dart

1726 lines
56 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:motorcycle_dash/core/theme/app_colors.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
void main() {
group('GroupRideService Offline & Validation Tests', () {
final organizer = RiderProfile(
uid: 'user_alice',
handle: 'alice_apex',
displayName: 'Alice Apex',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
test('rejects ride creation with invalid title', () async {
final service = GroupRideService();
const meetup = GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Meetup',
);
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Hi', // too short (< 3)
scheduledStartAt: DateTime.now(),
departureAt: DateTime.now().add(const Duration(minutes: 30)),
meetingPoint: meetup,
),
throwsA(isA<ArgumentError>()),
);
});
test('rejects departure before arrival time', () async {
final service = GroupRideService();
const meetup = GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Meetup',
);
final now = DateTime.now();
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Morning Cruise',
scheduledStartAt: now.add(const Duration(hours: 2)),
departureAt: now.add(const Duration(hours: 1)), // earlier!
meetingPoint: meetup,
),
throwsA(isA<ArgumentError>()),
);
});
test('rejects participant limit less than 2', () async {
final service = GroupRideService();
const meetup = GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Meetup',
);
final now = DateTime.now();
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Solo Ride',
scheduledStartAt: now,
departureAt: now.add(const Duration(minutes: 30)),
participantLimit: 1, // Invalid!
meetingPoint: meetup,
),
throwsA(isA<ArgumentError>()),
);
});
test('creates offline fallback ride safely when unconfigured', () async {
final service = GroupRideService();
const meetup = GroupRideLocation(
latitude: 34.5,
longitude: -83.9,
displayName: 'Dahlonega',
);
final start = DateTime.now().add(const Duration(days: 1));
final dep = start.add(const Duration(minutes: 30));
final ride = await service.createGroupRide(
organizer: organizer,
title: 'Sunday Morning Run',
description: 'Twisty mountain roads',
visibility: GroupRideVisibility.friendsOnly,
scheduledStartAt: start,
departureAt: dep,
meetingPoint: meetup,
);
expect(ride.title, equals('Sunday Morning Run'));
expect(ride.organizerUid, equals('user_alice'));
expect(ride.meetingPoint.displayName, equals('Dahlonega'));
expect(ride.participantCount, equals(1));
});
test('cannot invite self to group ride', () async {
final service = GroupRideService();
expect(
() => service.inviteFriend(
callerUid: 'user_alice',
groupRideId: 'ride_1',
friendProfile: organizer, // same UID!
),
throwsA(isA<ArgumentError>()),
);
});
test('publishLivePresence validates physical coordinates', () async {
final service = GroupRideService();
expect(
() => service.publishLivePresence(
callerUid: 'user_alice',
groupRideId: 'ride_1',
latitude: 105.0, // out of range
longitude: -84.0,
),
throwsA(isA<ArgumentError>()),
);
});
test('offline live presence and upcoming streams return safely', () async {
final service = GroupRideService();
final stream = service.livePresenceDocStream(groupRideId: 'ride_offline', targetUid: 'user_alice');
expect(await stream.first, isNull);
final pStream = service.participantDocStream(groupRideId: 'ride_offline', participantUid: 'user_alice');
expect(await pStream.first, isNull);
final upcoming = service.upcomingRidesStream(callerUid: 'user_alice');
expect(await upcoming.first, isEmpty);
});
});
group('Live Presence Point Listener Lifecycle Tests', () {
test('reconciles candidates from accessUids and only subscribes to accepted participants', () async {
final fakeService = _FakeGroupRideLifecycleService();
final rideStreamController = StreamController<GroupRide?>.broadcast();
final rideDoc = GroupRide(
id: 'ride_123',
organizerUid: 'organizer_1',
organizerHandle: 'organizer',
organizerDisplayName: 'Organizer',
title: 'Sunday Morning Twisties',
status: GroupRideStatus.scheduled,
scheduledStartAt: DateTime.now(),
departureAt: DateTime.now().add(const Duration(minutes: 15)),
meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'),
joinPolicy: GroupRideJoinPolicy.open,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
accessUids: const ['user_accepted', 'user_invited', 'user_declined'],
);
final container = ProviderContainer(
overrides: [
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_123').overrideWith((ref) => rideStreamController.stream),
],
);
addTearDown(container.dispose);
final states = <List<LiveGroupPresence>>[];
container.listen(
groupRideLivePresenceProvider('ride_123'),
(previous, next) {
next.whenData((data) => states.add(data));
},
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
fakeService.getParticipantController('user_accepted').add(
GroupRideParticipant(
userId: 'user_accepted',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'alice',
riderDisplayName: 'Alice',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_invited').add(
GroupRideParticipant(
userId: 'user_invited',
role: GroupRideRole.participant,
status: ParticipantStatus.invited,
riderHandle: 'bob',
riderDisplayName: 'Bob',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_declined').add(
GroupRideParticipant(
userId: 'user_declined',
role: GroupRideRole.participant,
status: ParticipantStatus.declined,
riderHandle: 'charlie',
riderDisplayName: 'Charlie',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
final presenceAlice = LiveGroupPresence(
userId: 'user_accepted',
groupRideId: 'ride_123',
latitude: 37.7749,
longitude: -122.4194,
speedMph: 45.0,
sharingEnabled: true,
updatedAt: DateTime.now(),
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
);
fakeService.getPresenceController('user_accepted').add(presenceAlice);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.isNotEmpty, isTrue);
expect(states.last.any((p) => p.userId == 'user_accepted'), isTrue);
expect(states.last.any((p) => p.userId == 'user_invited'), isFalse);
expect(states.last.any((p) => p.userId == 'user_declined'), isFalse);
});
test('peer error isolation: peer permission-denied does not drop other peer presence', () async {
final fakeService = _FakeGroupRideLifecycleService();
final rideStreamController = StreamController<GroupRide?>.broadcast();
final rideDoc = GroupRide(
id: 'ride_error_isolation',
organizerUid: 'organizer_1',
organizerHandle: 'organizer',
organizerDisplayName: 'Organizer',
title: 'Ride',
status: GroupRideStatus.scheduled,
scheduledStartAt: DateTime.now(),
departureAt: DateTime.now().add(const Duration(minutes: 15)),
meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'),
joinPolicy: GroupRideJoinPolicy.open,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
accessUids: const ['user_alice', 'user_bob'],
);
final container = ProviderContainer(
overrides: [
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_error_isolation').overrideWith((ref) => rideStreamController.stream),
],
);
addTearDown(container.dispose);
final states = <List<LiveGroupPresence>>[];
container.listen(
groupRideLivePresenceProvider('ride_error_isolation'),
(previous, next) {
next.whenData((data) => states.add(data));
},
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
fakeService.getParticipantController('user_alice').add(
GroupRideParticipant(
userId: 'user_alice',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'alice',
riderDisplayName: 'Alice',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_bob').add(
GroupRideParticipant(
userId: 'user_bob',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'bob',
riderDisplayName: 'Bob',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
final presenceAlice = LiveGroupPresence(
userId: 'user_alice',
groupRideId: 'ride_error_isolation',
latitude: 37.7749,
longitude: -122.4194,
speedMph: 45.0,
sharingEnabled: true,
updatedAt: DateTime.now(),
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
);
fakeService.getPresenceController('user_alice').add(presenceAlice);
fakeService.getPresenceController('user_bob').addError('permission-denied');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.isNotEmpty, isTrue);
expect(states.last.length, 1);
expect(states.last.first.userId, 'user_alice');
});
test('participants provider reconciles candidates from accessUids and callerUid without broad collection queries', () async {
final fakeService = _FakeGroupRideLifecycleService();
final rideStreamController = StreamController<GroupRide?>.broadcast();
final rideDoc = GroupRide(
id: 'ride_participants_test',
organizerUid: 'user_alice',
organizerHandle: 'alice',
organizerDisplayName: 'Alice',
title: 'Ride',
status: GroupRideStatus.scheduled,
scheduledStartAt: DateTime.now(),
departureAt: DateTime.now().add(const Duration(minutes: 15)),
meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'),
joinPolicy: GroupRideJoinPolicy.open,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
accessUids: const ['user_alice', 'user_bob'],
);
final container = ProviderContainer(
overrides: [
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_participants_test').overrideWith((ref) => rideStreamController.stream),
],
);
addTearDown(container.dispose);
final states = <List<GroupRideParticipant>>[];
container.listen(
groupRideParticipantsProvider('ride_participants_test'),
(previous, next) {
next.whenData((data) => states.add(data));
},
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
fakeService.getParticipantController('user_alice').add(
GroupRideParticipant(
userId: 'user_alice',
role: GroupRideRole.organizer,
status: ParticipantStatus.accepted,
riderHandle: 'alice',
riderDisplayName: 'Alice',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_bob').add(
GroupRideParticipant(
userId: 'user_bob',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'bob',
riderDisplayName: 'Bob',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.isNotEmpty, isTrue);
expect(states.last.length, 2);
expect(states.last.first.userId, 'user_alice');
expect(states.last.last.userId, 'user_bob');
});
});
group('UI Layout and Styling Regression Tests', () {
testWidgets('Live presence header does not overflow on 393px width constraint', (tester) async {
tester.view.physicalSize = const Size(393, 852);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.darkTheme,
home: Scaffold(
body: Center(
child: SizedBox(
width: 393,
child: Row(
children: [
const Icon(Icons.sensors, color: AppColors.brandBlue, size: 20),
const SizedBox(width: 8),
const Expanded(
child: Text(
'LIVE RIDE PRESENCE',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.white,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.brandBlue.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.brandBlue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
const Text(
'BROADCASTING',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppColors.brandBlue,
),
),
],
),
),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('LIVE RIDE PRESENCE'), findsOneWidget);
expect(find.text('BROADCASTING'), findsOneWidget);
});
test('Light theme AppBar has dark status bar icons and high contrast title', () {
final lightTheme = AppTheme.lightTheme;
expect(lightTheme.appBarTheme.titleTextStyle?.color, AppColors.lightOnSurface);
expect(lightTheme.appBarTheme.iconTheme?.color, AppColors.lightOnSurface);
expect(lightTheme.appBarTheme.systemOverlayStyle?.statusBarIconBrightness, Brightness.dark);
});
});
group('GroupRideService Club Affiliation & Discovery Tests', () {
late FakeFirebaseFirestore fakeFirestore;
late GroupRideService service;
late RiderProfile organizer;
const meetup = GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Starting Spot',
);
setUp(() {
fakeFirestore = FakeFirebaseFirestore();
service = GroupRideService(firestore: fakeFirestore);
organizer = RiderProfile(
uid: 'user_alice',
handle: 'alice_apex',
displayName: 'Alice Apex',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
});
test('personal ride creation omits clubId and does not perform club reads', () async {
final ride = await service.createGroupRide(
organizer: organizer,
title: 'Sunday Morning Cruise',
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: null,
);
expect(ride.clubId, isNull);
final doc = await fakeFirestore.collection('group_rides').doc(ride.id).get();
expect(doc.exists, isTrue);
expect(doc.data()!.containsKey('clubId'), isFalse);
});
test('active club admin creates club-affiliated ride with persisted clubId', () async {
final now = DateTime.now();
// Seed club document
await fakeFirestore.collection('clubs').doc('club_123').set({
'id': 'club_123',
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_bob',
'visibility': 'public',
'admissionPolicy': 'open',
'memberCount': 2,
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
// Seed membership document for organizer as admin
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
'clubId': 'club_123',
'uid': 'user_alice',
'role': 'admin',
'status': 'active',
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
final ride = await service.createGroupRide(
organizer: organizer,
title: 'Alpha Club Mountain Run',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_123',
);
expect(ride.clubId, 'club_123');
final doc = await fakeFirestore.collection('group_rides').doc(ride.id).get();
expect(doc.exists, isTrue);
expect(doc.data()!['clubId'], 'club_123');
});
test('active club owner creates club-affiliated ride with persisted clubId', () async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc('club_123').set({
'id': 'club_123',
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_alice',
'visibility': 'public',
'admissionPolicy': 'open',
'memberCount': 1,
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
'clubId': 'club_123',
'uid': 'user_alice',
'role': 'owner',
'status': 'active',
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
final ride = await service.createGroupRide(
organizer: organizer,
title: 'Alpha Club Mountain Run',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_123',
);
expect(ride.clubId, 'club_123');
});
test('rejects malformed clubId with ArgumentError', () async {
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Malformed Club Ride',
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club with spaces',
),
throwsA(isA<ArgumentError>()),
);
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Empty Club Ride',
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: ' ',
),
throwsA(isA<ArgumentError>()),
);
});
test('rejects creation when club does not exist (StateError)', () async {
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Nonexistent Club Ride',
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'nonexistent_club',
),
throwsA(isA<StateError>()),
);
});
test('rejects creation when club is archived (StateError)', () async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc('club_archived').set({
'id': 'club_archived',
'name': 'Archived Club',
'slug': 'archived-club',
'ownerUid': 'user_alice',
'archivedAt': now.toIso8601String(),
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore.collection('clubs').doc('club_archived').collection('members').doc('user_alice').set({
'clubId': 'club_archived',
'uid': 'user_alice',
'role': 'owner',
'status': 'active',
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Archived Club Ride',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_archived',
),
throwsA(isA<StateError>()),
);
});
test('rejects creation when organizer is ordinary member (StateError)', () async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc('club_123').set({
'id': 'club_123',
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_bob',
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
'clubId': 'club_123',
'uid': 'user_alice',
'role': 'member',
'status': 'active',
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Ordinary Member Club Ride',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_123',
),
throwsA(isA<StateError>()),
);
});
test('rejects creation when organizer membership is not active (StateError)', () async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc('club_123').set({
'id': 'club_123',
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_bob',
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
'clubId': 'club_123',
'uid': 'user_alice',
'role': 'admin',
'status': 'invited',
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Invited Member Club Ride',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_123',
),
throwsA(isA<StateError>()),
);
});
test('rejects creation when organizer is not a member (StateError)', () async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc('club_123').set({
'id': 'club_123',
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_bob',
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
expect(
() => service.createGroupRide(
organizer: organizer,
title: 'Non-Member Club Ride',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
meetingPoint: meetup,
clubId: 'club_123',
),
throwsA(isA<StateError>()),
);
});
});
group('Club Admin Group Ride Management Tests', () {
late FakeFirebaseFirestore fakeFirestore;
late GroupRideService service;
late GroupRideLocation meetup;
setUp(() {
fakeFirestore = FakeFirebaseFirestore();
service = GroupRideService(firestore: fakeFirestore);
meetup = const GroupRideLocation(
latitude: 37.7749,
longitude: -122.4194,
displayName: 'Twin Peaks SF',
);
});
Future<void> seedClubAndMember({
required String clubId,
required String uid,
required String role,
required String status,
}) async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc(clubId).set({
'id': clubId,
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_owner',
'visibility': 'public',
'admissionPolicy': 'open',
'memberCount': 2,
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore
.collection('clubs')
.doc(clubId)
.collection('members')
.doc(uid)
.set({
'clubId': clubId,
'uid': uid,
'role': role,
'status': status,
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
}
Future<void> seedRide({
required String rideId,
required String organizerUid,
String? clubId,
GroupRideStatus status = GroupRideStatus.scheduled,
}) async {
final now = DateTime.now();
await fakeFirestore.collection('group_rides').doc(rideId).set({
'id': rideId,
'organizerUid': organizerUid,
'title': 'Original Ride Title',
'description': 'Original description',
'status': status.name,
'visibility': 'public',
'joinPolicy': 'open',
'scheduledStartAt': now.add(const Duration(hours: 2)).toIso8601String(),
'departureAt': now.add(const Duration(hours: 2, minutes: 30)).toIso8601String(),
'meetingPoint': meetup.toJson(),
'stops': [],
'accessUids': [organizerUid],
'clubId': ?clubId,
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
}
test('active club admin can update scheduled club ride metadata', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_admin',
role: 'admin',
status: 'active',
);
await seedRide(
rideId: 'ride_1',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.scheduled,
);
await service.updateGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_1',
title: 'Updated Title by Club Admin',
description: 'Updated description by Club Admin',
);
final doc = await fakeFirestore.collection('group_rides').doc('ride_1').get();
expect(doc.data()!['title'], 'Updated Title by Club Admin');
expect(doc.data()!['description'], 'Updated description by Club Admin');
});
test('active club owner can update scheduled club ride metadata', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_owner',
role: 'owner',
status: 'active',
);
await seedRide(
rideId: 'ride_1',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.scheduled,
);
await service.updateGroupRide(
callerUid: 'user_owner',
groupRideId: 'ride_1',
title: 'Updated Title by Club Owner',
);
final doc = await fakeFirestore.collection('group_rides').doc('ride_1').get();
expect(doc.data()!['title'], 'Updated Title by Club Owner');
});
test('club admin cannot update assembling, underway, completed, or canceled club ride', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_admin',
role: 'admin',
status: 'active',
);
for (final s in [
GroupRideStatus.assembling,
GroupRideStatus.underway,
GroupRideStatus.completed,
GroupRideStatus.canceled,
]) {
await seedRide(
rideId: 'ride_${s.name}',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: s,
);
expect(
() => service.updateGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_${s.name}',
title: 'Attempted Update',
),
throwsA(isA<StateError>()),
);
}
});
test('active club admin can cancel scheduled or assembling club ride', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_admin',
role: 'admin',
status: 'active',
);
// Scheduled
await seedRide(
rideId: 'ride_sched',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.scheduled,
);
await service.cancelGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_sched',
reason: 'Weather cancellation by Club Admin',
);
final docSched = await fakeFirestore.collection('group_rides').doc('ride_sched').get();
expect(docSched.data()!['status'], 'canceled');
expect(docSched.data()!['canceledReason'], 'Weather cancellation by Club Admin');
// Assembling
await seedRide(
rideId: 'ride_assemb',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.assembling,
);
await service.cancelGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_assemb',
reason: 'Emergency cancellation by Club Admin',
);
final docAssemb = await fakeFirestore.collection('group_rides').doc('ride_assemb').get();
expect(docAssemb.data()!['status'], 'canceled');
});
test('club admin cannot cancel underway or completed club ride', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_admin',
role: 'admin',
status: 'active',
);
await seedRide(
rideId: 'ride_underway',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.underway,
);
expect(
() => service.cancelGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_underway',
),
throwsA(isA<StateError>()),
);
await seedRide(
rideId: 'ride_completed',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.completed,
);
expect(
() => service.cancelGroupRide(
callerUid: 'user_admin',
groupRideId: 'ride_completed',
),
throwsA(isA<StateError>()),
);
});
test('non-admin club member cannot update or cancel club ride', () async {
await seedClubAndMember(
clubId: 'club_123',
uid: 'user_member',
role: 'member',
status: 'active',
);
await seedRide(
rideId: 'ride_sched',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.scheduled,
);
expect(
() => service.updateGroupRide(
callerUid: 'user_member',
groupRideId: 'ride_sched',
title: 'Unauthorized Member Update',
),
throwsA(isA<StateError>()),
);
expect(
() => service.cancelGroupRide(
callerUid: 'user_member',
groupRideId: 'ride_sched',
),
throwsA(isA<StateError>()),
);
});
test('admin from different club cannot update or cancel club ride', () async {
await seedClubAndMember(
clubId: 'club_456',
uid: 'user_other_admin',
role: 'admin',
status: 'active',
);
await seedRide(
rideId: 'ride_sched',
organizerUid: 'user_organizer',
clubId: 'club_123',
status: GroupRideStatus.scheduled,
);
expect(
() => service.updateGroupRide(
callerUid: 'user_other_admin',
groupRideId: 'ride_sched',
title: 'Cross-Club Update',
),
throwsA(isA<StateError>()),
);
expect(
() => service.cancelGroupRide(
callerUid: 'user_other_admin',
groupRideId: 'ride_sched',
),
throwsA(isA<StateError>()),
);
});
});
group('GroupRide Join Workflow & Participant Moderation Tests', () {
late FakeFirebaseFirestore fakeFirestore;
late GroupRideService service;
const meetup = GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Atlanta Base',
);
final organizer = RiderProfile(
uid: 'user_org',
handle: 'alice_org',
displayName: 'Alice Org',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
final requester = RiderProfile(
uid: 'user_req',
handle: 'charlie_rider',
displayName: 'Charlie Rider',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
setUp(() {
fakeFirestore = FakeFirebaseFirestore();
service = GroupRideService(firestore: fakeFirestore);
});
Future<void> seedRide({
required String rideId,
required String organizerUid,
String? clubId,
GroupRideStatus status = GroupRideStatus.scheduled,
GroupRideJoinPolicy joinPolicy = GroupRideJoinPolicy.requestToJoin,
int? participantLimit,
int participantCount = 1,
List<String> accessUids = const ['user_org'],
}) async {
final now = DateTime.now();
await fakeFirestore.collection('group_rides').doc(rideId).set({
'id': rideId,
'organizerUid': organizerUid,
'title': 'Saturday Morning Run',
'status': status.name,
'visibility': 'public',
'joinPolicy': joinPolicy.name,
'participantLimit': ?participantLimit,
'participantCount': participantCount,
'scheduledStartAt': now.add(const Duration(hours: 2)).toIso8601String(),
'departureAt': now.add(const Duration(hours: 2, minutes: 30)).toIso8601String(),
'meetingPoint': meetup.toJson(),
'accessUids': accessUids,
'clubId': ?clubId,
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore
.collection('group_rides')
.doc(rideId)
.collection('participants')
.doc(organizerUid)
.set({
'userId': organizerUid,
'role': 'organizer',
'status': 'accepted',
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
}
Future<void> seedClubMember({
required String clubId,
required String uid,
required String role,
String status = 'active',
}) async {
final now = DateTime.now();
await fakeFirestore.collection('clubs').doc(clubId).set({
'id': clubId,
'name': 'Alpha Club',
'slug': 'alpha-club',
'ownerUid': 'user_org',
'visibility': 'public',
'admissionPolicy': 'open',
'memberCount': 2,
'createdAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
await fakeFirestore
.collection('clubs')
.doc(clubId)
.collection('members')
.doc(uid)
.set({
'clubId': clubId,
'uid': uid,
'role': role,
'status': status,
'joinedAt': now.toIso8601String(),
'updatedAt': now.toIso8601String(),
});
}
test('requestToJoin creates participant doc without mutating parent ride', () async {
await seedRide(rideId: 'ride_1', organizerUid: organizer.uid);
await service.requestToJoin(caller: requester, groupRideId: 'ride_1');
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_1').get();
final ride = GroupRide.fromJson(rideSnap.data()!);
expect(ride.accessUids, isNot(contains(requester.uid)));
expect(ride.participantCount, equals(1));
expect(rideSnap.data()!.containsKey('pendingUids'), isFalse);
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_1')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.exists, isTrue);
expect(partSnap.data()!['status'], equals('requested'));
});
test('requestToJoin succeeds even when ride is currently at capacity (enforced at approval)', () async {
await seedRide(
rideId: 'ride_full',
organizerUid: organizer.uid,
participantLimit: 2,
participantCount: 2,
accessUids: [organizer.uid, 'user_other'],
);
await service.requestToJoin(caller: requester, groupRideId: 'ride_full');
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_full')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.exists, isTrue);
expect(partSnap.data()!['status'], equals('requested'));
});
test('requestToJoin rejects when ride is underway, completed, or canceled', () async {
for (final s in [
GroupRideStatus.underway,
GroupRideStatus.completed,
GroupRideStatus.canceled,
]) {
await seedRide(
rideId: 'ride_${s.name}',
organizerUid: organizer.uid,
status: s,
);
expect(
() => service.requestToJoin(caller: requester, groupRideId: 'ride_${s.name}'),
throwsA(isA<StateError>()),
);
}
});
test('requestToJoin rejects when caller is mutually blocked with organizer', () async {
await seedRide(rideId: 'ride_blocked', organizerUid: organizer.uid);
await fakeFirestore
.collection('users')
.doc(organizer.uid)
.collection('blocks')
.doc(requester.uid)
.set({'blockedUid': requester.uid});
expect(
() => service.requestToJoin(caller: requester, groupRideId: 'ride_blocked'),
throwsA(isA<StateError>()),
);
});
test('requestToJoin on club ride requires active club membership', () async {
await seedRide(
rideId: 'ride_club',
organizerUid: organizer.uid,
clubId: 'club_1',
);
// Non-member fails
expect(
() => service.requestToJoin(caller: requester, groupRideId: 'ride_club'),
throwsA(isA<StateError>()),
);
// Active member succeeds
await seedClubMember(clubId: 'club_1', uid: requester.uid, role: 'member', status: 'active');
await service.requestToJoin(caller: requester, groupRideId: 'ride_club');
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_club')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.exists, isTrue);
expect(partSnap.data()!['status'], equals('requested'));
});
test('respondToJoinRequest approval moves participant to accepted, adds accessUid, and increments count', () async {
await seedRide(
rideId: 'ride_approve',
organizerUid: organizer.uid,
);
await fakeFirestore
.collection('group_rides')
.doc('ride_approve')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_approve',
requesterUid: requester.uid,
approve: true,
);
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_approve').get();
final ride = GroupRide.fromJson(rideSnap.data()!);
expect(ride.accessUids, contains(requester.uid));
expect(ride.participantCount, equals(2));
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_approve')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.data()!['status'], equals('accepted'));
});
test('respondToJoinRequest decline marks participant declined with zero parent ride mutations', () async {
await seedRide(
rideId: 'ride_decline',
organizerUid: organizer.uid,
);
await fakeFirestore
.collection('group_rides')
.doc('ride_decline')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_decline',
requesterUid: requester.uid,
approve: false,
);
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_decline').get();
final ride = GroupRide.fromJson(rideSnap.data()!);
expect(ride.accessUids, isNot(contains(requester.uid)));
expect(ride.participantCount, equals(1));
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_decline')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.data()!['status'], equals('declined'));
});
test('active club admin can approve requests on official club ride', () async {
await seedClubMember(clubId: 'club_mod', uid: 'admin_user', role: 'admin');
await seedClubMember(clubId: 'club_mod', uid: requester.uid, role: 'member');
await seedRide(
rideId: 'ride_mod',
organizerUid: organizer.uid,
clubId: 'club_mod',
);
await fakeFirestore
.collection('group_rides')
.doc('ride_mod')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.respondToJoinRequest(
callerUid: 'admin_user',
groupRideId: 'ride_mod',
requesterUid: requester.uid,
approve: true,
);
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_mod').get();
expect(rideSnap.data()!['accessUids'], contains(requester.uid));
});
test('non-admin club member cannot approve requests', () async {
await seedClubMember(clubId: 'club_mod2', uid: 'normal_member', role: 'member');
await seedClubMember(clubId: 'club_mod2', uid: requester.uid, role: 'member');
await seedRide(
rideId: 'ride_mod2',
organizerUid: organizer.uid,
clubId: 'club_mod2',
);
await fakeFirestore
.collection('group_rides')
.doc('ride_mod2')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
expect(
() => service.respondToJoinRequest(
callerUid: 'normal_member',
groupRideId: 'ride_mod2',
requesterUid: requester.uid,
approve: true,
),
throwsA(isA<StateError>()),
);
});
test('approval rejected when ride is underway or at capacity', () async {
// Underway
await seedRide(
rideId: 'ride_underway_app',
organizerUid: organizer.uid,
status: GroupRideStatus.underway,
);
await fakeFirestore
.collection('group_rides')
.doc('ride_underway_app')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
expect(
() => service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_underway_app',
requesterUid: requester.uid,
approve: true,
),
throwsA(isA<StateError>()),
);
// At capacity
await seedRide(
rideId: 'ride_cap_app',
organizerUid: organizer.uid,
participantLimit: 2,
participantCount: 2,
accessUids: [organizer.uid, 'user_other'],
);
await fakeFirestore
.collection('group_rides')
.doc('ride_cap_app')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
expect(
() => service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_cap_app',
requesterUid: requester.uid,
approve: true,
),
throwsA(isA<StateError>()),
);
});
test('leaveRide cancels pending request without mutating parent ride', () async {
await seedRide(
rideId: 'ride_leave_pending',
organizerUid: organizer.uid,
);
await fakeFirestore
.collection('group_rides')
.doc('ride_leave_pending')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.leaveRide(callerUid: requester.uid, groupRideId: 'ride_leave_pending');
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_leave_pending').get();
expect(rideSnap.data()!['accessUids'], isNot(contains(requester.uid)));
expect(rideSnap.data()!['participantCount'], equals(1));
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_leave_pending')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.data()!['status'], equals('left'));
});
test('leaveRide removes accepted rider from accessUids and decrements count', () async {
await seedRide(
rideId: 'ride_leave_accepted',
organizerUid: organizer.uid,
participantCount: 2,
accessUids: [organizer.uid, requester.uid],
);
await fakeFirestore
.collection('group_rides')
.doc('ride_leave_accepted')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'accepted',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.leaveRide(callerUid: requester.uid, groupRideId: 'ride_leave_accepted');
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_leave_accepted').get();
expect(rideSnap.data()!['accessUids'], isNot(contains(requester.uid)));
expect(rideSnap.data()!['participantCount'], equals(1));
});
test('respondToInvitation accept: true marks participant accepted and increments count', () async {
final invitee = RiderProfile(
uid: 'user_bob',
handle: 'bob_trail',
displayName: 'Bob Trail',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
await seedRide(
rideId: 'ride_inv_accept',
organizerUid: organizer.uid,
participantCount: 1,
accessUids: [organizer.uid, invitee.uid],
);
await fakeFirestore
.collection('group_rides')
.doc('ride_inv_accept')
.collection('participants')
.doc(invitee.uid)
.set({
'userId': invitee.uid,
'role': 'participant',
'status': 'invited',
'invitedBy': organizer.uid,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.respondToInvitation(
callerUid: invitee.uid,
groupRideId: 'ride_inv_accept',
accept: true,
);
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_inv_accept').get();
expect(rideSnap.data()!['participantCount'], equals(2));
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_inv_accept')
.collection('participants')
.doc(invitee.uid)
.get();
expect(partSnap.data()!['status'], equals('accepted'));
});
test('respondToInvitation accept: false marks participant declined and removes accessUid with count unchanged', () async {
final invitee = RiderProfile(
uid: 'user_bob',
handle: 'bob_trail',
displayName: 'Bob Trail',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
await seedRide(
rideId: 'ride_inv_decline',
organizerUid: organizer.uid,
participantCount: 1,
accessUids: [organizer.uid, invitee.uid],
);
await fakeFirestore
.collection('group_rides')
.doc('ride_inv_decline')
.collection('participants')
.doc(invitee.uid)
.set({
'userId': invitee.uid,
'role': 'participant',
'status': 'invited',
'invitedBy': organizer.uid,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
await service.respondToInvitation(
callerUid: invitee.uid,
groupRideId: 'ride_inv_decline',
accept: false,
);
final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_inv_decline').get();
expect(rideSnap.data()!['participantCount'], equals(1));
expect(rideSnap.data()!['accessUids'], isNot(contains(invitee.uid)));
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_inv_decline')
.collection('participants')
.doc(invitee.uid)
.get();
expect(partSnap.data()!['status'], equals('declined'));
});
test('respondToJoinRequest decline succeeds even when organizer and requester are mutually blocked', () async {
await seedRide(
rideId: 'ride_block_decline',
organizerUid: organizer.uid,
);
// Seed mutual block
await fakeFirestore
.collection('users')
.doc(organizer.uid)
.collection('blocks')
.doc(requester.uid)
.set({'blockedUid': requester.uid});
await fakeFirestore
.collection('users')
.doc(requester.uid)
.collection('blocks')
.doc(organizer.uid)
.set({'blockedUid': organizer.uid});
await fakeFirestore
.collection('group_rides')
.doc('ride_block_decline')
.collection('participants')
.doc(requester.uid)
.set({
'userId': requester.uid,
'role': 'participant',
'status': 'requested',
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
// 1. Approve fails
expect(
() => service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_block_decline',
requesterUid: requester.uid,
approve: true,
),
throwsA(isA<StateError>()),
);
// 2. Decline SUCCEEDS
await service.respondToJoinRequest(
callerUid: organizer.uid,
groupRideId: 'ride_block_decline',
requesterUid: requester.uid,
approve: false,
);
final partSnap = await fakeFirestore
.collection('group_rides')
.doc('ride_block_decline')
.collection('participants')
.doc(requester.uid)
.get();
expect(partSnap.data()!['status'], equals('declined'));
});
});
}
class _FakeGroupRideLifecycleService implements GroupRideService {
final Map<String, StreamController<GroupRideParticipant?>> participantControllers = {};
final Map<String, StreamController<LiveGroupPresence?>> presenceControllers = {};
StreamController<GroupRideParticipant?> getParticipantController(String uid) {
return participantControllers.putIfAbsent(
uid,
() => StreamController<GroupRideParticipant?>.broadcast(),
);
}
StreamController<LiveGroupPresence?> getPresenceController(String uid) {
return presenceControllers.putIfAbsent(
uid,
() => StreamController<LiveGroupPresence?>.broadcast(),
);
}
@override
Stream<GroupRideParticipant?> participantDocStream({
required String groupRideId,
required String participantUid,
}) {
return getParticipantController(participantUid).stream;
}
@override
Stream<LiveGroupPresence?> livePresenceDocStream({
required String groupRideId,
required String targetUid,
}) {
return getPresenceController(targetUid).stream;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}