From eb4ff01e04da019a87f34249e507b682dce6510b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 5 Aug 2026 08:12:40 +0200 Subject: [PATCH] Sync workouts through Firebase, mirroring to GitHub during the cutover Both surfaces of this repo move together, because they share one prefix: screen_locker (the PC, which reads what the phone logged) and workout_app (the phone, which writes). Firebase becomes the primary backend when it is set up, with GitHub kept as a mirror so a device that has not moved yet is still seen. MirrorSyncClient reads the union of both, so a workout logged against either backend still counts -- which is what lets the PC and the phone cut over on different days. The PC's two pulls are read-only, so they need only the client swapped. _manual_push does a real sync_log and now carries a revision cache, as does every push in workout_app: without it each push re-uploads the whole log and each pull re-downloads every peer's, regardless of change. remote_client is public rather than _private because _manual_push imports it; one decision point beats two copies that can drift. The config file is checked before constructing anything, so an unconfigured machine never reaches the network -- without that guard the PC's sync tests asserted against live Firebase data instead of their own fakes, which is how this was caught. screen_locker: 904 tests at 100% branch coverage. workout_app: 299 tests at 100% line coverage; pinned v0.1.0 -> v0.6.0 across three minor versions with no API drift. Four assertions moved off `.single`, which now finds two writes: the log, then the revision published after it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- screen_locker/_constants.py | 7 + screen_locker/_manual_push.py | 20 +- screen_locker/_workout_sync.py | 63 ++++- screen_locker/tests/conftest.py | 19 +- screen_locker/tests/test_workout_sync.py | 51 +++- .../lib/services/firebase_backend.dart | 84 ++++++ .../lib/services/sync_state_factory.dart | 34 +++ .../lib/services/workout_sync_service.dart | 61 ++++- .../workout_app/pubspec.lock | 6 +- .../workout_app/pubspec.yaml | 2 +- .../services/sync_state_factory_test.dart | 54 ++++ .../services/workout_sync_service_test.dart | 248 ++++++++++++++---- 15 files changed, 567 insertions(+), 88 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/services/firebase_backend.dart create mode 100644 stronglift_replacement/workout_app/lib/services/sync_state_factory.dart create mode 100644 stronglift_replacement/workout_app/test/services/sync_state_factory_test.dart diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9154677..a6f4a91 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -100,7 +100,7 @@ repos: additional_dependencies: - pytest - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock - - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.3.0#subdirectory=crdt-sync + - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync - mcp>=2,<3 - pydantic diff --git a/pyproject.toml b/pyproject.toml index c83ed03..e4f34cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ requires-python = ">=3.10" # also used by diet_guard; here for phone-workout GitHub sync (Workstream C) dependencies = [ "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock", - "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.3.0#subdirectory=crdt-sync", + "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index 36691ac..af4babb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ bandit>=1.7.0 codespell>=2.2.0 coverage>=7.4.0 -crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.3.0#subdirectory=crdt-sync +crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock mcp>=2,<3 mypy>=1.8.0 diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index e338028..82400d6 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -130,6 +130,13 @@ SYNC_PHONE_DEVICE_ID: str = "phone" # committed — this path is outside the repo entirely. Unlike diet_guard, # this file being absent is a normal, expected state (sync is optional here). SYNC_TOKEN_FILE: Path = Path.home() / ".config" / "screen_locker" / "sync_token" + +# Revision cache for cross-device sync. Without it every push re-uploads and +# every pull re-downloads regardless of change, which is the traffic the +# Firebase free tier's monthly budget depends on not happening. +SYNC_STATE_FILE: Path = ( + Path.home() / ".local" / "share" / "screen_locker" / "sync_state.json" +) SYNC_TIMEOUT_SECONDS: float = 10.0 # Sync runs from the morning routine, which is started right after boot and diff --git a/screen_locker/_manual_push.py b/screen_locker/_manual_push.py index 0b7bb4c..c6fc956 100644 --- a/screen_locker/_manual_push.py +++ b/screen_locker/_manual_push.py @@ -28,10 +28,12 @@ import logging from typing import TYPE_CHECKING from crdt_sync import ( + FileSyncStateStore, GitHubSyncClient, GitHubSyncError, Hlc, Record, + RemoteSyncError, RepoNotFoundError, sync_log, ) @@ -39,6 +41,7 @@ from crdt_sync import ( from screen_locker._constants import ( SYNC_REPO_NAME, SYNC_REPO_OWNER, + SYNC_STATE_FILE, SYNC_TIMEOUT_SECONDS, SYNC_TOKEN_FILE, ) @@ -46,7 +49,7 @@ from screen_locker._log_io import load_workout_log from screen_locker._log_mixin import _derive_workout_id from screen_locker._sync_retry import with_sync_retry from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES -from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token +from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token, remote_client if TYPE_CHECKING: from pathlib import Path @@ -175,11 +178,13 @@ def push_pc_workouts(log_file: Path) -> PushResult: _logger.warning("Workouts NOT synced: %s", reason) return PushResult(pushed=False, record_count=0, reason=reason) - client = GitHubSyncClient( - SYNC_REPO_OWNER, - SYNC_REPO_NAME, - token, - timeout_seconds=SYNC_TIMEOUT_SECONDS, + client = remote_client( + GitHubSyncClient( + SYNC_REPO_OWNER, + SYNC_REPO_NAME, + token, + timeout_seconds=SYNC_TIMEOUT_SECONDS, + ) ) try: with_sync_retry( @@ -190,10 +195,11 @@ def push_pc_workouts(log_file: Path) -> PushResult: local_log=log, encode=_encode_log, decode=_decode_log, + state_store=FileSyncStateStore(SYNC_STATE_FILE), ), description="push PC workouts", ) - except GitHubSyncError as exc: + except (GitHubSyncError, RemoteSyncError) as exc: reason = f"sync error: {exc}" # Report what actually happened. This used to assert "a 403 here means # the token lacks contents:write" for *every* failure, including a diff --git a/screen_locker/_workout_sync.py b/screen_locker/_workout_sync.py index f5aa703..6a3ac0f 100644 --- a/screen_locker/_workout_sync.py +++ b/screen_locker/_workout_sync.py @@ -13,7 +13,18 @@ from __future__ import annotations import json import logging -from crdt_sync import GitHubSyncClient, GitHubSyncError, Hlc, Record +from crdt_sync import ( + CONFIG_FILE, + ConfigError, + FirebaseAuthError, + GitHubSyncClient, + GitHubSyncError, + Hlc, + Record, + RemoteStore, + RemoteSyncError, + mirror_client_for, +) from screen_locker._constants import ( SYNC_PHONE_DEVICE_ID, @@ -84,6 +95,32 @@ def _latest_payload(log_json: str) -> dict | None: return payload +def remote_client(github: RemoteStore) -> RemoteStore: + """Return the backend to read the phone's workout log from. + + Firebase when ``~/.config/crdt-sync/`` is set up, with GitHub kept as a + mirror so a phone that has not moved yet is still seen; GitHub alone + otherwise. Both callers here are read-only pulls -- this side never pushes + -- and :class:`MirrorSyncClient` reads the union of both, so a workout + logged against either backend still counts. + + The config file is checked before constructing anything, so an + unconfigured machine never reaches the network. + + Rolling back is deleting this function and passing ``github`` straight + through: no data moves either way. + """ + if not CONFIG_FILE.is_file(): + return github + try: + return mirror_client_for("screen_locker", github) + except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc: + _logger.warning( + "Firebase unavailable, reading workouts via GitHub only: %s", exc + ) + return github + + def pull_synced_workout() -> tuple[dict | None, str | None]: """Return ``(data, error)``: the phone's last-synced workout, if any. @@ -107,11 +144,13 @@ def pull_synced_workout() -> tuple[dict | None, str | None]: ) return None, None - client = GitHubSyncClient( - SYNC_REPO_OWNER, - SYNC_REPO_NAME, - token, - timeout_seconds=SYNC_TIMEOUT_SECONDS, + client = remote_client( + GitHubSyncClient( + SYNC_REPO_OWNER, + SYNC_REPO_NAME, + token, + timeout_seconds=SYNC_TIMEOUT_SECONDS, + ) ) try: text = with_sync_retry( @@ -186,11 +225,13 @@ def pull_all_manual_records() -> list[tuple[str, dict]]: ) return [] - client = GitHubSyncClient( - SYNC_REPO_OWNER, - SYNC_REPO_NAME, - token, - timeout_seconds=SYNC_TIMEOUT_SECONDS, + client = remote_client( + GitHubSyncClient( + SYNC_REPO_OWNER, + SYNC_REPO_NAME, + token, + timeout_seconds=SYNC_TIMEOUT_SECONDS, + ) ) try: devices = with_sync_retry( diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 8d2f0e2..143608d 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -13,6 +13,7 @@ from __future__ import annotations from contextlib import ExitStack from datetime import datetime, timezone import json +from pathlib import Path import tkinter as tk from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -34,7 +35,6 @@ from screen_locker.tests._locker_factories import ( if TYPE_CHECKING: from collections.abc import Generator, Iterator - from pathlib import Path # Every module that imports ``tkinter as tk`` and calls it directly. The UI is @@ -300,3 +300,20 @@ def _mock_sys_exit(mock_sys_exit: MagicMock) -> MagicMock: def temp_log_file(tmp_path: Path) -> Path: """Create a temporary log file path.""" return tmp_path / "workout_log.json" + + +@pytest.fixture(autouse=True) +def _no_real_firebase_config(monkeypatch: pytest.MonkeyPatch) -> None: + """Point the Firebase config at a path that does not exist. + + ``remote_client`` reads ``crdt_sync.CONFIG_FILE`` to decide whether to + build a Firebase-primary mirror. On a developer machine that file *does* + exist, so without this the sync tests would reach the real database and + assert against live data instead of their own fakes. Tests that want the + Firebase path point it back at a file they control. + """ + from screen_locker import _workout_sync + + monkeypatch.setattr( + _workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json") + ) diff --git a/screen_locker/tests/test_workout_sync.py b/screen_locker/tests/test_workout_sync.py index 3129291..518b476 100644 --- a/screen_locker/tests/test_workout_sync.py +++ b/screen_locker/tests/test_workout_sync.py @@ -3,9 +3,10 @@ from __future__ import annotations import json +from pathlib import Path from unittest.mock import MagicMock, patch -from crdt_sync import GitHubSyncError, Hlc, Record +from crdt_sync import ConfigError, GitHubSyncError, Hlc, Record import pytest from screen_locker import _workout_sync @@ -292,3 +293,51 @@ class TestPullAllManualRecords: result = _workout_sync.pull_all_manual_records() assert len(result) == 1 assert result[0][1]["cost"] == "NEW" + + +class TestRemoteClient: + """Which backend the workout pull reads from during the cutover.""" + + def test_stays_on_github_without_firebase_config( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An unconfigured machine must not reach the network at all.""" + monkeypatch.setattr( + _workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json") + ) + github = object() + + assert _workout_sync.remote_client(github) is github + + def test_mirrors_to_github_when_configured( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Configured: reads union both, so either backend's workout counts.""" + config = tmp_path / "firebase.json" + config.write_text("{}", encoding="utf-8") + monkeypatch.setattr(_workout_sync, "CONFIG_FILE", config) + monkeypatch.setattr( + _workout_sync, + "mirror_client_for", + lambda _app, client: ("mirror", client), + ) + github = object() + + assert _workout_sync.remote_client(github) == ("mirror", github) + + def test_falls_back_when_firebase_is_unusable( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A broken Firebase must degrade to GitHub, never fail the pull.""" + config = tmp_path / "firebase.json" + config.write_text("{}", encoding="utf-8") + monkeypatch.setattr(_workout_sync, "CONFIG_FILE", config) + + def _boom(*_args: object, **_kwargs: object) -> None: + message = "no password" + raise ConfigError(message) + + monkeypatch.setattr(_workout_sync, "mirror_client_for", _boom) + github = object() + + assert _workout_sync.remote_client(github) is github diff --git a/stronglift_replacement/workout_app/lib/services/firebase_backend.dart b/stronglift_replacement/workout_app/lib/services/firebase_backend.dart new file mode 100644 index 0000000..a88b650 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/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/stronglift_replacement/workout_app/lib/services/sync_state_factory.dart b/stronglift_replacement/workout_app/lib/services/sync_state_factory.dart new file mode 100644 index 0000000..1a1ab59 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/sync_state_factory.dart @@ -0,0 +1,34 @@ +/// Where the sync revision cache lives on this device. +/// +/// Android only — this app has no web target — so unlike the other consumers +/// it needs no conditional export. +library; + +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 workout log it describes. +const kSyncStateFileName = 'sync_state.json'; + +/// Opens the revision cache in the app's support directory. +// coverage:ignore-start +// Resolves the real per-platform 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]. +/// +/// Must be cleared with the log it describes: skipping an unchanged peer is +/// only sound because that peer's records are already merged locally, 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/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart index c511cdb..7ce5f4b 100644 --- a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart @@ -8,7 +8,9 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:workout_app/models/manual_workout.dart'; import 'package:workout_app/models/workout_session.dart'; +import 'package:workout_app/services/firebase_backend.dart'; import 'package:workout_app/services/sync_settings.dart'; +import 'package:workout_app/services/sync_state_factory.dart'; const _deviceId = 'phone'; const _pathPrefix = 'screen-locker-sync/devices'; @@ -36,11 +38,29 @@ class WorkoutSyncService { this.owner = syncRepoOwner, this.repo = syncRepoName, http.Client? httpClient, + this.firebaseFactory, + this.stateStore, // Dart forbids private named params, so this can't be an initializing // formal; assign it explicitly (mirrors crdt_sync_dart's GitHubClient). // ignore: prefer_initializing_formals }) : _httpClient = httpClient; + /// 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; + + /// Firebase when this device is set up for it, else null (a normal state + /// during the cutover: sync then runs over GitHub exactly as before). + Future _openFirebase() => + (firebaseFactory ?? openFirebase)(); + + /// The revision cache, defaulting to a file beside the app's data. + Future _openStateStore() async => + stateStore ?? await openSyncStateStore(); + /// The repo owner/org to push to. final String owner; @@ -55,12 +75,16 @@ class WorkoutSyncService { final settings = await SyncSettings.load(); if (!settings.isConfigured) return; - final client = GitHubClient( + final github = GitHubClient( owner: owner, repo: repo, token: settings.token, httpClient: _httpClient, ); + final firebase = await _openFirebase(); + final client = firebase == null + ? github + : MirrorStore(primary: firebase, mirror: github); try { const path = '$_pathPrefix/$_deviceId/$_logFilename'; final existingText = await client.getFileText(path); @@ -79,11 +103,16 @@ class WorkoutSyncService { localLog: localLog, encode: _encode, decode: _decode, + // Without this every push re-uploads the whole log and every pull + // re-downloads every peer's, regardless of change -- the traffic the + // Firebase free tier's monthly budget depends on not happening. + stateStore: await _openStateStore(), ); } on GitHubSyncError catch (error) { debugPrint('WorkoutSyncService.push failed: $error'); } finally { - client.close(); + github.close(); + firebase?.close(); } } @@ -94,12 +123,16 @@ class WorkoutSyncService { final settings = await SyncSettings.load(); if (!settings.isConfigured) return; - final client = GitHubClient( + final github = GitHubClient( owner: owner, repo: repo, token: settings.token, httpClient: _httpClient, ); + final firebase = await _openFirebase(); + final client = firebase == null + ? github + : MirrorStore(primary: firebase, mirror: github); try { const path = '$_pathPrefix/$_deviceId/$_logFilename'; final existingText = await client.getFileText(path); @@ -114,11 +147,16 @@ class WorkoutSyncService { localLog: localLog, encode: _encode, decode: _decode, + // Without this every push re-uploads the whole log and every pull + // re-downloads every peer's, regardless of change -- the traffic the + // Firebase free tier's monthly budget depends on not happening. + stateStore: await _openStateStore(), ); } on GitHubSyncError catch (error) { debugPrint('WorkoutSyncService.pushManual failed: $error'); } finally { - client.close(); + github.close(); + firebase?.close(); } } @@ -181,16 +219,24 @@ class WorkoutSyncService { String token, String? kind, ) async { - final client = GitHubClient( + final github = GitHubClient( owner: owner, repo: repo, token: token, httpClient: _httpClient, ); + // Read-only: MirrorStore reads the union of both, so a workout logged + // against either backend still shows up during the cutover. + final firebase = await _openFirebase(); + final client = firebase == null + ? github + : MirrorStore(primary: firebase, mirror: github); try { final merged = {}; for (final device in await client.listDirectory(_pathPrefix)) { - final text = await client.getFileText('$_pathPrefix/$device/$_logFilename'); + final text = await client.getFileText( + '$_pathPrefix/$device/$_logFilename', + ); if (text == null) continue; _mergeRecords(_decode(text), merged, kind: kind); } @@ -198,7 +244,8 @@ class WorkoutSyncService { .map((r) => (r.fields['payload']!.$1! as Map).cast()) .toList(); } finally { - client.close(); + github.close(); + firebase?.close(); } } diff --git a/stronglift_replacement/workout_app/pubspec.lock b/stronglift_replacement/workout_app/pubspec.lock index 930752b..1fd63ae 100644 --- a/stronglift_replacement/workout_app/pubspec.lock +++ b/stronglift_replacement/workout_app/pubspec.lock @@ -141,11 +141,11 @@ packages: dependency: "direct main" description: path: crdt_sync_dart - ref: "crdt_sync_dart-v0.1.0" - resolved-ref: "6b14e6d36d137d90851f00d1e282a2069c13d3a6" + ref: "crdt_sync_dart-v0.6.0" + resolved-ref: fc99eff00599a4c3195e4c1573ba08f15909a10d url: "https://github.com/kuhyx/utils" source: git - version: "0.1.0" + version: "0.6.0" crypto: dependency: transitive description: diff --git a/stronglift_replacement/workout_app/pubspec.yaml b/stronglift_replacement/workout_app/pubspec.yaml index c228093..27e0eaa 100644 --- a/stronglift_replacement/workout_app/pubspec.yaml +++ b/stronglift_replacement/workout_app/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: crdt_sync: git: url: https://github.com/kuhyx/utils - ref: crdt_sync_dart-v0.1.0 + ref: crdt_sync_dart-v0.6.0 path: crdt_sync_dart flutter: sdk: flutter diff --git a/stronglift_replacement/workout_app/test/services/sync_state_factory_test.dart b/stronglift_replacement/workout_app/test/services/sync_state_factory_test.dart new file mode 100644 index 0000000..0a82c7e --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/sync_state_factory_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:workout_app/services/sync_state_factory.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory dir; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('wo_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); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart index 8c26d38..bedc8ee 100644 --- a/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart +++ b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart @@ -67,7 +67,35 @@ WorkoutSession _session({DateTime? startTime}) => WorkoutSession( ], ); +/// Builds the service with the platform kept out of it. +/// +/// The real factories want the OS keystore and an application-support +/// directory, neither of which exists under `flutter test`; passing null for +/// Firebase also asserts the pre-migration GitHub-only path still works. + +/// The PUT that carries the log itself. +/// +/// Each push now writes twice: the log, then this device's revision (which is +/// what lets a later tick skip an unchanged peer). These assertions are about +/// the log, so select it rather than assuming a single write. +_PutCall _logPut(List<_PutCall> calls) => + calls.firstWhere((c) => c.path.endsWith('log.json')); + +WorkoutSyncService _service({ + http.Client? httpClient, + String? owner, + String? repo, + Future Function()? firebaseFactory, +}) => WorkoutSyncService( + httpClient: httpClient, + owner: owner ?? syncRepoOwner, + repo: repo ?? syncRepoName, + firebaseFactory: firebaseFactory ?? () async => null, + stateStore: InMemorySyncStateStore(), +); + void main() { + _cutoverTests(); // installFakeSecureStorage touches the test binary messenger, which needs // the binding up first (widget tests get this for free via testWidgets). TestWidgetsFlutterBinding.ensureInitialized(); @@ -76,7 +104,7 @@ void main() { installFakeSecureStorage(); final (:httpClient, :putCalls) = _mockGitHub(); - await WorkoutSyncService(httpClient: httpClient).push(_session()); + await _service(httpClient: httpClient).push(_session()); expect(putCalls, isEmpty); }); @@ -88,14 +116,15 @@ void main() { ); final session = _session(); - await WorkoutSyncService(httpClient: httpClient).push(session); + await _service(httpClient: httpClient).push(session); - expect(putCalls, hasLength(1)); - expect(putCalls.single.path, 'screen-locker-sync/devices/phone/log.json'); + // Two writes: the log, then the revision published after it. + expect(putCalls, hasLength(2)); + expect(_logPut(putCalls).path, 'screen-locker-sync/devices/phone/log.json'); final pushedLog = jsonDecode( utf8.decode( - base64.decode(putCalls.single.body['content'] as String), + base64.decode(_logPut(putCalls).body['content'] as String), ), ) as Map; @@ -121,12 +150,12 @@ void main() { ); final session = _session(); - await WorkoutSyncService(httpClient: httpClient).push(session); + await _service(httpClient: httpClient).push(session); final pushedLog = jsonDecode( utf8.decode( - base64.decode(putCalls.single.body['content'] as String), + base64.decode(_logPut(putCalls).body['content'] as String), ), ) as Map; @@ -143,7 +172,7 @@ void main() { ); await expectLater( - WorkoutSyncService(httpClient: httpClient).push(_session()), + _service(httpClient: httpClient).push(_session()), completes, ); }); @@ -161,7 +190,7 @@ void main() { test('pushManual does nothing without a token', () async { installFakeSecureStorage(); final (:httpClient, :putCalls) = _mockGitHub(); - await WorkoutSyncService( + await _service( httpClient: httpClient, ).pushManual(_manual('manual:x', '40', 1000)); expect(putCalls, isEmpty); @@ -172,10 +201,10 @@ void main() { final (:httpClient, :putCalls) = _mockGitHub( contentResponses: {'screen-locker-sync/devices': _response(200, [])}, ); - await WorkoutSyncService( + await _service( httpClient: httpClient, ).pushManual(_manual('manual:2026-07-13T18:00', '40', 1000)); - expect(putCalls.single.path, 'screen-locker-sync/devices/phone/log.json'); + expect(_logPut(putCalls).path, 'screen-locker-sync/devices/phone/log.json'); }); test('pushManual merges onto an existing log', () async { @@ -189,13 +218,13 @@ void main() { 'screen-locker-sync/devices/phone/log.json': _fileContaining(existing), }, ); - await WorkoutSyncService( + await _service( httpClient: httpClient, ).pushManual(_manual('manual:2026-07-13T18:00', '40', 2000)); final pushedLog = jsonDecode( utf8.decode( - base64.decode(putCalls.single.body['content'] as String), + base64.decode(_logPut(putCalls).body['content'] as String), ), ) as Map; @@ -211,7 +240,7 @@ void main() { (request) async => http.Response('', 500), ); await expectLater( - WorkoutSyncService( + _service( httpClient: httpClient, ).pushManual(_manual('manual:x', '40', 1000)), completes, @@ -222,7 +251,7 @@ void main() { installFakeSecureStorage(); final (:httpClient, putCalls: _) = _mockGitHub(); expect( - await WorkoutSyncService( + await _service( httpClient: httpClient, ).readMergedManualPayloads(), isEmpty, @@ -234,12 +263,17 @@ void main() { final session = Record( id: 's', fields: { - 'payload': ({'workout_type': 'A'}, Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone')), + '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'))}, + fields: { + 'other': (1, Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone')), + }, ); final phoneLog = jsonEncode({ 'manual:a': _manual('manual:a', 'NEW', 2000).toJson(), @@ -298,7 +332,7 @@ void main() { 'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog), }, ); - final service = WorkoutSyncService(httpClient: httpClient); + final service = _service(httpClient: httpClient); final all = await service.readMergedWorkoutPayloads(); expect(all, hasLength(2)); @@ -308,37 +342,39 @@ void main() { ); }); - 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': _manual('manual:a', 'NEW', 2000).toJson(), - 'runnerup_verified:2026-07-13': run.toJson(), - }); - final (:httpClient, putCalls: _) = _mockGitHub( - contentResponses: { - 'screen-locker-sync/devices': _response(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 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': _manual('manual:a', 'NEW', 2000).toJson(), + 'runnerup_verified:2026-07-13': run.toJson(), + }); + final (:httpClient, putCalls: _) = _mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': _response(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'}); @@ -346,7 +382,7 @@ void main() { (request) async => http.Response('', 500), ); expect( - await WorkoutSyncService( + await _service( httpClient: httpClient, ).readMergedManualPayloads(), isEmpty, @@ -375,9 +411,12 @@ void main() { 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([ + {'name': 'pc', 'type': 'dir'}, + ]), + 200, + ); } return http.Response( jsonEncode({ @@ -414,8 +453,109 @@ void main() { ); expect( - await WorkoutSyncService(httpClient: httpClient).readMergedManualPayloads(), + await _service(httpClient: httpClient).readMergedManualPayloads(), isEmpty, ); }); } + +/// A Firebase client wired to an in-memory mock, for the cutover tests. +/// +/// The session's expiry uses the real clock: the token provider compares +/// against `DateTime.now()`, so a fixture-dated session looks expired and +/// triggers a refresh the mock never answers. +({FirebaseRestClient client, List puts}) _fakeFirebase() { + final puts = []; + final client = FirebaseRestClient( + databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app', + auth: FirebaseTokenProvider( + apiKey: 'AIzaKey', + store: InMemoryCredentialStore( + FirebaseCredentials( + idToken: 'id', + refreshToken: 'refresh', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ), + ), + ), + httpClient: http_testing.MockClient((request) async { + if (request.method == 'PUT') { + puts.add(request.url.path); + return http.Response(request.body, 200); + } + return http.Response('null', 200); + }), + ); + return (client: client, puts: puts); +} + +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 _service( + httpClient: httpClient, + firebaseFactory: () async => firebase.client, + ).push(_session()); + + 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 _service( + 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 _service( + httpClient: httpClient, + firebaseFactory: () async => firebase.client, + ).readMergedWorkoutPayloads(); + + expect(payloads, isEmpty); + }); + }); +}