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:
parent
128bcb127b
commit
bd3710ae58
2 changed files with 1557 additions and 224 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue