todo-app/test/sync_service_test.dart
Krzysztof kuhy Rudnicki 7f84414c87 Add list filters/sort, status, priority rework, export/import, structured template
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
  and last-updated (AND-combined), a priority filter, and a new status
  filter. Default view hides Done/Abandoned and renders as "unfiltered"
  (no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".

Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
  attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
  Schema migration v2->v3 rewrites legacy priority 0 -> Medium.

Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
  and "Import notes" (file picker + safe newer-wins merge by id).

Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
  estimate/refs scaffold.

Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
  injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
  (note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
  branch excluded via coverage:ignore (unreachable on the Linux test host).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:52:59 +02:00

153 lines
4.7 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.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';
void main() {
setUpAll(sqfliteFfiInit);
test('sync pulls and merges another device, then pushes its own', () async {
final dir = await Directory.systemTemp.createTemp('todo_sync');
addTearDown(() => dir.delete(recursive: true));
// Build a second device's changeset and serialise it the way it would
// be stored in the repo (hlc as a String, base64 in the API response).
final other = await NoteRepository.open('${dir.path}/other.db');
await other.upsert(
Note(
id: 'x',
text: 'from other device',
priority: Priority.medium,
status: Status.todo,
createdAt: DateTime(2026, 6, 15),
updatedAt: DateTime(2026, 6, 15),
),
);
final otherJson = const JsonEncoder.withIndent(
' ',
).convert(await other.getChangeset());
await other.close();
final fileResponse = jsonEncode({
'content': base64.encode(utf8.encode(otherJson)),
});
const otherFile = 'otherNode.json';
final listResponse = jsonEncode([
{
'type': 'file',
'name': otherFile,
'path': 'changesets/$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/changesets')) {
return http.Response(listResponse, 200); // directory listing
}
return http.Response(fileResponse, 200); // the other device's file
});
final local = await NoteRepository.open('${dir.path}/local.db');
addTearDown(local.close);
final github = GitHubClient(
owner: 'o',
repo: 'r',
token: 't',
httpClient: mock,
);
final result = await const SyncService().sync(local, github);
expect(result.mergedDevices, 1);
expect(result.pushed, isTrue);
expect(result.toString(), contains('mergedDevices: 1'));
expect(putCount, 1); // pushed our own changeset
final texts = (await local.listNotes()).map((n) => n.text);
expect(texts, contains('from other device'));
});
test('sync with no remote files still pushes own changeset', () async {
final dir = await Directory.systemTemp.createTemp('todo_sync_empty');
addTearDown(() => dir.delete(recursive: true));
var putCount = 0;
final mock = MockClient((req) async {
if (req.method == 'PUT') {
putCount++;
return http.Response('{}', 200);
}
return http.Response('', 404); // empty/missing changesets dir
});
final local = await NoteRepository.open('${dir.path}/local.db');
addTearDown(local.close);
final github = GitHubClient(
owner: 'o',
repo: 'r',
token: 't',
httpClient: mock,
);
final result = await const SyncService().sync(local, github);
expect(result.mergedDevices, 0);
expect(result.pushed, isTrue);
expect(putCount, 1);
});
test('sync updates its own already-present changeset file', () async {
final dir = await Directory.systemTemp.createTemp('todo_sync_own');
addTearDown(() => dir.delete(recursive: true));
final local = await NoteRepository.open('${dir.path}/local.db');
addTearDown(local.close);
// The remote listing already contains *this* device's changeset 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': 'changesets/$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 github = GitHubClient(
owner: 'o',
repo: 'r',
token: 't',
httpClient: mock,
);
final result = await const SyncService().sync(local, github);
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
});
}