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>
153 lines
4.8 KiB
Dart
153 lines
4.8 KiB
Dart
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');
|
|
});
|
|
}
|