utils/crdt_sync_flutter/test/account_store_test.dart
Krzysztof kuhy Rudnicki fff46de4c5 Extract the Flutter sync glue into crdt_sync_flutter, and document it
`crdt_sync` is pure Dart so it can run from CLI tools and headless systemd
jobs, which means it takes closures instead of depending on
flutter_secure_storage and google_sign_in. Every Flutter app then wrote the
same ~390 lines to supply them, and five apps ended up carrying near-identical
copies of firebase_backend.dart.

Those copies drifted. `todo` gained an opt-out flag and a seeded-session path
that home_inventory and wake_alarm never received, so the same bug was fixed
in one copy and left live in the others -- which is the actual cost being paid
here, not the line count.

This package owns that glue once: the keystore adapter, the persisted
per-install device id, the account store and a one-call bootstrap. A new app
needs a SyncApp const and `openSync(kApp)`.

The wrapper routes are a parameter rather than a constant, because todo's copy
hardcoded its own `~/.config/todo/firebase_auth.json`. An extraction only one
app could adopt would be worse than the copies.

Also documents what made this hard to find: crdt_sync_dart's README covered
only the *old* GitHub transport and never mentioned the Firebase surface at
all, and sync_settings_ui -- a complete drop-in settings screen -- had no
README whatsoever.

The five existing apps stay on their copies and on crdt_sync_dart-v0.10.0.
Migration is forward-only and those fallbacks are load-bearing; converging
them is a separate task, and the README says so.

35 tests, 100% line coverage, no suppressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 17:26:34 +02:00

216 lines
7.0 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
import 'package:crdt_sync_flutter/testing/fake_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const routes = WrapperRoutes(
credentialsPath: '/sync-credentials',
accountPath: '/sync-account',
);
final base = Uri.parse('http://127.0.0.1:8080/');
group('loadAccount', () {
test('returns the stored account without touching the wrapper', () async {
const stored = FirebaseAccount(email: 'a@b.c', password: 'pw');
installFakeSecureStorage(
initial: {kAccountKey: stored.toJsonString()},
);
var requests = 0;
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient((_) async {
requests += 1;
return http.Response('', 404);
}),
);
expect(account?.email, 'a@b.c');
expect(requests, 0, reason: 'the keystore already answered');
});
test('returns null with no account and no routes', () async {
installFakeSecureStorage();
expect(await loadAccount(), isNull);
});
test('honours the opt-out flag instead of re-provisioning', () async {
installFakeSecureStorage(initial: {kOptOutKey: 'true'});
var requests = 0;
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient((_) async {
requests += 1;
return http.Response('', 200);
}),
);
expect(account, isNull);
expect(requests, 0, reason: 'disconnect must stick');
});
test('adopts a seeded session and persists it', () async {
installFakeSecureStorage();
final body = jsonEncode({
'id_token': 'id',
'refresh_token': 'refresh',
'expires_at': DateTime.now()
.add(const Duration(hours: 1))
.toIso8601String(),
'email': 'seeded@b.c',
});
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient(
(request) async => request.url.path == '/sync-credentials'
? http.Response(body, 200)
: http.Response('', 404),
),
);
expect(account?.email, 'seeded@b.c');
expect(account?.password, isEmpty);
// Persisted, so the route is consulted once rather than forever.
expect((await storedAccount())?.email, 'seeded@b.c');
});
test('ignores a seeded body missing a required field', () async {
installFakeSecureStorage();
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient(
(_) async => http.Response(jsonEncode({'id_token': 'id'}), 200),
),
);
expect(account, isNull);
});
test('ignores a seeded body that is not a JSON object', () async {
installFakeSecureStorage();
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient((_) async => http.Response('[]', 200)),
);
expect(account, isNull);
});
test('skips the legacy route when the app declares none', () async {
installFakeSecureStorage();
var paths = <String>[];
final account = await loadAccount(
routes: const WrapperRoutes(credentialsPath: '/sync-credentials'),
base: base,
httpClient: MockClient((request) async {
paths.add(request.url.path);
return http.Response('', 404);
}),
);
expect(account, isNull);
expect(paths, ['/sync-credentials']);
});
test('reports not-configured when the keystore itself throws', () async {
installFakeSecureStorage(throwing: true);
expect(await loadAccount(routes: routes, base: base), isNull);
});
test('falls back to the legacy route and persists what it finds', () async {
// The un-reseeded machine: no seeded session, but the wrapper still
// serves an email/password pair from ~/.config/crdt-sync.
installFakeSecureStorage();
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient(
(request) async => request.url.path == '/sync-account'
? http.Response(
jsonEncode({'email': 'legacy@b.c', 'password': 'pw'}),
200,
)
: http.Response('', 404),
),
);
expect(account?.email, 'legacy@b.c');
// Written through, so the route is consulted once and not forever.
expect((await storedAccount())?.password, 'pw');
});
test('defaults to Uri.base and a real client with no wrapper', () async {
// The production call shape: no base, no injected client. Under
// `flutter test` Uri.base is a file:// URL with no host, so resolving
// the route raises an ArgumentError -- an Error, not an Exception,
// which is exactly the Android case the broad catch exists for.
installFakeSecureStorage();
expect(await loadAccount(routes: routes), isNull);
});
test('survives a wrapper that drops the connection', () async {
installFakeSecureStorage();
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient((_) async => throw const SocketException('x')),
);
expect(account, isNull);
});
});
group('account lifecycle', () {
test('saveAccount then storedAccount round-trips', () async {
installFakeSecureStorage();
await saveAccount(const FirebaseAccount(email: 'x@y.z', password: 'p'));
expect((await storedAccount())?.password, 'p');
});
test('clearAccount forgets the account and sets the opt-out', () async {
installFakeSecureStorage();
await saveAccount(const FirebaseAccount(email: 'x@y.z', password: 'p'));
await clearAccount();
expect(await storedAccount(), isNull);
// The flag is what stops the wrapper silently re-adopting it.
expect(await loadAccount(routes: routes, base: base), isNull);
});
test('clearAccountMarkerOnly leaves re-provisioning possible', () async {
installFakeSecureStorage();
await saveAccount(const FirebaseAccount(email: 'x@y.z', password: 'p'));
await clearAccountMarkerOnly();
expect(await storedAccount(), isNull);
final body = jsonEncode({
'id_token': 'id',
'refresh_token': 'refresh',
'expires_at': DateTime.now()
.add(const Duration(hours: 1))
.toIso8601String(),
'email': 'again@b.c',
});
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient((_) async => http.Response(body, 200)),
);
expect(account?.email, 'again@b.c');
});
});
}