mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 14:07:49 +01:00
NoteRepository is rewritten on crdt_sync's LogStore: each Note is stored *as* a Record (per-field LWW + sticky tombstone), so the on-disk log IS the sync wire format. Its public API is unchanged (upsert/delete/listNotes/watchNotes/ watchCount/importNotes/changes/nodeId), so the UI and most tests are untouched; only the internals, the two sync methods (getChangeset/merge -> exportLog/importLog over a Log), and the changeset-specific tests change. Filtering/sorting now run in Dart over the in-memory log; watchNotes/watchCount seed synchronously in onListen to avoid a subscription race. SyncService moves from per-device sqlite changesets to per-device crdt_sync Logs under a new todo-sync/notes/ path (clean format cutover; old clients can't misread a new file). todo's own GitHubClient transport is kept. One-time migration in NoteRepository.open: reads the legacy sqlite DB (kept untouched as a safety net; the dep stays only for this), seeds every note -- tombstones included -- into the log with each field's HLC derived from its updated_at (so a pre-cutover edit still wins by real edit time across two independently-migrating devices), reuses the legacy node id, and sets a persisted "migrated" flag so it never re-runs. Verified: flutter analyze + dart format clean; 178 tests at 100% coverage (incl. migration, tombstone, restore-race, and corrupt-peer cases). Device verified on dev.kuhy.todo: real backlog of 27 live notes (+5 tombstones) migrated intact -- confirmed against a pulled sqlite backup, the pulled LogStore JSON, and the app's own "27 saved" -- notes render with status/ priority/dates, no crash, migration one-time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
164 lines
5.3 KiB
Dart
164 lines
5.3 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:crdt_sync/crdt_sync.dart' hide GitHubClient;
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:todo/data/note.dart';
|
|
import 'package:todo/data/note_repository.dart';
|
|
import 'package:todo/sync/github_client.dart';
|
|
import 'package:todo/sync/sync_service.dart';
|
|
|
|
GitHubClient _github(MockClient 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('sync pulls and merges another device, then pushes its own', () async {
|
|
// Build a second device's note log and serialise it the way it is stored
|
|
// in the repo (a crdt_sync Log JSON, base64 in the API response).
|
|
final other = await NoteRepository.openInMemory(nodeId: 'otherNode');
|
|
await other.upsert(_note('x', 'from other device'));
|
|
final otherJson = logToJson(other.exportLog());
|
|
await other.close();
|
|
final fileResponse = jsonEncode({
|
|
'content': base64.encode(utf8.encode(otherJson)),
|
|
});
|
|
|
|
const otherFile = 'otherNode.json';
|
|
final listResponse = jsonEncode([
|
|
{
|
|
'type': 'file',
|
|
'name': otherFile,
|
|
'path': 'todo-sync/notes/$otherFile',
|
|
'sha': 'sha-other',
|
|
},
|
|
]);
|
|
|
|
var putCount = 0;
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') {
|
|
putCount++;
|
|
return http.Response('{}', 200);
|
|
}
|
|
if (req.url.path.endsWith('/contents/todo-sync/notes')) {
|
|
return http.Response(listResponse, 200); // directory listing
|
|
}
|
|
return http.Response(fileResponse, 200); // the other device's file
|
|
});
|
|
|
|
final local = await NoteRepository.openInMemory(nodeId: 'localNode');
|
|
addTearDown(local.close);
|
|
|
|
final result = await const SyncService().sync(local, _github(mock));
|
|
|
|
expect(result.mergedDevices, 1);
|
|
expect(result.pushed, isTrue);
|
|
expect(result.toString(), contains('mergedDevices: 1'));
|
|
expect(putCount, 1); // pushed our own log
|
|
final texts = (await local.listNotes()).map((n) => n.text);
|
|
expect(texts, contains('from other device'));
|
|
});
|
|
|
|
test('sync with no remote files still pushes own log', () async {
|
|
var putCount = 0;
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') {
|
|
putCount++;
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response('', 404); // empty/missing notes dir
|
|
});
|
|
|
|
final local = await NoteRepository.openInMemory(nodeId: 'localNode');
|
|
addTearDown(local.close);
|
|
|
|
final result = await const SyncService().sync(local, _github(mock));
|
|
expect(result.mergedDevices, 0);
|
|
expect(result.pushed, isTrue);
|
|
expect(putCount, 1);
|
|
});
|
|
|
|
test('sync updates its own already-present log file', () async {
|
|
final local = await NoteRepository.openInMemory(nodeId: 'localNode');
|
|
addTearDown(local.close);
|
|
|
|
// The remote listing already contains *this* device's log file. The sync
|
|
// must recognise it (by node id), skip merging itself, remember the sha,
|
|
// and PUT an update rather than treating it as a peer device.
|
|
final ownFile = '${local.nodeId}.json';
|
|
final listResponse = jsonEncode([
|
|
{
|
|
'type': 'file',
|
|
'name': ownFile,
|
|
'path': 'todo-sync/notes/$ownFile',
|
|
'sha': 'own-sha-123',
|
|
},
|
|
]);
|
|
|
|
String? putSha;
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') {
|
|
final body = jsonDecode(req.body) as Map<String, dynamic>;
|
|
putSha = body['sha'] as String?;
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response(listResponse, 200);
|
|
});
|
|
|
|
final result = await const SyncService().sync(local, _github(mock));
|
|
expect(result.mergedDevices, 0); // own file is not a peer to merge
|
|
expect(result.pushed, isTrue);
|
|
expect(putSha, 'own-sha-123'); // updated in place using the remembered sha
|
|
});
|
|
|
|
test('a corrupt or wrong-shape peer file is skipped, not fatal', () async {
|
|
String file(String content) =>
|
|
jsonEncode({'content': base64.encode(utf8.encode(content))});
|
|
final listResponse = jsonEncode([
|
|
{
|
|
'type': 'file',
|
|
'name': 'bad1.json',
|
|
'path': 'todo-sync/notes/bad1.json',
|
|
'sha': 's1',
|
|
},
|
|
{
|
|
'type': 'file',
|
|
'name': 'bad2.json',
|
|
'path': 'todo-sync/notes/bad2.json',
|
|
'sha': 's2',
|
|
},
|
|
]);
|
|
|
|
var putCount = 0;
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') {
|
|
putCount++;
|
|
return http.Response('{}', 200);
|
|
}
|
|
if (req.url.path.endsWith('/contents/todo-sync/notes')) {
|
|
return http.Response(listResponse, 200);
|
|
}
|
|
if (req.url.path.endsWith('bad1.json')) {
|
|
return http.Response(file('{not json'), 200); // FormatException
|
|
}
|
|
return http.Response(file('[]'), 200); // valid JSON, wrong shape
|
|
});
|
|
|
|
final local = await NoteRepository.openInMemory(nodeId: 'localNode');
|
|
addTearDown(local.close);
|
|
|
|
final result = await const SyncService().sync(local, _github(mock));
|
|
expect(result.mergedDevices, 0); // both bad files skipped
|
|
expect(putCount, 1); // still pushed our own log
|
|
});
|
|
}
|