Split workout_sync_service_test.dart (740) into four

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 22:09:54 +02:00
parent 0c49039378
commit 1e72b61826
4 changed files with 504 additions and 437 deletions

View File

@ -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);
});
});
}

View File

@ -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<String, dynamic> decodeLogPut(List<PutCall> calls) =>
jsonDecode(
utf8.decode(
base64.decode(logPut(calls).body['content'] as String),
),
)
as Map<String, dynamic>;
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',
);
}
});
});
}

View File

@ -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(<String>['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,
);
});
}

View File

@ -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(<String>['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<String, dynamic> decodeLogPut(List<PutCall> calls) =>
jsonDecode(
utf8.decode(
base64.decode(logPut(calls).body['content'] as String),
),
)
as Map<String, dynamic>;
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);
});
});
}