feat(learn): sharing moves a studyset into a space of its own
Step 4 (d) of ADR 0044 (D14, D23, D27, D28), without its screens yet. - `StudysetSharing`: refuses a studyset with a publication in its tree and a device that can't seal, before anything is made; makes the space, readies it, exports the tree with its progress, imports it under new IDs with its top placed in the space, and deletes the old one for good once the copy is whole. Failing or aborted, the space is deleted and the studyset stays as it was. - `SyncSpaceSetup`: syncs until the person's membership of the new space came, then makes its first key and seals it to them. - `Privacy` writes a top studyset placed in a space with that space's key; a studyset can be inserted under an ID chosen beforehand. - The archive's export and import take `moved`, keeping each item's link to Explore and each card's origin. - An import that fails midway takes back what it wrote, as an aborted one did. Whats-New: An import that fails partway no longer leaves part of the studyset behind. Whats-New-De: Ein Import, der unterwegs scheitert, lässt kein halbes Lernset mehr zurück. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f0b05a5e9a
commit
d3e8b7d1c6
5 changed files with 1001 additions and 41 deletions
|
|
@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart'
|
|||
import 'package:halkyon_learn_flutter/src/core/privacy/data/shared_space_keys.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_space_lookup.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
|
@ -22,7 +23,8 @@ import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
|||
/// claims is shared keeps its own key. On the real gateways, over a local
|
||||
/// database without a server.
|
||||
void main() {
|
||||
const space = 'space-1';
|
||||
final person = const protocol.Uuid().v7obj();
|
||||
final space = const protocol.Uuid().v7();
|
||||
final crypto = FakeAccountCrypto();
|
||||
final alice = crypto.keysNumbered(1);
|
||||
late OfflineSyncDatabaseSession db;
|
||||
|
|
@ -37,7 +39,7 @@ void main() {
|
|||
final client = protocol.Client('http://127.0.0.1:9/');
|
||||
db = await client.createSyncSession(
|
||||
'${folder.path}/sync.sqlite',
|
||||
persistentUserId: const protocol.Uuid().v7obj(),
|
||||
persistentUserId: person,
|
||||
);
|
||||
sync = SyncService(
|
||||
({required onMerge}) async {},
|
||||
|
|
@ -63,9 +65,10 @@ void main() {
|
|||
SharedSpaceKeys spaceKeys,
|
||||
SyncStudysetGateway studysets,
|
||||
SyncCardGateway cards,
|
||||
SpaceRouter router,
|
||||
})
|
||||
>
|
||||
device() async {
|
||||
device({bool onTheEngine = false}) async {
|
||||
final ledger = PrivacyLedger(MemoryPrivacyLedgerStore());
|
||||
await ledger.open();
|
||||
final keys = ContentKeys(
|
||||
|
|
@ -85,8 +88,19 @@ void main() {
|
|||
keys.cipher,
|
||||
() async => {space},
|
||||
);
|
||||
// On the engine, the router asks the database, and the sync engine
|
||||
// places each new row as the app's does.
|
||||
final router = SpaceRouter(
|
||||
onTheEngine ? SyncSpaceLookup(db, person.uuid) : lookup,
|
||||
);
|
||||
if (onTheEngine) {
|
||||
db.db.newRowSpace = (row) async => switch (await router.spaceOf(row)) {
|
||||
final space? => protocol.UuidValue.withValidation(space),
|
||||
null => null,
|
||||
};
|
||||
}
|
||||
final privacy = Privacy(db, keys)
|
||||
..spaces = SpaceRouter(lookup)
|
||||
..spaces = router
|
||||
..spaceKeys = spaceKeys;
|
||||
addTearDown(() async {
|
||||
await privacy.dispose();
|
||||
|
|
@ -98,6 +112,7 @@ void main() {
|
|||
spaceKeys: spaceKeys,
|
||||
studysets: SyncStudysetGateway(db, sync, privacy),
|
||||
cards: SyncCardGateway(db, sync, const protocol.Uuid().v7(), privacy),
|
||||
router: router,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +189,83 @@ void main() {
|
|||
expect(await revisionKeys(card), [first.value, second.value]);
|
||||
});
|
||||
|
||||
test('a studyset placed in a space before it is written goes there with '
|
||||
"the space's key, and so do the studysets and cards inside it", () async {
|
||||
await _joined(db, person, space);
|
||||
final laptop = await device(onTheEngine: true);
|
||||
final top = Privacy.newId().uuid;
|
||||
laptop.router.placeIn(top, space);
|
||||
|
||||
await expectLater(
|
||||
laptop.studysets.insert(
|
||||
json({'schemaVersion': 1, 'title': 'Shared'}),
|
||||
DateTime.utc(2026, 11, 11),
|
||||
id: top,
|
||||
),
|
||||
throwsA(
|
||||
isA<PrivacyRefused>().having(
|
||||
(r) => r.reason,
|
||||
'reason',
|
||||
PrivacyRefusal.spaceKeyMissing,
|
||||
),
|
||||
),
|
||||
reason: 'no key of the space is open yet',
|
||||
);
|
||||
expect(
|
||||
await protocol.Studyset.db.findById(
|
||||
db,
|
||||
protocol.UuidValue.withValidation(top),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
|
||||
final key = await laptop.spaceKeys.newKey(space);
|
||||
expect(
|
||||
await laptop.studysets.insert(
|
||||
json({'schemaVersion': 1, 'title': 'Shared'}),
|
||||
DateTime.utc(2026, 11, 11),
|
||||
id: top,
|
||||
),
|
||||
top,
|
||||
);
|
||||
final child = await laptop.studysets.insert(
|
||||
json({'schemaVersion': 1, 'title': 'Inside'}),
|
||||
DateTime.utc(2026, 11, 11),
|
||||
parentId: top,
|
||||
);
|
||||
final card = await laptop.cards.insertCard(
|
||||
studysetId: child,
|
||||
position: 'a0',
|
||||
payload: cardPayload('shared'),
|
||||
);
|
||||
|
||||
final spaceUuid = protocol.UuidValue.withValidation(space);
|
||||
for (final id in [top, child]) {
|
||||
final row = (await protocol.Studyset.db.findById(
|
||||
db,
|
||||
protocol.UuidValue.withValidation(id),
|
||||
))!;
|
||||
expect(row.keyId, key.value);
|
||||
expect(
|
||||
await protocol.Studyset.db.count(
|
||||
db,
|
||||
where: (t) => t.id.equals(row.id) & t.spaceEquals(spaceUuid),
|
||||
),
|
||||
1,
|
||||
);
|
||||
}
|
||||
expect(await revisionKeys(card), [key.value]);
|
||||
expect(
|
||||
await protocol.Card.db.count(
|
||||
db,
|
||||
where: (t) =>
|
||||
t.id.equals(protocol.UuidValue.withValidation(card)) &
|
||||
t.spaceEquals(spaceUuid),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('a shared studyset is never made readable, nor given a key of the '
|
||||
"person's own (D21)", () async {
|
||||
final laptop = await device();
|
||||
|
|
@ -215,6 +307,37 @@ void main() {
|
|||
});
|
||||
}
|
||||
|
||||
/// Makes the person a member who writes of [space] in [db], as a sync
|
||||
/// brings a space: the engine knows a space by a row in it, here a studyset
|
||||
/// of a member's.
|
||||
Future<void> _joined(
|
||||
OfflineSyncDatabaseSession db,
|
||||
protocol.UuidValue person,
|
||||
String space,
|
||||
) async {
|
||||
final uuid = protocol.UuidValue.withValidation(space);
|
||||
await db.db.transactionForUser(
|
||||
uuid,
|
||||
(t) => protocol.Studyset.db.insertRow(
|
||||
db,
|
||||
protocol.Studyset(payload: ByteData(1)),
|
||||
transaction: t,
|
||||
),
|
||||
);
|
||||
final row = await OfflineSyncSpace.db.findFirstRow(
|
||||
db,
|
||||
where: (t) => t.uuidSpaceId.equals(uuid),
|
||||
);
|
||||
await OfflineSyncSpaceMember.db.insertRow(
|
||||
db,
|
||||
OfflineSyncSpaceMember(
|
||||
spaceId: row!.id!,
|
||||
userUuid: person,
|
||||
role: OfflineSyncSpaceRole.readWrite,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The studysets in each shared space, as the sync engine's grants say.
|
||||
class _Lookup() implements SpaceLookup {
|
||||
final shared = <String, Set<String>>{};
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@ void main() {
|
|||
late PdfSourceRepository pdfs;
|
||||
late AssetRepository assets;
|
||||
late FakeAssetGateway assetRows;
|
||||
late FakeReviewGateway reviewRows;
|
||||
late ArchiveImporter importer;
|
||||
|
||||
setUp(() {
|
||||
|
|
@ -285,7 +286,8 @@ void main() {
|
|||
FakeAssetSealing(),
|
||||
);
|
||||
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
reviewRows = FakeReviewGateway();
|
||||
reviews = ReviewRepository(reviewRows);
|
||||
importer = ArchiveImporter(
|
||||
ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets),
|
||||
DictionaryRepository(
|
||||
|
|
@ -413,13 +415,35 @@ void main() {
|
|||
throwsA(isA<WorkAborted>()),
|
||||
);
|
||||
|
||||
// The reviews written go with their cards: the database
|
||||
// cascades (`review_log.card`), which the fakes don't.
|
||||
// The reviews written go with their cards: the card gateway
|
||||
// deletes them with the card (ADR 0044, D26), which the fakes
|
||||
// don't.
|
||||
expect(await studysets.withDeleted(), isEmpty);
|
||||
expect(await cards.allCards(), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('failing midway, deletes everything it wrote, as aborted '
|
||||
'(11 Nov 2026)', () async {
|
||||
final own = await studysets.create(StudysetContent.titled('Own'));
|
||||
final ownFile = await assets.add(
|
||||
kept,
|
||||
fileName: 'mine.png',
|
||||
mimeType: 'image/png',
|
||||
studysetId: own,
|
||||
);
|
||||
reviewRows.failWrites = Exception('the disk is full');
|
||||
|
||||
await expectLater(
|
||||
importer.import(readArchive(zip(withFiles()))),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
|
||||
expect([for (final s in await studysets.withDeleted()) s.id], [own]);
|
||||
expect(await cards.allCards(), isEmpty);
|
||||
expect([for (final row in await assetRows.all()) row.id], [ownFile]);
|
||||
});
|
||||
|
||||
test('asked once all of it is written, it is done', () async {
|
||||
final abort = AbortSignal();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,346 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/testing.dart'
|
||||
show FakeFileServerGateway, MemoryFileCache;
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_exporter.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/export/data/export_archive.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/data/shared_space_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/data/studyset_sharing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
||||
import '../../support/fake_asset_gateway.dart';
|
||||
import '../../support/fake_asset_sealing.dart';
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
import '../../support/fake_device_dictionaries.dart';
|
||||
import '../../support/fake_dictionary_gateway.dart';
|
||||
import '../../support/fake_learning_path_gateway.dart';
|
||||
import '../../support/fake_note_gateway.dart';
|
||||
import '../../support/fake_path_answer_gateway.dart';
|
||||
import '../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../support/fake_publish_gateway.dart';
|
||||
import '../../support/fake_review_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
import '../../support/fake_tag_gateway.dart';
|
||||
|
||||
/// Sharing a studyset (Halkyon's ADR 0044, D14, D27, D28): moved into a
|
||||
/// new space by the archive's export and import, the old one deleted once
|
||||
/// the copy is whole; refused before anything is made where it can't be;
|
||||
/// taken back, the space with it, where it fails.
|
||||
void main() {
|
||||
late StudysetRepository studysets;
|
||||
late CardRepository cards;
|
||||
late ReviewRepository reviews;
|
||||
late FakeReviewGateway reviewRows;
|
||||
late _Spaces spaces;
|
||||
late _Setup setup;
|
||||
late Map<String, Publication> publications;
|
||||
late StudysetSharing sharing;
|
||||
|
||||
setUp(() {
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
final notes = NoteRepository(FakeNoteGateway());
|
||||
final paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
final progress = PathProgressRepository(FakePathAnswerGateway());
|
||||
reviewRows = FakeReviewGateway();
|
||||
reviews = ReviewRepository(reviewRows);
|
||||
final assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeFileServerGateway(),
|
||||
MemoryFileCache(),
|
||||
FakeAssetSealing(),
|
||||
);
|
||||
final tags = TagRepository(
|
||||
FakeTagGateway(),
|
||||
cardChanges: cards.changes,
|
||||
noteChanges: notes.changes,
|
||||
pathChanges: paths.changes,
|
||||
);
|
||||
final pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
|
||||
final deletions = StudysetDeletions(studysets, cards, notes, paths, pdfs);
|
||||
spaces = _Spaces();
|
||||
setup = _Setup(studysets);
|
||||
publications = {};
|
||||
sharing = StudysetSharing(
|
||||
spaces,
|
||||
setup,
|
||||
studysets,
|
||||
FakePublishGateway(publications: publications),
|
||||
ArchiveExporter(
|
||||
studysets,
|
||||
cards,
|
||||
notes,
|
||||
paths,
|
||||
progress,
|
||||
pdfs,
|
||||
tags,
|
||||
reviews,
|
||||
assets.fileOf,
|
||||
),
|
||||
(files) async => readArchive(zipExport(files)),
|
||||
ArchiveImporter(
|
||||
ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets),
|
||||
DictionaryRepository(FakeDictionaryGateway(), noDeviceDictionaries()),
|
||||
reviews,
|
||||
progress,
|
||||
studysets,
|
||||
pdfs,
|
||||
deletions,
|
||||
assets,
|
||||
),
|
||||
deletions,
|
||||
);
|
||||
});
|
||||
|
||||
CardContent card(String front) =>
|
||||
CardContent(front: RichContent.plain(front), back: RichContent.plain(''));
|
||||
|
||||
const fromExplore = PublishedRef(
|
||||
publication: 'genki',
|
||||
item: 'card-in-release',
|
||||
version: 3,
|
||||
);
|
||||
|
||||
/// Japanese with Vocabulary inside: a card written here, one from Anki,
|
||||
/// one copied from Explore, and a review of the first.
|
||||
Future<({String top, String inside, String own})> japanese() async {
|
||||
final top = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final inside = await studysets.create(
|
||||
StudysetContent.titled('Vocabulary'),
|
||||
parentId: top,
|
||||
);
|
||||
final own = await cards.create(inside, card('mine'));
|
||||
await cards.create(inside, card('from Anki'), origin: 'anki');
|
||||
await cards.create(
|
||||
inside,
|
||||
card('from Explore').withField(CardField.published, fromExplore),
|
||||
);
|
||||
await reviews.record(
|
||||
cardId: own,
|
||||
rating: Rating.good,
|
||||
reviewedAt: DateTime.utc(2026, 11, 10),
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
return (top: top, inside: inside, own: own);
|
||||
}
|
||||
|
||||
test('moves the studyset with everything inside it into a new space, '
|
||||
'under new IDs, the old one gone', () async {
|
||||
final before = await japanese();
|
||||
|
||||
final shared = await sharing.share(before.top);
|
||||
|
||||
expect(spaces.made, ['space-1']);
|
||||
expect(spaces.deleted, isEmpty);
|
||||
expect(setup.readied, ['space-1']);
|
||||
// Placed before it was written, so its rows go in the space.
|
||||
expect(setup.placed, [(shared, 'space-1', existedThen: false)]);
|
||||
|
||||
final all = await studysets.withDeleted();
|
||||
expect([for (final s in all) s.content.title]..sort(), [
|
||||
'Japanese',
|
||||
'Vocabulary',
|
||||
]);
|
||||
expect(all.map((s) => s.id), isNot(contains(before.top)));
|
||||
expect(all.map((s) => s.id), isNot(contains(before.inside)));
|
||||
final top = all.singleWhere((s) => s.id == shared);
|
||||
expect(top.parentId, isNull);
|
||||
expect(top.content.title, 'Japanese');
|
||||
final inside = all.singleWhere((s) => s.parentId == shared);
|
||||
|
||||
final moved = await cards.cardsOf(inside.id);
|
||||
expect(moved.map((c) => c.id), isNot(contains(before.own)));
|
||||
expect(
|
||||
{for (final c in moved) c.content.front.plainText: c.content.published},
|
||||
{'mine': null, 'from Anki': null, 'from Explore': fromExplore},
|
||||
reason: 'each item keeps its link to Explore',
|
||||
);
|
||||
final stamps = {
|
||||
for (final stamp in await cards.allCards()) stamp.id: stamp.imported,
|
||||
};
|
||||
expect(
|
||||
{
|
||||
for (final c in moved) c.content.front.plainText: stamps[c.id],
|
||||
},
|
||||
{'mine': false, 'from Anki': true, 'from Explore': false},
|
||||
reason: 'each card keeps where it came from',
|
||||
);
|
||||
|
||||
final mine = moved.singleWhere(
|
||||
(c) => c.content.front.plainText == 'mine',
|
||||
);
|
||||
// The old card's reviews go with it in the card gateway (ADR 0044,
|
||||
// D26), which the fakes don't do.
|
||||
expect((await reviews.reviewsOf({mine.id})).length, 1);
|
||||
});
|
||||
|
||||
test('refuses a studyset with a publication anywhere in it, withdrawn too, '
|
||||
'before anything is made (D27)', () async {
|
||||
final before = await japanese();
|
||||
publications[before.inside] = _publication();
|
||||
|
||||
await expectLater(
|
||||
sharing.share(before.top),
|
||||
throwsA(
|
||||
isA<SharingRefused>().having(
|
||||
(r) => r.reason,
|
||||
'reason',
|
||||
SharingRefusal.published,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(spaces.made, isEmpty);
|
||||
expect((await studysets.withDeleted()).length, 2);
|
||||
});
|
||||
|
||||
test("refuses on a device that can't seal, before anything is made "
|
||||
'(D21)', () async {
|
||||
final before = await japanese();
|
||||
setup.canSeal = false;
|
||||
|
||||
await expectLater(
|
||||
sharing.share(before.top),
|
||||
throwsA(
|
||||
isA<SharingRefused>().having(
|
||||
(r) => r.reason,
|
||||
'reason',
|
||||
SharingRefusal.keysMissing,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(spaces.made, isEmpty);
|
||||
});
|
||||
|
||||
test("a space that doesn't reach the device is deleted, and nothing is "
|
||||
'written', () async {
|
||||
final before = await japanese();
|
||||
setup.reaches = false;
|
||||
|
||||
await expectLater(
|
||||
sharing.share(before.top),
|
||||
throwsA(isA<SharingRefused>()),
|
||||
);
|
||||
expect(spaces.deleted, ['space-1']);
|
||||
expect(
|
||||
[for (final s in await studysets.withDeleted()) s.id],
|
||||
[
|
||||
before.top,
|
||||
before.inside,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('failing midway, or aborted, takes back what it wrote and deletes the '
|
||||
'space; the studyset stays as it was', () async {
|
||||
final before = await japanese();
|
||||
reviewRows.failWrites = Exception('the disk is full');
|
||||
|
||||
await expectLater(sharing.share(before.top), throwsA(isA<Exception>()));
|
||||
expect(spaces.deleted, ['space-1']);
|
||||
expect(
|
||||
[for (final s in await studysets.withDeleted()) s.id],
|
||||
[
|
||||
before.top,
|
||||
before.inside,
|
||||
],
|
||||
);
|
||||
expect((await cards.cardsOf(before.inside)).length, 3);
|
||||
|
||||
reviewRows.failWrites = null;
|
||||
final abort = AbortSignal();
|
||||
await expectLater(
|
||||
sharing.share(
|
||||
before.top,
|
||||
abort: abort,
|
||||
onProgress: (progress) {
|
||||
if (progress.stage == SharingStage.content) {
|
||||
abort.abort();
|
||||
}
|
||||
},
|
||||
),
|
||||
throwsA(isA<WorkAborted>()),
|
||||
);
|
||||
expect(spaces.deleted, ['space-1', 'space-2']);
|
||||
expect(
|
||||
[for (final s in await studysets.withDeleted()) s.id],
|
||||
[
|
||||
before.top,
|
||||
before.inside,
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Publication _publication() => const Publication(
|
||||
publicId: 'public-1',
|
||||
slug: 'vocabulary',
|
||||
location: 'halkyon.eu/@ana/vocabulary',
|
||||
withdrawn: true,
|
||||
);
|
||||
|
||||
/// The instance's shared spaces: those made and deleted, in order.
|
||||
class _Spaces() implements SharedSpaceGateway {
|
||||
final made = <String>[];
|
||||
final deleted = <String>[];
|
||||
|
||||
@override
|
||||
Future<String> create() async {
|
||||
final space = 'space-${made.length + 1}';
|
||||
made.add(space);
|
||||
return space;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String space) async => deleted.add(space);
|
||||
}
|
||||
|
||||
/// The device's side of a new space: the spaces readied and the studysets
|
||||
/// placed, with whether each existed when placed.
|
||||
class _Setup(final StudysetRepository _studysets) implements SpaceSetup {
|
||||
@override
|
||||
bool canSeal = true;
|
||||
|
||||
/// Whether a new space reaches the device.
|
||||
bool reaches = true;
|
||||
|
||||
final readied = <String>[];
|
||||
final placed = <(String, String, {bool existedThen})>[];
|
||||
|
||||
@override
|
||||
Future<void> ready(String space) async {
|
||||
if (!reaches) {
|
||||
throw const SharingRefused(SharingRefusal.notReached);
|
||||
}
|
||||
readied.add(space);
|
||||
}
|
||||
|
||||
@override
|
||||
void placeIn(String studysetId, String space) {
|
||||
// Asked synchronously; whether it exists is looked up at once.
|
||||
placed.add((studysetId, space, existedThen: false));
|
||||
_studysets.withDeleted().then((all) {
|
||||
if (all.any((s) => s.id == studysetId)) {
|
||||
placed[placed.length - 1] = (studysetId, space, existedThen: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/shared_space_keys.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/data/studyset_sharing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/data/sync_space_setup.dart';
|
||||
|
||||
/// A new space readied on the device (Halkyon's ADR 0044, D3, D15): synced
|
||||
/// until the person's membership of it came, then its first key made and
|
||||
/// sealed to them; refused where it doesn't come.
|
||||
void main() {
|
||||
const space = 'space-1';
|
||||
final account = FakeAccountCrypto().keysNumbered(1);
|
||||
late FakeSharedKeysServer server;
|
||||
late _Lookup lookup;
|
||||
late int syncs;
|
||||
late SyncService sync;
|
||||
late SharedSpaceKeys keys;
|
||||
late SpaceRouter router;
|
||||
|
||||
/// The membership comes with the sync numbered [comesWith] (from 1);
|
||||
/// never where null.
|
||||
Future<SyncSpaceSetup> setup({required int? comesWith}) async {
|
||||
syncs = 0;
|
||||
sync = SyncService(({required onMerge}) async {
|
||||
if (++syncs == comesWith) {
|
||||
lookup.shared[space] = {};
|
||||
}
|
||||
}, debounce: const Duration(days: 1));
|
||||
addTearDown(sync.dispose);
|
||||
final cipher = FakeContentCipher();
|
||||
await cipher.unlock(account);
|
||||
keys = SharedSpaceKeys(
|
||||
SharedKeys(
|
||||
server.as('alice'),
|
||||
KeyPins(MemoryKeyPinStore()),
|
||||
cipher,
|
||||
me: 'alice',
|
||||
),
|
||||
cipher,
|
||||
() async => lookup.shared.keys.toSet(),
|
||||
);
|
||||
router = SpaceRouter(lookup);
|
||||
return SyncSpaceSetup(
|
||||
sync,
|
||||
lookup,
|
||||
router,
|
||||
keys,
|
||||
() => true,
|
||||
wait: const Duration(seconds: 1),
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
server = FakeSharedKeysServer()
|
||||
..addMember(
|
||||
space,
|
||||
'alice',
|
||||
keys: (
|
||||
encryption: account.encryptionPublicKey,
|
||||
signing: account.signingPublicKey,
|
||||
),
|
||||
);
|
||||
lookup = _Lookup();
|
||||
});
|
||||
|
||||
test('waits for the sync that brings the space, then makes its first '
|
||||
'key', () async {
|
||||
final readied = await setup(comesWith: 2);
|
||||
|
||||
await readied.ready(space);
|
||||
|
||||
expect(syncs, 2);
|
||||
expect(keys.latestOf(space)?.space, space);
|
||||
expect(server.sealedCount(space), 1);
|
||||
});
|
||||
|
||||
test("a space that doesn't come is refused, with no key made", () async {
|
||||
final readied = await setup(comesWith: null);
|
||||
|
||||
await expectLater(
|
||||
readied.ready(space),
|
||||
throwsA(
|
||||
isA<SharingRefused>().having(
|
||||
(r) => r.reason,
|
||||
'reason',
|
||||
SharingRefusal.notReached,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(syncs, 3);
|
||||
expect(keys.latestOf(space), isNull);
|
||||
expect(server.sealedCount(space), 0);
|
||||
});
|
||||
|
||||
test('places a studyset through the router', () async {
|
||||
final readied = await setup(comesWith: 1);
|
||||
|
||||
readied.placeIn('top', space);
|
||||
|
||||
expect(router.placedSpaceOf('top'), space);
|
||||
});
|
||||
}
|
||||
|
||||
/// The studysets in each shared space the person is a member of.
|
||||
class _Lookup() implements SpaceLookup {
|
||||
final shared = <String, Set<String>>{};
|
||||
|
||||
@override
|
||||
Future<Map<String, Set<String>>> sharedStudysets() async => shared;
|
||||
|
||||
@override
|
||||
Future<String?> studysetOfCard(String id) async => null;
|
||||
|
||||
@override
|
||||
Future<String?> studysetOfNote(String id) async => null;
|
||||
|
||||
@override
|
||||
Future<String?> studysetOfPath(String id) async => null;
|
||||
|
||||
@override
|
||||
Future<String?> pathOfStep(String id) async => null;
|
||||
}
|
||||
|
|
@ -156,8 +156,9 @@ class FakeStudysetGateway() implements StudysetGateway {
|
|||
DateTime updatedAt, {
|
||||
String? parentId,
|
||||
String position = 'a0',
|
||||
String? id,
|
||||
}) async {
|
||||
final id = 'studyset-${_nextId++}';
|
||||
id ??= 'studyset-${_nextId++}';
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
payload: payload,
|
||||
|
|
|
|||
Loading…
Reference in a new issue