feat(todo): the person's projects, sealed and synced, the Inbox made once (ADR 0033, D5, D6)

TodoPerson opens the person's own sync database in their folder,
halkyon_app's SyncService behind AppCompatibility, their key (TodoKeys,
Train's copied with Todo's own key IDs) and their projects, and closes
them in that order, the key last. A project is sealed for its row; a
readable, foreign or moved row shows locked, and a device without the
account's keys reads and writes nothing. The Inbox is the row under an
ID keyed by the account, so every device makes the same one and the
instance can't tell which it is; it can't be deleted. Settings shows
Sync's card first and the cog its dot while sync fails, as Learn's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-01 01:05:31 +01:00
commit 60a8f5b617
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 1285 additions and 36 deletions

View file

@ -0,0 +1,65 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// A project's payload (ADR 0033, D6): UTF-8 JSON with its schema's version,
/// read back as it was written, and never read half.
void main() {
ByteData json(Object value) =>
ByteData.sublistView(Uint8List.fromList(utf8.encode(jsonEncode(value))));
test('a project reads back as it was written', () {
const home = ProjectContent(
name: 'Home',
color: PaletteColor.fern,
position: 2.5,
);
expect(decodeProject(encodeProject(home)), home);
expect(
decodeProject(encodeProject(ProjectContent.inbox)),
ProjectContent.inbox,
);
});
test('the payload names its schema and the colour by its name', () {
final written = jsonDecode(
utf8.decode(
Uint8List.sublistView(
encodeProject(
const ProjectContent(
name: 'Home',
color: PaletteColor.fern,
),
),
),
),
) as Map<String, Object?>;
expect(written, {
'schemaVersion': 1,
'name': 'Home',
'color': 'fern',
'position': 0.0,
});
});
test("a colour this app doesn't know is none; a newer schema, a payload "
'without a name or one that is no JSON is skipped', () {
expect(
decodeProject(json({'schemaVersion': 1, 'name': 'A', 'color': 'mauve'})),
const ProjectContent(name: 'A'),
);
expect(decodeProject(json({'schemaVersion': 2, 'name': 'A'})), isNull);
expect(decodeProject(json({'schemaVersion': 1})), isNull);
expect(decodeProject(ByteData(0)), isNull);
expect(
decodeProject(ByteData.sublistView(Uint8List.fromList([0xff, 0]))),
isNull,
);
});
}

View file

