todo-app/test/sync_service_test.dart
Krzysztof kuhy Rudnicki 7564a6569f Surface sync outcomes so devices can't silently diverge
The desktop and phone split-brained for 5 days (39 vs 30 notes) after the
crdt_sync cutover because one device kept running the pre-migration build
— and nothing in the UI ever said a sync had failed or skipped a peer:

- SyncResult now reports peer files that were listed but couldn't be
  fetched/decoded (skippedFiles) instead of dropping them silently.
- Auto-sync no longer swallows errors: every sync (manual or automatic)
  records a persisted last-sync status shown in a status line under the
  editor, with failures in the error color.
- Desktop actually background-syncs now: focus loss (inactive/hidden,
  debounced 5s) triggers a sync — Flutter Linux rarely delivers the
  `paused` event the old code relied on. `paused` still syncs immediately
  for Android.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5C5jZ2c4mTaGedK7C1dDv
2026-07-16 17:58:41 +02:00

156 lines
5.6 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() {
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
});
}