diff --git a/lib/sync/firebase_backend.dart b/lib/sync/firebase_backend.dart new file mode 100644 index 0000000..a88b650 --- /dev/null +++ b/lib/sync/firebase_backend.dart @@ -0,0 +1,84 @@ +/// Wiring for the Firebase backend, during and after the GitHub cutover. +/// +/// Split by what is safe to publish, because this repo is public: +/// +/// * [kProject] holds the Web API key and database URL. Both are public +/// identifiers that already ship inside the APK; the security rules, not +/// their secrecy, are what protect the data. +/// * The account email and password are entered once per device and kept in +/// the OS keystore, next to the GitHub token this app already stores there. +/// +/// Nothing here reads `~/.config/crdt-sync/` — that is the desktop/Python +/// half. On Android there is no such file. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// The shared `kuhy-syncs` project. +/// +/// `databaseUrl` is the **regional** host. The plain `*.firebaseio.com` form +/// answers 404 with a `correctUrl` body rather than an obvious error, which +/// reads like an auth failure and wastes a debugging session. +const kProject = FirebaseProject( + apiKey: 'AIzaSyCF_sA3xCMehAYXK8eND-rAygb9NXXW_8E', + databaseUrl: + 'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app', +); + +/// Same options the GitHub token uses: off Android's deprecated +/// `encryptedSharedPreferences` path, and libsecret on Linux. +const _secure = FlutterSecureStorage(); + +// Everything below reaches the OS keystore through a platform channel, which +// `flutter test` has no binding for -- the same reason `main.dart` and +// `openRepository()` are excluded. The logic these wrap (parsing, the +// public/private split, sign-in) lives in `crdt_sync` and is covered there at +// 100%; what is left here is the two-line adapter. +// coverage:ignore-start + +/// The keystore-backed home for the Firebase refresh token. +SecureCredentialStore credentialStore() => SecureCredentialStore( + read: (key) => _secure.read(key: key), + write: (key, value) => _secure.write(key: key, value: value), + delete: (key) => _secure.delete(key: key), +); + +/// Reads the per-device account, or null when sync has not been set up. +Future loadAccount() async { + try { + return FirebaseAccount.tryParse( + await _secure.read(key: kFirebaseAccountKey), + ); + } on Exception { + // No secret service available: behave as "not configured" rather than + // crashing the settings screen. + return null; + } +} + +/// Stores the per-device account. Keystore only — never prefs, never source. +Future saveAccount(FirebaseAccount account) => + _secure.write(key: kFirebaseAccountKey, value: account.toJsonString()); + +/// Forgets the account and any cached session. +Future clearAccount() async { + await _secure.delete(key: kFirebaseAccountKey); + await credentialStore().clear(); +} + +/// Returns a signed-in Firebase client, or null when not configured. +/// +/// Signs in with the stored password only when there is no cached refresh +/// token, so the usual path costs no authentication round trip. +Future openFirebase() async { + final account = await loadAccount(); + if (account == null) return null; + return firebaseClientFor( + config: kProject.configFor(account.email), + store: credentialStore(), + password: account.password, + ); +} + +// coverage:ignore-end diff --git a/lib/sync/run_sync.dart b/lib/sync/run_sync.dart new file mode 100644 index 0000000..ef845d6 --- /dev/null +++ b/lib/sync/run_sync.dart @@ -0,0 +1,62 @@ +/// One place that decides *how* a sync runs, for every screen that +/// triggers one. +/// +/// The capture screen syncs on a timer and on demand, the settings screen on a +/// button; before this they each built their own client, so a change to the +/// backend meant editing four call sites and hoping none was missed. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:http/http.dart' as http; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/sync/firebase_backend.dart'; +import 'package:todo/sync/sync_service.dart'; +import 'package:todo/sync/sync_settings.dart'; +import 'package:todo/sync/sync_state_factory.dart'; + +/// Runs one sync, choosing the backend and carrying the revision cache. +/// +/// Firebase is the primary when this device has been set up for it, with +/// GitHub kept as a mirror so a device that has not moved yet still +/// converges. Not being set up is a normal state, not an error — sync runs +/// over GitHub exactly as it did before. +/// +/// [firebaseFactory] and [stateStore] exist for tests, which must not reach +/// the OS keystore or an application-support directory. +Future runSync( + NoteRepository repository, + SyncSettings settings, { + http.Client? httpClient, + Future Function()? firebaseFactory, + SyncStateStore? stateStore, +}) async { + final github = GitHubClient( + owner: settings.owner, + repo: settings.repo, + token: settings.token, + httpClient: httpClient, + ); + final factory = firebaseFactory ?? openFirebase; + final firebase = await factory(); + try { + final service = SyncService( + // The production default resolves a real application-support directory + // through path_provider, which has no binding under `flutter test`; + // every test injects a store instead. + // coverage:ignore-start + stateStore: stateStore ?? await openSyncStateStore(), + // coverage:ignore-end + ); + return await service.sync( + repository, + firebase == null + ? github + : MirrorStore(primary: firebase, mirror: github), + ); + } finally { + // Close both: unlike the long-lived desktop callers, each screen builds + // these per run. + github.close(); + firebase?.close(); + } +} diff --git a/lib/sync/sync_service.dart b/lib/sync/sync_service.dart index 4d8e3bc..ca31c36 100644 --- a/lib/sync/sync_service.dart +++ b/lib/sync/sync_service.dart @@ -9,6 +9,7 @@ class SyncResult { required this.mergedDevices, required this.pushed, this.skippedFiles = const [], + this.skippedUnchanged = 0, }); /// How many other devices' logs were pulled and merged. @@ -22,6 +23,12 @@ class SyncResult { /// Surfaced so a device silently dropping out of sync is visible. final List skippedFiles; + /// Peers whose logs were skipped because their revision was unchanged. + /// + /// Distinct from [skippedFiles]: this is the optimisation working, not a + /// device dropping out. + final int skippedUnchanged; + @override String toString() => 'SyncResult(mergedDevices: $mergedDevices, pushed: $pushed, ' @@ -42,50 +49,118 @@ class SyncResult { /// two never share a path and an old client can't misread a new file. class SyncService { /// Creates a [SyncService], optionally overriding [notesDir]. - const SyncService({this.notesDir = 'todo-sync/notes'}); + const SyncService({this.notesDir = 'todo-sync/notes', this.stateStore}); /// Directory in the repo under which per-device note-log files live. final String notesDir; + /// Remembers each peer's last-seen revision between runs. + /// + /// Without one, every sync downloads every peer's whole log — four files of + /// roughly 150 KB here — whether or not anything changed. With one, an + /// unchanged peer costs nothing beyond the shared revision map, which is + /// what keeps this inside the Firebase free tier's monthly budget. + final SyncStateStore? stateStore; + + /// Where revisions live, as a sibling of [notesDir]. + String get revsDir => defaultRevsPath(notesDir); + /// Runs a full pull-merge-push cycle. Safe to call repeatedly. - Future sync(NoteRepository repo, GitHubClient github) async { + /// + /// [remote] is any [RemoteStore]: GitHub alone before the cutover, or a + /// [MirrorStore] with Firebase primary during it. Swapping backends is a + /// change at the call site and nothing more. + Future sync(NoteRepository repo, RemoteStore remote) async { final nodeId = repo.nodeId; final ownFileName = '$nodeId.json'; + final state = await stateStore?.load() ?? const SyncState(); + final remoteRevs = stateStore == null + ? const {} + : await _revisions(remote); + final seenRevs = {}; + // 1. Pull: list all device log files, merge everyone else's. - final names = await github.listDirectory(notesDir); + final names = await remote.listDirectory(notesDir); var merged = 0; + var skippedUnchanged = 0; final skipped = []; for (final name in names) { if (name == ownFileName) continue; // our own file; skip it. - final text = await github.getFileText('$notesDir/$name'); - final remote = text == null ? null : _decodeLog(text); - if (remote == null) { + final peer = name.endsWith('.json') + ? name.substring(0, name.length - 5) + : name; + final remoteRev = remoteRevs[peer]; + if (remoteRev != null && remoteRev == state.peerRevs[peer]) { + // Unchanged since we last merged it, and that merge is already in the + // local log — so the ~150 KB download is pure waste. Carry the + // revision forward so it stays skipped next time. + seenRevs[peer] = remoteRev; + skippedUnchanged++; + continue; + } + final text = await remote.getFileText('$notesDir/$name'); + final remoteLog = text == null ? null : _decodeLog(text); + if (remoteLog == null) { // A vanished or undecodable peer file must not abort the sync, but // it must not vanish silently either — that's how a device drops // out of sync unnoticed. Report it in the result instead. + // Deliberately not recorded as seen: a corrupt push must be retried + // next run, not remembered as merged. skipped.add(name); continue; } - await repo.importLog(remote); + await repo.importLog(remoteLog); + seenRevs[peer] = remoteRev ?? revisionOf(text!); merged++; } // 2. Push: upload our own (now-merged) log under our node id. // putFileText resolves our file's current sha itself. - await github.putFileText( - '$notesDir/$ownFileName', - logToJson(repo.exportLog()), - message: 'sync: $nodeId @ ${DateTime.now().toUtc().toIso8601String()}', - ); + final encoded = logToJson(repo.exportLog()); + final revision = revisionOf(encoded); + final unchanged = stateStore != null && revision == state.pushedRev; + if (!unchanged) { + await remote.putFileText( + '$notesDir/$ownFileName', + encoded, + message: 'sync: $nodeId @ ${DateTime.now().toUtc().toIso8601String()}', + ); + // Published after the log, never before: a peer that cached "seen rev X" + // against a log it never received would skip it forever. + if (stateStore != null) { + await remote.putFileText( + '$revsDir/$nodeId', + revision, + message: 'sync: revision $nodeId', + ); + } + } + + if (stateStore != null) { + await stateStore!.save( + SyncState(pushedRev: revision, peerRevs: seenRevs), + ); + } return SyncResult( mergedDevices: merged, - pushed: true, + pushed: !unchanged, skippedFiles: skipped, + skippedUnchanged: skippedUnchanged, ); } + /// Reads every peer's published revision in one request where possible. + /// + /// Degrades to an empty map — meaning "fetch everything", the old + /// behaviour — on a backend without a bulk-map read, so correctness never + /// depends on the optimisation being available. + Future> _revisions(RemoteStore remote) async { + if (remote is! BulkMapReader) return const {}; + return (remote as BulkMapReader).getStringMap(revsDir); + } + /// Parses a remote note log, returning `null` for a corrupt or wrong-shape /// payload so one bad device file never aborts the whole sync. Log? _decodeLog(String text) { diff --git a/lib/sync/sync_state_factory.dart b/lib/sync/sync_state_factory.dart new file mode 100644 index 0000000..528ae67 --- /dev/null +++ b/lib/sync/sync_state_factory.dart @@ -0,0 +1,9 @@ +/// Platform entry point for the sync revision cache. +/// +/// A conditional export because `dart:io` cannot even be *imported* in a web +/// compile: Android gets a file, the Chrome-wrapper desktop build gets a +/// `SharedPreferences` entry. +library; + +export 'sync_state_factory_io.dart' + if (dart.library.js_interop) 'sync_state_factory_web.dart'; diff --git a/lib/sync/sync_state_factory_io.dart b/lib/sync/sync_state_factory_io.dart new file mode 100644 index 0000000..d03214e --- /dev/null +++ b/lib/sync/sync_state_factory_io.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:crdt_sync/crdt_sync_io.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// File holding the revision cache, beside the note log it describes. +const kSyncStateFileName = 'sync_state.json'; + +/// Opens the revision cache on a `dart:io` platform (Android). +// coverage:ignore-start +// Resolves the real per-platform application-support directory, so it cannot +// run under test; [openSyncStateStoreIn] holds the logic and is covered. +Future openSyncStateStore() async { + final dir = await getApplicationSupportDirectory(); + return openSyncStateStoreIn(dir.path); +} +// coverage:ignore-end + +/// Opens the revision cache rooted at [dirPath]. +/// +/// Lives next to the log it describes and must be cleared with it: skipping +/// an unchanged peer is only sound because that peer's records are already +/// merged into the local log, so state that outlived its log would skip peers +/// whose data had been lost. +SyncStateStore openSyncStateStoreIn(String dirPath) => PersistedSyncStateStore( + FileLogPersistence(File(p.join(dirPath, kSyncStateFileName))), +); diff --git a/lib/sync/sync_state_factory_web.dart b/lib/sync/sync_state_factory_web.dart new file mode 100644 index 0000000..ba072e9 --- /dev/null +++ b/lib/sync/sync_state_factory_web.dart @@ -0,0 +1,27 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Key holding the revision cache in `SharedPreferences`. +const kSyncStateKey = 'sync.state'; + +/// Opens the revision cache on web (the Chrome-wrapper desktop build). +/// +/// `SharedPreferences` rather than IndexedDB: the payload is two short hashes +/// per peer, so a key-value entry is the whole requirement, and the note +/// log already owns the IndexedDB store. +Future openSyncStateStore() async => PersistedSyncStateStore( + _PrefsPersistence(await SharedPreferences.getInstance()), +); + +class _PrefsPersistence implements LogPersistence { + _PrefsPersistence(this._prefs); + + final SharedPreferences _prefs; + + @override + Future read() async => _prefs.getString(kSyncStateKey); + + @override + Future write(String text) async => + _prefs.setString(kSyncStateKey, text); +} diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index bc20761..37819e3 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -10,6 +10,7 @@ import 'package:todo/data/note_template.dart'; import 'package:todo/frame_stats.dart'; import 'package:todo/sync/local_backup.dart'; import 'package:todo/sync/local_backup_factory.dart'; +import 'package:todo/sync/run_sync.dart'; import 'package:todo/sync/sync_service.dart'; import 'package:todo/sync/sync_settings.dart'; import 'package:todo/ui/note_editor.dart'; @@ -30,12 +31,21 @@ class CaptureScreen extends StatefulWidget { required this.repository, this.httpClient, this.localBackup, + this.firebaseFactory, + this.stateStore, super.key, }); /// The store the captured note is persisted to on every keystroke. final NoteRepository repository; + /// Builds the Firebase backend. Injected so tests can supply a fake, or + /// null to assert the pre-migration GitHub-only path still works. + final Future Function()? firebaseFactory; + + /// Revision cache. Injected so tests need no application-support directory. + final SyncStateStore? stateStore; + /// Injectable HTTP client for the sync path. Production leaves this null /// (the GitHubClient creates its own); tests pass a mock so the configured /// sync flow can be exercised without real network access. @@ -87,7 +97,6 @@ class _CaptureScreenState extends State Priority _draftPriority = Priority.defaultValue; Status _draftStatus = Status.todo; - final SyncService _syncService = const SyncService(); SyncSettings? _settings; bool _syncing = false; @@ -224,21 +233,20 @@ class _CaptureScreenState extends State final settings = _settings; if (_autoSyncing || settings == null || !settings.isConfigured) return; _autoSyncing = true; - final client = GitHubClient( - owner: settings.owner, - repo: settings.repo, - token: settings.token, - httpClient: widget.httpClient, - ); try { - final result = await _syncService.sync(widget.repository, client); + final result = await runSync( + widget.repository, + settings, + httpClient: widget.httpClient, + firebaseFactory: widget.firebaseFactory, + stateStore: widget.stateStore, + ); await _recordSyncStatus(ok: true, detail: _describe(result)); } on Exception catch (e) { // Offline or a transient GitHub error: still no snackbar (this path // must never interrupt capture), but the status line shows it. await _recordSyncStatus(ok: false, detail: '$e'); } finally { - client.close(); _autoSyncing = false; } } @@ -283,14 +291,14 @@ class _CaptureScreenState extends State return; } setState(() => _syncing = true); - final client = GitHubClient( - owner: settings.owner, - repo: settings.repo, - token: settings.token, - httpClient: widget.httpClient, - ); try { - final result = await _syncService.sync(widget.repository, client); + final result = await runSync( + widget.repository, + settings, + httpClient: widget.httpClient, + firebaseFactory: widget.firebaseFactory, + stateStore: widget.stateStore, + ); final detail = _describe(result); await _recordSyncStatus(ok: true, detail: detail); _showSnack('Synced: $detail'); @@ -298,7 +306,6 @@ class _CaptureScreenState extends State await _recordSyncStatus(ok: false, detail: '$e'); _showSnack('Sync failed: $e'); } finally { - client.close(); if (mounted) setState(() => _syncing = false); } } diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index 0581cc8..de2ddd1 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -9,7 +9,7 @@ import 'package:todo/data/note_repository.dart'; import 'package:todo/sync/backlog_export.dart'; import 'package:todo/sync/github_device_auth.dart'; import 'package:todo/sync/notes_markdown.dart'; -import 'package:todo/sync/sync_service.dart'; +import 'package:todo/sync/run_sync.dart'; import 'package:todo/sync/sync_settings.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -25,6 +25,8 @@ class SettingsScreen extends StatefulWidget { required this.initial, required this.repository, this.httpClient, + this.firebaseFactory, + this.stateStore, super.key, }); @@ -34,6 +36,13 @@ class SettingsScreen extends StatefulWidget { /// The store backup export/import reads from and writes to. final NoteRepository repository; + /// Builds the Firebase backend. Injected so tests can supply a fake, or + /// null to assert the pre-migration GitHub-only path still works. + final Future Function()? firebaseFactory; + + /// Revision cache. Injected so tests need no application-support directory. + final SyncStateStore? stateStore; + /// Optional HTTP client for the GitHub calls (test-connection and device /// flow). Injected by tests; production uses each client's default. final http.Client? httpClient; @@ -113,14 +122,14 @@ class _SettingsScreenState extends State { /// immediately and they get clear confirmation it worked. Future _syncAfterConnect() async { final s = _current; - final client = GitHubClient( - owner: s.owner, - repo: s.repo, - token: s.token, - httpClient: widget.httpClient, - ); try { - final result = await const SyncService().sync(widget.repository, client); + final result = await runSync( + widget.repository, + s, + httpClient: widget.httpClient, + firebaseFactory: widget.firebaseFactory, + stateStore: widget.stateStore, + ); if (mounted) { setState( () => _status = @@ -130,8 +139,6 @@ class _SettingsScreenState extends State { } } on Exception catch (e) { if (mounted) setState(() => _status = 'Connected, but sync failed: $e'); - } finally { - client.close(); } } diff --git a/pubspec.lock b/pubspec.lock index eb13eac..17f56de 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -101,11 +101,11 @@ packages: dependency: "direct main" description: path: crdt_sync_dart - ref: "crdt_sync_dart-v0.3.0" - resolved-ref: "5480e723354c31e2a38dd2757d5d2c75a04e9618" + ref: "crdt_sync_dart-v0.6.0" + resolved-ref: fc99eff00599a4c3195e4c1573ba08f15909a10d url: "https://github.com/kuhyx/utils" source: git - version: "0.3.0" + version: "0.6.0" cross_file: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bf5e00f..ae93879 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: crdt_sync: git: url: https://github.com/kuhyx/utils - ref: crdt_sync_dart-v0.3.0 + ref: crdt_sync_dart-v0.6.0 path: crdt_sync_dart # The following adds the Cupertino Icons font to your application. diff --git a/test/capture_screen_test.dart b/test/capture_screen_test.dart index a5a4f93..94a49a7 100644 --- a/test/capture_screen_test.dart +++ b/test/capture_screen_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -27,6 +28,7 @@ void main() { List seed = const [], FakeNoteRepository? repository, LocalBackup? localBackup, + Future Function()? firebaseFactory, }) async { SharedPreferences.setMockInitialValues(prefs); installFakeSecureStorage(); @@ -53,6 +55,11 @@ void main() { home: CaptureScreen( repository: repo, httpClient: httpClient, + // Both injected so the widget never reaches for the platform: the + // real factories want the OS keystore and an application-support + // directory, neither of which exists under `flutter test`. + firebaseFactory: firebaseFactory ?? () async => null, + stateStore: InMemorySyncStateStore(), localBackup: backup, ), ), @@ -343,7 +350,10 @@ void main() { await tester.pump(); await tester.pump(); - expect(methods, contains('PUT')); + // A sync ran. Not `contains('PUT')`: the launch sync above already pushed + // this log, so revision tracking correctly suppresses the second, unchanged + // push -- that suppression is the point of the revision cache. + expect(methods, isNotEmpty); }); testWidgets('auto-sync failure shows no snackbar but flags the status line', ( @@ -418,7 +428,10 @@ void main() { await tester.pump(); await tester.pump(); - expect(methods, contains('PUT')); + // A sync ran. Not `contains('PUT')`: the first sync above already pushed + // this log, so revision tracking correctly suppresses the second, unchanged + // push -- that suppression is the point of the revision cache. + expect(methods, isNotEmpty); }); testWidgets('recovers notes from the local backup into an empty DB', ( diff --git a/test/run_sync_test.dart b/test/run_sync_test.dart new file mode 100644 index 0000000..eec036a --- /dev/null +++ b/test/run_sync_test.dart @@ -0,0 +1,95 @@ +/// Tests for the one place that decides *how* a sync runs. +/// +/// The branch that matters is which backend ends up authoritative: a rollback +/// depends on getting exactly that right. +@TestOn('vm') +library; + +import 'dart:convert'; + +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:todo/data/note_repository.dart'; +import 'package:todo/sync/run_sync.dart'; +import 'package:todo/sync/sync_settings.dart'; + +const _settings = SyncSettings(owner: 'kuhyx', repo: 'syncs', token: 'tok'); + +void main() { + late NoteRepository repo; + + setUp(() async { + repo = await NoteRepository.openInMemory(nodeId: 'me'); + }); + + tearDown(() async => repo.close()); + + test('syncs over GitHub alone when Firebase is not set up', () async { + // The pre-migration path, and the rollback: no Firebase, no mirror. + final paths = []; + final result = await runSync( + repo, + _settings, + httpClient: http_testing.MockClient((request) async { + paths.add('${request.method} ${request.url.host}'); + return http.Response(jsonEncode({'content': '', 'sha': 'x'}), 200); + }), + firebaseFactory: () async => null, + stateStore: InMemorySyncStateStore(), + ); + + expect(result.pushed, isTrue); + expect(paths.every((p) => p.contains('api.github.com')), isTrue); + }); + + test('makes Firebase primary and still mirrors to GitHub', () async { + // The cutover guarantee: both backends receive the write. + final githubPaths = []; + final firebasePaths = []; + final firebase = FirebaseRestClient( + databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app', + auth: FirebaseTokenProvider( + apiKey: 'AIzaKey', + store: InMemoryCredentialStore( + FirebaseCredentials( + idToken: 'id', + refreshToken: 'refresh', + // Real clock: the provider compares against DateTime.now(), so a + // fixture-dated session would look expired and force a refresh. + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ), + ), + ), + httpClient: http_testing.MockClient((request) async { + firebasePaths.add('${request.method} ${request.url.path}'); + if (request.method == 'PUT') return http.Response(request.body, 200); + return http.Response('null', 200); + }), + ); + + final result = await runSync( + repo, + _settings, + httpClient: http_testing.MockClient((request) async { + githubPaths.add(request.method); + return http.Response(jsonEncode({'content': '', 'sha': 'x'}), 200); + }), + firebaseFactory: () async => firebase, + stateStore: InMemorySyncStateStore(), + ); + + expect(result.pushed, isTrue); + expect( + firebasePaths.any((p) => p.startsWith('PUT')), + isTrue, + reason: 'Firebase is primary and must receive the write', + ); + expect( + githubPaths, + contains('PUT'), + reason: 'GitHub must still be mirrored during the cutover', + ); + }); +} diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart index 1ea3ab8..8d12cdb 100644 --- a/test/settings_screen_test.dart +++ b/test/settings_screen_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:convert'; import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -104,6 +105,11 @@ void main() { initial: initial, repository: repo, httpClient: httpClient, + // Both injected so the widget never reaches for the platform: the + // real factories want the OS keystore and an application-support + // directory, neither of which exists under `flutter test`. + firebaseFactory: () async => null, + stateStore: InMemorySyncStateStore(), ), ), ); @@ -365,6 +371,8 @@ void main() { token: 'tok', ), repository: repo, + firebaseFactory: () async => null, + stateStore: InMemorySyncStateStore(), ), ), ), diff --git a/test/sync_service_test.dart b/test/sync_service_test.dart index 4444d1f..f93b7cd 100644 --- a/test/sync_service_test.dart +++ b/test/sync_service_test.dart @@ -55,6 +55,7 @@ Note _note(String id, String text) => Note( ); void main() { + _revisionTrackingTests(); test('pulls and merges another device, then pushes its own', () async { final other = await NoteRepository.openInMemory(nodeId: 'otherNode'); await other.upsert(_note('x', 'from other device')); @@ -153,3 +154,182 @@ void main() { expect(putCalls, hasLength(1)); // the push still happened }); } + +/// An in-memory [RemoteStore] that also serves revision maps in one read, +/// standing in for Firebase. Counts reads so tests can assert on *traffic* +/// rather than on the merge result. +class _FakeRemote implements RemoteStore, BulkMapReader { + _FakeRemote(this.files); + + final Map files; + final List reads = []; + final List writes = []; + + @override + Future> listDirectory(String path) async => files.keys + .where((k) => k.startsWith('$path/')) + .map((k) => k.substring(path.length + 1).split('/').first) + .toSet() + .toList(); + + @override + Future getFileText(String path) async { + reads.add(path); + return files[path]; + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + writes.add(path); + files[path] = text; + } + + @override + Future> getStringMap(String path) async => { + for (final e in files.entries) + if (e.key.startsWith('$path/')) e.key.substring(path.length + 1): e.value, + }; + + @override + Future deleteFile(String path, {String message = ''}) async => + files.remove(path); + + @override + Future canAccessRemote() async => true; + + @override + void close() {} +} + +/// A [RemoteStore] with no bulk-map read, standing in for GitHub. +/// +/// Delegates rather than extends: a subclass of [_FakeRemote] would inherit +/// `BulkMapReader`, the very capability this fake exists to lack. +class _FakeRemoteWithoutBulkRead implements RemoteStore { + _FakeRemoteWithoutBulkRead(Map files) + : _inner = _FakeRemote(files); + + final _FakeRemote _inner; + + List get reads => _inner.reads; + + @override + Future> listDirectory(String path) => _inner.listDirectory(path); + + @override + Future getFileText(String path) => _inner.getFileText(path); + + @override + Future putFileText( + String path, + String text, { + required String message, + }) => _inner.putFileText(path, text, message: message); + + @override + Future deleteFile(String path, {String message = ''}) => + _inner.deleteFile(path, message: message); + + @override + Future canAccessRemote() => _inner.canAccessRemote(); + + @override + void close() => _inner.close(); +} + +void _revisionTrackingTests() { + group('revision tracking', () { + late NoteRepository repo; + + setUp(() async { + repo = await NoteRepository.openInMemory(nodeId: 'me'); + }); + + tearDown(() async => repo.close()); + + test('skips a peer whose revision has not changed', () async { + // The saving the free-tier budget depends on: an unchanged ~150 KB peer + // log must not be downloaded again. + final peerLog = logToJson(await _peerLog()); + final remote = _FakeRemote({ + 'todo-sync/notes/phone.json': peerLog, + 'todo-sync/revs/phone': revisionOf(peerLog), + }); + final store = InMemorySyncStateStore(); + + await SyncService(stateStore: store).sync(repo, remote); + remote.reads.clear(); + final second = await SyncService(stateStore: store).sync(repo, remote); + + expect(remote.reads, isNot(contains('todo-sync/notes/phone.json'))); + expect(second.skippedUnchanged, 1); + expect(second.mergedDevices, 0); + }); + + test('downloads again once the peer publishes a new revision', () async { + final first = logToJson(await _peerLog()); + final remote = _FakeRemote({ + 'todo-sync/notes/phone.json': first, + 'todo-sync/revs/phone': revisionOf(first), + }); + final store = InMemorySyncStateStore(); + await SyncService(stateStore: store).sync(repo, remote); + remote.reads.clear(); + + final changed = logToJson(await _peerLog(title: 'changed')); + remote.files['todo-sync/notes/phone.json'] = changed; + remote.files['todo-sync/revs/phone'] = revisionOf(changed); + final second = await SyncService(stateStore: store).sync(repo, remote); + + expect(remote.reads, contains('todo-sync/notes/phone.json')); + expect(second.mergedDevices, 1); + }); + + test('suppresses an unchanged push and publishes a revision', () async { + final remote = _FakeRemote({}); + final store = InMemorySyncStateStore(); + + final first = await SyncService(stateStore: store).sync(repo, remote); + expect(first.pushed, isTrue); + expect(remote.writes, [ + 'todo-sync/notes/me.json', + 'todo-sync/revs/me', + ]); + remote.writes.clear(); + + final second = await SyncService(stateStore: store).sync(repo, remote); + + expect(second.pushed, isFalse); + expect(remote.writes, isEmpty); + }); + + test('still syncs on a backend without a bulk-map read', () async { + // GitHub has no cheap revision map; correctness must not depend on the + // optimisation being available. + final peerLog = logToJson(await _peerLog()); + final remote = _FakeRemoteWithoutBulkRead({ + 'todo-sync/notes/phone.json': peerLog, + }); + + final result = await SyncService( + stateStore: InMemorySyncStateStore(), + ).sync(repo, remote); + + expect(result.mergedDevices, 1); + expect(remote.reads, contains('todo-sync/notes/phone.json')); + }); + }); +} + +/// Builds a one-note log as it would arrive from another device. +Future _peerLog({String title = 'from phone'}) async { + final peer = await NoteRepository.openInMemory(nodeId: 'phone'); + await peer.upsert(_note('peer-note', title)); + final log = peer.exportLog(); + await peer.close(); + return log; +} diff --git a/test/sync_state_factory_io_test.dart b/test/sync_state_factory_io_test.dart new file mode 100644 index 0000000..837c6bf --- /dev/null +++ b/test/sync_state_factory_io_test.dart @@ -0,0 +1,54 @@ +/// Tests for the file-backed revision cache. +/// +/// The cache is what keeps a sync tick from re-downloading every peer's whole +/// log when nothing has changed, so "does it actually survive a restart" is +/// the behaviour worth pinning. +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/sync/sync_state_factory_io.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory dir; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('todo_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 everything. + await openSyncStateStoreIn(dir.path).save( + const SyncState(pushedRev: 'mine', peerRevs: {'phone': 'theirs'}), + ); + + final reloaded = await openSyncStateStoreIn(dir.path).load(); + + expect(reloaded.pushedRev, 'mine'); + expect(reloaded.peerRevs, {'phone': '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('reports nothing remembered before the first sync', () async { + final state = await openSyncStateStoreIn(dir.path).load(); + + expect(state.pushedRev, isNull); + expect(state.peerRevs, isEmpty); + }); +}