@ -0,0 +1,216 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_project_gateway.dart';
/// The person's projects, sealed on the way to the instance and opened on
/// the way back (ADR 0033, D5, D6), with libsodium.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late UnlockedAccountKeys bob;
late MemoryContentKeyGateway keyRows;
late MemoryProjectGateway rows;
final opened = <TodoKeys>[];
const home = ProjectContent(
name: 'Home',
color: PaletteColor.fern,
position: 1,
);
Future<ProjectRepository> device(UnlockedAccountKeys? account) async {
final keys = TodoKeys(keyRows, SodiumContentCipher(), () async => account);
opened.add(keys);
await keys.refresh();
return ProjectRepository(rows, keys);
}
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
bob = (await crypto.newAccount('bob password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemoryProjectGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
group('the Inbox', () {
test('is made once, under the same ID on every device of the account, '
'and first', () async {
final phone = await device(alice);
final id = await phone.ensureInbox();
await phone.save(home);
final laptop = await device(alice);
expect(await laptop.ensureInbox(), id);
expect(rows.rows.keys.where((key) => key == id), hasLength(1));
final all = await laptop.all();
expect(all.first, StoredProject(id, ProjectContent.inbox, inbox: true));
expect(all.where((project) => project.inbox), hasLength(1));
});
test('two devices making it offline write one row, not two', () async {
final phone = await device(alice);
final laptop = await device(alice);
// Neither sees the other's write before making its own.
final ids = await Future.wait([
phone.ensureInbox(),
laptop.ensureInbox(),
]);
expect(ids.toSet(), hasLength(1));
expect(rows.rows, hasLength(1));
});
test("names nothing of the person's that the instance could read: its "
"ID isn't the person's nor the same for another account", () async {
final mine = await (await device(alice)).ensureInbox();
final theirs = await (await device(bob)).ensureInbox();
expect(mine, isNot(theirs));
});
test("can't be deleted", () async {
final phone = await device(alice);
final id = await phone.ensureInbox();
await expectLater(phone.delete(id), throwsA(isA<InboxKept>()));
expect(rows.rows.keys, contains(id));
});
test('is only the row under its ID: a row claiming to be it elsewhere is '
'just a project', () async {
final phone = await device(alice);
final other = await phone.save(home);
final all = await phone.all();
expect(all.single, StoredProject(other, home));
});
});
test('a project made on one device reads on another of the account, '
'edited and deleted there', () async {
final laptop = await device(alice);
final id = await laptop.save(home);
final phone = await device(alice);
expect(await phone.all(), [StoredProject(id, home)]);
final renamed = home.copyWith(name: 'House', color: () => null);
await phone.update(id, renamed);
expect(await laptop.all(), [StoredProject(id, renamed)]);
await laptop.delete(id);
expect(await phone.all(), isEmpty);
});
test('projects come in their order, the Inbox first', () async {
final phone = await device(alice);
final second = await phone.save(home.copyWith(name: 'B', position: 2));
final first = await phone.save(home.copyWith(name: 'A', position: 1));
final inbox = await phone.ensureInbox();
expect(
[for (final project in await phone.all()) project.id],
[
inbox,
first,
second,
],
);
});
test('nothing the instance gets is readable: no name, no colour, no '
'JSON', () async {
await (await device(alice)).save(home);
final sealed = rows.rows.values.single;
expect(sealed.keyId, isNotNull);
final text = utf8.decode(
Uint8List.sublistView(sealed.payload),
allowMalformed: true,
);
expect(text, isNot(contains('Home')));
expect(text, isNot(contains('fern')));
expect(text, isNot(contains('schemaVersion')));
});
test("another person's project, a readable one, or one moved to another "
'row shows locked, never as theirs', () async {
final id = await (await device(alice)).save(home);
final sealed = rows.rows[id]!;
rows.rows
..['moved'] = sealed
..['readable'] = (
keyId: null,
nonce: null,
payload: ByteData.sublistView(
Uint8List.fromList(utf8.encode('{"schemaVersion":1,"name":"X"}')),
),
);
final others = await device(bob);
expect(
(await others.all()).where((project) => project.content != null),
isEmpty,
);
final mine = await device(alice);
expect(
{for (final project in await mine.all()) project.id: project.content},
{id: home, 'moved': null, 'readable': null},
);
});
test('an Inbox sealed for another row shows locked, never as the '
"person's Inbox", () async {
final phone = await device(alice);
final other = await phone.save(home);
final inbox = phone.inboxId;
rows.rows[inbox] = rows.rows[other]!;
final all = await phone.all();
expect(all.firstWhere((project) => project.inbox).content, isNull);
});
test('a device without the keys reads nothing and saves nothing, not '
'even the Inbox', () async {
await (await device(alice)).save(home);
final stranger = await device(null);
expect((await stranger.all()).single.content, isNull);
await expectLater(stranger.save(home), throwsA(isA<KeysMissing>()));
await expectLater(stranger.ensureInbox(), throwsA(isA<KeysMissing>()));
expect(rows.rows, hasLength(1));
});
test('a change here or from another device is told', () async {
final phone = await device(alice);
var told = 0;
final listening = phone.changes.listen((_) => told++);
await phone.save(home);
rows.bringRemoteChanges();
await Future<void>.delayed(Duration.zero);
expect(told, 2);
await listening.cancel();
});
}

View file

@ -0,0 +1,98 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
// The projects on the device's own database, as the sync engine keeps them,
// with libsodium as its build hook compiles it.
final _person = protocol.UuidValue.withValidation(
'01a16000-0000-7000-8000-000000000001',
);
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late Directory folder;
late OfflineSyncDatabaseSession db;
late SyncService sync;
late TodoKeys keys;
late ProjectRepository projects;
const home = ProjectContent(name: 'Home', position: 1);
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('projects');
db = await protocol.Client(
'http://127.0.0.1:9/',
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
sync = SyncService(
({required onMerge}) async {},
debounce: const Duration(days: 1),
);
keys = TodoKeys(
SyncContentKeyGateway(db, sync),
SodiumContentCipher(),
() async => alice,
);
await keys.refresh();
projects = ProjectRepository(SyncProjectGateway(db, sync), keys);
});
tearDown(() async {
await projects.dispose();
keys.dispose();
await sync.dispose();
await db.close();
await folder.delete(recursive: true);
});
test(
'the Inbox and a project are kept, edited and read back opened',
() async {
final inbox = await projects.ensureInbox();
expect(await projects.ensureInbox(), inbox);
final id = await projects.save(home);
const renamed = ProjectContent(name: 'House', position: 1);
await projects.update(id, renamed);
expect(await projects.all(), [
StoredProject(inbox, ProjectContent.inbox, inbox: true),
StoredProject(id, renamed),
]);
},
);
test('deleting a project blanks its payload first', () async {
final id = await projects.save(home);
final kept = await projects.save(home);
await projects.delete(id);
expect(await projects.all(), [StoredProject(kept, home)]);
// The sync engine keeps the deleted row as its tombstone: blank.
final rows = await db.db.unsafeQuery(
'SELECT length("payload"), "keyId", "nonce" FROM "project"',
);
final blank = [
for (final row in rows)
if (row[0] == 0) row,
];
expect(rows.length - blank.length, 1);
expect(blank, hasLength(1));
expect(blank.single.skip(1), [null, null]);
});
}