mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +01:00
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:
parent
0c49039378
commit
1e72b61826
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user