import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart'; import 'package:motorcycle_dash/services/alerts/swarm_road_alert_models.dart'; import 'package:motorcycle_dash/services/alerts/swarm_road_alert_provider.dart'; import 'package:motorcycle_dash/services/alerts/swarm_road_alert_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { SharedPreferences.setMockInitialValues({}); }); group('SwarmReport Model Tests', () { test('roundtrip serialization to/from JSON', () { final now = DateTime.now(); final expires = now.add(const Duration(hours: 1)); final report = SwarmReport( id: 'swarm_test_1', category: RoadAlertCategory.police, latitude: 35.3934, longitude: -83.9261, heading: 180.0, roadName: 'US-129 Deals Gap', description: 'Tennessee Highway Patrol stationary radar', reporterUid: 'rider_abc', reportedAt: now, expiresAt: expires, confirmations: 3, dismissals: 1, groupRideId: 'group_ride_123', ); final json = report.toJson(); expect(json['id'], 'swarm_test_1'); expect(json['category'], 'police'); expect(json['latitude'], 35.3934); expect(json['longitude'], -83.9261); expect(json['confirmations'], 3); expect(json['dismissals'], 1); expect(json['groupRideId'], 'group_ride_123'); final fromJson = SwarmReport.fromJson(json); expect(fromJson.id, report.id); expect(fromJson.category, RoadAlertCategory.police); expect(fromJson.latitude, 35.3934); expect(fromJson.longitude, -83.9261); expect(fromJson.confirmations, 3); expect(fromJson.dismissals, 1); expect(fromJson.groupRideId, 'group_ride_123'); }); test('confidence scoring based on confirmations and dismissals', () { final now = DateTime.now(); final expires = now.add(const Duration(hours: 1)); // 3 confirmations, 1 dismissal -> 3/4 = 0.75 final r1 = SwarmReport( id: '1', category: RoadAlertCategory.police, latitude: 35.0, longitude: -83.0, description: 'Test', reporterUid: 'u1', reportedAt: now, expiresAt: expires, confirmations: 3, dismissals: 1, ); expect(r1.confidence, closeTo(0.75, 0.001)); // 1 confirmation, 0 dismissals -> 1/1 = 1.0 final r2 = SwarmReport( id: '2', category: RoadAlertCategory.hazard, latitude: 35.0, longitude: -83.0, description: 'Test', reporterUid: 'u1', reportedAt: now, expiresAt: expires, confirmations: 1, dismissals: 0, ); expect(r2.confidence, 1.0); // Clamp minimum final r3 = SwarmReport( id: '3', category: RoadAlertCategory.hazard, latitude: 35.0, longitude: -83.0, description: 'Test', reporterUid: 'u1', reportedAt: now, expiresAt: expires, confirmations: 0, dismissals: 10, ); expect(r3.confidence, 0.1); }); test('toRoadAlert maps severity and source attributes properly', () { final now = DateTime.now(); final expires = now.add(const Duration(hours: 1)); final policeReport = SwarmReport( id: 'police_1', category: RoadAlertCategory.police, latitude: 35.39, longitude: -83.92, description: 'Speed trap', reporterUid: 'uid1', reportedAt: now, expiresAt: expires, ); final policeAlert = policeReport.toRoadAlert(distanceFromUser: 250.0); expect(policeAlert.providerId, 'swarm_alerts'); expect(policeAlert.severity, RoadAlertSeverity.high); expect(policeAlert.source, 'MotorSignal Swarm'); expect(policeAlert.sourceType, RoadAlertSourceType.userReported); expect(policeAlert.distanceFromUser, 250.0); final packReport = SwarmReport( id: 'hazard_1', category: RoadAlertCategory.hazard, latitude: 35.39, longitude: -83.92, description: 'Gravel in corner', reporterUid: 'uid2', reportedAt: now, expiresAt: expires, groupRideId: 'pack_77', ); final packAlert = packReport.toRoadAlert(); expect(packAlert.severity, RoadAlertSeverity.medium); expect(packAlert.source, 'Pack Rider Report'); }); test('isExpired detects when TTL has lapsed', () { final past = DateTime.now().subtract(const Duration(minutes: 5)); final report = SwarmReport( id: 'expired_1', category: RoadAlertCategory.police, latitude: 35.0, longitude: -83.0, description: 'Old trap', reporterUid: 'u1', reportedAt: past.subtract(const Duration(hours: 1)), expiresAt: past, ); expect(report.isExpired, isTrue); }); }); group('SwarmRoadAlertService Cloud & Cache Tests', () { late FakeFirebaseFirestore fakeFirestore; late SwarmRoadAlertService service; setUp(() { fakeFirestore = FakeFirebaseFirestore(); service = SwarmRoadAlertService(firestore: fakeFirestore); }); test('reportAlert saves to Cloud Firestore and caches in memory', () async { final report = await service.reportAlert( category: RoadAlertCategory.police, latitude: 34.0522, longitude: -118.2437, roadName: 'Mulholland Drive', description: 'Radar trap near lookout', ); expect(report.id, startsWith('swarm_')); expect(report.category, RoadAlertCategory.police); expect(report.confirmations, 1); expect(report.dismissals, 0); // Check Firestore doc final doc = await fakeFirestore.collection('road_alerts').doc(report.id).get(); expect(doc.exists, isTrue); expect(doc.data()!['category'], 'police'); expect(doc.data()!['latitude'], 34.0522); expect(doc.data()!['confirmations'], 1); // Default TTL for police is 60 minutes final reportedAt = DateTime.fromMillisecondsSinceEpoch(doc.data()!['reportedAt'] as int); final expiresAt = DateTime.fromMillisecondsSinceEpoch(doc.data()!['expiresAt'] as int); expect(expiresAt.difference(reportedAt).inMinutes, 60); }); test('reportAlert hazard defaults to 120 minutes TTL', () async { final report = await service.reportAlert( category: RoadAlertCategory.hazard, latitude: 35.0, longitude: -83.0, description: 'Gravel patch', ); final doc = await fakeFirestore.collection('road_alerts').doc(report.id).get(); final reportedAt = DateTime.fromMillisecondsSinceEpoch(doc.data()!['reportedAt'] as int); final expiresAt = DateTime.fromMillisecondsSinceEpoch(doc.data()!['expiresAt'] as int); expect(expiresAt.difference(reportedAt).inMinutes, 120); }); test('confirmAlert writes vote to subcollection and updates parent confirmations in Firestore and cache', () async { final report = await service.reportAlert( category: RoadAlertCategory.police, latitude: 34.0, longitude: -118.0, ); final result = await service.confirmAlert(report.id, voterUid: 'rider_trailing'); expect(result, isTrue); final voteDoc = await fakeFirestore .collection('road_alerts') .doc(report.id) .collection('votes') .doc('rider_trailing') .get(); expect(voteDoc.exists, isTrue); expect(voteDoc.data()!['type'], 'confirm'); expect(voteDoc.data()!['voterUid'], 'rider_trailing'); expect(service.getCachedReport(report.id)?.confirmations, 2); expect(service.getCachedReport(report.id)?.status, RoadAlertStatus.confirmed); // Verify Firestore parent doc is updated for cross-device visibility final parentDoc = await fakeFirestore .collection('road_alerts') .doc(report.id) .get(); expect(parentDoc.data()!['confirmations'], 2); expect(parentDoc.data()!['status'], 'confirmed'); }); test('clearAlert writes dismissal vote to subcollection and auto-clears across Firestore and cache', () async { final report = await service.reportAlert( category: RoadAlertCategory.hazard, latitude: 34.0, longitude: -118.0, ); // 1st clear: increments dismissals to 1 and stores vote in subcollection final res1 = await service.clearAlert(report.id, voterUid: 'rider_a'); expect(res1, isTrue); final voteA = await fakeFirestore .collection('road_alerts') .doc(report.id) .collection('votes') .doc('rider_a') .get(); expect(voteA.exists, isTrue); expect(voteA.data()!['type'], 'dismiss'); expect(service.getCachedReport(report.id)?.dismissals, 1); final parentDoc1 = await fakeFirestore.collection('road_alerts').doc(report.id).get(); expect(parentDoc1.data()!['dismissals'], 1); expect(parentDoc1.data()!['status'], 'active'); // 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately final res2 = await service.clearAlert(report.id, voterUid: 'rider_b'); expect(res2, isTrue); final voteB = await fakeFirestore .collection('road_alerts') .doc(report.id) .collection('votes') .doc('rider_b') .get(); expect(voteB.exists, isTrue); expect(voteB.data()!['type'], 'dismiss'); expect(service.getCachedReport(report.id)?.dismissals, 2); expect(service.getCachedReport(report.id)?.status, RoadAlertStatus.cleared); // Verify Firestore parent doc is cleared for all devices final parentDoc2 = await fakeFirestore.collection('road_alerts').doc(report.id).get(); expect(parentDoc2.data()!['dismissals'], 2); expect(parentDoc2.data()!['status'], 'cleared'); }); test('failed vote rolls back optimistic cache and vote state', () async { final past = DateTime.now().subtract(const Duration(hours: 2)); final expiredReport = SwarmReport( id: 'swarm_expired_test', category: RoadAlertCategory.hazard, latitude: 34.0, longitude: -118.0, description: 'Old obstacle', reporterUid: 'rider_original', reportedAt: past, expiresAt: past.add(const Duration(minutes: 30)), confirmations: 1, dismissals: 0, ); // Save expired alert to Firestore await fakeFirestore .collection('road_alerts') .doc(expiredReport.id) .set(expiredReport.toJson()); // Attempt to confirm expired alert final result = await service.confirmAlert(expiredReport.id, voterUid: 'rider_other'); expect(result, isFalse); // Verify vote record was rolled back expect(service.hasUserVoted(expiredReport.id, 'rider_other'), isFalse); expect(service.canUserVote(expiredReport.id, 'rider_other'), isTrue); // Verify Firestore parent doc was untouched final doc = await fakeFirestore.collection('road_alerts').doc(expiredReport.id).get(); expect(doc.data()!['confirmations'], 1); }); test('getAlertsNear finds alerts within radius and excludes distant ones', () async { // 1. Report alert near Downtown LA (lat: 34.05, lng: -118.25) await service.reportAlert( category: RoadAlertCategory.police, latitude: 34.0500, longitude: -118.2500, description: 'Downtown trap', ); // 2. Report alert in San Francisco (distant: > 500 km) await service.reportAlert( category: RoadAlertCategory.hazard, latitude: 37.7749, longitude: -122.4194, description: 'SF hazard', ); // Query around Downtown LA with 2000 meter radius final nearbyAlerts = await service.getAlertsNear( latitude: 34.0505, longitude: -118.2505, radiusMeters: 2000, ); expect(nearbyAlerts.length, 1); expect(nearbyAlerts.first.category, RoadAlertCategory.police); expect(nearbyAlerts.first.description, 'Downtown trap'); expect(nearbyAlerts.first.distanceFromUser, isNotNull); expect(nearbyAlerts.first.distanceFromUser!, lessThan(2000)); }); test('offline fallback works seamlessly without Cloud Firestore', () async { final offlineService = SwarmRoadAlertService(firestore: null, auth: null); expect(offlineService.isCloudAvailable, isFalse); final report = await offlineService.reportAlert( category: RoadAlertCategory.accident, latitude: 35.39, longitude: -83.92, description: 'Offline crash report', ); expect(report.id, startsWith('swarm_')); final alerts = await offlineService.getAlertsNear( latitude: 35.39, longitude: -83.92, radiusMeters: 1000, ); expect(alerts.length, 1); expect(alerts.first.category, RoadAlertCategory.accident); }); }); group('SwarmRoadAlertProvider Tests', () { test('implements RoadAlertProvider contract correctly', () async { final service = SwarmRoadAlertService(firestore: null, auth: null); final provider = SwarmRoadAlertProvider(service); expect(provider.id, 'swarm_alerts'); expect(provider.name, 'MotorSignal Rider Swarm'); expect(provider.supportedCategories.contains(RoadAlertCategory.police), isTrue); expect(provider.supportedCategories.contains(RoadAlertCategory.hazard), isTrue); final status = await provider.testConnection(''); expect(status, RoadAlertProviderStatus.connected); }); }); group('Riverpod Swarm Provider Wiring Tests', () { test('activeRoadAlertProviders respects swarmAlertsEnabledProvider toggle', () async { final container = ProviderContainer(); addTearDown(container.dispose); // Default is enabled final initialEnabled = container.read(swarmAlertsEnabledProvider); expect(initialEnabled, isTrue); var activeProviders = container.read(activeRoadAlertProviders); expect(activeProviders.any((p) => p.id == 'swarm_alerts'), isTrue); // Disable swarm alerts await container.read(swarmAlertsEnabledProvider.notifier).setEnabled(false); expect(container.read(swarmAlertsEnabledProvider), isFalse); activeProviders = container.read(activeRoadAlertProviders); expect(activeProviders.any((p) => p.id == 'swarm_alerts'), isFalse); // Re-enable await container.read(swarmAlertsEnabledProvider.notifier).setEnabled(true); expect(container.read(swarmAlertsEnabledProvider), isTrue); activeProviders = container.read(activeRoadAlertProviders); expect(activeProviders.any((p) => p.id == 'swarm_alerts'), isTrue); }); }); group('Native Road Alert Categories & Verification Trust Tests', () { test('all 10 native categories map properly to RoadAlert', () { final now = DateTime.now(); final expires = now.add(const Duration(hours: 2)); final categories = [ RoadAlertCategory.police, RoadAlertCategory.accident, RoadAlertCategory.hazard, RoadAlertCategory.pothole, RoadAlertCategory.debris, RoadAlertCategory.disabledVehicle, RoadAlertCategory.roadClosure, RoadAlertCategory.construction, RoadAlertCategory.traffic, RoadAlertCategory.weatherHazard, ]; for (final cat in categories) { final report = SwarmReport( id: 'report_${cat.name}', category: cat, subtype: 'severe_${cat.name}', latitude: 35.0, longitude: -84.0, description: 'Rider alert for ${cat.name}', reporterUid: 'rider_1', reportedAt: now, expiresAt: expires, confirmations: 1, dismissals: 0, source: 'MotorSignal Swarm', status: RoadAlertStatus.active, ); final alert = report.toRoadAlert(); expect(alert.category, cat); expect(alert.subtype, 'severe_${cat.name}'); expect(alert.isCommunityConfirmed, isFalse); expect(alert.confirmations, 1); expect(alert.dismissals, 0); expect(alert.status, RoadAlertStatus.active); } }); test('isCommunityConfirmed requires at least 2 confirmations', () { final now = DateTime.now(); final expires = now.add(const Duration(hours: 2)); final unconfirmed = SwarmReport( id: 'rep_1', category: RoadAlertCategory.pothole, latitude: 35.0, longitude: -84.0, description: 'Pot hole test', reporterUid: 'rider_1', reportedAt: now, expiresAt: expires, confirmations: 1, dismissals: 0, ).toRoadAlert(); expect(unconfirmed.isCommunityConfirmed, isFalse); final confirmed = SwarmReport( id: 'rep_2', category: RoadAlertCategory.pothole, latitude: 35.0, longitude: -84.0, description: 'Pot hole test', reporterUid: 'rider_1', reportedAt: now, expiresAt: expires, confirmations: 2, dismissals: 0, ).toRoadAlert(); expect(confirmed.isCommunityConfirmed, isTrue); }); }); group('Rate Limiting & Duplicate Detection Tests', () { late FakeFirebaseFirestore fakeFirestore; setUp(() { fakeFirestore = FakeFirebaseFirestore(); }); test('enforces rate limit interval and throws RoadAlertRateLimitException', () async { final rateLimitedService = SwarmRoadAlertService( firestore: fakeFirestore, minReportInterval: const Duration(seconds: 15), ); // First report succeeds final first = await rateLimitedService.reportAlert( category: RoadAlertCategory.police, latitude: 35.0, longitude: -84.0, ); expect(first.id, startsWith('swarm_')); // Second immediate report at distant location throws rate limit exception expect( () => rateLimitedService.reportAlert( category: RoadAlertCategory.accident, latitude: 36.0, longitude: -85.0, ), throwsA(isA().having( (e) => e.retryAfterSeconds, 'retryAfterSeconds', inInclusiveRange(1, 15), )), ); // With ignoreRateLimit: true, report succeeds final bypassReport = await rateLimitedService.reportAlert( category: RoadAlertCategory.accident, latitude: 36.0, longitude: -85.0, ignoreRateLimit: true, ); expect(bypassReport.id, startsWith('swarm_')); }); test('duplicate reports within 200m and 15min auto-confirm existing alert', () async { final service = SwarmRoadAlertService( firestore: fakeFirestore, minReportInterval: Duration.zero, duplicateDistanceMeters: 200.0, duplicateWindow: const Duration(minutes: 15), ); // Rider 1 reports a pothole at lat 35.0000, lng -84.0000 final first = await service.reportAlert( category: RoadAlertCategory.pothole, latitude: 35.0000, longitude: -84.0000, description: 'Deep rim bender', reporterUid: 'rider_1', ); expect(first.confirmations, 1); // Rider 2 reports a pothole 50m away (lat 35.0004, lng -84.0000) final duplicateMerged = await service.reportAlert( category: RoadAlertCategory.pothole, latitude: 35.0004, longitude: -84.0000, description: 'Bad crater in right lane', reporterUid: 'rider_2', ); // Auto-confirmed: ID matches original, confirmations incremented to 2 expect(duplicateMerged.id, first.id); expect(duplicateMerged.confirmations, 2); // Check vote subcollection has confirmation vote from rider_2 final voteDoc = await fakeFirestore .collection('road_alerts') .doc(first.id) .collection('votes') .doc('rider_2') .get(); expect(voteDoc.exists, isTrue); expect(voteDoc.data()!['type'], 'confirm'); expect(service.getCachedReport(first.id)?.confirmations, 2); // Distant report (500m away: lat 35.0050) creates a separate alert final separate = await service.reportAlert( category: RoadAlertCategory.pothole, latitude: 35.0050, longitude: -84.0000, ); expect(separate.id, isNot(first.id)); expect(separate.confirmations, 1); }); test('per-user voting restrictions prevent self-voting and repeat voting', () async { final service = SwarmRoadAlertService(firestore: fakeFirestore); final report = await service.reportAlert( category: RoadAlertCategory.debris, latitude: 35.0, longitude: -84.0, ); // Reporter cannot vote on their own alert expect(service.canUserVote(report.id, report.reporterUid), isFalse); final selfVote = await service.confirmAlert(report.id, voterUid: report.reporterUid); expect(selfVote, isFalse); // Another user can vote once const otherUser = 'rider_trail_42'; expect(service.canUserVote(report.id, otherUser), isTrue); expect(service.hasUserVoted(report.id, otherUser), isFalse); final firstVote = await service.confirmAlert(report.id, voterUid: otherUser); expect(firstVote, isTrue); expect(service.hasUserVoted(report.id, otherUser), isTrue); expect(service.canUserVote(report.id, otherUser), isFalse); // Repeat vote is rejected final repeatVote = await service.confirmAlert(report.id, voterUid: otherUser); expect(repeatVote, isFalse); }); test('cleared alerts are marked cleared and pruned from nearby queries', () async { final service = SwarmRoadAlertService(firestore: fakeFirestore); final report = await service.reportAlert( category: RoadAlertCategory.hazard, latitude: 35.1, longitude: -84.1, ); // Query finds it while active var nearby = await service.getAlertsNear( latitude: 35.1, longitude: -84.1, radiusMeters: 1000, ); expect(nearby.length, 1); // Two dismissals from different riders await service.clearAlert(report.id, voterUid: 'voter_a'); await service.clearAlert(report.id, voterUid: 'voter_b'); // Now marked cleared / expired final cached = service.getCachedReport(report.id); expect(cached, isNotNull); expect(cached!.status, RoadAlertStatus.cleared); // Query prunes cleared alerts nearby = await service.getAlertsNear( latitude: 35.1, longitude: -84.1, radiusMeters: 1000, ); expect(nearby, isEmpty); }); }); }