todo-app/test/sync_service_test.dart
Krzysztof kuhy Rudnicki f933b4baa4
Some checks are pending
release-apk / release (APK) (push) Waiting to run
Sync through Firebase, mirroring to GitHub, and stop re-downloading peers
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
2026-08-04 23:36:24 +02:00

336 lines
11 KiB
Dart

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.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/sync_service.dart';
http.Response _response(int statusCode, [Object? jsonBody]) =>
http.Response(jsonEncode(jsonBody ?? {}), statusCode);
http.Response _fileWith(String text, {String? sha}) =>
_response(200, {'content': base64.encode(utf8.encode(text)), 'sha': ?sha});
class _PutCall {
_PutCall(this.path, this.body);
final String path;
final Map<String, dynamic> body;
}
/// Mock GitHub Contents API router matching crdt_sync's client: GET
/// `.../contents/<key>` returns [contentResponses]`[key]` (404 if absent), the
/// bare repo-existence GET always succeeds, and every PUT is recorded.
({http.Client httpClient, List<_PutCall> putCalls}) _mockGitHub({
Map<String, http.Response> contentResponses = const {},
}) {
final putCalls = <_PutCall>[];
final client = http_testing.MockClient((request) async {
final path = request.url.path;
if (!path.contains('/contents/')) return _response(200); // repo-exists
final key = path.split('/contents/').last;
if (request.method == 'PUT') {
putCalls.add(
_PutCall(key, jsonDecode(request.body) as Map<String, dynamic>),
);
return _response(200);
}
return contentResponses[key] ?? _response(404);
});
return (httpClient: client, putCalls: putCalls);
}
GitHubClient _github(http.Client mock) =>
GitHubClient(owner: 'o', repo: 'r', token: 't', httpClient: mock);
Note _note(String id, String text) => Note(
id: id,
text: text,
priority: Priority.medium,
status: Status.todo,
createdAt: DateTime(2026, 6, 15),
updatedAt: DateTime(2026, 6, 15),
);
void main() {
_revisionTrackingTests();
test('pulls and merges another device, then pushes its own', () async {
final other = await NoteRepository.openInMemory(nodeId: 'otherNode');
await other.upsert(_note('x', 'from other device'));
final otherLog = logToJson(other.exportLog());
await other.close();
final (:httpClient, :putCalls) = _mockGitHub(
contentResponses: {
'todo-sync/notes': _response(200, [
{'name': 'otherNode.json'},
]),
'todo-sync/notes/otherNode.json': _fileWith(otherLog),
},
);
final local = await NoteRepository.openInMemory(nodeId: 'localNode');
addTearDown(local.close);
final result = await const SyncService().sync(local, _github(httpClient));
expect(result.mergedDevices, 1);
expect(result.pushed, isTrue);
expect(result.toString(), contains('mergedDevices: 1'));
expect(putCalls.single.path, 'todo-sync/notes/localNode.json');
final texts = (await local.listNotes()).map((n) => n.text);
expect(texts, contains('from other device'));
});
test('with no remote files, still pushes its own log', () async {
final (:httpClient, :putCalls) = _mockGitHub(
contentResponses: {'todo-sync/notes': _response(200, <Object>[])},
);
final local = await NoteRepository.openInMemory(nodeId: 'localNode');
addTearDown(local.close);
final result = await const SyncService().sync(local, _github(httpClient));
expect(result.mergedDevices, 0);
expect(putCalls, hasLength(1));
});
test('updates its own already-present file using its resolved sha', () async {
final (:httpClient, :putCalls) = _mockGitHub(
contentResponses: {
'todo-sync/notes': _response(200, [
{'name': 'localNode.json'},
]),
'todo-sync/notes/localNode.json': _fileWith('{}', sha: 'own-sha-123'),
},
);
final local = await NoteRepository.openInMemory(nodeId: 'localNode');
addTearDown(local.close);
final result = await const SyncService().sync(local, _github(httpClient));
expect(result.mergedDevices, 0); // own file is not a peer to merge
expect(putCalls.single.body['sha'], 'own-sha-123'); // put reused the sha
});
test('a corrupt or wrong-shape peer file is skipped, not fatal', () async {
final (:httpClient, :putCalls) = _mockGitHub(
contentResponses: {
'todo-sync/notes': _response(200, [
{'name': 'bad1.json'},
{'name': 'bad2.json'},
]),
'todo-sync/notes/bad1.json': _fileWith('{not json'),
'todo-sync/notes/bad2.json': _fileWith('[]'),
},
);
final local = await NoteRepository.openInMemory(nodeId: 'localNode');
addTearDown(local.close);
final result = await const SyncService().sync(local, _github(httpClient));
expect(result.mergedDevices, 0); // both bad files skipped
expect(putCalls, hasLength(1));
// The skips are reported, not silent: that's how a peer device dropping
// out of the merge becomes visible in the UI.
expect(result.skippedFiles, ['bad1.json', 'bad2.json']);
expect(result.toString(), contains('bad1.json'));
});
test('a listed-but-unfetchable peer file is reported as skipped', () async {
// The file appears in the directory listing but its GET 404s (deleted
// between list and fetch, or contents unavailable).
final (:httpClient, :putCalls) = _mockGitHub(
contentResponses: {
'todo-sync/notes': _response(200, [
{'name': 'gone.json'},
]),
},
);
final local = await NoteRepository.openInMemory(nodeId: 'localNode');
addTearDown(local.close);
final result = await const SyncService().sync(local, _github(httpClient));
expect(result.mergedDevices, 0);
expect(result.skippedFiles, ['gone.json']);
expect(putCalls, hasLength(1)); // the push still happened
});
}
/// An in-memory [RemoteStore] that also serves revision maps in one read,
/// standing in for Firebase. Counts reads so tests can assert on *traffic*
/// rather than on the merge result.
class _FakeRemote implements RemoteStore, BulkMapReader {
_FakeRemote(this.files);
final Map<String, String> files;
final List<String> reads = [];
final List<String> writes = [];
@override
Future<List<String>> listDirectory(String path) async => files.keys
.where((k) => k.startsWith('$path/'))
.map((k) => k.substring(path.length + 1).split('/').first)
.toSet()
.toList();
@override
Future<String?> getFileText(String path) async {
reads.add(path);
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
writes.add(path);
files[path] = text;
}
@override
Future<Map<String, String>> getStringMap(String path) async => {
for (final e in files.entries)
if (e.key.startsWith('$path/')) e.key.substring(path.length + 1): e.value,
};
@override
Future<void> deleteFile(String path, {String message = ''}) async =>
files.remove(path);
@override
Future<bool> canAccessRemote() async => true;
@override
void close() {}
}
/// A [RemoteStore] with no bulk-map read, standing in for GitHub.
///
/// Delegates rather than extends: a subclass of [_FakeRemote] would inherit
/// `BulkMapReader`, the very capability this fake exists to lack.
class _FakeRemoteWithoutBulkRead implements RemoteStore {
_FakeRemoteWithoutBulkRead(Map<String, String> files)
: _inner = _FakeRemote(files);
final _FakeRemote _inner;
List<String> get reads => _inner.reads;
@override
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
@override
Future<String?> getFileText(String path) => _inner.getFileText(path);
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) => _inner.putFileText(path, text, message: message);
@override
Future<void> deleteFile(String path, {String message = ''}) =>
_inner.deleteFile(path, message: message);
@override
Future<bool> canAccessRemote() => _inner.canAccessRemote();
@override
void close() => _inner.close();
}
void _revisionTrackingTests() {
group('revision tracking', () {
late NoteRepository repo;
setUp(() async {
repo = await NoteRepository.openInMemory(nodeId: 'me');
});
tearDown(() async => repo.close());
test('skips a peer whose revision has not changed', () async {
// The saving the free-tier budget depends on: an unchanged ~150 KB peer
// log must not be downloaded again.
final peerLog = logToJson(await _peerLog());
final remote = _FakeRemote({
'todo-sync/notes/phone.json': peerLog,
'todo-sync/revs/phone': revisionOf(peerLog),
});
final store = InMemorySyncStateStore();
await SyncService(stateStore: store).sync(repo, remote);
remote.reads.clear();
final second = await SyncService(stateStore: store).sync(repo, remote);
expect(remote.reads, isNot(contains('todo-sync/notes/phone.json')));
expect(second.skippedUnchanged, 1);
expect(second.mergedDevices, 0);
});
test('downloads again once the peer publishes a new revision', () async {
final first = logToJson(await _peerLog());
final remote = _FakeRemote({
'todo-sync/notes/phone.json': first,
'todo-sync/revs/phone': revisionOf(first),
});
final store = InMemorySyncStateStore();
await SyncService(stateStore: store).sync(repo, remote);
remote.reads.clear();
final changed = logToJson(await _peerLog(title: 'changed'));
remote.files['todo-sync/notes/phone.json'] = changed;
remote.files['todo-sync/revs/phone'] = revisionOf(changed);
final second = await SyncService(stateStore: store).sync(repo, remote);
expect(remote.reads, contains('todo-sync/notes/phone.json'));
expect(second.mergedDevices, 1);
});
test('suppresses an unchanged push and publishes a revision', () async {
final remote = _FakeRemote({});
final store = InMemorySyncStateStore();
final first = await SyncService(stateStore: store).sync(repo, remote);
expect(first.pushed, isTrue);
expect(remote.writes, [
'todo-sync/notes/me.json',
'todo-sync/revs/me',
]);
remote.writes.clear();
final second = await SyncService(stateStore: store).sync(repo, remote);
expect(second.pushed, isFalse);
expect(remote.writes, isEmpty);
});
test('still syncs on a backend without a bulk-map read', () async {
// GitHub has no cheap revision map; correctness must not depend on the
// optimisation being available.
final peerLog = logToJson(await _peerLog());
final remote = _FakeRemoteWithoutBulkRead({
'todo-sync/notes/phone.json': peerLog,
});
final result = await SyncService(
stateStore: InMemorySyncStateStore(),
).sync(repo, remote);
expect(result.mergedDevices, 1);
expect(remote.reads, contains('todo-sync/notes/phone.json'));
});
});
}
/// Builds a one-note log as it would arrive from another device.
Future<Log> _peerLog({String title = 'from phone'}) async {
final peer = await NoteRepository.openInMemory(nodeId: 'phone');
await peer.upsert(_note('peer-note', title));
final log = peer.exportLog();
await peer.close();
return log;
}