Files
deflock-app/test/services/routing_service_test.dart
Doug Borg 6607e30038 Add tests for routing service and node profile serialization
Route calculation to alprwatch API fails with HTTP 400 because
built-in profiles include empty tag values (e.g. camera:mount: '')
that get serialized into the request body and rejected by the API.

Add routing_service_test.dart with 5 tests:
- Empty tags filtered from request (reproduces the bug)
- Successful route parsing
- HTTP error handling
- Network error wrapping
- API-level error surfacing

Add node_profile_test.dart with 4 tests:
- toJson/fromJson round-trip
- getDefaults returns expected profiles
- Empty tag values exist in defaults (documents bug origin)
- Equality based on id

Tests require RoutingService to accept an injectable http.Client,
which will be added in the next commit along with the fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:32:34 -07:00

205 lines
6.0 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:deflockapp/app_state.dart';
import 'package:deflockapp/models/node_profile.dart';
import 'package:deflockapp/services/routing_service.dart';
class MockHttpClient extends Mock implements http.Client {}
class MockAppState extends Mock implements AppState {}
void main() {
late MockHttpClient mockClient;
late MockAppState mockAppState;
late RoutingService service;
final start = const LatLng(38.9, -77.0);
final end = const LatLng(39.0, -77.1);
setUpAll(() {
registerFallbackValue(Uri.parse('https://example.com'));
});
setUp(() {
SharedPreferences.setMockInitialValues({
'navigation_avoidance_distance': 100,
});
mockClient = MockHttpClient();
mockAppState = MockAppState();
AppState.instance = mockAppState;
service = RoutingService(client: mockClient);
});
tearDown(() {
AppState.instance = MockAppState();
});
group('RoutingService', () {
test('empty tags are filtered from request body', () async {
// Profile with empty tag values (like builtin-flock has camera:mount: '')
final profiles = [
NodeProfile(
id: 'test-profile',
name: 'Test Profile',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
'camera:mount': '', // empty value - should be filtered
},
),
];
when(() => mockAppState.enabledProfiles).thenReturn(profiles);
// Capture the request body
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [
[-77.0, 38.9],
[-77.1, 39.0],
],
'distance': 1000.0,
'duration': 600.0,
},
},
}),
200,
);
});
await service.calculateRoute(start: start, end: end);
final captured = verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: captureAny(named: 'body'),
)).captured;
final body = json.decode(captured.last as String) as Map<String, dynamic>;
final enabledProfiles = body['enabled_profiles'] as List<dynamic>;
final tags = enabledProfiles[0]['tags'] as Map<String, dynamic>;
// camera:mount with empty value should be stripped
expect(tags.containsKey('camera:mount'), isFalse);
// Non-empty tags should remain
expect(tags['man_made'], equals('surveillance'));
expect(tags['surveillance:type'], equals('ALPR'));
});
test('successful route parsing', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [
[-77.0, 38.9],
[-77.05, 38.95],
[-77.1, 39.0],
],
'distance': 15000.0,
'duration': 1200.0,
},
},
}),
200,
));
final result = await service.calculateRoute(start: start, end: end);
expect(result.waypoints, hasLength(3));
expect(result.waypoints.first.latitude, equals(38.9));
expect(result.waypoints.first.longitude, equals(-77.0));
expect(result.distanceMeters, equals(15000.0));
expect(result.durationSeconds, equals(1200.0));
});
test('HTTP error throws RoutingException with status code', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Bad Request',
400,
reasonPhrase: 'Bad Request',
));
expect(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.message,
'message',
contains('400'),
)),
);
});
test('network error is wrapped in RoutingException', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenThrow(http.ClientException('Connection refused'));
expect(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.message,
'message',
startsWith('Network error:'),
)),
);
});
test('API-level error surfaces alprwatch message', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
json.encode({
'ok': false,
'error': 'Invalid profile configuration',
}),
200,
));
expect(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.message,
'message',
contains('Invalid profile configuration'),
)),
);
});
});
}