// ignore_for_file: subtype_of_sealed_class import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:motorcycle_dash/services/routes/navigation_handoff.dart'; import 'package:motorcycle_dash/services/routes/route_models.dart'; import 'package:motorcycle_dash/services/routes/route_provider.dart'; import 'package:motorcycle_dash/services/routes/route_service.dart'; class FakeFirestore extends Fake implements FirebaseFirestore { final Map> routes = {}; final Map> groupRides = {}; @override CollectionReference> collection(String collectionPath) { return FakeCollectionReference(this, collectionPath); } @override WriteBatch batch() => FakeWriteBatch(this); } class FakeCollectionReference extends Fake implements CollectionReference> { @override final FakeFirestore firestore; @override final String path; FakeCollectionReference(this.firestore, this.path); @override DocumentReference> doc([String? docId]) { return FakeDocumentReference(firestore, path, docId ?? 'auto_id'); } } class FakeDocumentReference extends Fake implements DocumentReference> { @override final FakeFirestore firestore; final String collectionPath; final String docId; FakeDocumentReference(this.firestore, this.collectionPath, this.docId); @override String get id => docId; Map> get _store => collectionPath == 'routes' ? firestore.routes : firestore.groupRides; @override Future set(Map data, [SetOptions? options]) async { _store[docId] = Map.from(data); } @override Future>> get([GetOptions? options]) async { final data = _store[docId]; return FakeDocumentSnapshot(this, docId, data); } @override Future update(Map data) async { final existing = _store[docId] ?? {}; final updated = Map.from(existing); data.forEach((k, v) { if (v == FieldValue.delete()) { updated.remove(k.toString()); } else { updated[k.toString()] = v; } }); _store[docId] = updated; } @override Future delete() async { _store.remove(docId); } } class FakeDocumentSnapshot extends Fake implements DocumentSnapshot> { final DocumentReference> _ref; final String _id; final Map? _data; FakeDocumentSnapshot(this._ref, this._id, this._data); @override String get id => _id; @override bool get exists => _data != null; @override Map? data() => _data != null ? Map.from(_data) : null; @override DocumentReference> get reference => _ref; } class FakeWriteBatch extends Fake implements WriteBatch { final FakeFirestore firestore; final List Function()> operations = []; FakeWriteBatch(this.firestore); @override void update(DocumentReference document, Map data) { operations.add(() async { final doc = document as FakeDocumentReference; await doc.update(data); }); } @override void delete(DocumentReference document) { operations.add(() async { final doc = document as FakeDocumentReference; await doc.delete(); }); } @override Future commit() async { for (final op in operations) { await op(); } } } void main() { group('RouteService stop validation tests', () { test('validates route title length (minimum 3 characters)', () { final fakeDb = FakeFirestore(); final service = RouteService(firestore: fakeDb); expect( () => service.saveRoute( title: 'Hi', ownerUid: 'alice', stops: [], ), throwsA(isA().having( (e) => e.message, 'message', contains('between 3 and 80 characters'), )), ); }); test('validates route title length (maximum 80 characters)', () { final fakeDb = FakeFirestore(); final service = RouteService(firestore: fakeDb); final longTitle = 'A' * 81; expect( () => service.saveRoute( title: longTitle, ownerUid: 'alice', stops: [], ), throwsA(isA().having( (e) => e.message, 'message', contains('between 3 and 80 characters'), )), ); }); test('validates maximum stops technical cap (25 stops)', () { final stops = List.generate( 26, (i) => RouteStop( id: 'stop-$i', latitude: 37.0 + i * 0.01, longitude: -122.0, displayName: 'Stop $i', stopType: RouteStopType.waypoint, orderIndex: i, ), ); expect( () => RouteService.validateStops(stops), throwsA(isA().having( (e) => e.message, 'message', contains('cannot exceed 25 stops'), )), ); }); test('validates stop coordinates bounds', () { final invalidLatStop = [ const RouteStop( id: 'stop-bad-lat', latitude: 95.0, longitude: -122.0, displayName: 'Bad Lat', stopType: RouteStopType.origin, orderIndex: 0, ), ]; expect( () => RouteService.validateStops(invalidLatStop), throwsA(isA().having( (e) => e.message, 'message', contains('invalid coordinates'), )), ); final invalidLngStop = [ const RouteStop( id: 'stop-bad-lng', latitude: 37.0, longitude: -185.0, displayName: 'Bad Lng', stopType: RouteStopType.origin, orderIndex: 0, ), ]; expect( () => RouteService.validateStops(invalidLngStop), throwsA(isA().having( (e) => e.message, 'message', contains('invalid coordinates'), )), ); }); test('validates stop ID non-empty and bounded length', () { expect( () => RouteService.validateStops([ const RouteStop( id: '', latitude: 37.0, longitude: -122.0, displayName: 'Valid', stopType: RouteStopType.origin, ), ]), throwsA(isA().having( (e) => e.message, 'message', contains('missing an ID'), )), ); expect( () => RouteService.validateStops([ RouteStop( id: 'x' * 65, latitude: 37.0, longitude: -122.0, displayName: 'Valid', stopType: RouteStopType.origin, ), ]), throwsA(isA().having( (e) => e.message, 'message', contains('exceeds 64 characters'), )), ); }); test('validates duplicate stop IDs', () { final duplicateStops = [ const RouteStop( id: 'same-id', latitude: 37.0, longitude: -122.0, displayName: 'Stop 1', ), const RouteStop( id: 'same-id', latitude: 37.1, longitude: -122.1, displayName: 'Stop 2', ), ]; expect( () => RouteService.validateStops(duplicateStops), throwsA(isA().having( (e) => e.message, 'message', contains('Duplicate route stop ID'), )), ); }); test('validates stop notes and address bounding', () { expect( () => RouteService.validateStops([ RouteStop( id: 'stop-long-notes', latitude: 37.0, longitude: -122.0, displayName: 'Stop', notes: 'A' * 501, ), ]), throwsA(isA().having( (e) => e.message, 'message', contains('exceed 500 characters'), )), ); expect( () => RouteService.validateStops([ RouteStop( id: 'stop-long-addr', latitude: 37.0, longitude: -122.0, displayName: 'Stop', address: 'B' * 201, ), ]), throwsA(isA().having( (e) => e.message, 'message', contains('exceed 200 characters'), )), ); }); test('group-only visibility requires attached group ride', () { final fakeDb = FakeFirestore(); final service = RouteService(firestore: fakeDb); expect( () => service.saveRoute( title: 'Secret Group Route', ownerUid: 'alice', visibility: RouteVisibility.groupOnly, attachedGroupRideId: null, stops: [ const RouteStop( id: 's1', latitude: 37.0, longitude: -122.0, displayName: 'S1', ), ], ), throwsA(isA().having( (e) => e.message, 'message', contains('Group-only route visibility requires'), )), ); }); }); group('Requirement 9: Explicit Failure when Persistence Unavailable', () { final unconfiguredService = RouteService(firestore: null); test('saveRoute fails explicitly when persistence is unconfigured', () async { await expectLater( () => unconfiguredService.saveRoute( title: 'Skyline Ridge', ownerUid: 'alice', stops: [ const RouteStop( id: 's1', latitude: 37.0, longitude: -122.0, displayName: 'Stop 1', ), ], ), throwsA(isA().having( (e) => e.message, 'message', contains('persistence unavailable'), )), ); }); test('updateRoute fails explicitly when persistence is unconfigured', () async { final route = MotorSignalRoute( id: 'r1', ownerUid: 'alice', title: 'Title', createdAt: DateTime.now(), updatedAt: DateTime.now(), ); await expectLater( () => unconfiguredService.updateRoute(route: route, callerUid: 'alice'), throwsA(isA().having( (e) => e.message, 'message', contains('persistence unavailable'), )), ); }); test('deleteRoute fails explicitly when persistence is unconfigured', () async { await expectLater( () => unconfiguredService.deleteRoute('r1', callerUid: 'alice'), throwsA(isA().having( (e) => e.message, 'message', contains('persistence unavailable'), )), ); }); test('attachRouteToGroupRide fails explicitly when persistence is unconfigured', () async { await expectLater( () => unconfiguredService.attachRouteToGroupRide( routeId: 'r1', groupRideId: 'g1', callerUid: 'alice', ), throwsA(isA().having( (e) => e.message, 'message', contains('persistence unavailable'), )), ); }); test('detachRouteFromGroupRide fails explicitly when persistence is unconfigured', () async { await expectLater( () => unconfiguredService.detachRouteFromGroupRide('g1', callerUid: 'alice'), throwsA(isA().having( (e) => e.message, 'message', contains('persistence unavailable'), )), ); }); }); group('Requirement 5 & 10: Service Authority and Provenance Integrity', () { late FakeFirestore fakeDb; late RouteService service; setUp(() { fakeDb = FakeFirestore(); service = RouteService(firestore: fakeDb); }); test('create -> update: createdAt, ownerUid, id unchanged; updatedAt advances', () async { final originalCreatedAt = DateTime.utc(2026, 9, 4, 10, 0, 0); // Save initial route with explicit creation time final initial = await service.saveRoute( id: 'route_preserve_prov', title: 'Initial Title', ownerUid: 'alice', createdAt: originalCreatedAt, stops: [ const RouteStop( id: 's1', latitude: 37.7, longitude: -122.4, displayName: 'Start', ), const RouteStop( id: 's2', latitude: 37.3, longitude: -121.8, displayName: 'End', ), ], ); expect(initial.createdAt, equals(originalCreatedAt)); expect(initial.ownerUid, equals('alice')); expect(initial.id, equals('route_preserve_prov')); // Wait 5ms so DateTime.now() strictly advances await Future.delayed(const Duration(milliseconds: 5)); final updated = await service.updateRoute( route: initial.copyWith( title: 'Updated Title', ), callerUid: 'alice', ); expect(updated.id, equals('route_preserve_prov')); expect(updated.ownerUid, equals('alice')); expect(updated.title, equals('Updated Title')); expect(updated.createdAt, equals(originalCreatedAt)); // Provenance strictly preserved! expect(updated.updatedAt.isAfter(originalCreatedAt), isTrue); // Timestamp advances! }); test('missing or empty caller identity is rejected for authority operations', () async { final route = MotorSignalRoute( id: 'r1', ownerUid: 'alice', title: 'Title', createdAt: DateTime.now(), updatedAt: DateTime.now(), ); await expectLater( () => service.updateRoute(route: route, callerUid: ''), throwsA(isA().having( (e) => e.message, 'message', contains('Caller identity required'), )), ); await expectLater( () => service.deleteRoute('r1', callerUid: ''), throwsA(isA().having( (e) => e.message, 'message', contains('Caller identity required'), )), ); await expectLater( () => service.attachRouteToGroupRide( routeId: 'r1', groupRideId: 'g1', callerUid: '', ), throwsA(isA().having( (e) => e.message, 'message', contains('Caller identity required'), )), ); await expectLater( () => service.detachRouteFromGroupRide('g1', callerUid: ''), throwsA(isA().having( (e) => e.message, 'message', contains('Caller identity required'), )), ); }); test('wrong route owner cannot update or delete route', () async { final route = await service.saveRoute( id: 'route_alice', title: 'Alice Route', ownerUid: 'alice', stops: [ const RouteStop( id: 's1', latitude: 37.0, longitude: -122.0, displayName: 'S1', ), ], ); // Bob tries to update Alice's route -> DENIED await expectLater( () => service.updateRoute(route: route, callerUid: 'bob'), throwsA(isA().having( (e) => e.message, 'message', contains('Only the route owner can update'), )), ); // Bob tries to delete Alice's route -> DENIED await expectLater( () => service.deleteRoute('route_alice', callerUid: 'bob'), throwsA(isA().having( (e) => e.message, 'message', contains('Only the route owner can delete'), )), ); }); }); group('Requirement 6 & 7: Attachment Ownership Policy and Safe Detach', () { late FakeFirestore fakeDb; late RouteService service; setUp(() { fakeDb = FakeFirestore(); service = RouteService(firestore: fakeDb); // Seed group ride organized by Alice fakeDb.groupRides['ride_alice'] = { 'id': 'ride_alice', 'organizerUid': 'alice', 'title': 'Alice Group Ride', }; }); test('organizer + owned route -> ALLOW attach', () async { final route = await service.saveRoute( id: 'route_alice', title: 'Alice Route', ownerUid: 'alice', stops: [ const RouteStop( id: 's1', latitude: 37.7, longitude: -122.4, displayName: 'SF', ), ], ); await service.attachRouteToGroupRide( route: route, groupRideId: 'ride_alice', callerUid: 'alice', ); // Verified attached in Group Ride expect(fakeDb.groupRides['ride_alice']?['routeId'], equals('route_alice')); expect(fakeDb.groupRides['ride_alice']?['routeTitle'], equals('Alice Route')); // Verified attached in Route doc with groupOnly visibility expect(fakeDb.routes['route_alice']?['attachedGroupRideId'], equals('ride_alice')); expect(fakeDb.routes['route_alice']?['visibility'], equals(RouteVisibility.groupOnly.name)); }); test('organizer + someone else private route -> DENY attach', () async { await service.saveRoute( id: 'route_bob', title: 'Bob Private Route', ownerUid: 'bob', stops: [ const RouteStop( id: 's1', latitude: 37.7, longitude: -122.4, displayName: 'SF', ), ], ); // Alice is organizer of ride_alice, but tries to attach Bob's route -> DENIED await expectLater( () => service.attachRouteToGroupRide( routeId: 'route_bob', groupRideId: 'ride_alice', callerUid: 'alice', ), throwsA(isA().having( (e) => e.message, 'message', contains('Organizer can only attach a route they own'), )), ); }); test('ordinary participant + any route -> DENY attach', () async { final route = await service.saveRoute( id: 'route_bob', title: 'Bob Route', ownerUid: 'bob', stops: [ const RouteStop( id: 's1', latitude: 37.7, longitude: -122.4, displayName: 'SF', ), ], ); // Bob is participant, not organizer of ride_alice -> DENIED await expectLater( () => service.attachRouteToGroupRide( route: route, groupRideId: 'ride_alice', callerUid: 'bob', ), throwsA(isA().having( (e) => e.message, 'message', contains('Only the Group Ride organizer can attach a route'), )), ); }); test('safe detach: organizer authority clears group ride and restores route to private', () async { // 1. Setup attached route final route = await service.saveRoute( id: 'route_alice', title: 'Alice Route', ownerUid: 'alice', stops: [ const RouteStop( id: 's1', latitude: 37.7, longitude: -122.4, displayName: 'SF', ), ], ); await service.attachRouteToGroupRide( route: route, groupRideId: 'ride_alice', callerUid: 'alice', ); // 2. Non-organizer Bob attempts detach -> DENIED await expectLater( () => service.detachRouteFromGroupRide('ride_alice', callerUid: 'bob'), throwsA(isA().having( (e) => e.message, 'message', contains('Only the organizer can detach'), )), ); // 3. Organizer Alice detaches route -> ALLOWED await service.detachRouteFromGroupRide('ride_alice', callerUid: 'alice'); // Group Ride route fields deleted expect(fakeDb.groupRides['ride_alice']?.containsKey('routeId'), isFalse); expect(fakeDb.groupRides['ride_alice']?.containsKey('routeTitle'), isFalse); // Route attachedGroupRideId cleared and visibility restored to private expect(fakeDb.routes['route_alice']?.containsKey('attachedGroupRideId'), isFalse); expect(fakeDb.routes['route_alice']?['visibility'], equals(RouteVisibility.private.name)); }); }); group('Requirement 8: Safe Delete of Attached Route', () { late FakeFirestore fakeDb; late RouteService service; setUp(() { fakeDb = FakeFirestore(); service = RouteService(firestore: fakeDb); }); test('cannot delete attached route if caller is not the group organizer', () async { // Bob organizes group ride fakeDb.groupRides['ride_bob'] = { 'id': 'ride_bob', 'organizerUid': 'bob', 'title': 'Bob Ride', 'routeId': 'route_alice', }; // Alice owns route attached to Bob's group ride fakeDb.routes['route_alice'] = { 'id': 'route_alice', 'ownerUid': 'alice', 'title': 'Alice Route', 'attachedGroupRideId': 'ride_bob', 'visibility': 'groupOnly', 'stops': [], }; // Alice attempts to delete route without organizer authority to detach from ride_bob -> REFUSED await expectLater( () => service.deleteRoute('route_alice', callerUid: 'alice'), throwsA(isA().having( (e) => e.message, 'message', contains('Cannot delete a route attached to a Group Ride you do not organize'), )), ); // Route remains in database (not dangling) expect(fakeDb.routes.containsKey('route_alice'), isTrue); expect(fakeDb.groupRides['ride_bob']?['routeId'], equals('route_alice')); }); test('atomic delete of attached route succeeds when caller is route owner and organizer', () async { // Alice organizes group ride fakeDb.groupRides['ride_alice'] = { 'id': 'ride_alice', 'organizerUid': 'alice', 'title': 'Alice Ride', 'routeId': 'route_alice', 'routeTitle': 'Alice Route', }; // Alice owns route attached to her own group ride fakeDb.routes['route_alice'] = { 'id': 'route_alice', 'ownerUid': 'alice', 'title': 'Alice Route', 'attachedGroupRideId': 'ride_alice', 'visibility': 'groupOnly', 'stops': [], }; // Alice deletes route -> atomic batch clears group ride route reference and deletes route await service.deleteRoute('route_alice', callerUid: 'alice'); expect(fakeDb.routes.containsKey('route_alice'), isFalse); expect(fakeDb.groupRides['ride_alice']?.containsKey('routeId'), isFalse); }); }); group('StraightLineRouteProvider tests', () { const provider = StraightLineRouteProvider(); test('builds straight-line polyline and calculates distance', () async { const stops = [ RouteStop( id: 's1', latitude: 37.7749, longitude: -122.4194, displayName: 'SF', stopType: RouteStopType.origin, orderIndex: 0, ), RouteStop( id: 's2', latitude: 37.3382, longitude: -121.8863, displayName: 'San Jose', stopType: RouteStopType.destination, orderIndex: 1, ), ]; final result = await provider.calculateRoute(stops); expect(result.points.length, equals(2)); expect(result.points[0].latitude, equals(37.7749)); expect(result.points[1].latitude, equals(37.3382)); expect(result.totalDistanceMeters, greaterThan(65000)); expect(result.isHonestStraightLine, isTrue); }); test('empty stops produces zero points and zero distance', () async { final result = await provider.calculateRoute(const []); expect(result.points.isEmpty, isTrue); expect(result.totalDistanceMeters, equals(0.0)); }); }); group('NavigationHandoffService URI tests', () { test('builds valid Google Maps navigation URI', () { final uri = NavigationHandoffService.buildGoogleMapsUri( latitude: 37.7749, longitude: -122.4194, displayName: 'San Francisco Hub', ); expect(uri.scheme, equals('https')); expect(uri.host, equals('www.google.com')); expect(uri.path, equals('/maps/dir/')); expect(uri.queryParameters['api'], equals('1')); expect(uri.queryParameters['destination'], equals('37.774900,-122.419400')); }); test('builds valid Waze navigation URI', () { final uri = NavigationHandoffService.buildWazeUri( latitude: 37.7749, longitude: -122.4194, ); expect(uri.scheme, equals('https')); expect(uri.host, equals('waze.com')); expect(uri.path, equals('/ul')); expect(uri.queryParameters['ll'], equals('37.774900,-122.419400')); expect(uri.queryParameters['navigate'], equals('yes')); }); test('builds valid system geo URI', () { final uri = NavigationHandoffService.buildSystemGeoUri( latitude: 37.7749, longitude: -122.4194, displayName: 'SF Landmark', ); expect(uri.scheme, equals('geo')); expect(uri.path, equals('37.774900,-122.419400')); expect(uri.queryParameters['q'], equals('37.774900,-122.419400(SF Landmark)')); }); }); }