diet-guard/app/test/services/sync_state_factory_test.dart
Krzysztof kuhy Rudnicki da0b54d886
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Sync meals and the food bank through Firebase, mirroring to GitHub
The largest consumer moves last on purpose: diet_guard was 71% of all sync
traffic, and both its surfaces (the PC timer and the phone app) share one
prefix, so they move together.

Firebase becomes the primary backend when it is set up, with GitHub kept as a
mirror so a device that has not moved yet still converges. An unconfigured
device keeps syncing exactly as before, which is also the rollback.

Revision tracking is new here. The PC hand-rolled its own pull-merge-push
instead of calling sync_log, so it had none: every tick re-downloaded every
peer's whole food log -- hundreds of KB, 96 times a day -- and re-pushed its
own regardless of change. It now reads one small revision map first, skips any
peer whose hash is unchanged, and suppresses a no-op push. Every sub-sync
(log, budget, food bank, manual bank) takes the client as a parameter, so one
swap covers all four.

The app could not have reached Firebase at all: nothing called saveAccount(),
so loadAccount() always returned null and openFirebase() always returned null.
Settings now has a Firebase section that stores the account in the OS keystore
and signs in immediately, so a typo surfaces on the screen where it can be
fixed rather than as a silently failed background sync hours later. The email
and password never touch the repo -- it is public.

Platform reaches now degrade instead of failing: an unavailable keystore or
application-support directory costs one tick of extra traffic, where before it
would have taken sync down entirely. Caught by `on Object` rather than
`on Exception`, because an uninitialised binding raises an Error.

Verified end to end against the real database, not just in tests: a live PC
sync merged 57 days / 339 entries and put 221 KB of food log plus 97 food-bank
records into Firebase; a second unchanged tick issued zero pushes; and the
Dart client signed in and read back all 339 meals and 97 foods, then completed
a push / publish-revision / suppress-no-op round trip under a throwaway prefix.

diet_guard: 618 tests at 100% branch coverage.
diet_guard_app: 477 tests. Two assertions moved from 4 pushes to 5 -- the
extra one is this device's revision, published after the log so a peer can
never cache "seen rev X" against a log it never received.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-05 08:35:21 +02:00

90 lines
2.7 KiB
Dart

/// Tests for the revision cache and the backend choice.
///
/// The cache is what keeps a sync tick from re-downloading every peer's whole
/// food log when nothing changed, so "does it survive a restart" and "does a
/// dead platform channel take sync down with it" are the behaviours worth
/// pinning.
@TestOn('vm')
library;
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/services/firebase_backend.dart';
import 'package:diet_guard_app/services/sync_state_factory_io.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
/// A do-nothing [RemoteStore], standing in for the GitHub client.
class _StubRemote implements RemoteStore {
@override
Future<List<String>> listDirectory(String path) async => [];
@override
Future<String?> getFileText(String path) async => null;
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {}
@override
Future<void> deleteFile(String path, {String message = ''}) async {}
@override
Future<bool> canAccessRemote() async => true;
@override
void close() {}
}
void main() {
late Directory dir;
setUp(() async {
dir = await Directory.systemTemp.createTemp('dg_state_');
});
tearDown(() async {
if (dir.existsSync()) await dir.delete(recursive: true);
});
test('persists revisions across a restart', () async {
// A fresh store instance stands in for the next app launch: an in-memory
// cache would forget every peer and re-download all of them.
await openSyncStateStoreIn(dir.path).save(
const SyncState(pushedRev: 'mine', peerRevs: {'pc': 'theirs'}),
);
final reloaded = await openSyncStateStoreIn(dir.path).load();
expect(reloaded.pushedRev, 'mine');
expect(reloaded.peerRevs, {'pc': 'theirs'});
});
test('writes beside the log it describes', () async {
await openSyncStateStoreIn(dir.path).save(const SyncState(pushedRev: 'x'));
expect(File(p.join(dir.path, kSyncStateFileName)).existsSync(), isTrue);
});
test('degrades to memory when the platform channel is unavailable', () async {
// No binding under `flutter test`. Losing the cache costs one tick of
// extra traffic; failing would take sync down entirely.
final store = await openSyncStateStore();
expect(store, isA<SyncStateStore>());
expect((await store.load()).pushedRev, isNull);
});
test('stays on GitHub when Firebase is not set up', () async {
// The pre-migration path, and the rollback. Reaching the keystore without
// a binding must read as "not configured", not as an error.
final github = _StubRemote();
expect(await syncBackend(github), same(github));
});
}