mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
Sync workouts through Firebase, mirroring to GitHub during the cutover
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
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
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
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
This commit is contained in:
parent
e3cd68dc21
commit
eb4ff01e04
@ -100,7 +100,7 @@ repos:
|
|||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- pytest
|
- pytest
|
||||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
|
- 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
|
- mcp>=2,<3
|
||||||
- pydantic
|
- pydantic
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ requires-python = ">=3.10"
|
|||||||
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
|
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock",
|
"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]
|
[project.scripts]
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
bandit>=1.7.0
|
bandit>=1.7.0
|
||||||
codespell>=2.2.0
|
codespell>=2.2.0
|
||||||
coverage>=7.4.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
|
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
|
||||||
mcp>=2,<3
|
mcp>=2,<3
|
||||||
mypy>=1.8.0
|
mypy>=1.8.0
|
||||||
|
|||||||
@ -130,6 +130,13 @@ SYNC_PHONE_DEVICE_ID: str = "phone"
|
|||||||
# committed — this path is outside the repo entirely. Unlike diet_guard,
|
# committed — this path is outside the repo entirely. Unlike diet_guard,
|
||||||
# this file being absent is a normal, expected state (sync is optional here).
|
# this file being absent is a normal, expected state (sync is optional here).
|
||||||
SYNC_TOKEN_FILE: Path = Path.home() / ".config" / "screen_locker" / "sync_token"
|
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_TIMEOUT_SECONDS: float = 10.0
|
||||||
|
|
||||||
# Sync runs from the morning routine, which is started right after boot and
|
# Sync runs from the morning routine, which is started right after boot and
|
||||||
|
|||||||
@ -28,10 +28,12 @@ import logging
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from crdt_sync import (
|
from crdt_sync import (
|
||||||
|
FileSyncStateStore,
|
||||||
GitHubSyncClient,
|
GitHubSyncClient,
|
||||||
GitHubSyncError,
|
GitHubSyncError,
|
||||||
Hlc,
|
Hlc,
|
||||||
Record,
|
Record,
|
||||||
|
RemoteSyncError,
|
||||||
RepoNotFoundError,
|
RepoNotFoundError,
|
||||||
sync_log,
|
sync_log,
|
||||||
)
|
)
|
||||||
@ -39,6 +41,7 @@ from crdt_sync import (
|
|||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
SYNC_REPO_NAME,
|
SYNC_REPO_NAME,
|
||||||
SYNC_REPO_OWNER,
|
SYNC_REPO_OWNER,
|
||||||
|
SYNC_STATE_FILE,
|
||||||
SYNC_TIMEOUT_SECONDS,
|
SYNC_TIMEOUT_SECONDS,
|
||||||
SYNC_TOKEN_FILE,
|
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._log_mixin import _derive_workout_id
|
||||||
from screen_locker._sync_retry import with_sync_retry
|
from screen_locker._sync_retry import with_sync_retry
|
||||||
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
|
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:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -175,11 +178,13 @@ def push_pc_workouts(log_file: Path) -> PushResult:
|
|||||||
_logger.warning("Workouts NOT synced: %s", reason)
|
_logger.warning("Workouts NOT synced: %s", reason)
|
||||||
return PushResult(pushed=False, record_count=0, reason=reason)
|
return PushResult(pushed=False, record_count=0, reason=reason)
|
||||||
|
|
||||||
client = GitHubSyncClient(
|
client = remote_client(
|
||||||
SYNC_REPO_OWNER,
|
GitHubSyncClient(
|
||||||
SYNC_REPO_NAME,
|
SYNC_REPO_OWNER,
|
||||||
token,
|
SYNC_REPO_NAME,
|
||||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
token,
|
||||||
|
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with_sync_retry(
|
with_sync_retry(
|
||||||
@ -190,10 +195,11 @@ def push_pc_workouts(log_file: Path) -> PushResult:
|
|||||||
local_log=log,
|
local_log=log,
|
||||||
encode=_encode_log,
|
encode=_encode_log,
|
||||||
decode=_decode_log,
|
decode=_decode_log,
|
||||||
|
state_store=FileSyncStateStore(SYNC_STATE_FILE),
|
||||||
),
|
),
|
||||||
description="push PC workouts",
|
description="push PC workouts",
|
||||||
)
|
)
|
||||||
except GitHubSyncError as exc:
|
except (GitHubSyncError, RemoteSyncError) as exc:
|
||||||
reason = f"sync error: {exc}"
|
reason = f"sync error: {exc}"
|
||||||
# Report what actually happened. This used to assert "a 403 here means
|
# Report what actually happened. This used to assert "a 403 here means
|
||||||
# the token lacks contents:write" for *every* failure, including a
|
# the token lacks contents:write" for *every* failure, including a
|
||||||
|
|||||||
@ -13,7 +13,18 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
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 (
|
from screen_locker._constants import (
|
||||||
SYNC_PHONE_DEVICE_ID,
|
SYNC_PHONE_DEVICE_ID,
|
||||||
@ -84,6 +95,32 @@ def _latest_payload(log_json: str) -> dict | None:
|
|||||||
return payload
|
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]:
|
def pull_synced_workout() -> tuple[dict | None, str | None]:
|
||||||
"""Return ``(data, error)``: the phone's last-synced workout, if any.
|
"""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
|
return None, None
|
||||||
|
|
||||||
client = GitHubSyncClient(
|
client = remote_client(
|
||||||
SYNC_REPO_OWNER,
|
GitHubSyncClient(
|
||||||
SYNC_REPO_NAME,
|
SYNC_REPO_OWNER,
|
||||||
token,
|
SYNC_REPO_NAME,
|
||||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
token,
|
||||||
|
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
text = with_sync_retry(
|
text = with_sync_retry(
|
||||||
@ -186,11 +225,13 @@ def pull_all_manual_records() -> list[tuple[str, dict]]:
|
|||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
client = GitHubSyncClient(
|
client = remote_client(
|
||||||
SYNC_REPO_OWNER,
|
GitHubSyncClient(
|
||||||
SYNC_REPO_NAME,
|
SYNC_REPO_OWNER,
|
||||||
token,
|
SYNC_REPO_NAME,
|
||||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
token,
|
||||||
|
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
devices = with_sync_retry(
|
devices = with_sync_retry(
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@ -34,7 +35,6 @@ from screen_locker.tests._locker_factories import (
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator, Iterator
|
from collections.abc import Generator, Iterator
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# Every module that imports ``tkinter as tk`` and calls it directly. The UI is
|
# 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:
|
def temp_log_file(tmp_path: Path) -> Path:
|
||||||
"""Create a temporary log file path."""
|
"""Create a temporary log file path."""
|
||||||
return tmp_path / "workout_log.json"
|
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")
|
||||||
|
)
|
||||||
|
|||||||
@ -3,9 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from crdt_sync import GitHubSyncError, Hlc, Record
|
from crdt_sync import ConfigError, GitHubSyncError, Hlc, Record
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from screen_locker import _workout_sync
|
from screen_locker import _workout_sync
|
||||||
@ -292,3 +293,51 @@ class TestPullAllManualRecords:
|
|||||||
result = _workout_sync.pull_all_manual_records()
|
result = _workout_sync.pull_all_manual_records()
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0][1]["cost"] == "NEW"
|
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
|
||||||
|
|||||||
@ -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<FirebaseAccount?> 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<void> saveAccount(FirebaseAccount account) =>
|
||||||
|
_secure.write(key: kFirebaseAccountKey, value: account.toJsonString());
|
||||||
|
|
||||||
|
/// Forgets the account and any cached session.
|
||||||
|
Future<void> 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<FirebaseRestClient?> 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
|
||||||
@ -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<SyncStateStore> 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))),
|
||||||
|
);
|
||||||
@ -8,7 +8,9 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:workout_app/models/manual_workout.dart';
|
import 'package:workout_app/models/manual_workout.dart';
|
||||||
import 'package:workout_app/models/workout_session.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_settings.dart';
|
||||||
|
import 'package:workout_app/services/sync_state_factory.dart';
|
||||||
|
|
||||||
const _deviceId = 'phone';
|
const _deviceId = 'phone';
|
||||||
const _pathPrefix = 'screen-locker-sync/devices';
|
const _pathPrefix = 'screen-locker-sync/devices';
|
||||||
@ -36,11 +38,29 @@ class WorkoutSyncService {
|
|||||||
this.owner = syncRepoOwner,
|
this.owner = syncRepoOwner,
|
||||||
this.repo = syncRepoName,
|
this.repo = syncRepoName,
|
||||||
http.Client? httpClient,
|
http.Client? httpClient,
|
||||||
|
this.firebaseFactory,
|
||||||
|
this.stateStore,
|
||||||
// Dart forbids private named params, so this can't be an initializing
|
// Dart forbids private named params, so this can't be an initializing
|
||||||
// formal; assign it explicitly (mirrors crdt_sync_dart's GitHubClient).
|
// formal; assign it explicitly (mirrors crdt_sync_dart's GitHubClient).
|
||||||
// ignore: prefer_initializing_formals
|
// ignore: prefer_initializing_formals
|
||||||
}) : _httpClient = httpClient;
|
}) : _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<FirebaseRestClient?> 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<FirebaseRestClient?> _openFirebase() =>
|
||||||
|
(firebaseFactory ?? openFirebase)();
|
||||||
|
|
||||||
|
/// The revision cache, defaulting to a file beside the app's data.
|
||||||
|
Future<SyncStateStore> _openStateStore() async =>
|
||||||
|
stateStore ?? await openSyncStateStore();
|
||||||
|
|
||||||
/// The repo owner/org to push to.
|
/// The repo owner/org to push to.
|
||||||
final String owner;
|
final String owner;
|
||||||
|
|
||||||
@ -55,12 +75,16 @@ class WorkoutSyncService {
|
|||||||
final settings = await SyncSettings.load();
|
final settings = await SyncSettings.load();
|
||||||
if (!settings.isConfigured) return;
|
if (!settings.isConfigured) return;
|
||||||
|
|
||||||
final client = GitHubClient(
|
final github = GitHubClient(
|
||||||
owner: owner,
|
owner: owner,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
token: settings.token,
|
token: settings.token,
|
||||||
httpClient: _httpClient,
|
httpClient: _httpClient,
|
||||||
);
|
);
|
||||||
|
final firebase = await _openFirebase();
|
||||||
|
final client = firebase == null
|
||||||
|
? github
|
||||||
|
: MirrorStore(primary: firebase, mirror: github);
|
||||||
try {
|
try {
|
||||||
const path = '$_pathPrefix/$_deviceId/$_logFilename';
|
const path = '$_pathPrefix/$_deviceId/$_logFilename';
|
||||||
final existingText = await client.getFileText(path);
|
final existingText = await client.getFileText(path);
|
||||||
@ -79,11 +103,16 @@ class WorkoutSyncService {
|
|||||||
localLog: localLog,
|
localLog: localLog,
|
||||||
encode: _encode,
|
encode: _encode,
|
||||||
decode: _decode,
|
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) {
|
} on GitHubSyncError catch (error) {
|
||||||
debugPrint('WorkoutSyncService.push failed: $error');
|
debugPrint('WorkoutSyncService.push failed: $error');
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
github.close();
|
||||||
|
firebase?.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,12 +123,16 @@ class WorkoutSyncService {
|
|||||||
final settings = await SyncSettings.load();
|
final settings = await SyncSettings.load();
|
||||||
if (!settings.isConfigured) return;
|
if (!settings.isConfigured) return;
|
||||||
|
|
||||||
final client = GitHubClient(
|
final github = GitHubClient(
|
||||||
owner: owner,
|
owner: owner,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
token: settings.token,
|
token: settings.token,
|
||||||
httpClient: _httpClient,
|
httpClient: _httpClient,
|
||||||
);
|
);
|
||||||
|
final firebase = await _openFirebase();
|
||||||
|
final client = firebase == null
|
||||||
|
? github
|
||||||
|
: MirrorStore(primary: firebase, mirror: github);
|
||||||
try {
|
try {
|
||||||
const path = '$_pathPrefix/$_deviceId/$_logFilename';
|
const path = '$_pathPrefix/$_deviceId/$_logFilename';
|
||||||
final existingText = await client.getFileText(path);
|
final existingText = await client.getFileText(path);
|
||||||
@ -114,11 +147,16 @@ class WorkoutSyncService {
|
|||||||
localLog: localLog,
|
localLog: localLog,
|
||||||
encode: _encode,
|
encode: _encode,
|
||||||
decode: _decode,
|
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) {
|
} on GitHubSyncError catch (error) {
|
||||||
debugPrint('WorkoutSyncService.pushManual failed: $error');
|
debugPrint('WorkoutSyncService.pushManual failed: $error');
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
github.close();
|
||||||
|
firebase?.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,16 +219,24 @@ class WorkoutSyncService {
|
|||||||
String token,
|
String token,
|
||||||
String? kind,
|
String? kind,
|
||||||
) async {
|
) async {
|
||||||
final client = GitHubClient(
|
final github = GitHubClient(
|
||||||
owner: owner,
|
owner: owner,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
token: token,
|
token: token,
|
||||||
httpClient: _httpClient,
|
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 {
|
try {
|
||||||
final merged = <String, Record>{};
|
final merged = <String, Record>{};
|
||||||
for (final device in await client.listDirectory(_pathPrefix)) {
|
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;
|
if (text == null) continue;
|
||||||
_mergeRecords(_decode(text), merged, kind: kind);
|
_mergeRecords(_decode(text), merged, kind: kind);
|
||||||
}
|
}
|
||||||
@ -198,7 +244,8 @@ class WorkoutSyncService {
|
|||||||
.map((r) => (r.fields['payload']!.$1! as Map).cast<String, dynamic>())
|
.map((r) => (r.fields['payload']!.$1! as Map).cast<String, dynamic>())
|
||||||
.toList();
|
.toList();
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
github.close();
|
||||||
|
firebase?.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -141,11 +141,11 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: crdt_sync_dart
|
path: crdt_sync_dart
|
||||||
ref: "crdt_sync_dart-v0.1.0"
|
ref: "crdt_sync_dart-v0.6.0"
|
||||||
resolved-ref: "6b14e6d36d137d90851f00d1e282a2069c13d3a6"
|
resolved-ref: fc99eff00599a4c3195e4c1573ba08f15909a10d
|
||||||
url: "https://github.com/kuhyx/utils"
|
url: "https://github.com/kuhyx/utils"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.6.0"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -12,7 +12,7 @@ dependencies:
|
|||||||
crdt_sync:
|
crdt_sync:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: crdt_sync_dart-v0.1.0
|
ref: crdt_sync_dart-v0.6.0
|
||||||
path: crdt_sync_dart
|
path: crdt_sync_dart
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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<FirebaseRestClient?> Function()? firebaseFactory,
|
||||||
|
}) => WorkoutSyncService(
|
||||||
|
httpClient: httpClient,
|
||||||
|
owner: owner ?? syncRepoOwner,
|
||||||
|
repo: repo ?? syncRepoName,
|
||||||
|
firebaseFactory: firebaseFactory ?? () async => null,
|
||||||
|
stateStore: InMemorySyncStateStore(),
|
||||||
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
_cutoverTests();
|
||||||
// installFakeSecureStorage touches the test binary messenger, which needs
|
// installFakeSecureStorage touches the test binary messenger, which needs
|
||||||
// the binding up first (widget tests get this for free via testWidgets).
|
// the binding up first (widget tests get this for free via testWidgets).
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
@ -76,7 +104,7 @@ void main() {
|
|||||||
installFakeSecureStorage();
|
installFakeSecureStorage();
|
||||||
final (:httpClient, :putCalls) = _mockGitHub();
|
final (:httpClient, :putCalls) = _mockGitHub();
|
||||||
|
|
||||||
await WorkoutSyncService(httpClient: httpClient).push(_session());
|
await _service(httpClient: httpClient).push(_session());
|
||||||
|
|
||||||
expect(putCalls, isEmpty);
|
expect(putCalls, isEmpty);
|
||||||
});
|
});
|
||||||
@ -88,14 +116,15 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final session = _session();
|
final session = _session();
|
||||||
await WorkoutSyncService(httpClient: httpClient).push(session);
|
await _service(httpClient: httpClient).push(session);
|
||||||
|
|
||||||
expect(putCalls, hasLength(1));
|
// Two writes: the log, then the revision published after it.
|
||||||
expect(putCalls.single.path, 'screen-locker-sync/devices/phone/log.json');
|
expect(putCalls, hasLength(2));
|
||||||
|
expect(_logPut(putCalls).path, 'screen-locker-sync/devices/phone/log.json');
|
||||||
final pushedLog =
|
final pushedLog =
|
||||||
jsonDecode(
|
jsonDecode(
|
||||||
utf8.decode(
|
utf8.decode(
|
||||||
base64.decode(putCalls.single.body['content'] as String),
|
base64.decode(_logPut(putCalls).body['content'] as String),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
as Map<String, dynamic>;
|
as Map<String, dynamic>;
|
||||||
@ -121,12 +150,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final session = _session();
|
final session = _session();
|
||||||
await WorkoutSyncService(httpClient: httpClient).push(session);
|
await _service(httpClient: httpClient).push(session);
|
||||||
|
|
||||||
final pushedLog =
|
final pushedLog =
|
||||||
jsonDecode(
|
jsonDecode(
|
||||||
utf8.decode(
|
utf8.decode(
|
||||||
base64.decode(putCalls.single.body['content'] as String),
|
base64.decode(_logPut(putCalls).body['content'] as String),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
as Map<String, dynamic>;
|
as Map<String, dynamic>;
|
||||||
@ -143,7 +172,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expectLater(
|
await expectLater(
|
||||||
WorkoutSyncService(httpClient: httpClient).push(_session()),
|
_service(httpClient: httpClient).push(_session()),
|
||||||
completes,
|
completes,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -161,7 +190,7 @@ void main() {
|
|||||||
test('pushManual does nothing without a token', () async {
|
test('pushManual does nothing without a token', () async {
|
||||||
installFakeSecureStorage();
|
installFakeSecureStorage();
|
||||||
final (:httpClient, :putCalls) = _mockGitHub();
|
final (:httpClient, :putCalls) = _mockGitHub();
|
||||||
await WorkoutSyncService(
|
await _service(
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
).pushManual(_manual('manual:x', '40', 1000));
|
).pushManual(_manual('manual:x', '40', 1000));
|
||||||
expect(putCalls, isEmpty);
|
expect(putCalls, isEmpty);
|
||||||
@ -172,10 +201,10 @@ void main() {
|
|||||||
final (:httpClient, :putCalls) = _mockGitHub(
|
final (:httpClient, :putCalls) = _mockGitHub(
|
||||||
contentResponses: {'screen-locker-sync/devices': _response(200, [])},
|
contentResponses: {'screen-locker-sync/devices': _response(200, [])},
|
||||||
);
|
);
|
||||||
await WorkoutSyncService(
|
await _service(
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
).pushManual(_manual('manual:2026-07-13T18:00', '40', 1000));
|
).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 {
|
test('pushManual merges onto an existing log', () async {
|
||||||
@ -189,13 +218,13 @@ void main() {
|
|||||||
'screen-locker-sync/devices/phone/log.json': _fileContaining(existing),
|
'screen-locker-sync/devices/phone/log.json': _fileContaining(existing),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await WorkoutSyncService(
|
await _service(
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
).pushManual(_manual('manual:2026-07-13T18:00', '40', 2000));
|
).pushManual(_manual('manual:2026-07-13T18:00', '40', 2000));
|
||||||
final pushedLog =
|
final pushedLog =
|
||||||
jsonDecode(
|
jsonDecode(
|
||||||
utf8.decode(
|
utf8.decode(
|
||||||
base64.decode(putCalls.single.body['content'] as String),
|
base64.decode(_logPut(putCalls).body['content'] as String),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
as Map<String, dynamic>;
|
as Map<String, dynamic>;
|
||||||
@ -211,7 +240,7 @@ void main() {
|
|||||||
(request) async => http.Response('', 500),
|
(request) async => http.Response('', 500),
|
||||||
);
|
);
|
||||||
await expectLater(
|
await expectLater(
|
||||||
WorkoutSyncService(
|
_service(
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
).pushManual(_manual('manual:x', '40', 1000)),
|
).pushManual(_manual('manual:x', '40', 1000)),
|
||||||
completes,
|
completes,
|
||||||
@ -222,7 +251,7 @@ void main() {
|
|||||||
installFakeSecureStorage();
|
installFakeSecureStorage();
|
||||||
final (:httpClient, putCalls: _) = _mockGitHub();
|
final (:httpClient, putCalls: _) = _mockGitHub();
|
||||||
expect(
|
expect(
|
||||||
await WorkoutSyncService(
|
await _service(
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
).readMergedManualPayloads(),
|
).readMergedManualPayloads(),
|
||||||
isEmpty,
|
isEmpty,
|
||||||
@ -234,12 +263,17 @@ void main() {
|
|||||||
final session = Record(
|
final session = Record(
|
||||||
id: 's',
|
id: 's',
|
||||||
fields: {
|
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(
|
final noPayload = Record(
|
||||||
id: 'np',
|
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({
|
final phoneLog = jsonEncode({
|
||||||
'manual:a': _manual('manual:a', 'NEW', 2000).toJson(),
|
'manual:a': _manual('manual:a', 'NEW', 2000).toJson(),
|
||||||
@ -298,7 +332,7 @@ void main() {
|
|||||||
'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog),
|
'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final service = WorkoutSyncService(httpClient: httpClient);
|
final service = _service(httpClient: httpClient);
|
||||||
|
|
||||||
final all = await service.readMergedWorkoutPayloads();
|
final all = await service.readMergedWorkoutPayloads();
|
||||||
expect(all, hasLength(2));
|
expect(all, hasLength(2));
|
||||||
@ -308,37 +342,39 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readMergedManualPayloads still excludes runs (budget stays manual)',
|
test(
|
||||||
() async {
|
'readMergedManualPayloads still excludes runs (budget stays manual)',
|
||||||
// A verified run must never consume the manual self-report budget.
|
() async {
|
||||||
installFakeSecureStorage(initial: {'sync.token': 'tok'});
|
// A verified run must never consume the manual self-report budget.
|
||||||
final run = Record(
|
installFakeSecureStorage(initial: {'sync.token': 'tok'});
|
||||||
id: 'runnerup_verified:2026-07-13',
|
final run = Record(
|
||||||
fields: {
|
id: 'runnerup_verified:2026-07-13',
|
||||||
'payload': (
|
fields: {
|
||||||
{'kind': 'runnerup_verified', 'date': '2026-07-13'},
|
'payload': (
|
||||||
Hlc(wallTimeMs: 3000, counter: 0, nodeId: 'pc'),
|
{'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(),
|
final pcLog = jsonEncode({
|
||||||
'runnerup_verified:2026-07-13': run.toJson(),
|
'manual:a': _manual('manual:a', 'NEW', 2000).toJson(),
|
||||||
});
|
'runnerup_verified:2026-07-13': run.toJson(),
|
||||||
final (:httpClient, putCalls: _) = _mockGitHub(
|
});
|
||||||
contentResponses: {
|
final (:httpClient, putCalls: _) = _mockGitHub(
|
||||||
'screen-locker-sync/devices': _response(200, [
|
contentResponses: {
|
||||||
{'name': 'pc'},
|
'screen-locker-sync/devices': _response(200, [
|
||||||
]),
|
{'name': 'pc'},
|
||||||
'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog),
|
]),
|
||||||
},
|
'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog),
|
||||||
);
|
},
|
||||||
final manuals = await WorkoutSyncService(
|
);
|
||||||
httpClient: httpClient,
|
final manuals = await WorkoutSyncService(
|
||||||
).readMergedManualPayloads();
|
httpClient: httpClient,
|
||||||
expect(manuals, hasLength(1));
|
).readMergedManualPayloads();
|
||||||
expect(manuals.single['kind'], 'manual_workout');
|
expect(manuals, hasLength(1));
|
||||||
});
|
expect(manuals.single['kind'], 'manual_workout');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('readMergedManualPayloads swallows a sync error', () async {
|
test('readMergedManualPayloads swallows a sync error', () async {
|
||||||
installFakeSecureStorage(initial: {'sync.token': 'tok'});
|
installFakeSecureStorage(initial: {'sync.token': 'tok'});
|
||||||
@ -346,7 +382,7 @@ void main() {
|
|||||||
(request) async => http.Response('', 500),
|
(request) async => http.Response('', 500),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
await WorkoutSyncService(
|
await _service(
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
).readMergedManualPayloads(),
|
).readMergedManualPayloads(),
|
||||||
isEmpty,
|
isEmpty,
|
||||||
@ -375,9 +411,12 @@ void main() {
|
|||||||
return http.Response('Bad credentials', 401);
|
return http.Response('Bad credentials', 401);
|
||||||
}
|
}
|
||||||
if (req.url.path.endsWith('screen-locker-sync/devices')) {
|
if (req.url.path.endsWith('screen-locker-sync/devices')) {
|
||||||
return http.Response(jsonEncode([
|
return http.Response(
|
||||||
{'name': 'pc', 'type': 'dir'},
|
jsonEncode([
|
||||||
]), 200);
|
{'name': 'pc', 'type': 'dir'},
|
||||||
|
]),
|
||||||
|
200,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return http.Response(
|
return http.Response(
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
@ -414,8 +453,109 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await WorkoutSyncService(httpClient: httpClient).readMergedManualPayloads(),
|
await _service(httpClient: httpClient).readMergedManualPayloads(),
|
||||||
isEmpty,
|
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<String> puts}) _fakeFirebase() {
|
||||||
|
final puts = <String>[];
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user