1094 lines
36 KiB
Dart
1094 lines
36 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>()),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|