todo-app/test/app_settings_test.dart
Krzysztof kuhy Rudnicki ce2ea5b410 Simplify capture UI behind an advanced-mode toggle, add usage analytics
Hide priority/status/template/mode chrome and routine sync-status text
behind a Firebase-synced "Enable advanced" setting (default off); remove
the Capture title and Save button in favor of autosave + a plain reset
icon; sort notes by priority then recency; add interaction-only usage
analytics buffered locally and flushed to Firebase alongside note sync.

Code review (self + follow-up verification pass) found and fixed 3 real
bugs during this work: a widget-key placement bug that discarded an
in-progress draft when advancedMode was toggled mid-typing, a data-loss
bug in the analytics buffer's flush-and-clear logic under concurrent
cap-eviction, and two spots where an expired/wrong-account Firebase
session (FirebaseAuthError) escaped a narrower catch clause and would
have failed an otherwise-successful sync pass.

Also fixes an unrelated pre-existing test-isolation bug in
wrapper_server_test.dart, which was reading the real
~/.config/todo/firebase_auth.json instead of a test-scoped path.

320 tests, 100% line coverage on all touched files (one pre-existing,
out-of-scope 5-line gap in settings_screen.dart unrelated to this
change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 21:30:40 +02:00

232 lines
7.4 KiB
Dart

/// Tests for the `advancedMode` preference: local persistence and
/// reconciliation against the Firebase mirror.
@TestOn('vm')
library;
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:shared_preferences/shared_preferences.dart';
import 'package:todo/data/app_settings.dart';
FirebaseRestClient _fakeClient(
Future<http.Response> Function(http.Request request) handler,
) => 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(handler),
);
/// A client with no stored session: any call throws [FirebaseAuthError],
/// the sibling of [FirebaseSyncError] under the shared [RemoteSyncError]
/// supertype — used to confirm the wide-plus-network catches also cover an
/// expired/wrong-account session, not just a transient network failure.
FirebaseRestClient _authFailingClient() => FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'AIzaKey',
store: InMemoryCredentialStore(),
),
);
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
group('load', () {
test('defaults advancedMode to false with no updatedAt', () async {
final settings = await AppSettings.load();
expect(settings.advancedMode, isFalse);
expect(settings.advancedModeUpdatedAt, isNull);
});
test('reads back a previously persisted value', () async {
final now = DateTime(2026, 1, 1, 12);
SharedPreferences.setMockInitialValues({
'app.advancedMode': true,
'app.advancedMode.updatedAt': now.millisecondsSinceEpoch,
});
final settings = await AppSettings.load();
expect(settings.advancedMode, isTrue);
expect(settings.advancedModeUpdatedAt, now);
});
});
group('withAdvancedMode', () {
test('persists locally with no client', () async {
const initial = AppSettings(advancedMode: false);
final updated = await initial.withAdvancedMode(value: true);
expect(updated.advancedMode, isTrue);
expect(updated.advancedModeUpdatedAt, isNotNull);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
});
test('mirrors to a live client via patchValues', () async {
final requests = <String>[];
final client = _fakeClient((request) async {
requests.add('${request.method} ${request.url.path}');
return http.Response(request.body, 200);
});
const initial = AppSettings(advancedMode: false);
final updated = await initial.withAdvancedMode(
value: true,
client: client,
);
expect(updated.advancedMode, isTrue);
expect(requests, contains('PATCH /settings/advancedMode.json'));
});
test('swallows a push failure and keeps the local write', () async {
final client = _fakeClient(
(_) async => http.Response('error', 500),
);
const initial = AppSettings(advancedMode: false);
final updated = await initial.withAdvancedMode(
value: true,
client: client,
);
// The local write still landed even though the mirror push failed.
expect(updated.advancedMode, isTrue);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
});
test(
'swallows a FirebaseAuthError (expired session) and keeps the local '
'write',
() async {
const initial = AppSettings(advancedMode: false);
final updated = await initial.withAdvancedMode(
value: true,
client: _authFailingClient(),
);
expect(updated.advancedMode, isTrue);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
},
);
});
group('reconcileWithRemote', () {
test('returns unchanged when client is null', () async {
const initial = AppSettings(advancedMode: false);
final result = await initial.reconcileWithRemote(null);
expect(identical(result, initial), isTrue);
});
test('returns unchanged when nothing is stored remotely', () async {
final client = _fakeClient((_) async => http.Response('null', 200));
const initial = AppSettings(advancedMode: false);
final result = await initial.reconcileWithRemote(client);
expect(result.advancedMode, isFalse);
expect(result.advancedModeUpdatedAt, isNull);
});
test(
'adopts the remote value when local has never round-tripped',
() async {
final remoteTime = DateTime(2026);
final client = _fakeClient(
(_) async => http.Response(
'{"value": "true", '
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
200,
),
);
const initial = AppSettings(advancedMode: false);
final result = await initial.reconcileWithRemote(client);
expect(result.advancedMode, isTrue);
expect(result.advancedModeUpdatedAt, remoteTime);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
},
);
test('ignores a remote value that is not newer than local', () async {
final localTime = DateTime(2026, 2);
final remoteTime = DateTime(2026); // older
final client = _fakeClient(
(_) async => http.Response(
'{"value": "true", '
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
200,
),
);
final initial = AppSettings(
advancedMode: false,
advancedModeUpdatedAt: localTime,
);
final result = await initial.reconcileWithRemote(client);
expect(result.advancedMode, isFalse);
expect(result.advancedModeUpdatedAt, localTime);
});
test('adopts a remote value newer than local', () async {
final localTime = DateTime(2026);
final remoteTime = DateTime(2026, 2); // newer
final client = _fakeClient(
(_) async => http.Response(
'{"value": "true", '
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
200,
),
);
final initial = AppSettings(
advancedMode: false,
advancedModeUpdatedAt: localTime,
);
final result = await initial.reconcileWithRemote(client);
expect(result.advancedMode, isTrue);
expect(result.advancedModeUpdatedAt, remoteTime);
});
test('swallows a fetch failure and returns unchanged', () async {
final client = _fakeClient((_) async => http.Response('error', 500));
const initial = AppSettings(advancedMode: false);
final result = await initial.reconcileWithRemote(client);
expect(result.advancedMode, isFalse);
});
test(
'swallows a FirebaseAuthError (expired session) and returns unchanged',
() async {
const initial = AppSettings(advancedMode: false);
final result = await initial.reconcileWithRemote(
_authFailingClient(),
);
expect(identical(result, initial), isTrue);
},
);
});
}