MotorSignal-App/test/route_service_test.dart

886 lines
25 KiB
Dart

// 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<String, Map<String, dynamic>> routes = {};
final Map<String, Map<String, dynamic>> groupRides = {};
@override
CollectionReference<Map<String, dynamic>> collection(String collectionPath) {
return FakeCollectionReference(this, collectionPath);
}
@override
WriteBatch batch() => FakeWriteBatch(this);
}
class FakeCollectionReference extends Fake
implements CollectionReference<Map<String, dynamic>> {
@override
final FakeFirestore firestore;
@override
final String path;
FakeCollectionReference(this.firestore, this.path);
@override
DocumentReference<Map<String, dynamic>> doc([String? docId]) {
return FakeDocumentReference(firestore, path, docId ?? 'auto_id');
}
}
class FakeDocumentReference extends Fake
implements DocumentReference<Map<String, dynamic>> {
@override
final FakeFirestore firestore;
final String collectionPath;
final String docId;
FakeDocumentReference(this.firestore, this.collectionPath, this.docId);
@override
String get id => docId;
Map<String, Map<String, dynamic>> get _store =>
collectionPath == 'routes' ? firestore.routes : firestore.groupRides;
@override
Future<void> set(Map<String, dynamic> data, [SetOptions? options]) async {
_store[docId] = Map<String, dynamic>.from(data);
}
@override
Future<DocumentSnapshot<Map<String, dynamic>>> get([GetOptions? options]) async {
final data = _store[docId];
return FakeDocumentSnapshot(this, docId, data);
}
@override
Future<void> update(Map<Object, Object?> data) async {
final existing = _store[docId] ?? {};
final updated = Map<String, dynamic>.from(existing);
data.forEach((k, v) {
if (v == FieldValue.delete()) {
updated.remove(k.toString());
} else {
updated[k.toString()] = v;
}
});
_store[docId] = updated;
}
@override
Future<void> delete() async {
_store.remove(docId);
}
}
class FakeDocumentSnapshot extends Fake
implements DocumentSnapshot<Map<String, dynamic>> {
final DocumentReference<Map<String, dynamic>> _ref;
final String _id;
final Map<String, dynamic>? _data;
FakeDocumentSnapshot(this._ref, this._id, this._data);
@override
String get id => _id;
@override
bool get exists => _data != null;
@override
Map<String, dynamic>? data() =>
_data != null ? Map<String, dynamic>.from(_data) : null;
@override
DocumentReference<Map<String, dynamic>> get reference => _ref;
}
class FakeWriteBatch extends Fake implements WriteBatch {
final FakeFirestore firestore;
final List<Future<void> Function()> operations = [];
FakeWriteBatch(this.firestore);
@override
void update(DocumentReference<Object?> document, Map<Object, Object?> data) {
operations.add(() async {
final doc = document as FakeDocumentReference;
await doc.update(data);
});
}
@override
void delete(DocumentReference<Object?> document) {
operations.add(() async {
final doc = document as FakeDocumentReference;
await doc.delete();
});
}
@override
Future<void> 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<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().having(
(e) => e.message,
'message',
contains('Caller identity required'),
)),
);
await expectLater(
() => service.deleteRoute('r1', callerUid: ''),
throwsA(isA<StateError>().having(
(e) => e.message,
'message',
contains('Caller identity required'),
)),
);
await expectLater(
() => service.attachRouteToGroupRide(
routeId: 'r1',
groupRideId: 'g1',
callerUid: '',
),
throwsA(isA<StateError>().having(
(e) => e.message,
'message',
contains('Caller identity required'),
)),
);
await expectLater(
() => service.detachRouteFromGroupRide('g1', callerUid: ''),
throwsA(isA<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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)'));
});
});
}