mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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
191 lines
5.8 KiB
Dart
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>()),
|
|
);
|
|
});
|
|
});
|
|
}
|