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:
2026-08-15 22:20:39 -05:00
parent d501528e69
commit a3bcd014c0
10 changed files with 732 additions and 4 deletions

View 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');
});
}