feat(todo): the person's tasks, sections and labels, sealed and synced (ADR 0033, D6)

A table's rows are sealed for their table and row by one SealedRows,
which the repositories of projects, sections, tasks and labels share,
each table with its own sync gateway, a deletion blanking its rows first
in one transaction. A task's payload holds its title, description,
project, section, parent, place, date (a day, an optional wall-clock
time and duration), deadline, priority, labels and when it was done;
deleting a task deletes its sub-tasks, and a project or a section goes
with its tasks. Rows that don't open show locked; a device without the
account's keys writes nothing.

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:14:54 +01:00
commit bd3710ae58
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 1557 additions and 224 deletions

View file

@ -0,0 +1,149 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/label/data/label_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// Tasks', sections' and labels' payloads (ADR 0033, D6): UTF-8 JSON with
/// their schema's version, read back as written, and never read half.
void main() {
ByteData json(Object value) =>
ByteData.sublistView(Uint8List.fromList(utf8.encode(jsonEncode(value))));
Map<String, Object?> written(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>;
final full = TaskContent(
title: 'Pilates',
projectId: 'p',
description: 'Bring the mat',
sectionId: 's',
parentId: 't',
position: 3,
date: const TaskDate(
Day(2026, 11, 2),
time: 6 * 60 + 30,
duration: Duration(minutes: 30),
),
deadline: const Day(2026, 11, 8),
priority: Priority.p2,
labelIds: const ['l1', 'l2'],
completedAt: DateTime(2026, 11, 2, 7),
);
group('a task', () {
test('reads back as it was written, in full or barely', () {
expect(decodeTask(encodeTask(full)), full);
const bare = TaskContent(title: 'Milk', projectId: 'p');
expect(decodeTask(encodeTask(bare)), bare);
});
test('is written as days, minutes and numbers, and leaves out what it '
"hasn't", () {
expect(written(encodeTask(full)), {
'schemaVersion': 1,
'title': 'Pilates',
'projectId': 'p',
'description': 'Bring the mat',
'sectionId': 's',
'parentId': 't',
'position': 3.0,
'date': {'day': '2026-11-02', 'time': 390, 'minutes': 30},
'deadline': '2026-11-08',
'priority': 2,
'labelIds': ['l1', 'l2'],
'completedAt': DateTime(2026, 11, 2, 7).toUtc().toIso8601String(),
});
expect(
written(encodeTask(const TaskContent(title: 'Milk', projectId: 'p'))),
{
'schemaVersion': 1,
'title': 'Milk',
'projectId': 'p',
'position': 0.0,
},
);
});
test('of a newer schema, without a title or project, with a day that '
"isn't one or a time past midnight, isn't read at all", () {
final base = {'schemaVersion': 1, 'title': 'A', 'projectId': 'p'};
expect(decodeTask(json(base)), isNotNull);
for (final broken in [
{...base, 'schemaVersion': 2},
{'schemaVersion': 1, 'projectId': 'p'},
{'schemaVersion': 1, 'title': 'A'},
{
...base,
'date': {'day': '2026-02-31'},
},
{
...base,
'date': {'day': '2026-11-02', 'time': 1440},
},
{...base, 'deadline': 'soon'},
{...base, 'date': 'today'},
]) {
expect(decodeTask(json(broken)), isNull, reason: '$broken');
}
expect(decodeTask(ByteData(0)), isNull);
});
test('a priority it doesn’t know is none', () {
expect(
decodeTask(
json({
'schemaVersion': 1,
'title': 'A',
'projectId': 'p',
'priority': 9,
}),
)?.priority,
Priority.none,
);
});
});
group('a day', () {
test('reads only as YYYY-MM-DD, and only a day there is', () {
expect(Day.tryParse('2028-02-29'), const Day(2028, 2, 29));
expect(Day.tryParse('2026-02-29'), isNull);
expect(Day.tryParse('2026-1-2'), isNull);
expect(const Day(2026, 1, 2).toString(), '2026-01-02');
});
test('counts and orders across months and a change of clocks', () {
const first = Day(2026, 10, 24);
expect(first.plus(8), const Day(2026, 11, 1));
expect(first.daysUntil(const Day(2026, 11, 1)), 8);
expect(first < const Day(2026, 11, 1), isTrue);
expect(const Day(2026, 11, 1) > first, isTrue);
expect(first.compareTo(first), 0);
});
});
test('a section and a label read back as they were written', () {
const section = SectionContent(
name: 'Everyday life',
projectId: 'p',
description: 'About everyday life',
position: 2,
);
expect(decodeSection(encodeSection(section)), section);
const label = LabelContent(
name: 'errand',
color: PaletteColor.carrot,
position: 1,
);
expect(decodeLabel(encodeLabel(label)), label);
expect(decodeSection(json({'schemaVersion': 1, 'name': 'A'})), isNull);
expect(decodeLabel(json({'schemaVersion': 2, 'name': 'A'})), isNull);
});
}

View file

@ -0,0 +1,218 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
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/label/data/label_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_sealed_row_gateway.dart';
/// The person's tasks, sections and labels, 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 MemorySealedRowGateway rows;
final opened = <TodoKeys>[];
const milk = TaskContent(
title: 'Buy milk',
projectId: 'inbox',
date: TaskDate(Day(2026, 11, 2), time: 9 * 60),
priority: Priority.p1,
);
Future<TodoKeys> keysOf(UnlockedAccountKeys? account) async {
final keys = TodoKeys(keyRows, SodiumContentCipher(), () async => account);
opened.add(keys);
await keys.refresh();
return keys;
}
Future<TaskRepository> device(UnlockedAccountKeys? account) async =>
TaskRepository(rows, await keysOf(account));
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
bob = (await crypto.newAccount('bob password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemorySealedRowGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('a task made on one device reads on another of the account, edited, '
'done and deleted there', () async {
final phone = await device(alice);
final id = await phone.add(milk);
final laptop = await device(alice);
expect(await laptop.all(), [StoredTask(id, milk)]);
final at = DateTime(2026, 11, 2, 9, 30);
final done = (await laptop.all()).single;
await withClock(Clock.fixed(at), () => laptop.complete(done));
expect(
(await phone.all()).single.content,
milk.copyWith(completedAt: () => at),
);
await phone.complete((await phone.all()).single, done: false);
expect((await laptop.all()).single.content?.completed, isFalse);
await laptop.delete(id);
expect(await phone.all(), isEmpty);
});
test(
'deleting a task deletes its sub-tasks and theirs, and no other',
() async {
final phone = await device(alice);
final parent = await phone.add(milk);
final child = await phone.add(
const TaskContent(title: 'Oat', projectId: 'inbox').copyWith(
parentId: () => parent,
),
);
await phone.add(
const TaskContent(title: 'Barista', projectId: 'inbox').copyWith(
parentId: () => child,
),
);
final other = await phone.add(milk);
await phone.delete(parent);
expect([for (final task in await phone.all()) task.id], [other]);
},
);
test("deleting a project's or a section's tasks deletes only theirs, "
'with their sub-tasks', () async {
final phone = await device(alice);
final inSection = await phone.add(
const TaskContent(title: 'A', projectId: 'home', sectionId: 's'),
);
await phone.add(
TaskContent(title: 'A1', projectId: 'home', parentId: inSection),
);
final aboveSections = await phone.add(
const TaskContent(title: 'B', projectId: 'home'),
);
final elsewhere = await phone.add(milk);
await phone.deleteIn('home', sectionId: 's');
expect(
{for (final task in await phone.all()) task.id},
{
aboveSections,
elsewhere,
},
);
await phone.deleteIn('home');
expect([for (final task in await phone.all()) task.id], [elsewhere]);
});
test('nothing the instance gets is readable: no title, no project, no '
'date, not whether it is done', () async {
final phone = await device(alice);
final id = await phone.add(milk);
await phone.complete(StoredTask(id, milk));
final sealed = rows.byId.values.single;
expect(sealed.keyId, isNotNull);
final text = utf8.decode(
Uint8List.sublistView(sealed.payload),
allowMalformed: true,
);
for (final word in ['milk', 'inbox', '2026', 'completedAt', 'title']) {
expect(text, isNot(contains(word)), reason: word);
}
});
test("another person's task, a readable one, or one moved to another row "
'shows locked, never as theirs', () async {
final id = await (await device(alice)).add(milk);
final sealed = rows.byId[id]!;
rows.byId
..['moved'] = sealed
..['readable'] = (
keyId: null,
nonce: null,
payload: ByteData.sublistView(
Uint8List.fromList(
utf8.encode('{"schemaVersion":1,"title":"X","projectId":"p"}'),
),
),
);
expect(
(await (await device(bob)).all()).where((task) => task.content != null),
isEmpty,
);
expect(
{
for (final task in await (await device(alice)).all())
task.id: task.content,
},
{id: milk, 'moved': null, 'readable': null},
);
});
test('a device without the keys reads nothing and writes nothing', () async {
await (await device(alice)).add(milk);
final stranger = await device(null);
expect((await stranger.all()).single.content, isNull);
await expectLater(stranger.add(milk), throwsA(isA<KeysMissing>()));
await expectLater(
stranger.update(rows.byId.keys.single, milk),
throwsA(isA<KeysMissing>()),
);
expect(rows.byId, hasLength(1));
});
test('sections and labels are sealed and opened alike, and locked for '
'anyone else', () async {
final sections = SectionRepository(rows, await keysOf(alice));
const section = SectionContent(name: 'Everyday life', projectId: 'home');
final id = await sections.add(section);
expect((await sections.all()).single.content, section);
expect(
(await SectionRepository(rows, await keysOf(bob)).all()).single.content,
isNull,
);
await sections.delete([id]);
expect(await sections.all(), isEmpty);
final labels = LabelRepository(rows, await keysOf(alice));
const label = LabelContent(name: 'errand');
await labels.add(label);
expect((await labels.all()).single.content, label);
expect(
(await LabelRepository(rows, await keysOf(bob)).all()).single.content,
isNull,
);
await expectLater(
LabelRepository(rows, await keysOf(null)).add(label),
throwsA(isA<KeysMissing>()),
);
});
}