851 lines
27 KiB
Dart
851 lines
27 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';
|
|
import 'package:motorcycle_dash/features/records/records_service.dart';
|
|
import 'package:shared_preferences/shared_preferences.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 {}
|
|
@override Future<void> ensureBackendIdentity() 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',
|
|
),
|
|
);
|
|
}
|
|
|
|
for (final epoch in [1791761491, 1791761491000, 1791761491000000, '1791761491000']) {
|
|
test('reconciliation decodes numeric epoch $epoch without duplicating local ride', () async {
|
|
await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: DateTime.fromMillisecondsSinceEpoch(1791761491000, isUtc: true),
|
|
));
|
|
final metadata = CloudReconciliationService(
|
|
db: db,
|
|
authService: auth,
|
|
metadataStore: _MemoryRideMetadataStore(byUid: {
|
|
'user_a': [CloudRideMetadataDocument(id: 'ride_numeric', data: {
|
|
'ownerId': 'user_a', 'localRideId': 1, 'startTime': epoch,
|
|
})],
|
|
}),
|
|
);
|
|
expect((await metadata.reconcileMetadata()).unchanged, 1);
|
|
final rides = await db.select(db.rides).get();
|
|
expect(rides, hasLength(1));
|
|
expect(rides.single.cloudRideId, 'ride_numeric');
|
|
});
|
|
}
|
|
|
|
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.startTime.toUtc(), DateTime.utc(2026, 9, 3, 10));
|
|
expect(rides.single.endTime?.toUtc(), DateTime.utc(2026, 9, 3, 10, 12));
|
|
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);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'legacy cloud metadata storing uid instead of ownerId is restored',
|
|
() async {
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [
|
|
CloudRideMetadataDocument(
|
|
id: 'ride_legacy_uid',
|
|
data: {
|
|
'id': 'ride_legacy_uid',
|
|
'uid': 'user_a', // Legacy schema: uid present, ownerId null
|
|
'startTime': DateTime.utc(2026, 9, 3, 10).toIso8601String(),
|
|
'endTime': DateTime.utc(2026, 9, 3, 10, 15).toIso8601String(),
|
|
'distanceMiles': 5.5,
|
|
'topSpeedMph': 75.0,
|
|
'maxLeanLeft': 28.0,
|
|
'maxLeanRight': 31.0,
|
|
'maxAccelG': 0.65,
|
|
'maxBrakingG': -0.72,
|
|
'best0to60Ms': 3450.0,
|
|
'movingTime': 900,
|
|
'stoppedTime': 0,
|
|
'elevationGain': 450.0,
|
|
'elevationLoss': 200.0,
|
|
},
|
|
),
|
|
],
|
|
},
|
|
);
|
|
|
|
final zipBytes = _telemetryZip(samples: 2);
|
|
final client = _downloadClient(zipBytes);
|
|
final restore = service(store: store, client: client);
|
|
|
|
final result = await restore.restore();
|
|
|
|
expect(result.cloudRidesDiscovered, 1);
|
|
expect(result.metadataRowsAdded, 1);
|
|
expect(result.telemetryRidesRestored, 1);
|
|
expect(result.telemetrySamplesRestored, 2);
|
|
|
|
final rides = await db.select(db.rides).get();
|
|
expect(rides, hasLength(1));
|
|
expect(rides.single.cloudRideId, 'ride_legacy_uid');
|
|
expect(rides.single.distanceMiles, 5.5);
|
|
expect(rides.single.topSpeedMph, 75.0);
|
|
expect(rides.single.best0to60Ms, 3450.0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'local ID collision does not cause conflict; inserts cloud ride as a new local ride',
|
|
() async {
|
|
// Pre-insert a local ride with SQLite ID 1 and a DIFFERENT cloudRideId
|
|
await db.customStatement('''
|
|
INSERT INTO rides (id, cloud_ride_id, start_time, distance_miles, top_speed_mph)
|
|
VALUES (1, 'ride_existing_local', 1700000000000, 10.0, 50.0)
|
|
''');
|
|
|
|
// Cloud ride also has localRideId: 1 from its original device, but cloud ID 'ride_from_cloud'
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [
|
|
CloudRideMetadataDocument(
|
|
id: 'ride_from_cloud',
|
|
data: {
|
|
'id': 'ride_from_cloud',
|
|
'localRideId': 1,
|
|
'ownerId': 'user_a',
|
|
'startTime': DateTime.utc(2026, 9, 10, 14).toIso8601String(),
|
|
'distanceMiles': 22.4,
|
|
'topSpeedMph': 88.0,
|
|
},
|
|
),
|
|
],
|
|
},
|
|
);
|
|
|
|
final restore = service(store: store, client: _downloadClient(_telemetryZip()));
|
|
final result = await restore.restore();
|
|
|
|
// Must NOT report conflict! Must add as a new row
|
|
expect(result.metadata.conflicts, 0);
|
|
expect(result.metadataRowsAdded, 1);
|
|
|
|
final rides = await db.select(db.rides).get();
|
|
expect(rides, hasLength(2));
|
|
expect(rides.any((r) => r.cloudRideId == 'ride_existing_local'), isTrue);
|
|
expect(rides.any((r) => r.cloudRideId == 'ride_from_cloud'), isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'recalculating records from database populates personal records and benchmarks',
|
|
() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
// Insert rides with speed, lean, Gs, best 0-60
|
|
await db.customStatement('''
|
|
INSERT INTO rides (
|
|
cloud_ride_id, start_time, distance_miles, top_speed_mph,
|
|
max_lean_left, max_lean_right, max_accel_g, max_braking_g,
|
|
elevation_gain, best0to60_ms
|
|
) VALUES (
|
|
'ride_rec_1', 1700000000000, 45.2, 94.5,
|
|
38.5, 42.0, 0.85, -0.92,
|
|
1850.0, 3150.0
|
|
)
|
|
''');
|
|
|
|
// Insert a performance run for 0-100
|
|
await db.addPerformanceRun(
|
|
PerformanceRunsCompanion.insert(
|
|
type: '0-100',
|
|
timeMs: 7200.0,
|
|
topSpeedReached: 101.2,
|
|
timestamp: DateTime.utc(2026, 9, 15),
|
|
),
|
|
);
|
|
|
|
final recordsService = RecordsService(prefs);
|
|
expect(recordsService.state.topSpeedMph, 0.0); // initially empty
|
|
|
|
await recordsService.recalculateFromDatabase(db);
|
|
|
|
expect(recordsService.state.topSpeedMph, 94.5);
|
|
expect(recordsService.state.maxLeanLeft, 38.5);
|
|
expect(recordsService.state.maxLeanRight, 42.0);
|
|
expect(recordsService.state.maxAccelG, 0.85);
|
|
expect(recordsService.state.maxBrakingG, 0.92);
|
|
expect(recordsService.state.longestRideMiles, 45.2);
|
|
expect(recordsService.state.greatestElevationGain, 1850.0);
|
|
expect(recordsService.state.fastest0To60, 3.15); // 3150 ms / 1000
|
|
expect(recordsService.state.fastest0To100, 7.20); // 7200 ms / 1000
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
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)),
|
|
);
|
|
}
|