T22/T24/T26: uploader, integration tests, and release readiness
T22 -- TelemetryUploader on package:http with MockClient standing in for MockWebServer, tested against a real in-memory Drift database rather than a fake DAO. Upload runs on its own timer, injected as a callback so the engine has no opinion about HTTP and tests need no network. Config on shared_preferences with a hand-rolled UUID v4. Still no UI for the endpoint, exactly as in the native app. 7 tests. T24 -- integration_test/app_test.dart, 4 tests passing on the iOS simulator. These cover what widget tests cannot: Drift opening against real platform storage, plugin registration, go_router driving a real Navigator, cold start. T26 -- RELEASE-IOS.md. Usage strings are specific rather than generic, which is the leading Guideline 5.1.1 rejection cause; privacy-label answers decided; a pre-submission list covering the bundle-id switch back to com.rippr, the still default app icon, a release build, and a demo video for review notes. T25 written up as REAL-RIDE-CHECKLIST.md but outstanding by nature. Two items decide real things: force-stopping mid-ride on Android confirms the 111 km crash-gap bug is fixed, and parking 15 minutes mid-recording on iOS decides whether geolocator is sufficient or the paid engine is needed. Two prefer_initializing_formals lints suppressed with a reason: Dart forbids a named parameter beginning with an underscore, so the suggested fix will not compile. 178 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
152
test/telemetry_uploader_test.dart
Normal file
152
test/telemetry_uploader_test.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/telemetry/telemetry_uploader.dart';
|
||||
|
||||
/// Ported from `com.rippr.TelemetryUploaderTest`.
|
||||
///
|
||||
/// The Kotlin suite used MockWebServer plus an in-memory fake DAO. Here a `MockClient`
|
||||
/// stands in for the network and a real in-memory Drift database stands in for the DAO —
|
||||
/// closer to production, and still no device required.
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
});
|
||||
|
||||
tearDown(() async => db.close());
|
||||
|
||||
Future<void> seed(int count) async {
|
||||
final tripId =
|
||||
await db.insertTrip(const Trip(startedAt: 1000, state: TripState.recording));
|
||||
final segmentId = await db.insertSegment(tripId, 1000);
|
||||
await db.insertPoints([
|
||||
for (var i = 0; i < count; i++)
|
||||
TrackPoint(
|
||||
tripId: tripId,
|
||||
segmentId: segmentId,
|
||||
timestamp: 1000 + i,
|
||||
latitude: 51.0,
|
||||
longitude: -114.0,
|
||||
speedKmh: 40,
|
||||
altitudeM: 1000,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
TelemetryUploader uploader({
|
||||
required http.Client client,
|
||||
String endpoint = 'https://example.test/ingest',
|
||||
}) =>
|
||||
TelemetryUploader(
|
||||
db: db,
|
||||
endpoint: endpoint,
|
||||
deviceId: 'device-abc',
|
||||
client: client,
|
||||
);
|
||||
|
||||
test('an empty endpoint disables upload entirely', () async {
|
||||
var called = false;
|
||||
final client = MockClient((_) async {
|
||||
called = true;
|
||||
return http.Response('', 200);
|
||||
});
|
||||
|
||||
final result = await uploader(client: client, endpoint: '').uploadPending();
|
||||
|
||||
expect(result, isA<UploadDisabled>());
|
||||
expect(called, isFalse, reason: 'recording must work with no server at all');
|
||||
});
|
||||
|
||||
test('a successful run marks points synced', () async {
|
||||
await seed(5);
|
||||
final client = MockClient((_) async => http.Response('{}', 200));
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadSuccess>());
|
||||
expect((result as UploadSuccess).uploaded, 5);
|
||||
expect(await db.countUnsynced(), 0);
|
||||
});
|
||||
|
||||
test('a rejected batch leaves the backlog intact for retry', () async {
|
||||
await seed(5);
|
||||
final client = MockClient((_) async => http.Response('nope', 500));
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadFailed>());
|
||||
expect(await db.countUnsynced(), 5,
|
||||
reason: 'nothing may be marked synced when the server refused it');
|
||||
});
|
||||
|
||||
test('a network error is swallowed and retried later', () async {
|
||||
await seed(3);
|
||||
final client = MockClient((_) async => throw http.ClientException('offline'));
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadFailed>());
|
||||
expect(await db.countUnsynced(), 3);
|
||||
});
|
||||
|
||||
test('the payload carries trip and segment identity per point', () async {
|
||||
await seed(2);
|
||||
String? captured;
|
||||
final client = MockClient((req) async {
|
||||
captured = req.body;
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
await uploader(client: client).uploadPending();
|
||||
|
||||
final json = jsonDecode(captured!) as Map<String, dynamic>;
|
||||
expect(json['device_id'], 'device-abc');
|
||||
final first = (json['points'] as List).first as Map<String, dynamic>;
|
||||
// Batches are drawn by id and can straddle a boundary, so identity travels with the
|
||||
// point rather than the batch.
|
||||
expect(first.containsKey('trip_id'), isTrue);
|
||||
expect(first.containsKey('segment_id'), isTrue);
|
||||
});
|
||||
|
||||
test('a backlog larger than one batch is sent across several requests',
|
||||
() async {
|
||||
// Two full batches plus a remainder.
|
||||
await seed(uploadBatchSize * 2 + 7);
|
||||
var requests = 0;
|
||||
final client = MockClient((_) async {
|
||||
requests++;
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(requests, 3);
|
||||
expect((result as UploadSuccess).uploaded, uploadBatchSize * 2 + 7);
|
||||
expect(await db.countUnsynced(), 0);
|
||||
});
|
||||
|
||||
test('a failure partway through reports what did land', () async {
|
||||
await seed(uploadBatchSize + 10);
|
||||
var requests = 0;
|
||||
final client = MockClient((_) async {
|
||||
requests++;
|
||||
return http.Response('{}', requests == 1 ? 200 : 503);
|
||||
});
|
||||
|
||||
final result = await uploader(client: client).uploadPending();
|
||||
|
||||
expect(result, isA<UploadPartial>());
|
||||
expect((result as UploadPartial).uploaded, uploadBatchSize);
|
||||
expect(await db.countUnsynced(), 10,
|
||||
reason: 'the first batch landed; the rest must survive for retry');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user