From 5d3547c2211695daf351294fce296f5d34a46178 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 17:31:18 +0100 Subject: [PATCH] Fix gate: file caps, silent catch, pin bump Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD --- install_systemd.sh | 31 +- requirements.txt | 2 +- .../lib/services/map_tile_cache.dart | 4 +- .../lib/services/runnerup_import_state.dart | 4 + .../lib/services/runnerup_importer.dart | 7 +- .../services/run_credential_store_test.dart | 83 +++++ .../services/run_recorder_start_test.dart | 110 ++++++ .../test/services/run_recorder_test.dart | 100 ----- .../test/services/run_saver_test.dart | 47 +++ .../services/run_uploader_queue_test.dart | 128 +++++++ .../test/services/run_uploader_support.dart | 86 +++++ .../test/services/run_uploader_test.dart | 341 ++---------------- .../storage_service_runs_fixtures.dart | 74 ++++ .../storage_service_runs_import_test.dart | 109 ++++++ .../storage_service_runs_transfer_test.dart | 198 ++-------- 15 files changed, 701 insertions(+), 623 deletions(-) create mode 100644 stronglift_replacement/workout_app/test/services/run_credential_store_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/run_recorder_start_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/run_saver_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/run_uploader_queue_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/run_uploader_support.dart create mode 100644 stronglift_replacement/workout_app/test/services/storage_service_runs_fixtures.dart create mode 100644 stronglift_replacement/workout_app/test/services/storage_service_runs_import_test.dart diff --git a/install_systemd.sh b/install_systemd.sh index f13b812..d2ae7a4 100755 --- a/install_systemd.sh +++ b/install_systemd.sh @@ -167,28 +167,17 @@ systemctl --user daemon-reload # Enable the service to start on login (one-shot, no periodic timer) systemctl --user enable "$SERVICE_NAME" -# Enable the early bird re-check timer -systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME" +# Timers: early bird re-check, periodic re-check, workout sync, the instant +# bonus pass on ledger writes, and the daily RunnerUp retirement retry +systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME" "$LOCKER_TIMER_NAME" \ + "$SYNC_TIMER_NAME" "$BONUS_PATH_NAME" "$RETIRE_TIMER_NAME" -# Enable the periodic re-check timer -systemctl --user enable --now "$LOCKER_TIMER_NAME" - -# Enable the periodic workout sync -systemctl --user enable --now "$SYNC_TIMER_NAME" - -# Enable the instant bonus pass on ledger writes -systemctl --user enable --now "$BONUS_PATH_NAME" - -# Enable the instant RunnerUp credit on upload (restart picks up a new script) -systemctl --user enable "$RUNNERUP_WATCH_NAME" -systemctl --user restart "$RUNNERUP_WATCH_NAME" - -# Enable the LAN workout poke listener (restart picks up new code) -systemctl --user enable "$WORKOUT_POKE_NAME" -systemctl --user restart "$WORKOUT_POKE_NAME" - -# Enable the daily RunnerUp retirement retry -systemctl --user enable --now "$RETIRE_TIMER_NAME" +# Instant RunnerUp credit on upload, and the LAN workout poke listener +# (restart picks up new code) +for unit in "$RUNNERUP_WATCH_NAME" "$WORKOUT_POKE_NAME"; do + systemctl --user enable "$unit" + systemctl --user restart "$unit" +done # Verify enforcement is actually armed. enable can silently no-op when systemd # breaks an ordering cycle by deleting the timer's job -- exactly what happened diff --git a/requirements.txt b/requirements.txt index b7a4489..9ea15f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ pre-commit==4.7.0 pylint==4.1.3 pytest==9.2.0 pytest-cov==7.1.0 -pytest-randomly==5.0.0 +pytest-randomly==5.1.0 pytest-sugar==1.1.1 pytest-xdist==3.8.0 # Direct dependency since the Firebase session recovery exchanges a refresh diff --git a/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart b/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart index 1dcf04c..ff40e6c 100644 --- a/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart +++ b/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart @@ -62,7 +62,9 @@ Future _resolveCacheDir() async { final dir = await getApplicationCacheDirectory(); return '${dir.path}/osm_tiles'; } on Object catch (error) { - logMapWarning('map tile cache unavailable, tiles will not persist: $error'); + final message = 'map tile cache unavailable, tiles not persisted: $error'; + developer.log(message, name: 'RunMap', level: 900); + debugPrint('RunMap WARNING: $message'); return null; } } diff --git a/stronglift_replacement/workout_app/lib/services/runnerup_import_state.dart b/stronglift_replacement/workout_app/lib/services/runnerup_import_state.dart index 4d7c169..fcd20ee 100644 --- a/stronglift_replacement/workout_app/lib/services/runnerup_import_state.dart +++ b/stronglift_replacement/workout_app/lib/services/runnerup_import_state.dart @@ -49,6 +49,10 @@ const RunnerUpImportReport kNothingImported = ( failures: [], ); +/// The report of a pass that did nothing but fail for [reason]. +RunnerUpImportReport failedReport(String reason) => + (imported: 0, duplicates: 0, skipped: 0, failures: [reason]); + /// Counts one import pass and logs each failure as it happens. class ImportTally { /// Runs stored this pass. diff --git a/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart b/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart index 7edbd2f..b9b00f7 100644 --- a/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart +++ b/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart @@ -94,12 +94,7 @@ class RunnerUpImporter { } on Object catch (error) { debugPrint('WorkoutApp: RunnerUp import WARNING: aborted: $error'); runnerUpImportStatus.value = RunnerUpImportStatus.failed; - return ( - imported: 0, - duplicates: 0, - skipped: 0, - failures: ['aborted: $error'], - ); + return failedReport('aborted: $error'); } } diff --git a/stronglift_replacement/workout_app/test/services/run_credential_store_test.dart b/stronglift_replacement/workout_app/test/services/run_credential_store_test.dart new file mode 100644 index 0000000..e915229 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/run_credential_store_test.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/run_upload_credentials.dart'; + +import '../fake_secure_storage.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('provisioning', () { + late Directory dir; + setUp(() { + installFakeSecureStorage(); + dir = Directory.systemTemp.createTempSync('provision_test_'); + addTearDown(() => dir.deleteSync(recursive: true)); + }); + + RunCredentialStore store() => + RunCredentialStore(externalDir: () async => dir); + File file() => File('${dir.path}/$kProvisionFileName'); + + test('a login file is imported into secure storage and deleted', () async { + file().writeAsStringSync( + jsonEncode({ + 'url': 'https://cloud.example/', + 'user': 'workout_app', + 'password': 'secret', + }), + ); + final s = store(); + expect(await s.read(), isNull); + + final outcome = await s.importProvisionFile(); + + expect(outcome.status, ProvisionStatus.imported); + expect(file().existsSync(), isFalse); + final login = (await s.read())!; + expect(login.url, 'https://cloud.example'); + expect(login.user, 'workout_app'); + expect(login.password, 'secret'); + expect( + login.authorization, + 'Basic ${base64.encode(utf8.encode('workout_app:secret'))}', + ); + }); + + test('no file, or no directory, is a quiet no-op', () async { + expect( + (await store().importProvisionFile()).status, + ProvisionStatus.absent, + ); + final none = RunCredentialStore(externalDir: () async => null); + expect((await none.importProvisionFile()).status, ProvisionStatus.absent); + // The default directory lookup answers null off Android. + expect( + (await RunCredentialStore().importProvisionFile()).status, + ProvisionStatus.absent, + ); + }); + + test('an unusable file is kept and reported', () async { + final bad = [ + 'not json', + '[]', + jsonEncode({'url': 'https://x', 'user': 'u'}), + jsonEncode({'url': 'ftp://x', 'user': 'u', 'password': 'p'}), + jsonEncode({'url': 'nonsense', 'user': 'u', 'password': 'p'}), + jsonEncode({'url': 'https://x', 'user': '', 'password': 'p'}), + jsonEncode({'url': 'https://x', 'user': 'u', 'password': ''}), + ]; + for (final text in bad) { + file().writeAsStringSync(text); + final s = store(); + final outcome = await s.importProvisionFile(); + expect(outcome.status, ProvisionStatus.invalid, reason: text); + expect(outcome.reason, contains('provision_run_upload.sh')); + expect(file().existsSync(), isTrue); + expect(await s.read(), isNull); + } + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/run_recorder_start_test.dart b/stronglift_replacement/workout_app/test/services/run_recorder_start_test.dart new file mode 100644 index 0000000..33a1abf --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/run_recorder_start_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/position_source.dart'; +import 'package:workout_app/services/run_recorder.dart'; + +import '../run_test_fixtures.dart'; + +RunRecorder _recorder(ManualPositionSource source, {List? km}) => + RunRecorder(sourceFor: (_) => source, onKilometre: km?.add); + +void main() { + group('start', () { + test('refusals stay idle and say why', () async { + for (final (access, issue) in [ + (LocationAccess.denied, RunIssue.denied), + (LocationAccess.deniedForever, RunIssue.deniedForever), + (LocationAccess.serviceOff, RunIssue.serviceOff), + ]) { + var served = false; + final recorder = _recorder(ManualPositionSource(access: access)); + expect( + await recorder.start(afterAccess: () async => served = true), + isFalse, + ); + expect(recorder.phase, RunPhase.idle); + expect(recorder.issue, issue); + expect(served, isFalse); + recorder.dispose(); + } + }); + + test( + 'afterAccess runs once access is held, and a retry can succeed', + () async { + final order = []; + final recorder = _recorder(ManualPositionSource()); + expect( + await recorder.start(afterAccess: () async => order.add('service')), + isTrue, + ); + expect(order, ['service']); + expect(recorder.issue, RunIssue.none); + recorder.dispose(); + }, + ); + + test('a double tap starts one recording', () async { + final source = ManualPositionSource(); + final recorder = _recorder(source); + final first = recorder.start(); + final second = recorder.start(); + expect(await first, isTrue); + expect(await second, isFalse); + expect(await recorder.start(), isFalse); + expect(source.subscriptions, 1); + recorder.dispose(); + }); + + test('the sport is chosen while idle only, and picks the source', () async { + final seen = []; + final recorder = RunRecorder( + sourceFor: (sport) { + seen.add(sport); + return ManualPositionSource(); + }, + ); + expect(recorder.selectSport(RunSport.walking), isTrue); + await recorder.start(); + expect(recorder.selectSport(RunSport.running), isFalse); + expect(recorder.sport, RunSport.walking); + expect(seen, [RunSport.walking]); + recorder.dispose(); + }); + }); + + group('trouble', () { + test('a stream error is flagged and clears with the next fix', () async { + final source = ManualPositionSource(); + final recorder = _recorder(source); + await recorder.start(); + source.controller + ..add(fixAt(0, 0)) + ..addError(StateError('gps off')); + await pumpEventQueue(); + expect(recorder.issue, RunIssue.streamError); + source.controller.add(fixAt(2, 6)); + await pumpEventQueue(); + expect(recorder.issue, RunIssue.none); + recorder.dispose(); + }); + + test('finishing with under two fixes saves nothing', () async { + final source = ManualPositionSource(); + final recorder = _recorder(source); + await recorder.start(); + source.controller.add(fixAt(0, 0)); + await pumpEventQueue(); + expect(await recorder.finish(), isNull); + expect(recorder.issue, RunIssue.tooShort); + recorder.dispose(); + }); + + test('finish before any start is a no-op', () async { + final recorder = _recorder(ManualPositionSource()); + expect(await recorder.finish(), isNull); + expect(recorder.distanceM, 0); + recorder.dispose(); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/run_recorder_test.dart b/stronglift_replacement/workout_app/test/services/run_recorder_test.dart index 771c449..e66e181 100644 --- a/stronglift_replacement/workout_app/test/services/run_recorder_test.dart +++ b/stronglift_replacement/workout_app/test/services/run_recorder_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:workout_app/models/run_track.dart'; -import 'package:workout_app/services/position_source.dart'; import 'package:workout_app/services/position_source_scripted.dart'; import 'package:workout_app/services/run_recorder.dart'; @@ -179,103 +178,4 @@ void main() { }, ); }); - - group('start', () { - test('refusals stay idle and say why', () async { - for (final (access, issue) in [ - (LocationAccess.denied, RunIssue.denied), - (LocationAccess.deniedForever, RunIssue.deniedForever), - (LocationAccess.serviceOff, RunIssue.serviceOff), - ]) { - var served = false; - final recorder = _recorder(ManualPositionSource(access: access)); - expect( - await recorder.start(afterAccess: () async => served = true), - isFalse, - ); - expect(recorder.phase, RunPhase.idle); - expect(recorder.issue, issue); - expect(served, isFalse); - recorder.dispose(); - } - }); - - test( - 'afterAccess runs once access is held, and a retry can succeed', - () async { - final order = []; - final recorder = _recorder(ManualPositionSource()); - expect( - await recorder.start(afterAccess: () async => order.add('service')), - isTrue, - ); - expect(order, ['service']); - expect(recorder.issue, RunIssue.none); - recorder.dispose(); - }, - ); - - test('a double tap starts one recording', () async { - final source = ManualPositionSource(); - final recorder = _recorder(source); - final first = recorder.start(); - final second = recorder.start(); - expect(await first, isTrue); - expect(await second, isFalse); - expect(await recorder.start(), isFalse); - expect(source.subscriptions, 1); - recorder.dispose(); - }); - - test('the sport is chosen while idle only, and picks the source', () async { - final seen = []; - final recorder = RunRecorder( - sourceFor: (sport) { - seen.add(sport); - return ManualPositionSource(); - }, - ); - expect(recorder.selectSport(RunSport.walking), isTrue); - await recorder.start(); - expect(recorder.selectSport(RunSport.running), isFalse); - expect(recorder.sport, RunSport.walking); - expect(seen, [RunSport.walking]); - recorder.dispose(); - }); - }); - - group('trouble', () { - test('a stream error is flagged and clears with the next fix', () async { - final source = ManualPositionSource(); - final recorder = _recorder(source); - await recorder.start(); - source.controller - ..add(fixAt(0, 0)) - ..addError(StateError('gps off')); - await pumpEventQueue(); - expect(recorder.issue, RunIssue.streamError); - source.controller.add(fixAt(2, 6)); - await pumpEventQueue(); - expect(recorder.issue, RunIssue.none); - recorder.dispose(); - }); - - test('finishing with under two fixes saves nothing', () async { - final source = ManualPositionSource(); - final recorder = _recorder(source); - await recorder.start(); - source.controller.add(fixAt(0, 0)); - await pumpEventQueue(); - expect(await recorder.finish(), isNull); - expect(recorder.issue, RunIssue.tooShort); - recorder.dispose(); - }); - - test('finish before any start is a no-op', () async { - final recorder = _recorder(ManualPositionSource()); - expect(await recorder.finish(), isNull); - expect(recorder.distanceM, 0); - recorder.dispose(); - }); - }); } diff --git a/stronglift_replacement/workout_app/test/services/run_saver_test.dart b/stronglift_replacement/workout_app/test/services/run_saver_test.dart new file mode 100644 index 0000000..114f813 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/run_saver_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/services/run_saver.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '../run_test_fixtures.dart'; +import 'run_uploader_support.dart'; + +void main() { + useUploaderStorage(); + + group('saveRecordedRun', () { + test('stores a pending app run and starts the upload', () async { + var enqueued = 0; + final track = sampleTrack(); + final result = await saveRecordedRun( + track, + storage: db, + enqueue: () async { + enqueued++; + return null; + }, + ); + expect(result.stored, isTrue); + expect(enqueued, 1); + final record = (await db.getRun(track.id))!; + expect(record.uploadState, RunUploadState.pending); + expect(record.track.points, hasLength(3)); + expect(record.tcxFilename, endsWith('_Running.tcx')); + }); + + test('a run already stored is reported and not queued twice', () async { + var enqueued = 0; + Future enqueue() async { + enqueued++; + return null; + } + + final track = sampleTrack(); + await saveRecordedRun(track, storage: db, enqueue: enqueue); + final again = await saveRecordedRun(track, storage: db, enqueue: enqueue); + expect(again.stored, isFalse); + expect(again.reason, contains('already stored')); + expect(enqueued, 1); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/run_uploader_queue_test.dart b/stronglift_replacement/workout_app/test/services/run_uploader_queue_test.dart new file mode 100644 index 0000000..0117384 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/run_uploader_queue_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/services/dufs_credentials.dart'; +import 'package:workout_app/services/run_uploader.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/tcx_codec.dart'; + +import '../run_test_fixtures.dart'; +import 'run_uploader_support.dart'; + +void main() { + useUploaderStorage(); + + test('the sandbox keeps runs and never opens a connection', () async { + Sandbox.enabled = true; + final run = await saveRun('2026-11-09T12:00:00Z'); + final up = uploaderFor( + MockClient((_) async => fail('the sandbox must never PUT')), + store: FixedStore(testLogin), + ); + final status = await up.uploadPending(); + expect(status.health, UploadHealth.sandbox); + expect(status.pending, 1); + expect(await stateOf(run.track.id), RunUploadState.pending); + up.dispose(); + }); + + test('an empty queue is idle', () async { + final up = uploaderFor(MockClient((_) async => fail('nothing to send'))); + final status = await up.uploadPending(); + expect(status.health, UploadHealth.idle); + expect(status.pending, 0); + up.dispose(); + }); + + test('concurrent calls share one pass', () async { + await saveRun('2026-11-09T12:00:00Z'); + var calls = 0; + final up = uploaderFor( + MockClient((_) async { + calls++; + return http.Response('', 201); + }), + ); + final both = await Future.wait([up.uploadPending(), up.uploadPending()]); + expect(calls, 1); + expect(both.first, same(both.last)); + up.dispose(); + }); + + test('a stored hash that no longer matches still uploads', () async { + final track = sampleTrack(); + await db.insertRun( + track, + tcxFilename: tcxFilename(track), + tcxSha256: 'stale', + uploadState: RunUploadState.pending, + ); + var calls = 0; + final up = uploaderFor( + MockClient((_) async { + calls++; + return http.Response('', 200); + }), + ); + await up.uploadPending(); + expect(calls, 1); + expect(await stateOf(track.id), RunUploadState.uploaded); + up.dispose(); + }); + + test( + 'a run deleted mid-pass is skipped; one deleted mid-PUT is logged', + () async { + final first = await saveRun('2026-11-08T12:00:00Z'); + final second = await saveRun('2026-11-09T12:00:00Z'); + final seen = []; + final up = uploaderFor( + MockClient((r) async { + seen.add(r.url.pathSegments.last); + await db.deleteRun(second.track.id); + return http.Response('', 201); + }), + store: FixedStore( + testLogin, + onRead: () async { + await db.deleteRun(first.track.id); + }, + ), + ); + final status = await up.uploadPending(); + expect(seen, [second.tcxFilename]); + expect(status.health, UploadHealth.idle); + up.dispose(); + }, + ); + + test('a crashing keystore is reported, not thrown', () async { + await saveRun('2026-11-09T12:00:00Z'); + final timers = []; + final up = uploaderFor( + MockClient((_) async => fail('no login, no PUT')), + store: FixedStore(testLogin, throwing: true), + timers: timers, + ); + final status = await up.uploadPending(); + expect(status.health, UploadHealth.retrying); + expect(status.reason, contains('crashed')); + expect(timers, hasLength(1)); + up.dispose(); + }); + + test('a queue with no injected client opens and closes its own', () async { + await saveRun('2026-11-09T12:00:00Z'); + final up = RunUploader( + credentials: FixedStore( + DufsCredentials(url: 'http://127.0.0.1:1', user: 'u', password: 'p'), + ), + ); + final status = await up.uploadPending(); + expect(status.health, UploadHealth.retrying); + expect(RunUploader.instance.status.value.health, UploadHealth.idle); + up.dispose(); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/run_uploader_support.dart b/stronglift_replacement/workout_app/test/services/run_uploader_support.dart new file mode 100644 index 0000000..c9c2629 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/run_uploader_support.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/services/dufs_credentials.dart'; +import 'package:workout_app/services/run_saver.dart'; +import 'package:workout_app/services/run_upload_credentials.dart'; +import 'package:workout_app/services/run_uploader.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '../run_test_fixtures.dart'; + +final testLogin = DufsCredentials( + url: 'https://cloud.example', + user: 'workout_app', + password: 'pw', +); + +/// A store that already holds [testLogin] (or fails like a broken keystore). +class FixedStore implements RunCredentialStore { + FixedStore(this._creds, {this.onRead, this.throwing = false}); + + final DufsCredentials? _creds; + final Future Function()? onRead; + final bool throwing; + + @override + Future read() async { + await onRead?.call(); + if (throwing) throw PlatformException(code: 'keystore'); + return _creds; + } + + @override + Future importProvisionFile() => throw UnimplementedError(); +} + +StorageService get db => StorageService.instance; + +Future saveRun(String start) async { + final track = sampleTrack(start: start); + await saveRecordedRun(track, storage: db, enqueue: () async => null); + return (await db.listRuns()).firstWhere((r) => r.track.id == track.id); +} + +Future stateOf(String id) async => + (await db.getRun(id))!.uploadState; + +/// Opens a fresh in-memory run database around every test. +void useUploaderStorage() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + }); + + tearDown(() { + Sandbox.enabled = false; + StorageService.resetForTesting(); + }); +} + +/// An uploader over [client] whose retry timer never fires on its own. +RunUploader uploaderFor( + http.Client client, { + RunCredentialStore? store, + List? delays, + List? timers, +}) => RunUploader( + credentials: store ?? FixedStore(testLogin), + client: client, + scheduler: (d, f) { + delays?.add(d); + timers?.add(f); + return Timer(const Duration(days: 1), f); + }, +); diff --git a/stronglift_replacement/workout_app/test/services/run_uploader_test.dart b/stronglift_replacement/workout_app/test/services/run_uploader_test.dart index cf6be63..75e2072 100644 --- a/stronglift_replacement/workout_app/test/services/run_uploader_test.dart +++ b/stronglift_replacement/workout_app/test/services/run_uploader_test.dart @@ -1,96 +1,22 @@ -import 'dart:async'; import 'dart:convert'; -import 'dart:io'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:workout_app/models/run_record.dart'; -import 'package:workout_app/sandbox/sandbox.dart'; -import 'package:workout_app/services/dufs_credentials.dart'; -import 'package:workout_app/services/run_saver.dart'; -import 'package:workout_app/services/run_upload_credentials.dart'; import 'package:workout_app/services/run_uploader.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/tcx_codec.dart'; -import '../fake_secure_storage.dart'; -import '../run_test_fixtures.dart'; - -final _login = DufsCredentials( - url: 'https://cloud.example', - user: 'workout_app', - password: 'pw', -); - -/// A store that already holds [_login] (or fails like a broken keystore). -class _FixedStore implements RunCredentialStore { - _FixedStore(this._login, {this.onRead, this.throwing = false}); - - final DufsCredentials? _login; - final Future Function()? onRead; - final bool throwing; - - @override - Future read() async { - await onRead?.call(); - if (throwing) throw PlatformException(code: 'keystore'); - return _login; - } - - @override - Future importProvisionFile() => throw UnimplementedError(); -} - -StorageService get _db => StorageService.instance; - -Future _save(String start) async { - final track = sampleTrack(start: start); - await saveRecordedRun(track, storage: _db, enqueue: () async => null); - return (await _db.listRuns()).firstWhere((r) => r.track.id == track.id); -} - -Future _stateOf(String id) async => - (await _db.getRun(id))!.uploadState; +import 'run_uploader_support.dart'; void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - }); - - setUp(() async { - StorageService.resetForTesting(); - await StorageService.init(); - }); - - tearDown(() { - Sandbox.enabled = false; - StorageService.resetForTesting(); - }); - - RunUploader uploader( - http.Client client, { - RunCredentialStore? store, - List? delays, - List? timers, - }) => RunUploader( - credentials: store ?? _FixedStore(_login), - client: client, - scheduler: (d, f) { - delays?.add(d); - timers?.add(f); - return Timer(const Duration(days: 1), f); - }, - ); + useUploaderStorage(); test('a 201 marks the run uploaded after a Basic-auth PUT', () async { - final run = await _save('2026-11-09T12:00:00Z'); + final run = await saveRun('2026-11-09T12:00:00Z'); late http.Request seen; - final up = uploader( + final up = uploaderFor( MockClient((r) async { seen = r; return http.Response('', 201); @@ -108,19 +34,19 @@ void main() { seen.headers['Authorization'], 'Basic ${base64.encode(utf8.encode('workout_app:pw'))}', ); - expect(seen.body, encodeTcx((await _db.getRun(run.track.id))!.track)); + expect(seen.body, encodeTcx((await db.getRun(run.track.id))!.track)); expect(status.health, UploadHealth.idle); expect(status.pending, 0); - expect(await _stateOf(run.track.id), RunUploadState.uploaded); + expect(await stateOf(run.track.id), RunUploadState.uploaded); up.dispose(); }); test('a 401 keeps the run pending and retries on a timer', () async { - final run = await _save('2026-11-09T12:00:00Z'); + final run = await saveRun('2026-11-09T12:00:00Z'); final delays = []; final timers = []; var code = 401; - final up = uploader( + final up = uploaderFor( MockClient((_) async => http.Response('', code)), delays: delays, timers: timers, @@ -132,39 +58,39 @@ void main() { expect(status.reason, contains('401')); expect(up.status.value, same(status)); expect(delays, [kUploadRetryAfter]); - expect(await _stateOf(run.track.id), RunUploadState.pending); + expect(await stateOf(run.track.id), RunUploadState.pending); code = 201; timers.single(); // Joins the pass the timer just started (single flight). await up.uploadPending(); - expect(await _stateOf(run.track.id), RunUploadState.uploaded); + expect(await stateOf(run.track.id), RunUploadState.uploaded); up.dispose(); }); test('a network error or server fault stays pending', () async { - final run = await _save('2026-11-09T12:00:00Z'); - final broken = uploader( + final run = await saveRun('2026-11-09T12:00:00Z'); + final broken = uploaderFor( MockClient((_) async => throw http.ClientException('no route')), ); final first = await broken.uploadPending(); expect(first.health, UploadHealth.retrying); expect(first.reason, contains('no route')); - final faulty = uploader(MockClient((_) async => http.Response('', 503))); + final faulty = uploaderFor(MockClient((_) async => http.Response('', 503))); final second = await faulty.uploadPending(); expect(second.reason, contains('503')); expect(second.reason, isNot(contains('provision'))); - expect(await _stateOf(run.track.id), RunUploadState.pending); + expect(await stateOf(run.track.id), RunUploadState.pending); broken.dispose(); faulty.dispose(); }); test('runs go oldest first and a failure stops the pass', () async { - final older = await _save('2026-11-08T12:00:00Z'); - final newer = await _save('2026-11-09T12:00:00Z'); + final older = await saveRun('2026-11-08T12:00:00Z'); + final newer = await saveRun('2026-11-09T12:00:00Z'); final urls = []; - final up = uploader( + final up = uploaderFor( MockClient((r) async { urls.add(r.url.pathSegments.last); return http.Response('', 500); @@ -173,22 +99,22 @@ void main() { final status = await up.uploadPending(); expect(urls, [older.tcxFilename]); expect(status.pending, 2); - expect(await _stateOf(newer.track.id), RunUploadState.pending); + expect(await stateOf(newer.track.id), RunUploadState.pending); up.dispose(); }); test( 'without a provisioned login nothing is sent and no retry runs', () async { - await _save('2026-11-09T12:00:00Z'); + await saveRun('2026-11-09T12:00:00Z'); final timers = []; var calls = 0; - final up = uploader( + final up = uploaderFor( MockClient((_) async { calls++; return http.Response('', 201); }), - store: _FixedStore(null), + store: FixedStore(null), timers: timers, ); final status = await up.uploadPending(); @@ -199,229 +125,4 @@ void main() { up.dispose(); }, ); - - test('the sandbox keeps runs and never opens a connection', () async { - Sandbox.enabled = true; - final run = await _save('2026-11-09T12:00:00Z'); - final up = uploader( - MockClient((_) async => fail('the sandbox must never PUT')), - store: _FixedStore(_login), - ); - final status = await up.uploadPending(); - expect(status.health, UploadHealth.sandbox); - expect(status.pending, 1); - expect(await _stateOf(run.track.id), RunUploadState.pending); - up.dispose(); - }); - - test('an empty queue is idle', () async { - final up = uploader(MockClient((_) async => fail('nothing to send'))); - final status = await up.uploadPending(); - expect(status.health, UploadHealth.idle); - expect(status.pending, 0); - up.dispose(); - }); - - test('concurrent calls share one pass', () async { - await _save('2026-11-09T12:00:00Z'); - var calls = 0; - final up = uploader( - MockClient((_) async { - calls++; - return http.Response('', 201); - }), - ); - final both = await Future.wait([up.uploadPending(), up.uploadPending()]); - expect(calls, 1); - expect(both.first, same(both.last)); - up.dispose(); - }); - - test('a stored hash that no longer matches still uploads', () async { - final track = sampleTrack(); - await _db.insertRun( - track, - tcxFilename: tcxFilename(track), - tcxSha256: 'stale', - uploadState: RunUploadState.pending, - ); - var calls = 0; - final up = uploader( - MockClient((_) async { - calls++; - return http.Response('', 200); - }), - ); - await up.uploadPending(); - expect(calls, 1); - expect(await _stateOf(track.id), RunUploadState.uploaded); - up.dispose(); - }); - - test( - 'a run deleted mid-pass is skipped; one deleted mid-PUT is logged', - () async { - final first = await _save('2026-11-08T12:00:00Z'); - final second = await _save('2026-11-09T12:00:00Z'); - final seen = []; - final up = uploader( - MockClient((r) async { - seen.add(r.url.pathSegments.last); - await _db.deleteRun(second.track.id); - return http.Response('', 201); - }), - store: _FixedStore( - _login, - onRead: () async { - await _db.deleteRun(first.track.id); - }, - ), - ); - final status = await up.uploadPending(); - expect(seen, [second.tcxFilename]); - expect(status.health, UploadHealth.idle); - up.dispose(); - }, - ); - - test('a crashing keystore is reported, not thrown', () async { - await _save('2026-11-09T12:00:00Z'); - final timers = []; - final up = uploader( - MockClient((_) async => fail('no login, no PUT')), - store: _FixedStore(_login, throwing: true), - timers: timers, - ); - final status = await up.uploadPending(); - expect(status.health, UploadHealth.retrying); - expect(status.reason, contains('crashed')); - expect(timers, hasLength(1)); - up.dispose(); - }); - - test('a queue with no injected client opens and closes its own', () async { - await _save('2026-11-09T12:00:00Z'); - final up = RunUploader( - credentials: _FixedStore( - DufsCredentials(url: 'http://127.0.0.1:1', user: 'u', password: 'p'), - ), - ); - final status = await up.uploadPending(); - expect(status.health, UploadHealth.retrying); - expect(RunUploader.instance.status.value.health, UploadHealth.idle); - up.dispose(); - }); - - group('provisioning', () { - late Directory dir; - setUp(() { - installFakeSecureStorage(); - dir = Directory.systemTemp.createTempSync('provision_test_'); - addTearDown(() => dir.deleteSync(recursive: true)); - }); - - RunCredentialStore store() => - RunCredentialStore(externalDir: () async => dir); - File file() => File('${dir.path}/$kProvisionFileName'); - - test('a login file is imported into secure storage and deleted', () async { - file().writeAsStringSync( - jsonEncode({ - 'url': 'https://cloud.example/', - 'user': 'workout_app', - 'password': 'secret', - }), - ); - final s = store(); - expect(await s.read(), isNull); - - final outcome = await s.importProvisionFile(); - - expect(outcome.status, ProvisionStatus.imported); - expect(file().existsSync(), isFalse); - final login = (await s.read())!; - expect(login.url, 'https://cloud.example'); - expect(login.user, 'workout_app'); - expect(login.password, 'secret'); - expect( - login.authorization, - 'Basic ${base64.encode(utf8.encode('workout_app:secret'))}', - ); - }); - - test('no file, or no directory, is a quiet no-op', () async { - expect( - (await store().importProvisionFile()).status, - ProvisionStatus.absent, - ); - final none = RunCredentialStore(externalDir: () async => null); - expect((await none.importProvisionFile()).status, ProvisionStatus.absent); - // The default directory lookup answers null off Android. - expect( - (await RunCredentialStore().importProvisionFile()).status, - ProvisionStatus.absent, - ); - }); - - test('an unusable file is kept and reported', () async { - final bad = [ - 'not json', - '[]', - jsonEncode({'url': 'https://x', 'user': 'u'}), - jsonEncode({'url': 'ftp://x', 'user': 'u', 'password': 'p'}), - jsonEncode({'url': 'nonsense', 'user': 'u', 'password': 'p'}), - jsonEncode({'url': 'https://x', 'user': '', 'password': 'p'}), - jsonEncode({'url': 'https://x', 'user': 'u', 'password': ''}), - ]; - for (final text in bad) { - file().writeAsStringSync(text); - final s = store(); - final outcome = await s.importProvisionFile(); - expect(outcome.status, ProvisionStatus.invalid, reason: text); - expect(outcome.reason, contains('provision_run_upload.sh')); - expect(file().existsSync(), isTrue); - expect(await s.read(), isNull); - } - }); - }); - - group('saveRecordedRun', () { - test('stores a pending app run and starts the upload', () async { - var enqueued = 0; - final track = sampleTrack(); - final result = await saveRecordedRun( - track, - storage: _db, - enqueue: () async { - enqueued++; - return null; - }, - ); - expect(result.stored, isTrue); - expect(enqueued, 1); - final record = (await _db.getRun(track.id))!; - expect(record.uploadState, RunUploadState.pending); - expect(record.track.points, hasLength(3)); - expect(record.tcxFilename, endsWith('_Running.tcx')); - }); - - test('a run already stored is reported and not queued twice', () async { - var enqueued = 0; - Future enqueue() async { - enqueued++; - return null; - } - - final track = sampleTrack(); - await saveRecordedRun(track, storage: _db, enqueue: enqueue); - final again = await saveRecordedRun( - track, - storage: _db, - enqueue: enqueue, - ); - expect(again.stored, isFalse); - expect(again.reason, contains('already stored')); - expect(enqueued, 1); - }); - }); } diff --git a/stronglift_replacement/workout_app/test/services/storage_service_runs_fixtures.dart b/stronglift_replacement/workout_app/test/services/storage_service_runs_fixtures.dart new file mode 100644 index 0000000..5134dbf --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_runs_fixtures.dart @@ -0,0 +1,74 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/tcx_codec.dart'; + +import '_runnerup_fake_dufs.dart'; + +StorageService get svc => StorageService.instance; + +/// The three fixtures, decoded: two app-made, one RunnerUp export. +List<(String, String, RunTrack)> tracks() => [ + for (final e in fixtureTcx().entries) + ( + e.key, + utf8.decode(e.value), + decodeTcx(utf8.decode(e.value), fileName: e.key), + ), +]; + +Future insertAll() async { + for (final (name, xml, track) in tracks()) { + final imported = track.source == RunSource.runnerupImport; + await svc.insertRun( + track, + tcxFilename: name, + tcxSha256: tcxSha256(xml), + uploadState: imported ? RunUploadState.never : RunUploadState.pending, + ); + } +} + +Future freshInstall() async { + StorageService.resetForTesting(); + await StorageService.init(); +} + +Future> backupOnceItHolds(int runs) async { + for (var i = 0; i < 150; i++) { + final b = await BackupService.instance.readBackup(); + if (b != null && ((b['runs'] as List?)?.length ?? 0) >= runs) return b; + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('backup.json never held $runs run(s)'); +} + +/// A fresh in-memory database and a throwaway backup directory per test. +void useRunsTransferSandbox() { + late Directory tmp; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tmp = Directory.systemTemp.createTempSync('runs_transfer'); + BackupService.baseDir = tmp.path; + await freshInstall(); + }); + + tearDown(() { + BackupService.baseDir = kBackupDir; + tmp.deleteSync(recursive: true); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/storage_service_runs_import_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_runs_import_test.dart new file mode 100644 index 0000000..6501181 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_runs_import_test.dart @@ -0,0 +1,109 @@ +@TestOn('vm') +library; + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import 'storage_service_runs_fixtures.dart'; + +void main() { + useRunsTransferSandbox(); + + group('user export / merge-import', () { + test( + 'round trip keeps every run and point; a re-import adds nothing', + () async { + await insertAll(); + final originals = { + for (final r in await svc.listRuns()) + r.track.id: (await svc.getRun(r.track.id))!, + }; + final data = + jsonDecode(jsonEncode(await svc.exportData())) + as Map; + expect(data['version'], kExportVersion); + expect(data['runs'], hasLength(3)); + + await freshInstall(); + final report = await svc.importDataReport(data); + + expect(report.rows, greaterThanOrEqualTo(3)); + for (final run in originals.values) { + final back = (await svc.getRun(run.track.id))!; + expect(back.track.points, run.track.points); + expect(back.track.source, run.track.source); + expect(back.uploadState, run.uploadState); + } + final again = await svc.importDataReport(data); + expect(again.rows, 0); + expect(await svc.listRuns(), hasLength(3)); + }, + ); + + test('an old export without runs still imports', () async { + final report = await svc.importDataReport({ + 'format': kExportFormat, + 'version': 2, + 'settings': [], + }); + expect(report.rows, 0); + expect(await svc.listRuns(), isEmpty); + }); + + test( + 'a runs entry that is not a list is skipped, the rest merges', + () async { + final report = await svc.importDataReport({ + 'settings': [], + 'runs': 5, + }); + expect(report.rows, 0); + }, + ); + + test('an export holding only runs is accepted; junk is not', () async { + await insertAll(); + final runs = (await svc.exportData())['runs']! as List; + await freshInstall(); + expect((await svc.importDataReport({'runs': runs})).rows, 3); + await expectLater( + svc.importDataReport({'runs': 'nope'}), + throwsFormatException, + ); + }); + + test( + 'bad entries are skipped, good ones kept, imports stay never', + () async { + await insertAll(); + final good = ((await svc.exportData())['runs']! as List) + .cast>(); + await freshInstall(); + final forged = { + ...good.first, + 'source': 'runnerup_import', + 'upload_state': 'pending', + }; + final clash = {...good[1], 'id': 'other-id'}; // same hash, new id + final badPoints = {...good[2], 'points': 'not json'}; + final badSport = {...good[2], 'id': 'z', 'sport': 'Cycling'}; + final noId = {...good[2], 'id': ''}; + + final report = await svc.importDataReport({ + 'runs': [forged, good[1], clash, badPoints, badSport, noId, 'str', 7], + }); + + expect(report.rows, 2); + final stored = await svc.listRuns(); + expect(stored, hasLength(2)); + final coerced = stored.firstWhere((r) => r.track.id == forged['id']); + expect(coerced.track.source, RunSource.runnerupImport); + expect(coerced.uploadState, RunUploadState.never); + }, + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart index 5a934e1..5eebdf1 100644 --- a/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart +++ b/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart @@ -2,85 +2,29 @@ library; import 'dart:convert'; -import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:workout_app/models/run_record.dart'; import 'package:workout_app/models/run_track.dart'; import 'package:workout_app/services/backup_service.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/tcx_codec.dart'; -import '_runnerup_fake_dufs.dart'; - -StorageService get _svc => StorageService.instance; - -/// The three fixtures, decoded: two app-made, one RunnerUp export. -List<(String, String, RunTrack)> _tracks() => [ - for (final e in fixtureTcx().entries) - ( - e.key, - utf8.decode(e.value), - decodeTcx(utf8.decode(e.value), fileName: e.key), - ), -]; - -Future _insertAll() async { - for (final (name, xml, track) in _tracks()) { - final imported = track.source == RunSource.runnerupImport; - await _svc.insertRun( - track, - tcxFilename: name, - tcxSha256: tcxSha256(xml), - uploadState: imported ? RunUploadState.never : RunUploadState.pending, - ); - } -} - -Future _freshInstall() async { - StorageService.resetForTesting(); - await StorageService.init(); -} - -Future> _backupOnceItHolds(int runs) async { - for (var i = 0; i < 150; i++) { - final b = await BackupService.instance.readBackup(); - if (b != null && ((b['runs'] as List?)?.length ?? 0) >= runs) return b; - await Future.delayed(const Duration(milliseconds: 20)); - } - fail('backup.json never held $runs run(s)'); -} +import 'storage_service_runs_fixtures.dart'; void main() { - late Directory tmp; - - setUpAll(() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - }); - - setUp(() async { - tmp = Directory.systemTemp.createTempSync('runs_transfer'); - BackupService.baseDir = tmp.path; - await _freshInstall(); - }); - - tearDown(() { - BackupService.baseDir = kBackupDir; - tmp.deleteSync(recursive: true); - }); + useRunsTransferSandbox(); group('backup.json', () { test( 'wipe the DB, restore: recorded runs and their points return', () async { - await _insertAll(); - final backup = await _backupOnceItHolds(2); + await insertAll(); + final backup = await backupOnceItHolds(2); final saved = { - for (final r in await _svc.listRuns()) + for (final r in await svc.listRuns()) if (r.track.source == RunSource.app) - r.track.id: await _svc.getRun(r.track.id), + r.track.id: await svc.getRun(r.track.id), }; expect(saved, hasLength(2)); // RunnerUp history is reproducible from the PC: not duplicated here. @@ -95,14 +39,14 @@ void main() { reason: 'points must stay one string per run, not one line per fix', ); - await _freshInstall(); // the reinstall - expect(await _svc.listRuns(), isEmpty); - await _svc.restoreFromBackupIfNeeded(); + await freshInstall(); // the reinstall + expect(await svc.listRuns(), isEmpty); + await svc.restoreFromBackupIfNeeded(); - final restored = await _svc.listRuns(); + final restored = await svc.listRuns(); expect(restored.map((r) => r.track.id).toSet(), saved.keys.toSet()); for (final run in saved.values) { - final back = (await _svc.getRun(run!.track.id))!; + final back = (await svc.getRun(run!.track.id))!; expect(back.track.points, run.track.points); expect(back.track.points, isNotEmpty); expect(back.uploadState, run.uploadState); @@ -114,23 +58,23 @@ void main() { ); test('restoring twice stores nothing twice', () async { - await _insertAll(); - await _backupOnceItHolds(2); - await _freshInstall(); - await _svc.restoreFromBackupIfNeeded(); - final once = await _svc.listRuns(); + await insertAll(); + await backupOnceItHolds(2); + await freshInstall(); + await svc.restoreFromBackupIfNeeded(); + final once = await svc.listRuns(); - await _svc.restoreFromBackupIfNeeded(); + await svc.restoreFromBackupIfNeeded(); - expect(await _svc.listRuns(), hasLength(once.length)); + expect(await svc.listRuns(), hasLength(once.length)); }); test('a run-less fresh DB cannot overwrite a runs-only backup', () async { - await _insertAll(); - final before = await _backupOnceItHolds(2); - await _freshInstall(); + await insertAll(); + final before = await backupOnceItHolds(2); + await freshInstall(); - await _svc.setLastWorkoutType('A'); // triggers _backupNow + await svc.setLastWorkoutType('A'); // triggers _backupNow await Future.delayed(const Duration(milliseconds: 100)); final after = await BackupService.instance.readBackup(); @@ -157,11 +101,11 @@ void main() { ], 'settings': [], }); - final (name, xml, track) = _tracks().firstWhere( + final (name, xml, track) = tracks().firstWhere( (t) => t.$3.source == RunSource.app, ); - await _svc.insertRun( + await svc.insertRun( track, tcxFilename: name, tcxSha256: tcxSha256(xml), @@ -173,98 +117,4 @@ void main() { expect(after!['workout_history'], hasLength(1)); }, ); - - group('user export / merge-import', () { - test( - 'round trip keeps every run and point; a re-import adds nothing', - () async { - await _insertAll(); - final originals = { - for (final r in await _svc.listRuns()) - r.track.id: (await _svc.getRun(r.track.id))!, - }; - final data = - jsonDecode(jsonEncode(await _svc.exportData())) - as Map; - expect(data['version'], kExportVersion); - expect(data['runs'], hasLength(3)); - - await _freshInstall(); - final report = await _svc.importDataReport(data); - - expect(report.rows, greaterThanOrEqualTo(3)); - for (final run in originals.values) { - final back = (await _svc.getRun(run.track.id))!; - expect(back.track.points, run.track.points); - expect(back.track.source, run.track.source); - expect(back.uploadState, run.uploadState); - } - final again = await _svc.importDataReport(data); - expect(again.rows, 0); - expect(await _svc.listRuns(), hasLength(3)); - }, - ); - - test('an old export without runs still imports', () async { - final report = await _svc.importDataReport({ - 'format': kExportFormat, - 'version': 2, - 'settings': [], - }); - expect(report.rows, 0); - expect(await _svc.listRuns(), isEmpty); - }); - - test( - 'a runs entry that is not a list is skipped, the rest merges', - () async { - final report = await _svc.importDataReport({ - 'settings': [], - 'runs': 5, - }); - expect(report.rows, 0); - }, - ); - - test('an export holding only runs is accepted; junk is not', () async { - await _insertAll(); - final runs = (await _svc.exportData())['runs']! as List; - await _freshInstall(); - expect((await _svc.importDataReport({'runs': runs})).rows, 3); - await expectLater( - _svc.importDataReport({'runs': 'nope'}), - throwsFormatException, - ); - }); - - test( - 'bad entries are skipped, good ones kept, imports stay never', - () async { - await _insertAll(); - final good = ((await _svc.exportData())['runs']! as List) - .cast>(); - await _freshInstall(); - final forged = { - ...good.first, - 'source': 'runnerup_import', - 'upload_state': 'pending', - }; - final clash = {...good[1], 'id': 'other-id'}; // same hash, new id - final badPoints = {...good[2], 'points': 'not json'}; - final badSport = {...good[2], 'id': 'z', 'sport': 'Cycling'}; - final noId = {...good[2], 'id': ''}; - - final report = await _svc.importDataReport({ - 'runs': [forged, good[1], clash, badPoints, badSport, noId, 'str', 7], - }); - - expect(report.rows, 2); - final stored = await _svc.listRuns(); - expect(stored, hasLength(2)); - final coerced = stored.firstWhere((r) => r.track.id == forged['id']); - expect(coerced.track.source, RunSource.runnerupImport); - expect(coerced.uploadState, RunUploadState.never); - }, - ); - }); }