1953 lines
63 KiB
Dart
1953 lines
63 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:motorcycle_dash/core/theme/app_colors.dart';
|
|
import 'package:motorcycle_dash/core/theme/theme.dart';
|
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
|
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
|
|
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
|
|
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
|
|
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
|
|
|
|
void main() {
|
|
group('GroupRideService Offline & Validation Tests', () {
|
|
final organizer = RiderProfile(
|
|
uid: 'user_alice',
|
|
handle: 'alice_apex',
|
|
displayName: 'Alice Apex',
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
);
|
|
|
|
test('rejects ride creation with invalid title', () async {
|
|
final service = GroupRideService();
|
|
const meetup = GroupRideLocation(
|
|
latitude: 34.0,
|
|
longitude: -84.0,
|
|
displayName: 'Meetup',
|
|
);
|
|
|
|
expect(
|
|
() => service.createGroupRide(
|
|
organizer: organizer,
|
|
title: 'Hi', // too short (< 3)
|
|
scheduledStartAt: DateTime.now(),
|
|
departureAt: DateTime.now().add(const Duration(minutes: 30)),
|
|
meetingPoint: meetup,
|
|
),
|
|
throwsA(isA<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: 50));
|
|
|
|
fakeService.getParticipantController('user_accepted').add(
|
|
GroupRideParticipant(
|
|
userId: 'user_accepted',
|
|
role: GroupRideRole.participant,
|
|
status: ParticipantStatus.accepted,
|
|
riderHandle: 'alice',
|
|
riderDisplayName: 'Alice',
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
fakeService.getParticipantController('user_invited').add(
|
|
GroupRideParticipant(
|
|
userId: 'user_invited',
|
|
role: GroupRideRole.participant,
|
|
status: ParticipantStatus.invited,
|
|
riderHandle: 'bob',
|
|
riderDisplayName: 'Bob',
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
fakeService.getParticipantController('user_declined').add(
|
|
GroupRideParticipant(
|
|
userId: 'user_declined',
|
|
role: GroupRideRole.participant,
|
|
status: ParticipantStatus.declined,
|
|
riderHandle: 'charlie',
|
|
riderDisplayName: 'Charlie',
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
|
|
|
final presenceAlice = LiveGroupPresence(
|
|
userId: 'user_accepted',
|
|
groupRideId: 'ride_123',
|
|
latitude: 37.7749,
|
|
longitude: -122.4194,
|
|
speedMph: 45.0,
|
|
sharingEnabled: true,
|
|
updatedAt: DateTime.now(),
|
|
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
|
|
);
|
|
fakeService.getPresenceController('user_accepted').add(presenceAlice);
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
|
|
|
expect(states.isNotEmpty, isTrue);
|
|
expect(states.last.any((p) => p.userId == 'user_accepted'), isTrue);
|
|
expect(states.last.any((p) => p.userId == 'user_invited'), isFalse);
|
|
expect(states.last.any((p) => p.userId == 'user_declined'), isFalse);
|
|
});
|
|
|
|
test('peer error isolation: peer permission-denied does not drop other peer presence', () async {
|
|
final fakeService = _FakeGroupRideLifecycleService();
|
|
final rideStreamController = StreamController<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: 50));
|
|
|
|
fakeService.getParticipantController('user_alice').add(
|
|
GroupRideParticipant(
|
|
userId: 'user_alice',
|
|
role: GroupRideRole.participant,
|
|
status: ParticipantStatus.accepted,
|
|
riderHandle: 'alice',
|
|
riderDisplayName: 'Alice',
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
fakeService.getParticipantController('user_bob').add(
|
|
GroupRideParticipant(
|
|
userId: 'user_bob',
|
|
role: GroupRideRole.participant,
|
|
status: ParticipantStatus.accepted,
|
|
riderHandle: 'bob',
|
|
riderDisplayName: 'Bob',
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
|
|
|
final presenceAlice = LiveGroupPresence(
|
|
userId: 'user_alice',
|
|
groupRideId: 'ride_error_isolation',
|
|
latitude: 37.7749,
|
|
longitude: -122.4194,
|
|
speedMph: 45.0,
|
|
sharingEnabled: true,
|
|
updatedAt: DateTime.now(),
|
|
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
|
|
);
|
|
fakeService.getPresenceController('user_alice').add(presenceAlice);
|
|
fakeService.getPresenceController('user_bob').addError('permission-denied');
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
|
|
|
expect(states.isNotEmpty, isTrue);
|
|
expect(states.last.length, 1);
|
|
expect(states.last.first.userId, 'user_alice');
|
|
});
|
|
|
|
test('participants provider reconciles candidates from accessUids and callerUid without broad collection queries', () async {
|
|
final fakeService = _FakeGroupRideLifecycleService();
|
|
final rideStreamController = StreamController<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: 50));
|
|
|
|
fakeService.getParticipantController('user_alice').add(
|
|
GroupRideParticipant(
|
|
userId: 'user_alice',
|
|
role: GroupRideRole.organizer,
|
|
status: ParticipantStatus.accepted,
|
|
riderHandle: 'alice',
|
|
riderDisplayName: 'Alice',
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
fakeService.getParticipantController('user_bob').add(
|
|
GroupRideParticipant(
|
|
userId: 'user_bob',
|
|
role: GroupRideRole.participant,
|
|
status: ParticipantStatus.accepted,
|
|
riderHandle: 'bob',
|
|
riderDisplayName: 'Bob',
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
|
|
|
expect(states.isNotEmpty, isTrue);
|
|
expect(states.last.length, 2);
|
|
expect(states.last.first.userId, 'user_alice');
|
|
expect(states.last.last.userId, 'user_bob');
|
|
});
|
|
});
|
|
|
|
|
|
|
|
group('UI Layout and Styling Regression Tests', () {
|
|
testWidgets('Live presence header does not overflow on 393px width constraint', (tester) async {
|
|
tester.view.physicalSize = const Size(393, 852);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.darkTheme,
|
|
home: Scaffold(
|
|
body: Center(
|
|
child: SizedBox(
|
|
width: 393,
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.sensors, color: AppColors.brandBlue, size: 20),
|
|
const SizedBox(width: 8),
|
|
const Expanded(
|
|
child: Text(
|
|
'LIVE RIDE PRESENCE',
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
letterSpacing: 1.2,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.brandBlue.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.brandBlue,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
const Text(
|
|
'BROADCASTING',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.brandBlue,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.pumpAndSettle();
|
|
expect(tester.takeException(), isNull);
|
|
expect(find.text('LIVE RIDE PRESENCE'), findsOneWidget);
|
|
expect(find.text('BROADCASTING'), findsOneWidget);
|
|
});
|
|
|
|
test('Light theme AppBar has dark status bar icons and high contrast title', () {
|
|
final lightTheme = AppTheme.lightTheme;
|
|
expect(lightTheme.appBarTheme.titleTextStyle?.color, AppColors.lightOnSurface);
|
|
expect(lightTheme.appBarTheme.iconTheme?.color, AppColors.lightOnSurface);
|
|
expect(lightTheme.appBarTheme.systemOverlayStyle?.statusBarIconBrightness, Brightness.dark);
|
|
});
|
|
});
|
|
|
|
group('GroupRideService Club Affiliation & Discovery Tests', () {
|
|
late FakeFirebaseFirestore fakeFirestore;
|
|
late GroupRideService service;
|
|
late RiderProfile organizer;
|
|
const meetup = GroupRideLocation(
|
|
latitude: 34.0,
|
|
longitude: -84.0,
|
|
displayName: 'Starting Spot',
|
|
);
|
|
|
|
setUp(() {
|
|
fakeFirestore = FakeFirebaseFirestore();
|
|
service = GroupRideService(firestore: fakeFirestore);
|
|
organizer = RiderProfile(
|
|
uid: 'user_alice',
|
|
handle: 'alice_apex',
|
|
displayName: 'Alice Apex',
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
);
|
|
});
|
|
|
|
test('personal ride creation omits clubId and does not perform club reads', () async {
|
|
final ride = await service.createGroupRide(
|
|
organizer: organizer,
|
|
title: 'Sunday Morning Cruise',
|
|
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
|
|
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
|
|
meetingPoint: meetup,
|
|
clubId: null,
|
|
);
|
|
|
|
expect(ride.clubId, isNull);
|
|
|
|
final doc = await fakeFirestore.collection('group_rides').doc(ride.id).get();
|
|
expect(doc.exists, isTrue);
|
|
expect(doc.data()!.containsKey('clubId'), isFalse);
|
|
});
|
|
|
|
test('active club admin creates club-affiliated ride with persisted clubId', () async {
|
|
final now = DateTime.now();
|
|
// Seed club document
|
|
await fakeFirestore.collection('clubs').doc('club_123').set({
|
|
'id': 'club_123',
|
|
'name': 'Alpha Club',
|
|
'slug': 'alpha-club',
|
|
'ownerUid': 'user_bob',
|
|
'visibility': 'public',
|
|
'admissionPolicy': 'open',
|
|
'memberCount': 2,
|
|
'createdAt': now.toIso8601String(),
|
|
'updatedAt': now.toIso8601String(),
|
|
});
|
|
// Seed membership document for organizer as admin
|
|
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
|
|
'clubId': 'club_123',
|
|
'uid': 'user_alice',
|
|
'role': 'admin',
|
|
'status': 'active',
|
|
'joinedAt': now.toIso8601String(),
|
|
'updatedAt': now.toIso8601String(),
|
|
});
|
|
|
|
final ride = await service.createGroupRide(
|
|
organizer: organizer,
|
|
title: 'Alpha Club Mountain Run',
|
|
scheduledStartAt: now.add(const Duration(days: 1)),
|
|
departureAt: now.add(const Duration(days: 1, minutes: 30)),
|
|
meetingPoint: meetup,
|
|
clubId: 'club_123',
|
|
);
|
|
|
|
expect(ride.clubId, 'club_123');
|
|
|
|
final doc = await fakeFirestore.collection('group_rides').doc(ride.id).get();
|
|
expect(doc.exists, isTrue);
|
|
expect(doc.data()!['clubId'], 'club_123');
|
|
});
|
|
|
|
test('active club owner creates club-affiliated ride with persisted clubId', () async {
|
|
final now = DateTime.now();
|
|
await fakeFirestore.collection('clubs').doc('club_123').set({
|
|
'id': 'club_123',
|
|
'name': 'Alpha Club',
|
|
'slug': 'alpha-club',
|
|
'ownerUid': 'user_alice',
|
|
'visibility': 'public',
|
|
'admissionPolicy': 'open',
|
|
'memberCount': 1,
|
|
'createdAt': now.toIso8601String(),
|
|
'updatedAt': now.toIso8601String(),
|
|
});
|
|
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
|
|
'clubId': 'club_123',
|
|
'uid': 'user_alice',
|
|
'role': 'owner',
|
|
'status': 'active',
|
|
'joinedAt': now.toIso8601String(),
|
|
'updatedAt': now.toIso8601String(),
|
|
});
|
|
|
|
final ride = await service.createGroupRide(
|
|
organizer: organizer,
|
|
title: 'Alpha Club Mountain Run',
|
|
scheduledStartAt: now.add(const Duration(days: 1)),
|
|
departureAt: now.add(const Duration(days: 1, minutes: 30)),
|
|
meetingPoint: meetup,
|
|
clubId: 'club_123',
|
|
);
|
|
|
|
expect(ride.clubId, 'club_123');
|
|
});
|
|
|
|
test('rejects malformed clubId with ArgumentError', () async {
|
|
expect(
|
|
() => service.createGroupRide(
|
|
organizer: organizer,
|
|
title: 'Malformed Club Ride',
|
|
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
|
|
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
|
|
meetingPoint: meetup,
|
|
clubId: 'club with spaces',
|
|
),
|
|
throwsA(isA<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'));
|
|
});
|
|
});
|
|
|
|
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 {
|
|
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);
|
|
}
|