MotorSignal-App/test/cloud_restore_manual_test.dart

687 lines
21 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:drift/drift.dart' as drift;
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:motorcycle_dash/database/database.dart';
import 'package:motorcycle_dash/services/auth/auth_service.dart';
import 'package:motorcycle_dash/services/auth/auth_state.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/sync/cloud_reconciliation_service.dart';
import 'package:motorcycle_dash/services/sync/cloud_restore_controller.dart';
import 'package:motorcycle_dash/services/sync/cloud_restore_service.dart';
import 'package:motorcycle_dash/services/sync/r2_download_service.dart';
class _TestAuthService implements AuthService {
@override bool get isEmailVerified => false;
@override Future<void> sendEmailVerification() async {}
@override Future<void> reloadCurrentUser() async {}
@override Future<void> reauthenticate({String? password}) async {}
@override Future<void> deleteCurrentUser() async {}
AuthUser? _user;
String? token = 'token';
bool configured = true;
_TestAuthService([this._user]);
void setUser(AuthUser? user) => _user = user;
@override
Stream<AuthState> get authStateChanges => Stream.value(currentState);
@override
AuthState get currentState {
final user = _user;
if (user == null) {
return AuthState.unauthenticated(isFirebaseConfigured: configured);
}
return user.isAnonymous
? AuthState.anonymous(user, isFirebaseConfigured: configured)
: AuthState.authenticated(user, isFirebaseConfigured: configured);
}
@override
AuthUser? get currentUser => _user;
@override
bool get isConfigured => configured;
@override
Future<String?> getIdToken([bool forceRefresh = false]) async => token;
@override
Future<void> createUserWithEmailAndPassword(
String email,
String password, {
String? displayName,
}) async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> signInAnonymously() async {
_user = const AuthUser(uid: 'anon_1', isAnonymous: true);
}
@override
Future<void> signInWithEmailAndPassword(String email, String password) async {
_user = AuthUser(uid: 'user_a', email: email);
}
@override
Future<void> signInWithGoogle() async {
_user = const AuthUser(uid: 'user_google', email: 'rider@test.com');
}
@override
Future<void> signOut() async {
_user = null;
}
@override
void dispose() {}
}
class _MemoryRideMetadataStore implements CloudRideMetadataStore {
final Map<String, List<CloudRideMetadataDocument>> byUid;
final List<CloudRideMetadataDocument>? documentsForEveryUser;
_MemoryRideMetadataStore({
Map<String, List<CloudRideMetadataDocument>>? byUid,
this.documentsForEveryUser,
}) : byUid = byUid ?? {};
@override
Future<List<CloudRideMetadataDocument>> listRideDocuments(String uid) async {
return documentsForEveryUser ?? byUid[uid] ?? const [];
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Manual MotorCloud restore', () {
late AppDatabase db;
late _TestAuthService auth;
late int r2Requests;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
auth = _TestAuthService(
const AuthUser(uid: 'user_a', email: 'a@test.com'),
);
r2Requests = 0;
});
tearDown(() async {
await db.close();
});
CloudRestoreService service({
required CloudRideMetadataStore store,
required http.Client client,
}) {
return CloudRestoreService(
db: db,
authService: auth,
metadataStore: store,
r2DownloadService: R2DownloadService(
client: client,
workerBaseUrl: 'https://worker.test',
),
);
}
test(
'clean install restores metadata, duration, telemetry, and is idempotent',
() async {
final store = _MemoryRideMetadataStore(
byUid: {
'user_a': [
_cloudRide(
id: 'ride_clean_install',
ownerId: 'user_a',
movingTime: 600,
stoppedTime: 45,
performanceRuns: [
_performanceRun(
type: '0-60',
timeMs: 3210,
topSpeedReached: 62,
),
],
),
],
},
);
final zipBytes = _telemetryZip(samples: 2);
final client = _downloadClient(zipBytes);
final restore = service(store: store, client: client);
final first = await restore.restore();
final second = await restore.restore();
final rides = await db.select(db.rides).get();
final samples = await db.select(db.telemetrySamples).get();
expect(first.cloudRidesDiscovered, 1);
expect(first.metadataRowsAdded, 1);
expect(first.telemetryRidesRestored, 1);
expect(first.telemetrySamplesRestored, 2);
expect(first.performanceRunsRestored, 1);
expect(rides, hasLength(1));
expect(rides.single.cloudRideId, 'ride_clean_install');
expect(rides.single.movingTime, 600);
expect(rides.single.stoppedTime, 45);
expect(samples, hasLength(2));
final runs = await db.select(db.performanceRuns).get();
expect(runs, hasLength(1));
expect(runs.single.rideId, rides.single.id);
expect(runs.single.type, '0-60');
expect(runs.single.timeMs, 3210);
expect(second.metadataRowsAdded, 0);
expect(second.telemetrySamplesRestored, 0);
expect(second.telemetryAlreadyPresent, 1);
expect(second.performanceRunsRestored, 0);
expect(second.performanceRunsAlreadyPresent, 1);
expect(await db.select(db.rides).get(), hasLength(1));
expect(await db.select(db.telemetrySamples).get(), hasLength(2));
expect(await db.select(db.performanceRuns).get(), hasLength(1));
},
);
test(
'signed out and anonymous users cannot mutate local restore data',
() async {
final store = _MemoryRideMetadataStore(
documentsForEveryUser: [
_cloudRide(id: 'ride_auth_guard', ownerId: 'user_a'),
],
);
final restore = service(
store: store,
client: _downloadClient(_telemetryZip()),
);
auth.setUser(null);
await expectLater(restore.restore(), throwsA(isA<StateError>()));
expect(await db.select(db.rides).get(), isEmpty);
auth.setUser(const AuthUser(uid: 'anon_1', isAnonymous: true));
await expectLater(restore.restore(), throwsA(isA<StateError>()));
expect(await db.select(db.rides).get(), isEmpty);
},
);
test(
'missing R2 object keeps additive metadata but restores no telemetry',
() async {
final store = _MemoryRideMetadataStore(
byUid: {
'user_a': [_cloudRide(id: 'ride_missing_r2', ownerId: 'user_a')],
},
);
final restore = service(
store: store,
client: MockClient((request) async {
r2Requests++;
return http.Response('missing', 404);
}),
);
final result = await restore.restore();
expect(result.metadataRowsAdded, 1);
expect(result.telemetryFailed, 1);
expect(r2Requests, 1);
expect(await db.select(db.rides).get(), hasLength(1));
expect(await db.select(db.telemetrySamples).get(), isEmpty);
},
);
test('SHA mismatch does not insert telemetry', () async {
final store = _MemoryRideMetadataStore(
byUid: {
'user_a': [_cloudRide(id: 'ride_bad_sha', ownerId: 'user_a')],
},
);
final body = _telemetryZip(samples: 1);
final restore = service(
store: store,
client: MockClient(
(request) async => http.Response.bytes(
body,
200,
headers: {
'x-motorsignal-ride-id': request.url.queryParameters['rideId']!,
'x-motorsignal-sha256': '0' * 64,
},
),
),
);
final result = await restore.restore();
expect(result.metadataRowsAdded, 1);
expect(result.telemetryFailed, 1);
expect(await db.select(db.telemetrySamples).get(), isEmpty);
});
test('R2 authentication failures are reported separately', () async {
final store = _MemoryRideMetadataStore(
byUid: {
'user_a': [_cloudRide(id: 'ride_auth_r2', ownerId: 'user_a')],
},
);
final restore = service(
store: store,
client: MockClient(
(request) async => http.Response('auth required', 401),
),
);
final result = await restore.restore();
expect(result.metadataRowsAdded, 1);
expect(result.telemetryAuthFailures, 1);
expect(result.requiresAuthentication, isTrue);
expect(await db.select(db.telemetrySamples).get(), isEmpty);
});
test(
'existing local telemetry is never overwritten or downloaded again',
() async {
final rideId = await db
.into(db.rides)
.insert(
RidesCompanion.insert(
cloudRideId: const drift.Value('ride_existing_local'),
startTime: DateTime.utc(2026, 9, 3, 10),
),
);
await db
.into(db.telemetrySamples)
.insert(
TelemetrySamplesCompanion.insert(
rideId: rideId,
timestamp: DateTime.utc(2026, 9, 3, 10),
speed: const drift.Value(31),
),
);
final store = _MemoryRideMetadataStore(
byUid: {
'user_a': [
_cloudRide(id: 'ride_existing_local', ownerId: 'user_a'),
],
},
);
final restore = service(
store: store,
client: MockClient((request) async {
r2Requests++;
return http.Response.bytes(_telemetryZip(), 200);
}),
);
final result = await restore.restore();
final samples = await db.select(db.telemetrySamples).get();
expect(result.telemetryAlreadyPresent, 1);
expect(r2Requests, 0);
expect(samples, hasLength(1));
expect(samples.single.speed, 31);
},
);
test(
'two cloud rides restore performance runs without cross-association',
() async {
final store = _MemoryRideMetadataStore(
byUid: {
'user_a': [
_cloudRide(
id: 'ride_child_a',
ownerId: 'user_a',
performanceRuns: [
_performanceRun(
type: '0-60',
timeMs: 3000,
topSpeedReached: 61,
timestamp: DateTime.utc(2026, 9, 3, 10, 1),
),
],
),
_cloudRide(
id: 'ride_child_b',
ownerId: 'user_a',
performanceRuns: [
_performanceRun(
type: '0-100',
timeMs: 6400,
topSpeedReached: 102,
timestamp: DateTime.utc(2026, 9, 3, 11, 1),
),
],
),
],
},
);
final result = await service(
store: store,
client: _downloadClient(_telemetryZip()),
).restore();
final rides = await db.select(db.rides).get();
final rideA = rides.singleWhere(
(ride) => ride.cloudRideId == 'ride_child_a',
);
final rideB = rides.singleWhere(
(ride) => ride.cloudRideId == 'ride_child_b',
);
final rideARuns = await (db.select(
db.performanceRuns,
)..where((run) => run.rideId.equals(rideA.id))).get();
final rideBRuns = await (db.select(
db.performanceRuns,
)..where((run) => run.rideId.equals(rideB.id))).get();
expect(result.performanceRunsRestored, 2);
expect(rideARuns.single.type, '0-60');
expect(rideBRuns.single.type, '0-100');
expect(rideARuns.single.rideId, isNot(rideBRuns.single.rideId));
},
);
test(
'existing local performance and ride events are not overwritten or duplicated',
() async {
final rideId = await db
.into(db.rides)
.insert(
RidesCompanion.insert(
cloudRideId: const drift.Value('ride_existing_children'),
startTime: DateTime.utc(2026, 9, 3, 10),
),
);
await db
.into(db.performanceRuns)
.insert(
PerformanceRunsCompanion.insert(
rideId: drift.Value(rideId),
type: '0-60',
timeMs: 3500,
topSpeedReached: 63,
timestamp: DateTime.utc(2026, 9, 3, 10, 1),
),
);
await db
.into(db.rideEvents)
.insert(
RideEventsCompanion.insert(
rideId: rideId,
timestamp: DateTime.utc(2026, 9, 3, 10, 2),
eventType: 'Hard Braking',
value: -0.9,
),
);
final store = _MemoryRideMetadataStore(
byUid: {
'user_a': [
_cloudRide(
id: 'ride_existing_children',
ownerId: 'user_a',
totalEventsCount: 1,
performanceRuns: [
_performanceRun(
type: '0-60',
timeMs: 3500,
topSpeedReached: 63,
timestamp: DateTime.utc(2026, 9, 3, 10, 1),
),
_performanceRun(
type: '0-100',
timeMs: 7000,
topSpeedReached: 101,
timestamp: DateTime.utc(2026, 9, 3, 10, 3),
),
],
),
],
},
);
final result = await service(
store: store,
client: _downloadClient(_telemetryZip()),
).restore();
final runs = await db.select(db.performanceRuns).get();
final events = await db.select(db.rideEvents).get();
expect(result.performanceRunsRestored, 1);
expect(result.performanceRunsAlreadyPresent, 1);
expect(result.rideEventsAlreadyPresent, 1);
expect(runs, hasLength(2));
expect(events, hasLength(1));
expect(events.single.eventType, 'Hard Braking');
},
);
test(
'malformed performance data does not destroy restored telemetry',
() async {
final store = _MemoryRideMetadataStore(
byUid: {
'user_a': [
_cloudRide(
id: 'ride_bad_performance',
ownerId: 'user_a',
performanceRuns: [
{'type': '0-60', 'timeMs': 'bad'},
],
),
],
},
);
final result = await service(
store: store,
client: _downloadClient(_telemetryZip(samples: 2)),
).restore();
expect(result.telemetrySamplesRestored, 2);
expect(result.performanceRunsSkipped, 1);
expect(await db.select(db.telemetrySamples).get(), hasLength(2));
expect(await db.select(db.performanceRuns).get(), isEmpty);
},
);
test(
'canonical ride events are not fabricated from aggregate cloud counts',
() async {
final store = _MemoryRideMetadataStore(
byUid: {
'user_a': [
_cloudRide(
id: 'ride_event_gap',
ownerId: 'user_a',
totalEventsCount: 3,
),
],
},
);
final result = await service(
store: store,
client: _downloadClient(_telemetryZip(samples: 1)),
).restore();
expect(result.telemetrySamplesRestored, 1);
expect(result.rideEventsUnavailable, 3);
expect(await db.select(db.rideEvents).get(), isEmpty);
},
);
test(
'legacy cloud bundle without child data still restores ride and telemetry',
() async {
final store = _MemoryRideMetadataStore(
byUid: {
'user_a': [_cloudRide(id: 'ride_legacy_bundle', ownerId: 'user_a')],
},
);
final result = await service(
store: store,
client: _downloadClient(_telemetryZip(samples: 1)),
).restore();
expect(result.metadataRowsAdded, 1);
expect(result.telemetrySamplesRestored, 1);
expect(result.performanceRunsRestored, 0);
expect(result.rideEventsRestored, 0);
expect(await db.select(db.rides).get(), hasLength(1));
expect(await db.select(db.telemetrySamples).get(), hasLength(1));
},
);
test(
'wrong-owner cloud metadata is skipped for the current account',
() async {
final store = _MemoryRideMetadataStore(
documentsForEveryUser: [
_cloudRide(id: 'ride_user_a_only', ownerId: 'user_a'),
],
);
auth.setUser(const AuthUser(uid: 'user_b', email: 'b@test.com'));
final result = await service(
store: store,
client: _downloadClient(_telemetryZip()),
).restore();
expect(result.cloudRidesDiscovered, 1);
expect(result.metadata.skipped, 1);
expect(await db.select(db.rides).get(), isEmpty);
expect(await db.select(db.telemetrySamples).get(), isEmpty);
},
);
test(
'controller reports authentication required without invoking restore',
() async {
auth.setUser(null);
final controller = CloudRestoreController(
authService: auth,
restoreService: service(
store: _MemoryRideMetadataStore(
documentsForEveryUser: [
_cloudRide(id: 'ride_controller', ownerId: 'user_a'),
],
),
client: _downloadClient(_telemetryZip()),
),
);
final result = await controller.restoreFromMotorCloud();
expect(result, isNull);
expect(controller.state.status, CloudRestoreStatus.authRequired);
expect(await db.select(db.rides).get(), isEmpty);
},
);
});
}
CloudRideMetadataDocument _cloudRide({
required String id,
required String ownerId,
int movingTime = 120,
int stoppedTime = 10,
List<Map<String, dynamic>>? performanceRuns,
int totalEventsCount = 0,
}) {
return CloudRideMetadataDocument(
id: id,
data: {
'id': id,
'ownerId': ownerId,
'startTime': DateTime.utc(2026, 9, 3, 10).toIso8601String(),
'endTime': DateTime.utc(2026, 9, 3, 10, 12).toIso8601String(),
'distanceMiles': 4.2,
'topSpeedMph': 62.5,
'maxLeanLeft': 12.0,
'maxLeanRight': 14.0,
'maxAccelG': 0.4,
'maxBrakingG': -0.6,
'averageSpeed': 35.0,
'movingTime': movingTime,
'stoppedTime': stoppedTime,
'elevationGain': 50.0,
'elevationLoss': 20.0,
'motorcycleId': 7,
'motorcycleYear': 2024,
'motorcycleMake': 'Yamaha',
'motorcycleModel': 'MT-09',
'motorcycleVariant': 'SP',
'totalEventsCount': totalEventsCount,
'performanceRuns': ?performanceRuns,
'telemetryBundlePath': 'users/$ownerId/rides/$id/telemetry.zip',
},
);
}
Map<String, dynamic> _performanceRun({
required String type,
required double timeMs,
required double topSpeedReached,
DateTime? timestamp,
}) {
return {
'type': type,
'timeMs': timeMs,
'topSpeedReached': topSpeedReached,
'timestamp': (timestamp ?? DateTime.utc(2026, 9, 3, 10, 1))
.toIso8601String(),
};
}
MockClient _downloadClient(List<int> zipBytes) {
return MockClient((request) async {
final rideId = request.url.queryParameters['rideId']!;
return http.Response.bytes(
zipBytes,
200,
headers: {
'x-motorsignal-ride-id': rideId,
'x-motorsignal-sha256': sha256.convert(zipBytes).toString(),
},
);
});
}
Uint8List _telemetryZip({int samples = 1}) {
final csv = StringBuffer()
..writeln(
'timestamp,latitude,longitude,altitude,speed,heading,gpsAccuracy,leanAngle,pitchAngle,accelX,accelY,accelZ,longitudinalG,lateralG',
);
for (var i = 0; i < samples; i++) {
csv.writeln(
'2026-09-03T10:00:0${i}Z,37.$i,-122.$i,10,${20 + i},90,5,10,1,0.1,0.2,0.3,0.01,0.02',
);
}
final bytes = utf8.encode(csv.toString());
final archive = Archive()
..addFile(ArchiveFile('telemetry.csv', bytes.length, bytes));
return Uint8List.fromList(
ZipEncoder().encode(archive, modified: DateTime.utc(1980, 1, 1)),
);
}