mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 18:14:02 +01:00
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
156 lines
5.6 KiB
Dart
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
|
|
});
|
|
}
|