mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
Some checks are pending
release-apk / release (APK) (push) Waiting to run
Firebase becomes the primary backend when this device has been set up for it;
GitHub stays 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 before, which is also the rollback.
This app hand-rolled its own pull-merge-push instead of calling syncLog, so it
had no revision tracking at all: every sync downloaded all four peer note logs
-- roughly 150 KB each -- whether or not anything had changed. It now reads one
small revision map first and skips any peer whose hash is unchanged, and
suppresses its own push when its log is unchanged. That is the single largest
traffic saving in the fleet and what keeps this inside the free tier.
sync() now takes a RemoteStore rather than a GitHubClient, so choosing a
backend is a change at the call site and nothing more.
runSync() is the one place that decides how a sync runs. The capture screen
syncs on a timer and on demand and the settings screen on a button; each used
to build its own client, so a backend change meant editing four call sites and
hoping none was missed.
Credentials are split by what is safe to publish, because this repo is public:
the Web API key and database URL ship in source (both already ship inside the
APK, and the security rules protect the data), while the account email and
password are entered per device and live in the OS keystore.
247 tests at 100% line coverage. Two assertions changed from `contains('PUT')`
to `isNotEmpty`: those tests sync twice, and the second push is now correctly
suppressed as a no-op -- that suppression is the point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
96 lines
3.1 KiB
Dart
96 lines
3.1 KiB
Dart
/// 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 = <String>[];
|
|
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 = <String>[];
|
|
final firebasePaths = <String>[];
|
|
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',
|
|
);
|
|
});
|
|
}
|