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:
parent
3876e30681
commit
60a8f5b617
7 changed files with 1285 additions and 36 deletions
|
|
@ -3,9 +3,14 @@ import 'package:halkyon_app/halkyon_app.dart';
|
|||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_app.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.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_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/memory_content_key_gateway.dart';
|
||||
import '../support/memory_project_gateway.dart';
|
||||
|
||||
void main() {
|
||||
final builtIn = Instance.parse('todo.example.org')!;
|
||||
late MemoryInstanceStore store;
|
||||
|
|
@ -86,7 +91,26 @@ void main() {
|
|||
'https://code.halkyon.app/project-halkyon/halkyon',
|
||||
openPerson: (person) async {
|
||||
opened.add(person);
|
||||
return TodoPerson(person);
|
||||
final keys = TodoKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
SodiumContentCipher(),
|
||||
() async => null,
|
||||
);
|
||||
final sync = SyncService(
|
||||
({required onMerge}) async {},
|
||||
debounce: const Duration(days: 1),
|
||||
);
|
||||
final projects = ProjectRepository(MemoryProjectGateway(), keys);
|
||||
return TodoPerson(
|
||||
person,
|
||||
sync: sync,
|
||||
projects: projects,
|
||||
onDispose: () async {
|
||||
await projects.dispose();
|
||||
await sync.dispose();
|
||||
keys.dispose();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -212,19 +236,23 @@ void main() {
|
|||
expect(find.widgetWithText(AppBar, 'Browse'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Settings has the account, then About Halkyon Todo last and '
|
||||
'the version small at the very foot', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openSettings(tester);
|
||||
testWidgets(
|
||||
'Settings has Sync first, the account, then About Halkyon Todo last and '
|
||||
'the version small at the very foot',
|
||||
(tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openSettings(tester);
|
||||
|
||||
final order = [
|
||||
'Account',
|
||||
'Sign out',
|
||||
'About Halkyon Todo',
|
||||
'Version 0.1.0 (build 1)',
|
||||
].map((text) => tester.getTopLeft(find.text(text)).dy).toList();
|
||||
expect(order, [...order]..sort());
|
||||
});
|
||||
final order = [
|
||||
'Sync',
|
||||
'Account',
|
||||
'Sign out',
|
||||
'About Halkyon Todo',
|
||||
'Version 0.1.0 (build 1)',
|
||||
].map((text) => tester.getTopLeft(find.text(text)).dy).toList();
|
||||
expect(order, [...order]..sort());
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets("from medium windows on, a rail with Todo's icon on top", (
|
||||
tester,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
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 '../../support/memory_content_key_gateway.dart';
|
||||
|
||||
/// The person's key on Todo's devices (ADR 0033, D5, as Train's ADR 0030, D15), with libsodium as
|
||||
/// its build hook compiles it.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late MemoryContentKeyGateway rows;
|
||||
final opened = <TodoKeys>[];
|
||||
|
||||
/// A device of the account whose keys are [account] (none: signed in
|
||||
/// without them), on [rows].
|
||||
Future<TodoKeys> device(UnlockedAccountKeys? account) async {
|
||||
final keys = TodoKeys(rows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
return keys;
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
bob = (await crypto.newAccount('bob password')).keys;
|
||||
});
|
||||
|
||||
setUp(() => rows = MemoryContentKeyGateway());
|
||||
|
||||
tearDown(() {
|
||||
for (final keys in opened) {
|
||||
keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
});
|
||||
|
||||
test("the first write makes the person's key, which their other devices "
|
||||
'open', () async {
|
||||
final phone = await device(alice);
|
||||
expect(rows.rows, isEmpty);
|
||||
|
||||
final keyId = await phone.keyToWrite();
|
||||
|
||||
expect(rows.rows.map((row) => row.dataKeyId), [keyId]);
|
||||
expect(keyId, startsWith('todo1.'));
|
||||
final laptop = await device(alice);
|
||||
expect(laptop.canOpen(keyId), isTrue);
|
||||
expect(await laptop.keyToWrite(), keyId);
|
||||
expect(rows.rows, hasLength(1));
|
||||
});
|
||||
|
||||
test('a device that has read no key rows yet writes with the key already '
|
||||
'there, and makes none beside it', () async {
|
||||
final phone = await device(alice);
|
||||
final keyId = await phone.keyToWrite();
|
||||
final laptop = TodoKeys(rows, SodiumContentCipher(), () async => alice);
|
||||
opened.add(laptop);
|
||||
|
||||
expect(await laptop.keyToWrite(), keyId);
|
||||
expect(rows.rows, hasLength(1));
|
||||
});
|
||||
|
||||
test('two devices that made a key each, offline, both write with the '
|
||||
'older one once they see both', () async {
|
||||
final phone = await device(alice);
|
||||
final laptop = await device(alice);
|
||||
final older = await withClock(
|
||||
Clock.fixed(DateTime.utc(2026, 10, 28, 8)),
|
||||
phone.keyToWrite,
|
||||
);
|
||||
// The laptop hasn't seen the phone's yet: as offline.
|
||||
final row = rows.rows.removeLast();
|
||||
final newer = await withClock(
|
||||
Clock.fixed(DateTime.utc(2026, 10, 28, 9)),
|
||||
laptop.keyToWrite,
|
||||
);
|
||||
rows.rows.add(row);
|
||||
|
||||
await phone.refresh();
|
||||
await laptop.refresh();
|
||||
|
||||
expect(await phone.keyToWrite(), older);
|
||||
expect(await laptop.keyToWrite(), older);
|
||||
expect(laptop.canOpen(newer), isTrue);
|
||||
expect(phone.canOpen(newer), isTrue);
|
||||
});
|
||||
|
||||
test("a device without the account's keys opens nothing and writes "
|
||||
'nothing', () async {
|
||||
final phone = await device(alice);
|
||||
final keyId = await phone.keyToWrite();
|
||||
|
||||
final elsewhere = await device(null);
|
||||
|
||||
expect(elsewhere.canOpen(keyId), isFalse);
|
||||
expect(elsewhere.keyToWrite, throwsA(isA<KeysMissing>()));
|
||||
expect(rows.rows, hasLength(1));
|
||||
});
|
||||
|
||||
test("another account's key, put among the person's by the instance, is "
|
||||
'never opened or written with', () async {
|
||||
final bobs = await device(bob);
|
||||
final bobsKey = await bobs.keyToWrite();
|
||||
|
||||
final phone = await device(alice);
|
||||
|
||||
expect(phone.canOpen(bobsKey), isFalse);
|
||||
expect(await phone.keyToWrite(), isNot(bobsKey));
|
||||
});
|
||||
|
||||
test('a key relabelled by the instance, or one the account sealed for '
|
||||
'another app, is never opened', () async {
|
||||
final phone = await device(alice);
|
||||
final keyId = await phone.keyToWrite();
|
||||
// The same sealed key under an ID that would sort before it.
|
||||
rows.rows.add((
|
||||
dataKeyId: 'todo1.1.relabelled',
|
||||
sealedKey: rows.rows.single.sealedKey,
|
||||
));
|
||||
// A key of the same account sealed under Learn's ID for its personal
|
||||
// key, which would open as what it was sealed for.
|
||||
final learn = SodiumContentCipher();
|
||||
await learn.unlock(alice);
|
||||
addTearDown(learn.dispose);
|
||||
rows.rows.add((
|
||||
dataKeyId: 'p1.1.learn',
|
||||
sealedKey: learn.newKey('p1.1.learn'),
|
||||
));
|
||||
|
||||
final laptop = await device(alice);
|
||||
|
||||
expect(laptop.canOpen('todo1.1.relabelled'), isFalse);
|
||||
expect(laptop.canOpen('p1.1.learn'), isFalse);
|
||||
expect(await laptop.keyToWrite(), keyId);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/content_key_gateway.dart';
|
||||
|
||||
/// [ContentKeyGateway] in memory, which tests share between "devices" as the
|
||||
/// sync engine would, and change as an instance could.
|
||||
class MemoryContentKeyGateway() implements ContentKeyGateway {
|
||||
/// The rows, as the instance holds them.
|
||||
final rows = <ContentKeyRow>[];
|
||||
|
||||
@override
|
||||
Future<List<ContentKeyRow>> all() async => List.of(rows);
|
||||
|
||||
@override
|
||||
Future<void> insert({
|
||||
required String dataKeyId,
|
||||
required Uint8List sealedKey,
|
||||
}) async => rows.add((dataKeyId: dataKeyId, sealedKey: sealedKey));
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_gateway.dart';
|
||||
|
||||
/// [ProjectGateway] in memory, whose rows a test changes as an instance could.
|
||||
class MemoryProjectGateway() implements ProjectGateway {
|
||||
final rows = <String, Envelope>{};
|
||||
final _remote = StreamController<void>.broadcast();
|
||||
|
||||
/// As a sync bringing others' changes.
|
||||
void bringRemoteChanges() => _remote.add(null);
|
||||
|
||||
@override
|
||||
Future<List<ProjectRow>> projects() async => [
|
||||
for (final MapEntry(:key, :value) in rows.entries)
|
||||
(id: key, envelope: value),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(ProjectRow project) async =>
|
||||
rows[project.id] = project.envelope;
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (rows.containsKey(id)) {
|
||||
rows[id] = envelope;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async => rows.remove(id);
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remote.stream;
|
||||
}
|
||||
Loading…
Reference in a new issue