MotorSignal-App/test/sync_architecture_hardening...

111 lines
4.2 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/services/sync/r2_upload_service.dart';
import 'package:motorcycle_dash/services/sync/sync_models.dart';
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Sync outbox hardening', () {
test('interrupted uploading item is requeued after restart', () async {
SharedPreferences.setMockInitialValues({
'motorsignal_sync_outbox_manifest_v2_local': jsonEncode([
SyncItem(
localRideId: 42,
providerId: 'firebase',
status: SyncStatus.uploading,
createdAt: DateTime(2026, 9, 1),
).toJson(),
]),
});
final prefs = await SharedPreferences.getInstance();
final outbox = SyncOutbox(prefs);
expect(outbox.getItem(42, 'firebase')?.status, SyncStatus.queued);
});
test('duplicate enqueue remains one logical provider/ride item', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final outbox = SyncOutbox(prefs);
await Future.wait([
outbox.enqueue(7, 'firebase'),
outbox.enqueue(7, 'firebase'),
]);
expect(outbox.count, 1);
expect(outbox.getItem(7, 'firebase')?.idempotencyKey, 'firebase:7');
});
test('malformed persisted identity is ignored without killing the queue', () async {
SharedPreferences.setMockInitialValues({
'motorsignal_sync_outbox_manifest_v2_local': jsonEncode([
{'localRideId': -1, 'providerId': '', 'status': 'queued', 'createdAt': 'bad'},
SyncItem(localRideId: 8, providerId: 'firebase', createdAt: DateTime(2026, 9, 1)).toJson(),
]),
});
final prefs = await SharedPreferences.getInstance();
final outbox = SyncOutbox(prefs);
expect(outbox.count, 1);
expect(outbox.getItem(8, 'firebase'), isNotNull);
});
});
group('R2 gateway hardening', () {
const validHash = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
test('valid response requires a user-scoped object key', () async {
final client = MockClient((request) async => http.Response(
jsonEncode({'objectKey': 'users/u1/rides/ride_1_20260901_120000/telemetry.zip', 'size': 4}),
200,
));
final service = R2UploadService(client: client, workerBaseUrl: 'https://worker.test');
final result = await service.uploadTelemetryBundle(
idToken: 'token',
remoteRideId: 'ride_1_20260901_120000',
zipBytes: Uint8List.fromList([1, 2, 3, 4]),
sha256Hash: validHash,
appVersion: '1.0.2',
);
expect(result.success, isTrue);
});
test('invalid identifiers are rejected before network I/O', () async {
var requests = 0;
final client = MockClient((request) async {
requests++;
return http.Response('{}', 200);
});
final service = R2UploadService(client: client, workerBaseUrl: 'https://worker.test');
final result = await service.uploadTelemetryBundle(
idToken: 'token',
remoteRideId: '../escape',
zipBytes: Uint8List.fromList([1]),
sha256Hash: validHash,
appVersion: '1.0.2',
);
expect(result.success, isFalse);
expect(result.isRetryable, isFalse);
expect(requests, 0);
});
test('rate limiting is retryable and delegated to SyncManager backoff', () async {
final client = MockClient((request) async => http.Response('rate limited', 429));
final service = R2UploadService(client: client, workerBaseUrl: 'https://worker.test');
final result = await service.uploadTelemetryBundle(
idToken: 'token',
remoteRideId: 'ride_1_20260901_120000',
zipBytes: Uint8List.fromList([1]),
sha256Hash: validHash,
appVersion: '1.0.2',
);
expect(result.success, isFalse);
expect(result.isRetryable, isTrue);
});
});
}