utils/crdt_sync_dart/test/mirror_store_test.dart
Krzysztof kuhy Rudnicki 200432a2b4 dart: split ten oversized test files by group
firebase_auth_rest_test.dart  525 -> 153 + _google 158 + _tokens 206 + _uid 141
  sync_revisions_test.dart      451 -> 223 + _peers 228 + _backends 158
                                       + _order 165 + _persistence 215
  sync_settings_screen_test     457 -> 207 + _backup 143 + _google 145
  mirror_store_test.dart        452 -> 209 + _reads 172 + _fallback 196 + _revisions 244
  firebase_client_test.dart     374 -> 198 + _writes 227
  firebase_config_test.dart     360 -> 138 + _client 148 + _client_google 164
  device_auth_test.dart         307 -> 128 + _poll 201
  github_client_test.dart       291 -> 143 + _writes 201
  sync_test.dart                269 -> 160 + _skips 175

Two traps this hit, both invisible to a line count:

  - _FakePersistence is declared *after* main(), so a naive cut at the last
    brace dropped it; sync_revisions_persistence_test.dart failed to load
    until it was carried across. Audited every other split file for trailing
    top-level declarations — this was the only one.
  - Copying the preamble into each sibling left helpers the sibling never
    uses. `dart analyze` flagged 12; all removed rather than left as dead
    code, and the analyzer is now clean.

crdt_sync_dart 280 passed, sync_settings_ui 29 passed, github_device_auth
analyze clean — all matching a clean HEAD worktree.

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

191 lines
5.8 KiB
Dart

/// Tests for the dual-write decorator used during the GitHub -> Firebase
/// cutover.
///
/// The asymmetry is the whole point and is what these assert: a primary
/// failure must fail the tick, a mirror failure must not, and reads must
/// consult both so that a half-migrated app (one device moved, one not) still
/// converges in both directions.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:test/test.dart';
/// A scriptable in-memory [RemoteStore] that can be told to fail.
class _FakeStore implements RemoteStore, BulkMapReader {
_FakeStore({Map<String, String>? files, this.failing = false})
: files = files ?? {};
final Map<String, String> files;
bool failing;
final List<String> writes = [];
bool closed = false;
void _guard(String what) {
if (failing) throw RemoteSyncError('$what failed');
}
@override
Future<List<String>> listDirectory(String path) async {
_guard('list');
return files.keys
.where((key) => key.startsWith('$path/'))
.map((key) => key.substring(path.length + 1).split('/').first)
.toSet()
.toList();
}
@override
Future<String?> getFileText(String path) async {
_guard('read');
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
_guard('write');
writes.add(path);
files[path] = text;
}
@override
Future<Map<String, String>> getStringMap(String path) async {
_guard('map read');
return {
for (final entry in files.entries)
if (entry.key.startsWith('$path/'))
entry.key.substring(path.length + 1): entry.value,
};
}
@override
Future<void> deleteFile(String path, {String message = ''}) async {
_guard('delete');
files.remove(path);
}
@override
Future<bool> canAccessRemote() async => !failing;
@override
void close() => closed = true;
}
({
MirrorStore store,
_FakeStore primary,
_FakeStore mirror,
List<String> failures,
})
_mirror({
Map<String, String>? primaryFiles,
Map<String, String>? mirrorFiles,
bool primaryFailing = false,
bool mirrorFailing = false,
}) {
final primary = _FakeStore(files: primaryFiles, failing: primaryFailing);
final mirror = _FakeStore(files: mirrorFiles, failing: mirrorFailing);
final failures = <String>[];
return (
store: MirrorStore(
primary: primary,
mirror: mirror,
onMirrorFailure: (operation, _) => failures.add(operation),
),
primary: primary,
mirror: mirror,
failures: failures,
);
}
void main() {
group('writes', () {
test('go to both backends', () async {
final m = _mirror();
await m.store.putFileText('ns/pc/log.json', '{}', message: 'm');
expect(m.primary.writes, ['ns/pc/log.json']);
expect(m.mirror.writes, ['ns/pc/log.json']);
});
test('a primary failure fails the tick', () async {
// Fail-closed: the primary is authoritative, so a sync that could not
// write it must not be reported as successful.
final m = _mirror(primaryFailing: true);
await expectLater(
() => m.store.putFileText('ns/pc/log.json', '{}', message: 'm'),
throwsA(isA<RemoteSyncError>()),
);
});
test('a mirror failure is loud but does not fail the tick', () async {
final m = _mirror(mirrorFailing: true);
await m.store.putFileText('ns/pc/log.json', '{}', message: 'm');
expect(m.primary.writes, ['ns/pc/log.json']);
expect(m.failures, ['putFileText ns/pc/log.json']);
});
test('deletes behave the same way', () async {
final m = _mirror(
primaryFiles: {'ns/pc/log.json': '{}'},
mirrorFiles: {'ns/pc/log.json': '{}'},
mirrorFailing: true,
);
await m.store.deleteFile('ns/pc/log.json');
expect(m.primary.files, isEmpty);
expect(m.failures, ['deleteFile ns/pc/log.json']);
});
test('a primary delete failure fails the tick', () async {
final m = _mirror(primaryFailing: true);
await expectLater(
() => m.store.deleteFile('ns/pc/log.json'),
throwsA(isA<RemoteSyncError>()),
);
});
});
group('reads consult both backends', () {
test('prefer the primary when it has the file', () async {
final m = _mirror(
primaryFiles: {'ns/pc/log.json': 'from-primary'},
mirrorFiles: {'ns/pc/log.json': 'from-mirror'},
);
expect(await m.store.getFileText('ns/pc/log.json'), 'from-primary');
});
test('fall back to the mirror for an un-migrated device', () async {
// This is why reads are not primary-only: a migrated desktop must
// still see an un-migrated phone's writes, or convergence silently
// becomes one-directional with no error raised.
final m = _mirror(mirrorFiles: {'ns/phone/log.json': 'from-mirror'});
expect(await m.store.getFileText('ns/phone/log.json'), 'from-mirror');
});
test('return null when neither backend has the file', () async {
final m = _mirror();
expect(await m.store.getFileText('ns/nobody/log.json'), isNull);
});
test('a mirror read failure degrades to the primary answer', () async {
final m = _mirror(mirrorFailing: true);
expect(await m.store.getFileText('ns/pc/log.json'), isNull);
expect(m.failures, ['getFileText ns/pc/log.json']);
});
test('a primary read failure with nothing in the mirror throws', () async {
// Named for what it actually exercises: the primary throws AND the
// mirror does not hold the file, so neither side has an answer. A
// primary failure alone no longer fails the read -- see the
// "primary read fallback" group.
final m = _mirror(primaryFailing: true);
await expectLater(
() => m.store.getFileText('ns/pc/log.json'),
throwsA(isA<RemoteSyncError>()),
);
});
});
}