From 1e72b6182600df3b6b10327293921ef1570a1ef7 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 22:09:54 +0200 Subject: [PATCH] Split workout_sync_service_test.dart (740) into four MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The largest file on the list. Now that the shared helpers live in _sync_test_fixtures.dart, the two post-`main()` group functions were already self-contained and became their own files rather than needing the splitter: workout_sync_service_test.dart 195 push / pushManual workout_sync_read_test.dart 238 readMerged*, token recovery workout_sync_manual_survival_test.dart 174 Firebase-connect survival workout_sync_cutover_test.dart 92 dual-write cutover Each new file gets its own `TestWidgetsFlutterBinding.ensureInitialized()`. That was previously supplied once by the shared `main()`, and `installFakeSecureStorage` touches the test binary messenger — without it the files would fail to load, which shows up as a dropped test count rather than a red assertion. 402 tests pass and the test-name set is byte-identical to the baseline, so nothing was lost, duplicated or silently skipped in the move. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- .../services/workout_sync_cutover_test.dart | 92 ++++ .../workout_sync_manual_survival_test.dart | 174 +++++++ .../test/services/workout_sync_read_test.dart | 238 ++++++++++ .../services/workout_sync_service_test.dart | 437 ------------------ 4 files changed, 504 insertions(+), 437 deletions(-) create mode 100644 stronglift_replacement/workout_app/test/services/workout_sync_cutover_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/workout_sync_manual_survival_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/workout_sync_read_test.dart diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_cutover_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_cutover_test.dart new file mode 100644 index 0000000..51ea2ab --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/workout_sync_cutover_test.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/models/exercise_result.dart'; +import 'package:workout_app/models/set_result.dart'; +import 'package:workout_app/models/workout_session.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/sync_settings.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; + +import '../fake_secure_storage.dart'; +import '_sync_test_fixtures.dart'; + +void main() { + // installFakeSecureStorage touches the test binary messenger, which needs + // the binding up first (widget tests get this for free via testWidgets). + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Firebase cutover', () { + test('pushes to Firebase and still mirrors to GitHub', () async { + // The cutover guarantee: both backends receive the write, so a PC that + // has not moved yet still sees the workout. + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final (:httpClient, :putCalls) = mockGitHub(); + final firebase = fakeFirebase(); + + await syncService( + httpClient: httpClient, + firebaseFactory: () async => firebase.client, + ).push(workoutSession()); + + expect( + firebase.puts.any((p) => p.contains('screen-locker-sync')), + isTrue, + reason: 'Firebase is primary and must receive the write', + ); + expect( + putCalls, + isNotEmpty, + reason: 'GitHub must still be mirrored during the cutover', + ); + }); + + test('pushManual also mirrors to both backends', () async { + // pushManual is a separate path from push(); it needs its own proof + // that Firebase became primary rather than being skipped. + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final (:httpClient, :putCalls) = mockGitHub(); + final firebase = fakeFirebase(); + + await syncService( + httpClient: httpClient, + firebaseFactory: () async => firebase.client, + ).pushManual( + Record( + id: 'manual:x', + fields: { + 'payload': ( + {'kind': 'manual_workout', 'date': '2026-07-13', 'cost': '40'}, + Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone'), + ), + }, + ), + ); + + expect( + firebase.puts.any((p) => p.contains('screen-locker-sync')), + isTrue, + ); + expect(putCalls, isNotEmpty); + }); + + test('reads merge both backends', () async { + // Read-only path: a workout logged against either backend must count. + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final firebase = fakeFirebase(); + + final (:httpClient, :putCalls) = mockGitHub(); + final payloads = await syncService( + httpClient: httpClient, + firebaseFactory: () async => firebase.client, + ).readMergedWorkoutPayloads(); + + expect(payloads, isEmpty); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_manual_survival_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_manual_survival_test.dart new file mode 100644 index 0000000..62a5362 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/workout_sync_manual_survival_test.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/models/exercise_result.dart'; +import 'package:workout_app/models/set_result.dart'; +import 'package:workout_app/models/workout_session.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/sync_settings.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; + +import '../fake_secure_storage.dart'; +import '_sync_test_fixtures.dart'; + +// Regression tests for the one thing that went RIGHT on 2026-08-15. +// +// A manual workout was logged while the phone was disconnected. When +// Firebase later connected, the entry was NOT erased -- the user called this +// out as good behaviour worth keeping. It is currently correct by +// construction (`MirrorStore.getFileText` falls through to the GitHub mirror +// when the Firebase primary returns null, so a first connect reads the prior +// log rather than an empty one), which is exactly why it needs pinning: +// nothing else would fail if that fall-through were removed. +void main() { + // installFakeSecureStorage touches the test binary messenger, which needs + // the binding up first (widget tests get this for free via testWidgets). + TestWidgetsFlutterBinding.ensureInitialized(); + + group('connecting to Firebase does not erase local manual workouts', () { + /// A manual record already in this device's log, logged while offline. + Record priorManual() => Record( + id: 'manual:2026-08-01T10:00', + fields: { + 'payload': ( + {'kind': 'manual_workout', 'date': '2026-08-01'}, + Hlc.newTick('phone'), + ), + }, + ); + + Map decodeLogPut(List calls) => + jsonDecode( + utf8.decode( + base64.decode(logPut(calls).body['content'] as String), + ), + ) + as Map; + + test( + 'a first Firebase connect keeps a manual record only GitHub has', + () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final existingLog = jsonEncode({ + priorManual().id: priorManual().toJson(), + }); + final (:httpClient, :putCalls) = mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': syncResponse(200, []), + 'screen-locker-sync/devices/phone/log.json': fileContaining( + existingLog, + ), + }, + ); + // Firebase answers 'null' to every GET -- the fresh-connect state, and + // the exact condition under which a naive implementation would treat + // the remote as empty and overwrite the log with just the new record. + final firebase = fakeFirebase(); + + final newManual = Record( + id: 'manual:2026-08-15T12:18', + fields: { + 'payload': ( + {'kind': 'manual_workout', 'date': '2026-08-15'}, + Hlc.newTick('phone'), + ), + }, + ); + final result = await syncService( + httpClient: httpClient, + firebaseFactory: () async => firebase.client, + ).pushManual(newManual); + + expect(result.pushed, isTrue); + final pushedLog = decodeLogPut(putCalls); + expect( + pushedLog.keys, + containsAll([priorManual().id, newManual.id]), + reason: 'the offline-logged manual workout must survive the connect', + ); + }, + ); + + test( + 'syncNow reports "not configured" rather than pretending to work', + () async { + installFakeSecureStorage(); + final (:httpClient, :putCalls) = mockGitHub(); + + final result = await syncService(httpClient: httpClient).syncNow(); + + expect(putCalls, isEmpty); + expect(result.pushed, isFalse); + // The card renders this reason, so it has to name the actual problem. + expect(result.reason, contains('not configured')); + }, + ); + + test('syncNow works on a device with no log yet', () async { + // Nothing to preserve on a brand-new device: the tick must still + // succeed rather than failing on the absent log. + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final (:httpClient, :putCalls) = mockGitHub( + contentResponses: {'screen-locker-sync/devices': syncResponse(200, [])}, + ); + + final result = await syncService(httpClient: httpClient).syncNow(); + + expect(result.pushed, isTrue); + expect(result.reason, 'synced'); + }); + + test('syncNow reports the reason when the round trip fails', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final httpClient = http_testing.MockClient( + (request) async => http.Response('', 500), + ); + + final result = await syncService(httpClient: httpClient).syncNow(); + + // Never throws into the UI, but never silent either: this string is + // exactly what the "Sync failed" card shows the user. + expect(result.pushed, isFalse); + expect(result.reason, contains('sync failed')); + }); + + test('a plain sync tick on first connect does not drop the log', () async { + // syncNow() pushes no new record, so if the fresh-connect read came + // back empty this would write an EMPTY log over a good one -- the + // worst version of the bug, triggered just by opening the app. + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final existingLog = jsonEncode({ + priorManual().id: priorManual().toJson(), + }); + final (:httpClient, :putCalls) = mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': syncResponse(200, []), + 'screen-locker-sync/devices/phone/log.json': fileContaining( + existingLog, + ), + }, + ); + final firebase = fakeFirebase(); + + final result = await syncService( + httpClient: httpClient, + firebaseFactory: () async => firebase.client, + ).syncNow(); + + expect(result.pushed, isTrue); + final logPuts = putCalls.where((c) => c.path.endsWith('log.json')); + if (logPuts.isNotEmpty) { + expect( + decodeLogPut(putCalls).keys, + contains(priorManual().id), + reason: 'a no-op tick must never blank the log', + ); + } + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_read_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_read_test.dart new file mode 100644 index 0000000..5e54356 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/workout_sync_read_test.dart @@ -0,0 +1,238 @@ +// Reading merged workout/manual payloads back off the sync backend, and the +// token-recovery path that a rejected token takes. +// +// Split out of workout_sync_service_test.dart, which holds the push side. +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/models/exercise_result.dart'; +import 'package:workout_app/models/set_result.dart'; +import 'package:workout_app/models/workout_session.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/sync_settings.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; + +import '../fake_secure_storage.dart'; +import '_sync_test_fixtures.dart'; + +void main() { + // installFakeSecureStorage touches the test binary messenger, which needs + // the binding up first (widget tests get this for free via testWidgets). + TestWidgetsFlutterBinding.ensureInitialized(); + + test('readMergedManualPayloads is empty without a token', () async { + installFakeSecureStorage(); + final (:httpClient, putCalls: _) = mockGitHub(); + expect( + await syncService( + httpClient: httpClient, + ).readMergedManualPayloads(), + isEmpty, + ); + }); + + test('readMergedManualPayloads merges manuals and skips the rest', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final session = Record( + id: 's', + fields: { + 'payload': ( + {'workout_type': 'A'}, + Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone'), + ), + }, + ); + final noPayload = Record( + id: 'np', + fields: { + 'other': (1, Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone')), + }, + ); + final phoneLog = jsonEncode({ + 'manual:a': manualRecord('manual:a', 'NEW', 2000).toJson(), + 's': session.toJson(), + 'np': noPayload.toJson(), + }); + final pcLog = jsonEncode({ + // Same id, older clock -> must lose to phone's newer copy. + 'manual:a': manualRecord('manual:a', 'OLD', 1000).toJson(), + }); + final (:httpClient, putCalls: _) = mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': syncResponse(200, [ + {'name': 'phone'}, + {'name': 'pc'}, + {'name': 'empty'}, // its log.json 404s -> skipped + ]), + 'screen-locker-sync/devices/phone/log.json': fileContaining(phoneLog), + 'screen-locker-sync/devices/pc/log.json': fileContaining(pcLog), + }, + ); + final payloads = await WorkoutSyncService( + httpClient: httpClient, + ).readMergedManualPayloads(); + expect(payloads, hasLength(1)); + expect(payloads.single['cost'], 'NEW'); + }); + + test('readMergedWorkoutPayloads returns the PC runs too', () async { + // The PC publishes its whole workout_log.json, so the phone must be able + // to see verified runs — not just manual self-reports — or the two + // devices never show the same history. + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final run = Record( + id: 'runnerup_verified:2026-07-13', + fields: { + 'payload': ( + { + 'kind': 'runnerup_verified', + 'date': '2026-07-13', + 'source': 'Running: 9.8 km in 55 min', + }, + Hlc(wallTimeMs: 3000, counter: 0, nodeId: 'pc'), + ), + }, + ); + final pcLog = jsonEncode({ + 'manual:a': manualRecord('manual:a', 'NEW', 2000).toJson(), + 'runnerup_verified:2026-07-13': run.toJson(), + }); + final (:httpClient, putCalls: _) = mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': syncResponse(200, [ + {'name': 'pc'}, + ]), + 'screen-locker-sync/devices/pc/log.json': fileContaining(pcLog), + }, + ); + final service = syncService(httpClient: httpClient); + + final all = await service.readMergedWorkoutPayloads(); + expect(all, hasLength(2)); + expect( + all.map((p) => p['kind']), + containsAll(['manual_workout', 'runnerup_verified']), + ); + }); + + test( + 'readMergedManualPayloads still excludes runs (budget stays manual)', + () async { + // A verified run must never consume the manual self-report budget. + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final run = Record( + id: 'runnerup_verified:2026-07-13', + fields: { + 'payload': ( + {'kind': 'runnerup_verified', 'date': '2026-07-13'}, + Hlc(wallTimeMs: 3000, counter: 0, nodeId: 'pc'), + ), + }, + ); + final pcLog = jsonEncode({ + 'manual:a': manualRecord('manual:a', 'NEW', 2000).toJson(), + 'runnerup_verified:2026-07-13': run.toJson(), + }); + final (:httpClient, putCalls: _) = mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': syncResponse(200, [ + {'name': 'pc'}, + ]), + 'screen-locker-sync/devices/pc/log.json': fileContaining(pcLog), + }, + ); + final manuals = await WorkoutSyncService( + httpClient: httpClient, + ).readMergedManualPayloads(); + expect(manuals, hasLength(1)); + expect(manuals.single['kind'], 'manual_workout'); + }, + ); + + test('readMergedManualPayloads swallows a sync error', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final httpClient = http_testing.MockClient( + (request) async => http.Response('', 500), + ); + expect( + await syncService( + httpClient: httpClient, + ).readMergedManualPayloads(), + isEmpty, + ); + }); + + test('recovers from the backup when the stored token is rejected', () async { + // A stale keystore token shadows a good backup (load() only falls back + // when the keystore is EMPTY), which would leave history silently empty. + final tempDir = Directory.systemTemp.createTempSync('sync_recover_'); + BackupService.baseDirForTesting = tempDir.path; + addTearDown(() { + BackupService.baseDirForTesting = kBackupDir; + tempDir.deleteSync(recursive: true); + }); + + installFakeSecureStorage(initial: {'sync.token': 'stale'}); + await const SyncSettings(token: 'good').save(); + installFakeSecureStorage(initial: {'sync.token': 'stale'}); + + final pcLog = jsonEncode({ + 'manual:a': manualRecord('manual:a', 'NEW', 2000).toJson(), + }); + final httpClient = http_testing.MockClient((req) async { + if (req.headers['Authorization']?.contains('good') != true) { + return http.Response('Bad credentials', 401); + } + if (req.url.path.endsWith('screen-locker-sync/devices')) { + return http.Response( + jsonEncode([ + {'name': 'pc', 'type': 'dir'}, + ]), + 200, + ); + } + return http.Response( + jsonEncode({ + 'content': base64Encode(utf8.encode(pcLog)), + 'sha': 'sha', + }), + 200, + ); + }); + + final payloads = await WorkoutSyncService( + httpClient: httpClient, + ).readMergedManualPayloads(); + + expect(payloads, hasLength(1)); + expect((await SyncSettings.load()).token, 'good'); + }); + + test('reports the failure when the backup token is rejected too', () async { + final tempDir = Directory.systemTemp.createTempSync('sync_recover_fail_'); + BackupService.baseDirForTesting = tempDir.path; + addTearDown(() { + BackupService.baseDirForTesting = kBackupDir; + tempDir.deleteSync(recursive: true); + }); + + installFakeSecureStorage(initial: {'sync.token': 'stale'}); + await const SyncSettings(token: 'also-bad').save(); + installFakeSecureStorage(initial: {'sync.token': 'stale'}); + + // Every token 401s, so the recovery retry fails too. + final httpClient = http_testing.MockClient( + (req) async => http.Response('Bad credentials', 401), + ); + + expect( + await syncService(httpClient: httpClient).readMergedManualPayloads(), + isEmpty, + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart index 760f97a..7f436e9 100644 --- a/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart +++ b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart @@ -17,8 +17,6 @@ import '../fake_secure_storage.dart'; import '_sync_test_fixtures.dart'; void main() { - _cutoverTests(); - _manualWorkoutSurvivalTests(); // installFakeSecureStorage touches the test binary messenger, which needs // the binding up first (widget tests get this for free via testWidgets). TestWidgetsFlutterBinding.ensureInitialized(); @@ -194,439 +192,4 @@ void main() { completes, ); }); - - test('readMergedManualPayloads is empty without a token', () async { - installFakeSecureStorage(); - final (:httpClient, putCalls: _) = mockGitHub(); - expect( - await syncService( - httpClient: httpClient, - ).readMergedManualPayloads(), - isEmpty, - ); - }); - - test('readMergedManualPayloads merges manuals and skips the rest', () async { - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - final session = Record( - id: 's', - fields: { - 'payload': ( - {'workout_type': 'A'}, - Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone'), - ), - }, - ); - final noPayload = Record( - id: 'np', - fields: { - 'other': (1, Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone')), - }, - ); - final phoneLog = jsonEncode({ - 'manual:a': manualRecord('manual:a', 'NEW', 2000).toJson(), - 's': session.toJson(), - 'np': noPayload.toJson(), - }); - final pcLog = jsonEncode({ - // Same id, older clock -> must lose to phone's newer copy. - 'manual:a': manualRecord('manual:a', 'OLD', 1000).toJson(), - }); - final (:httpClient, putCalls: _) = mockGitHub( - contentResponses: { - 'screen-locker-sync/devices': syncResponse(200, [ - {'name': 'phone'}, - {'name': 'pc'}, - {'name': 'empty'}, // its log.json 404s -> skipped - ]), - 'screen-locker-sync/devices/phone/log.json': fileContaining(phoneLog), - 'screen-locker-sync/devices/pc/log.json': fileContaining(pcLog), - }, - ); - final payloads = await WorkoutSyncService( - httpClient: httpClient, - ).readMergedManualPayloads(); - expect(payloads, hasLength(1)); - expect(payloads.single['cost'], 'NEW'); - }); - - test('readMergedWorkoutPayloads returns the PC runs too', () async { - // The PC publishes its whole workout_log.json, so the phone must be able - // to see verified runs — not just manual self-reports — or the two - // devices never show the same history. - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - final run = Record( - id: 'runnerup_verified:2026-07-13', - fields: { - 'payload': ( - { - 'kind': 'runnerup_verified', - 'date': '2026-07-13', - 'source': 'Running: 9.8 km in 55 min', - }, - Hlc(wallTimeMs: 3000, counter: 0, nodeId: 'pc'), - ), - }, - ); - final pcLog = jsonEncode({ - 'manual:a': manualRecord('manual:a', 'NEW', 2000).toJson(), - 'runnerup_verified:2026-07-13': run.toJson(), - }); - final (:httpClient, putCalls: _) = mockGitHub( - contentResponses: { - 'screen-locker-sync/devices': syncResponse(200, [ - {'name': 'pc'}, - ]), - 'screen-locker-sync/devices/pc/log.json': fileContaining(pcLog), - }, - ); - final service = syncService(httpClient: httpClient); - - final all = await service.readMergedWorkoutPayloads(); - expect(all, hasLength(2)); - expect( - all.map((p) => p['kind']), - containsAll(['manual_workout', 'runnerup_verified']), - ); - }); - - test( - 'readMergedManualPayloads still excludes runs (budget stays manual)', - () async { - // A verified run must never consume the manual self-report budget. - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - final run = Record( - id: 'runnerup_verified:2026-07-13', - fields: { - 'payload': ( - {'kind': 'runnerup_verified', 'date': '2026-07-13'}, - Hlc(wallTimeMs: 3000, counter: 0, nodeId: 'pc'), - ), - }, - ); - final pcLog = jsonEncode({ - 'manual:a': manualRecord('manual:a', 'NEW', 2000).toJson(), - 'runnerup_verified:2026-07-13': run.toJson(), - }); - final (:httpClient, putCalls: _) = mockGitHub( - contentResponses: { - 'screen-locker-sync/devices': syncResponse(200, [ - {'name': 'pc'}, - ]), - 'screen-locker-sync/devices/pc/log.json': fileContaining(pcLog), - }, - ); - final manuals = await WorkoutSyncService( - httpClient: httpClient, - ).readMergedManualPayloads(); - expect(manuals, hasLength(1)); - expect(manuals.single['kind'], 'manual_workout'); - }, - ); - - test('readMergedManualPayloads swallows a sync error', () async { - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - final httpClient = http_testing.MockClient( - (request) async => http.Response('', 500), - ); - expect( - await syncService( - httpClient: httpClient, - ).readMergedManualPayloads(), - isEmpty, - ); - }); - - test('recovers from the backup when the stored token is rejected', () async { - // A stale keystore token shadows a good backup (load() only falls back - // when the keystore is EMPTY), which would leave history silently empty. - final tempDir = Directory.systemTemp.createTempSync('sync_recover_'); - BackupService.baseDirForTesting = tempDir.path; - addTearDown(() { - BackupService.baseDirForTesting = kBackupDir; - tempDir.deleteSync(recursive: true); - }); - - installFakeSecureStorage(initial: {'sync.token': 'stale'}); - await const SyncSettings(token: 'good').save(); - installFakeSecureStorage(initial: {'sync.token': 'stale'}); - - final pcLog = jsonEncode({ - 'manual:a': manualRecord('manual:a', 'NEW', 2000).toJson(), - }); - final httpClient = http_testing.MockClient((req) async { - if (req.headers['Authorization']?.contains('good') != true) { - return http.Response('Bad credentials', 401); - } - if (req.url.path.endsWith('screen-locker-sync/devices')) { - return http.Response( - jsonEncode([ - {'name': 'pc', 'type': 'dir'}, - ]), - 200, - ); - } - return http.Response( - jsonEncode({ - 'content': base64Encode(utf8.encode(pcLog)), - 'sha': 'sha', - }), - 200, - ); - }); - - final payloads = await WorkoutSyncService( - httpClient: httpClient, - ).readMergedManualPayloads(); - - expect(payloads, hasLength(1)); - expect((await SyncSettings.load()).token, 'good'); - }); - - test('reports the failure when the backup token is rejected too', () async { - final tempDir = Directory.systemTemp.createTempSync('sync_recover_fail_'); - BackupService.baseDirForTesting = tempDir.path; - addTearDown(() { - BackupService.baseDirForTesting = kBackupDir; - tempDir.deleteSync(recursive: true); - }); - - installFakeSecureStorage(initial: {'sync.token': 'stale'}); - await const SyncSettings(token: 'also-bad').save(); - installFakeSecureStorage(initial: {'sync.token': 'stale'}); - - // Every token 401s, so the recovery retry fails too. - final httpClient = http_testing.MockClient( - (req) async => http.Response('Bad credentials', 401), - ); - - expect( - await syncService(httpClient: httpClient).readMergedManualPayloads(), - isEmpty, - ); - }); -} - -/// Regression tests for the one thing that went RIGHT on 2026-08-15. -/// -/// A manual workout was logged while the phone was disconnected. When -/// Firebase later connected, the entry was NOT erased -- the user called this -/// out as good behaviour worth keeping. It is currently correct by -/// construction (`MirrorStore.getFileText` falls through to the GitHub mirror -/// when the Firebase primary returns null, so a first connect reads the prior -/// log rather than an empty one), which is exactly why it needs pinning: -/// nothing else would fail if that fall-through were removed. -void _manualWorkoutSurvivalTests() { - group('connecting to Firebase does not erase local manual workouts', () { - /// A manual record already in this device's log, logged while offline. - Record priorManual() => Record( - id: 'manual:2026-08-01T10:00', - fields: { - 'payload': ( - {'kind': 'manual_workout', 'date': '2026-08-01'}, - Hlc.newTick('phone'), - ), - }, - ); - - Map decodeLogPut(List calls) => - jsonDecode( - utf8.decode( - base64.decode(logPut(calls).body['content'] as String), - ), - ) - as Map; - - test( - 'a first Firebase connect keeps a manual record only GitHub has', - () async { - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - final existingLog = jsonEncode({ - priorManual().id: priorManual().toJson(), - }); - final (:httpClient, :putCalls) = mockGitHub( - contentResponses: { - 'screen-locker-sync/devices': syncResponse(200, []), - 'screen-locker-sync/devices/phone/log.json': fileContaining( - existingLog, - ), - }, - ); - // Firebase answers 'null' to every GET -- the fresh-connect state, and - // the exact condition under which a naive implementation would treat - // the remote as empty and overwrite the log with just the new record. - final firebase = fakeFirebase(); - - final newManual = Record( - id: 'manual:2026-08-15T12:18', - fields: { - 'payload': ( - {'kind': 'manual_workout', 'date': '2026-08-15'}, - Hlc.newTick('phone'), - ), - }, - ); - final result = await syncService( - httpClient: httpClient, - firebaseFactory: () async => firebase.client, - ).pushManual(newManual); - - expect(result.pushed, isTrue); - final pushedLog = decodeLogPut(putCalls); - expect( - pushedLog.keys, - containsAll([priorManual().id, newManual.id]), - reason: 'the offline-logged manual workout must survive the connect', - ); - }, - ); - - test( - 'syncNow reports "not configured" rather than pretending to work', - () async { - installFakeSecureStorage(); - final (:httpClient, :putCalls) = mockGitHub(); - - final result = await syncService(httpClient: httpClient).syncNow(); - - expect(putCalls, isEmpty); - expect(result.pushed, isFalse); - // The card renders this reason, so it has to name the actual problem. - expect(result.reason, contains('not configured')); - }, - ); - - test('syncNow works on a device with no log yet', () async { - // Nothing to preserve on a brand-new device: the tick must still - // succeed rather than failing on the absent log. - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - final (:httpClient, :putCalls) = mockGitHub( - contentResponses: {'screen-locker-sync/devices': syncResponse(200, [])}, - ); - - final result = await syncService(httpClient: httpClient).syncNow(); - - expect(result.pushed, isTrue); - expect(result.reason, 'synced'); - }); - - test('syncNow reports the reason when the round trip fails', () async { - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - final httpClient = http_testing.MockClient( - (request) async => http.Response('', 500), - ); - - final result = await syncService(httpClient: httpClient).syncNow(); - - // Never throws into the UI, but never silent either: this string is - // exactly what the "Sync failed" card shows the user. - expect(result.pushed, isFalse); - expect(result.reason, contains('sync failed')); - }); - - test('a plain sync tick on first connect does not drop the log', () async { - // syncNow() pushes no new record, so if the fresh-connect read came - // back empty this would write an EMPTY log over a good one -- the - // worst version of the bug, triggered just by opening the app. - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - final existingLog = jsonEncode({ - priorManual().id: priorManual().toJson(), - }); - final (:httpClient, :putCalls) = mockGitHub( - contentResponses: { - 'screen-locker-sync/devices': syncResponse(200, []), - 'screen-locker-sync/devices/phone/log.json': fileContaining( - existingLog, - ), - }, - ); - final firebase = fakeFirebase(); - - final result = await syncService( - httpClient: httpClient, - firebaseFactory: () async => firebase.client, - ).syncNow(); - - expect(result.pushed, isTrue); - final logPuts = putCalls.where((c) => c.path.endsWith('log.json')); - if (logPuts.isNotEmpty) { - expect( - decodeLogPut(putCalls).keys, - contains(priorManual().id), - reason: 'a no-op tick must never blank the log', - ); - } - }); - }); -} - -void _cutoverTests() { - group('Firebase cutover', () { - test('pushes to Firebase and still mirrors to GitHub', () async { - // The cutover guarantee: both backends receive the write, so a PC that - // has not moved yet still sees the workout. - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - final (:httpClient, :putCalls) = mockGitHub(); - final firebase = fakeFirebase(); - - await syncService( - httpClient: httpClient, - firebaseFactory: () async => firebase.client, - ).push(workoutSession()); - - expect( - firebase.puts.any((p) => p.contains('screen-locker-sync')), - isTrue, - reason: 'Firebase is primary and must receive the write', - ); - expect( - putCalls, - isNotEmpty, - reason: 'GitHub must still be mirrored during the cutover', - ); - }); - - test('pushManual also mirrors to both backends', () async { - // pushManual is a separate path from push(); it needs its own proof - // that Firebase became primary rather than being skipped. - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - final (:httpClient, :putCalls) = mockGitHub(); - final firebase = fakeFirebase(); - - await syncService( - httpClient: httpClient, - firebaseFactory: () async => firebase.client, - ).pushManual( - Record( - id: 'manual:x', - fields: { - 'payload': ( - {'kind': 'manual_workout', 'date': '2026-07-13', 'cost': '40'}, - Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone'), - ), - }, - ), - ); - - expect( - firebase.puts.any((p) => p.contains('screen-locker-sync')), - isTrue, - ); - expect(putCalls, isNotEmpty); - }); - - test('reads merge both backends', () async { - // Read-only path: a workout logged against either backend must count. - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - final firebase = fakeFirebase(); - - final (:httpClient, :putCalls) = mockGitHub(); - final payloads = await syncService( - httpClient: httpClient, - firebaseFactory: () async => firebase.client, - ).readMergedWorkoutPayloads(); - - expect(payloads, isEmpty); - }); - }); }