fix(alerts): restore auth on startup, enforce token header, and prevent immutable list sort crashes
This commit is contained in:
parent
35f2b6982a
commit
5fcaafbe36
|
|
@ -67,15 +67,27 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
|
|||
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
||||
try {
|
||||
final headers = await _buildHeaders(apiKey: apiKey);
|
||||
if (getAuthToken != null && !headers.containsKey('Authorization')) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[Georgia511RoadAlertProvider] testConnection: Missing Authorization token (auth token returned null/empty and no API key provided)');
|
||||
}
|
||||
return RoadAlertProviderStatus.authError;
|
||||
}
|
||||
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health');
|
||||
final res = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 4));
|
||||
if (res.statusCode == 200) {
|
||||
return RoadAlertProviderStatus.connected;
|
||||
} else if (res.statusCode == 401 || res.statusCode == 403) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[Georgia511RoadAlertProvider] testConnection auth rejected: ${res.statusCode} ${res.body}');
|
||||
}
|
||||
return RoadAlertProviderStatus.authError;
|
||||
}
|
||||
return RoadAlertProviderStatus.networkError;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[Georgia511RoadAlertProvider] testConnection error: $e');
|
||||
}
|
||||
return RoadAlertProviderStatus.networkError;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class MultiSourceAlertDeduplicator {
|
|||
Set<String> dismissedIds = const {},
|
||||
double proximityMeters = defaultProximityMeters,
|
||||
}) {
|
||||
if (alerts.isEmpty) return const [];
|
||||
if (alerts.isEmpty) return <RoadAlert>[];
|
||||
|
||||
final activeAlerts = alerts.where((a) {
|
||||
if (dismissedIds.contains(a.id)) return false;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class RoadAlertCompliance {
|
|||
required double userLng,
|
||||
bool complianceEnabled = true,
|
||||
}) {
|
||||
if (!complianceEnabled) return alerts;
|
||||
if (!complianceEnabled) return List<RoadAlert>.from(alerts);
|
||||
|
||||
final jurisdiction = evaluateJurisdiction(userLat, userLng);
|
||||
switch (jurisdiction) {
|
||||
|
|
@ -78,7 +78,7 @@ class RoadAlertCompliance {
|
|||
|
||||
case AlertComplianceJurisdiction.standard:
|
||||
// United States standard: 100% full fidelity exact camera & mobile police alerts
|
||||
return alerts;
|
||||
return List<RoadAlert>.from(alerts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,11 +237,13 @@ class RoadAlertService {
|
|||
);
|
||||
|
||||
// Apply jurisdictional regulatory compliance (US standard, France danger zone, Germany/Switzerland prohibited)
|
||||
final compliantList = RoadAlertCompliance.applyCompliance(
|
||||
deduplicatedAlerts,
|
||||
userLat: position.latitude,
|
||||
userLng: position.longitude,
|
||||
complianceEnabled: complianceEnabled,
|
||||
final compliantList = List<RoadAlert>.from(
|
||||
RoadAlertCompliance.applyCompliance(
|
||||
deduplicatedAlerts,
|
||||
userLat: position.latitude,
|
||||
userLng: position.longitude,
|
||||
complianceEnabled: complianceEnabled,
|
||||
),
|
||||
);
|
||||
|
||||
final navSession = _ref.read(navigationSessionProvider);
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
|||
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
||||
try {
|
||||
final headers = await _buildHeaders(apiKey: apiKey);
|
||||
if (getAuthToken != null && !headers.containsKey('Authorization')) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[ZylaRoadAlertProvider] testConnection: Missing Authorization token (auth token returned null/empty and no API key provided)');
|
||||
}
|
||||
return RoadAlertProviderStatus.authError;
|
||||
}
|
||||
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health');
|
||||
final res = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 4));
|
||||
if (res.statusCode == 200) {
|
||||
|
|
@ -93,6 +99,9 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
|||
return RoadAlertProviderStatus.connected;
|
||||
}
|
||||
if (res.statusCode == 401 || res.statusCode == 403) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[ZylaRoadAlertProvider] testConnection auth rejected: ${res.statusCode} ${res.body}');
|
||||
}
|
||||
return RoadAlertProviderStatus.authError;
|
||||
}
|
||||
return RoadAlertProviderStatus.networkError;
|
||||
|
|
|
|||
|
|
@ -88,6 +88,14 @@ class FirebaseAuthService implements AuthService {
|
|||
return;
|
||||
}
|
||||
|
||||
final initialUser = auth.currentUser;
|
||||
if (initialUser != null) {
|
||||
final appUser = _mapFirebaseUser(initialUser);
|
||||
_currentState = initialUser.isAnonymous
|
||||
? AuthState.anonymous(appUser, isFirebaseConfigured: true)
|
||||
: AuthState.authenticated(appUser, isFirebaseConfigured: true);
|
||||
}
|
||||
|
||||
try {
|
||||
// Listen to native Firebase auth state stream
|
||||
_fbAuthSubscription = auth.authStateChanges().listen(
|
||||
|
|
@ -474,12 +482,61 @@ class FirebaseAuthService implements AuthService {
|
|||
@override
|
||||
Future<String?> getIdToken([bool forceRefresh = false]) async {
|
||||
final auth = _firebaseAuth;
|
||||
if (auth == null) return null;
|
||||
final user = auth.currentUser;
|
||||
if (user == null) return null;
|
||||
if (auth == null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[FirebaseAuthService] getIdToken: Firebase not configured');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var user = auth.currentUser;
|
||||
if (user == null && _googleSignIn != null) {
|
||||
try {
|
||||
final googleUser = await _googleSignIn.signInSilently();
|
||||
if (googleUser != null) {
|
||||
final googleAuth = await googleUser.authentication;
|
||||
final credential = fb.GoogleAuthProvider.credential(
|
||||
accessToken: googleAuth.accessToken,
|
||||
idToken: googleAuth.idToken,
|
||||
);
|
||||
final result = await auth.signInWithCredential(credential);
|
||||
user = result.user;
|
||||
if (user != null) {
|
||||
_updateAuthenticatedUser(user);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[FirebaseAuthService] silent Google restoration failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guest / unauthenticated state: do NOT create an anonymous account silently.
|
||||
if (user == null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[FirebaseAuthService] getIdToken: No user available');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await user.getIdToken(forceRefresh);
|
||||
} catch (_) {
|
||||
final token = await user.getIdToken(forceRefresh);
|
||||
if (token == null || token.isEmpty) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[FirebaseAuthService] getIdToken: token returned empty');
|
||||
}
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[FirebaseAuthService] getIdToken error (forceRefresh=$forceRefresh): $e');
|
||||
}
|
||||
if (forceRefresh) {
|
||||
try {
|
||||
return await user.getIdToken(false);
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -387,6 +387,25 @@ void main() {
|
|||
expect(status, equals(RoadAlertProviderStatus.authError));
|
||||
});
|
||||
|
||||
test('testConnection returns authError when getAuthToken returns null and no apiKey provided', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response(
|
||||
json.encode({'providers': []}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
final provider = ZylaRoadAlertProvider(
|
||||
client: mockClient,
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
getAuthToken: () async => null,
|
||||
);
|
||||
|
||||
final status = await provider.testConnection('');
|
||||
expect(status, equals(RoadAlertProviderStatus.authError));
|
||||
});
|
||||
|
||||
test('getAlerts handles 401 Unauthorized gracefully by returning empty list', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,331 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:motorcycle_dash/services/alerts/ga511_road_alert_provider.dart';
|
||||
import 'package:motorcycle_dash/services/alerts/multi_source_alert_deduplicator.dart';
|
||||
import 'package:motorcycle_dash/services/alerts/road_alert_compliance.dart';
|
||||
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
|
||||
import 'package:motorcycle_dash/services/alerts/zyla_road_alert_provider.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final now = DateTime.utc(2026, 10, 13, 12, 0, 0);
|
||||
|
||||
final sampleAlert1 = RoadAlert(
|
||||
id: 'alert_1',
|
||||
providerId: 'zyla_waze_4814',
|
||||
category: RoadAlertCategory.police,
|
||||
severity: RoadAlertSeverity.high,
|
||||
latitude: 34.2560,
|
||||
longitude: -85.1650,
|
||||
reportedAt: now,
|
||||
lastUpdated: now,
|
||||
source: 'Zyla Waze',
|
||||
description: 'Police radar stationary',
|
||||
);
|
||||
|
||||
final sampleAlert2 = RoadAlert(
|
||||
id: 'alert_2',
|
||||
providerId: 'ga511_georgia_dot',
|
||||
category: RoadAlertCategory.speedCamera,
|
||||
severity: RoadAlertSeverity.medium,
|
||||
latitude: 34.2580,
|
||||
longitude: -85.1670,
|
||||
reportedAt: now.add(const Duration(minutes: 1)),
|
||||
lastUpdated: now.add(const Duration(minutes: 1)),
|
||||
source: 'Georgia 511',
|
||||
description: 'Speed camera northbound',
|
||||
);
|
||||
|
||||
final sampleAlert3 = RoadAlert(
|
||||
id: 'alert_3',
|
||||
providerId: 'ga511_georgia_dot',
|
||||
category: RoadAlertCategory.hazard,
|
||||
severity: RoadAlertSeverity.low,
|
||||
latitude: 34.2590,
|
||||
longitude: -85.1680,
|
||||
reportedAt: now.add(const Duration(minutes: 2)),
|
||||
lastUpdated: now.add(const Duration(minutes: 2)),
|
||||
source: 'Georgia 511',
|
||||
description: 'Debris on road',
|
||||
);
|
||||
|
||||
group('Zyla & GA511 Road Alert Provider Authentication Scenarios', () {
|
||||
test('1. Authenticated scenario: passes Bearer token to backend and returns connected on 200', () async {
|
||||
String? capturedAuthHeader;
|
||||
final mockClient = MockClient((request) async {
|
||||
capturedAuthHeader = request.headers['Authorization'];
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'providers': [
|
||||
{'provider_id': 'zyla_waze_4814', 'status': 'operational'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
final provider = ZylaRoadAlertProvider(
|
||||
client: mockClient,
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
getAuthToken: () async => 'valid_firebase_id_token_xyz',
|
||||
);
|
||||
|
||||
final status = await provider.testConnection('');
|
||||
expect(status, equals(RoadAlertProviderStatus.connected));
|
||||
expect(capturedAuthHeader, equals('Bearer valid_firebase_id_token_xyz'));
|
||||
});
|
||||
|
||||
test('2. Missing-token scenario: getAuthToken returns null fails closed as authError', () async {
|
||||
bool networkCallMade = false;
|
||||
final mockClient = MockClient((request) async {
|
||||
networkCallMade = true;
|
||||
return http.Response(json.encode({'providers': []}), 200);
|
||||
});
|
||||
|
||||
final provider = ZylaRoadAlertProvider(
|
||||
client: mockClient,
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
getAuthToken: () async => null,
|
||||
);
|
||||
|
||||
final status = await provider.testConnection('');
|
||||
expect(status, equals(RoadAlertProviderStatus.authError));
|
||||
expect(networkCallMade, isFalse);
|
||||
});
|
||||
|
||||
test('3. Missing-token scenario (empty string): fails closed as authError', () async {
|
||||
bool networkCallMade = false;
|
||||
final mockClient = MockClient((request) async {
|
||||
networkCallMade = true;
|
||||
return http.Response(json.encode({'providers': []}), 200);
|
||||
});
|
||||
|
||||
final provider = Georgia511RoadAlertProvider(
|
||||
client: mockClient,
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
getAuthToken: () async => '',
|
||||
);
|
||||
|
||||
final status = await provider.testConnection('');
|
||||
expect(status, equals(RoadAlertProviderStatus.authError));
|
||||
expect(networkCallMade, isFalse);
|
||||
});
|
||||
|
||||
test('4. Expired-token / 401 Unauthorized scenario: backend rejects token as authError', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response(
|
||||
json.encode({'detail': 'Token expired or invalid'}),
|
||||
401,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
final provider = ZylaRoadAlertProvider(
|
||||
client: mockClient,
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
getAuthToken: () async => 'expired_firebase_token',
|
||||
);
|
||||
|
||||
final status = await provider.testConnection('');
|
||||
expect(status, equals(RoadAlertProviderStatus.authError));
|
||||
});
|
||||
|
||||
test('5. Expired-token / 403 Forbidden scenario: backend rejects as authError', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response(
|
||||
json.encode({'detail': 'Forbidden'}),
|
||||
403,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
final provider = Georgia511RoadAlertProvider(
|
||||
client: mockClient,
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
getAuthToken: () async => 'unauthorized_token',
|
||||
);
|
||||
|
||||
final status = await provider.testConnection('');
|
||||
expect(status, equals(RoadAlertProviderStatus.authError));
|
||||
});
|
||||
|
||||
test('6. Guest scenario: guest caller with null token returns authError and does not call backend', () async {
|
||||
int anonymousBootstrapAttempts = 0;
|
||||
Future<String?> guestTokenGetter() async {
|
||||
// Guest mode: no signed-in user, does not create an anonymous user
|
||||
return null;
|
||||
}
|
||||
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response(json.encode({'status': 'ok'}), 200);
|
||||
});
|
||||
|
||||
final provider = ZylaRoadAlertProvider(
|
||||
client: mockClient,
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
getAuthToken: guestTokenGetter,
|
||||
);
|
||||
|
||||
final status = await provider.testConnection('');
|
||||
expect(status, equals(RoadAlertProviderStatus.authError));
|
||||
expect(anonymousBootstrapAttempts, equals(0));
|
||||
});
|
||||
|
||||
test('7. API key override fallback: when apiKey is provided, it is attached as Bearer header', () async {
|
||||
String? capturedAuthHeader;
|
||||
final mockClient = MockClient((request) async {
|
||||
capturedAuthHeader = request.headers['Authorization'];
|
||||
return http.Response(
|
||||
json.encode({'providers': [{'provider_id': 'zyla_waze_4814', 'status': 'operational'}]}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final provider = ZylaRoadAlertProvider(
|
||||
client: mockClient,
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
getAuthToken: null,
|
||||
);
|
||||
|
||||
final status = await provider.testConnection('custom_secret_api_key');
|
||||
expect(status, equals(RoadAlertProviderStatus.connected));
|
||||
expect(capturedAuthHeader, equals('Bearer custom_secret_api_key'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Empty and Immutable Alert Lists Regression Tests', () {
|
||||
test('1. MultiSourceAlertDeduplicator.deduplicate handles const <RoadAlert>[] without throwing and returns mutable list', () {
|
||||
const constEmptyList = <RoadAlert>[];
|
||||
final result = MultiSourceAlertDeduplicator.deduplicate(constEmptyList);
|
||||
|
||||
expect(result, isEmpty);
|
||||
// Prove returned list is mutable and can be sorted
|
||||
expect(() => result.sort((a, b) => a.id.compareTo(b.id)), returnsNormally);
|
||||
expect(() => result.add(sampleAlert1), returnsNormally);
|
||||
expect(result.length, equals(1));
|
||||
});
|
||||
|
||||
test('2. MultiSourceAlertDeduplicator.deduplicate handles List.unmodifiable without crashing on sorting', () {
|
||||
final unmodifiableList = List<RoadAlert>.unmodifiable([sampleAlert2, sampleAlert1]);
|
||||
|
||||
expect(() => unmodifiableList.sort(), throwsUnsupportedError);
|
||||
|
||||
final result = MultiSourceAlertDeduplicator.deduplicate(unmodifiableList);
|
||||
expect(result.length, equals(2));
|
||||
// Prove result is a new mutable list that can be sorted
|
||||
expect(() => result.sort((a, b) => a.reportedAt.compareTo(b.reportedAt)), returnsNormally);
|
||||
});
|
||||
|
||||
test('3. RoadAlertCompliance.applyCompliance handles const [] across all jurisdictions without returning unmodifiable list', () {
|
||||
const constEmptyList = <RoadAlert>[];
|
||||
|
||||
// Standard US
|
||||
final usResult = RoadAlertCompliance.applyCompliance(
|
||||
constEmptyList,
|
||||
userLat: 34.2560,
|
||||
userLng: -85.1650,
|
||||
);
|
||||
expect(usResult, isEmpty);
|
||||
expect(() => usResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally);
|
||||
|
||||
// Compliance disabled
|
||||
final disabledResult = RoadAlertCompliance.applyCompliance(
|
||||
constEmptyList,
|
||||
userLat: 34.2560,
|
||||
userLng: -85.1650,
|
||||
complianceEnabled: false,
|
||||
);
|
||||
expect(disabledResult, isEmpty);
|
||||
expect(() => disabledResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally);
|
||||
|
||||
// French danger zone
|
||||
final frResult = RoadAlertCompliance.applyCompliance(
|
||||
constEmptyList,
|
||||
userLat: 48.8566,
|
||||
userLng: 2.3522,
|
||||
);
|
||||
expect(frResult, isEmpty);
|
||||
expect(() => frResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally);
|
||||
|
||||
// German prohibited zone
|
||||
final deResult = RoadAlertCompliance.applyCompliance(
|
||||
constEmptyList,
|
||||
userLat: 52.5200,
|
||||
userLng: 13.4050,
|
||||
);
|
||||
expect(deResult, isEmpty);
|
||||
expect(() => deResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally);
|
||||
});
|
||||
|
||||
test('4. RoadAlertCompliance.applyCompliance handles List.unmodifiable and returns mutable list', () {
|
||||
final unmodifiableList = List<RoadAlert>.unmodifiable([sampleAlert1, sampleAlert2, sampleAlert3]);
|
||||
|
||||
// Standard US
|
||||
final usResult = RoadAlertCompliance.applyCompliance(
|
||||
unmodifiableList,
|
||||
userLat: 34.2560,
|
||||
userLng: -85.1650,
|
||||
);
|
||||
expect(usResult.length, equals(3));
|
||||
expect(() => usResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally);
|
||||
|
||||
// Compliance disabled
|
||||
final disabledResult = RoadAlertCompliance.applyCompliance(
|
||||
unmodifiableList,
|
||||
userLat: 34.2560,
|
||||
userLng: -85.1650,
|
||||
complianceEnabled: false,
|
||||
);
|
||||
expect(disabledResult.length, equals(3));
|
||||
expect(() => disabledResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally);
|
||||
|
||||
// French danger zone transforms speedCamera to hazard
|
||||
final frResult = RoadAlertCompliance.applyCompliance(
|
||||
unmodifiableList,
|
||||
userLat: 48.8566,
|
||||
userLng: 2.3522,
|
||||
);
|
||||
expect(frResult.length, equals(3));
|
||||
expect(frResult.where((a) => a.category == RoadAlertCategory.hazard).length, equals(2));
|
||||
expect(() => frResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally);
|
||||
|
||||
// German prohibited suppresses speed camera and police radar trap, retains general hazard
|
||||
final deResult = RoadAlertCompliance.applyCompliance(
|
||||
unmodifiableList,
|
||||
userLat: 52.5200,
|
||||
userLng: 13.4050,
|
||||
);
|
||||
expect(deResult.length, equals(1));
|
||||
expect(deResult.first.category, equals(RoadAlertCategory.hazard));
|
||||
expect(() => deResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally);
|
||||
});
|
||||
|
||||
test('5. End-to-end pipeline: immutable list -> deduplicate -> compliance -> in-place sorting succeeds', () {
|
||||
final immutableAlerts = List<RoadAlert>.unmodifiable([sampleAlert2, sampleAlert1]);
|
||||
|
||||
final deduplicated = MultiSourceAlertDeduplicator.deduplicate(immutableAlerts);
|
||||
final compliant = List<RoadAlert>.from(
|
||||
RoadAlertCompliance.applyCompliance(
|
||||
deduplicated,
|
||||
userLat: 34.2560,
|
||||
userLng: -85.1650,
|
||||
),
|
||||
);
|
||||
|
||||
// Simulates RoadAlertService line 252 sort
|
||||
expect(() {
|
||||
compliant.sort((a, b) {
|
||||
final comp = a.severity.index.compareTo(b.severity.index);
|
||||
if (comp != 0) return comp;
|
||||
return a.reportedAt.compareTo(b.reportedAt);
|
||||
});
|
||||
}, returnsNormally);
|
||||
|
||||
expect(compliant.length, equals(2));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue