886 lines
25 KiB
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)'));
|
|
});
|
|
});
|
|
}
|