halkyon/halkyon_learn_server/test/integration/publish/publish_test.dart
Tizian 「ティツィアーン」 4644125cc4
feat(studyset): deleted studysets wait 14 days in Recently deleted
Deleting a studyset blanked it with everything inside it once a
four-second Undo was over. It now only marks it (`studyset.deletedAt`, a
column as `archivedAt`): it leaves every list, count and session with
what is inside it and waits in the library's Recently deleted for 14
days, saying how many are left, with Restore and Delete now. Deleting no
longer asks first and offers Undo; Delete now asks. Once the 14 days are
over, whichever of the person's devices looks next (as their data opens,
after every sync) deletes it for good, blanking first as before; the
server can't blank a payload and never writes a person's space (ADR
0007). The connector, publishing and the learner count leave deleted
studysets out too.

The migration adds one nullable column: app versions before it stop
syncing until they update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 21:21:43 +02:00

977 lines
33 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
import 'package:halkyon_learn_server/src/auth/account.dart';
import 'package:halkyon_learn_server/src/auth/scopes.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import '../support/auth_fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
withServerpod(
'Publishing',
(sessionBuilder, endpoints) {
late Session session;
late UuidValue alice;
late UuidValue bob;
late String domain;
/// The people a test made, whose spaces, profiles and publications it
/// deletes again.
final people = <UuidValue>{};
setUp(() async {
// The test pod is built without the generated Serverpod class, which
// sets up sync for the running server.
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
session = sessionBuilder.build();
repositoriesFolder = await Directory.systemTemp.createTemp('repos-');
alice = const Uuid().v7obj();
bob = const Uuid().v7obj();
people.addAll([alice, bob]);
domain = (await endpoints.instance.info(sessionBuilder)).accountDomain!;
});
tearDown(() async {
await PublishedStudyset.db.deleteWhere(
session,
where: (t) => t.authUserId.inSet(people),
);
await PublicProfile.db.deleteWhere(
session,
where: (t) => t.authUserId.inSet(people),
);
for (final person in people) {
await session.db.unsafeExecute(
'DELETE FROM "serverpod_cloud_storage" WHERE "path" LIKE @prefix',
parameters: QueryParameters.named({
'prefix': '${assetPath(person, '')}%',
}),
);
await session.db.unsafeExecute(
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
parameters: QueryParameters.named({'space': person.uuid}),
);
}
people.clear();
await repositoriesFolder.delete(recursive: true);
repositoriesFolder = null;
});
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{identified},
),
);
Future<Profile> profile(UuidValue person, String handle) => endpoints
.profile
.save(signedIn(person), handle, handle.toUpperCase(), '', []);
Future<Publication> publish(
UuidValue person,
UuidValue studyset, {
String slug = 'japanese',
String notes = '',
}) => endpoints.publish.publish(
signedIn(person),
studyset,
slug,
notes,
);
Matcher refused(PublishRefusal reason, [List<UuidValue>? items]) =>
throwsA(
isA<PublishRefusedException>()
.having((error) => error.reason, 'reason', reason)
.having(
(error) => error.items?.toSet(),
'items',
items?.toSet(),
),
);
/// Writes as [person] through the sync engine, as their device would.
Future<T> asPerson<T>(
UuidValue person,
Future<T> Function(Transaction transaction) write,
) => _syncDatabases[session]!.transactionForUser(person, write);
/// Runs git on the repository of the published studyset [publicId].
Future<String> git(UuidValue publicId, List<String> arguments) async {
final result = await Process.run('git', [
'--git-dir',
repositoryOf(publicId).directory.path,
...arguments,
]);
expect(result.exitCode, 0, reason: '${result.stderr}');
return '${result.stdout}'.trim();
}
Future<Map<String, Object?>> fileAt(
Publication publication,
String tag,
String path,
) async =>
jsonDecode(await git(publication.publicId, ['show', '$tag:$path']))
as Map<String, Object?>;
/// A Japanese studyset of [person] (under [license]) holding a Kanji
/// studyset (under its own [kanjiLicense], else the one above), with a
/// card each, the first tagged and reported, the second showing an
/// image; a note; a learning path with a step; and a PDF with a
/// deadline in Kanji. The files are stored unless [stored] is false.
Future<_Japanese> japanese(
UuidValue person, {
String? license = 'CC-BY-SA-4.0',
String? kanjiLicense,
bool stored = true,
Map<String, Object?>? copyOf,
}) async {
final image = utf8.encode('an image');
final pdf = utf8.encode('%PDF-1.7 a book');
final japanese = await asPerson(person, (transaction) async {
Future<UuidValue> assetOf(List<int> bytes, String mimeType) async =>
(await Asset.db.insertRow(
session,
Asset(
sha256: sha256.convert(bytes).toString(),
mimeType: mimeType,
byteSize: bytes.length,
payload: _json({'schemaVersion': 1, 'fileName': 'a file'}),
),
transaction: transaction,
)).id!;
final root = (await Studyset.db.insertRow(
session,
Studyset(
payload: _json({
'schemaVersion': 4,
'title': 'Japanese',
'license': ?license,
'licenseHolder': 'Alice',
'study': {'newPerDay': 10},
'published': ?copyOf,
}),
),
transaction: transaction,
)).id!;
final kanji = (await Studyset.db.insertRow(
session,
Studyset(
parentId: root,
position: 'a1',
payload: _json({
'schemaVersion': 4,
'title': 'Kanji',
'license': ?kanjiLicense,
}),
),
transaction: transaction,
)).id!;
final tag = (await Tag.db.insertRow(
session,
Tag(
payload: _json({
'schemaVersion': 1,
'name': 'JLPT-N5',
'color': 'blue',
}),
),
transaction: transaction,
)).id!;
final imageAsset = await assetOf(image, 'image/png');
final pdfAsset = await assetOf(pdf, 'application/pdf');
Future<UuidValue> card(UuidValue studyset, Object? front) async {
final card = (await Card.db.insertRow(
session,
Card(studysetId: studyset, position: 'a0'),
transaction: transaction,
)).id!;
await CardRevision.db.insertRow(
session,
CardRevision(
cardId: card,
parents: [],
authorId: person,
payload: _json({
'schemaVersion': 8,
'type': 'vocabulary',
'front': front,
'back': 'a word',
'report': {'category': 'typo', 'at': '2026-10-09T00:00:00Z'},
}),
),
transaction: transaction,
);
return card;
}
final word = await card(root, '日本');
await CardTag.db.insertRow(
session,
CardTag(cardId: word, tagId: tag),
transaction: transaction,
);
final pictured = await card(kanji, {
'image': 'asset:${imageAsset.uuid}',
});
final note = (await Note.db.insertRow(
session,
Note(studysetId: root, position: 'a0'),
transaction: transaction,
)).id!;
await NoteRevision.db.insertRow(
session,
NoteRevision(
noteId: note,
parents: [],
authorId: person,
payload: _json({'schemaVersion': 2, 'title': 'Particles'}),
),
transaction: transaction,
);
final path = (await LearningPath.db.insertRow(
session,
LearningPath(
studysetId: root,
position: 'a0',
payload: _json({'schemaVersion': 1, 'title': 'Hiragana'}),
),
transaction: transaction,
)).id!;
final step = (await LearningPathStep.db.insertRow(
session,
LearningPathStep(pathId: path, position: 'a0', kind: 'info'),
transaction: transaction,
)).id!;
await LearningPathStepRevision.db.insertRow(
session,
LearningPathStepRevision(
stepId: step,
parents: [],
authorId: person,
payload: _json({'schemaVersion': 2, 'title': 'あ'}),
),
transaction: transaction,
);
final book = (await PdfSource.db.insertRow(
session,
PdfSource(
studysetId: kanji,
payload: _json({
'schemaVersion': 1,
'title': 'A book',
'fileName': 'book.pdf',
'asset': pdfAsset.uuid,
'deadline': {'targetPage': 10, 'due': '2026-11-01'},
}),
),
transaction: transaction,
)).id!;
return _Japanese(
root: root,
kanji: kanji,
word: word,
pictured: pictured,
note: note,
path: path,
step: step,
book: book,
image: sha256.convert(image).toString(),
pdf: sha256.convert(pdf).toString(),
);
});
if (stored) {
for (final bytes in [image, pdf]) {
await session.storage.storeFile(
storageId: assetStorage,
path: assetPath(person, sha256.convert(bytes).toString()),
byteData: ByteData.sublistView(Uint8List.fromList(bytes)),
);
}
}
return japanese;
}
/// Gives [card] of [person] another revision on [parents], the card's
/// front then [front].
Future<void> revise(
UuidValue person,
UuidValue card,
List<UuidValue> parents,
String front,
) => asPerson(
person,
(transaction) => CardRevision.db.insertRow(
session,
CardRevision(
cardId: card,
parents: parents,
authorId: person,
payload: _json({
'schemaVersion': 8,
'type': 'vocabulary',
'front': front,
'back': 'a word',
}),
),
transaction: transaction,
),
);
Future<List<UuidValue>> revisionsOf(UuidValue card) async => [
for (final revision in await CardRevision.db.find(
session,
where: (t) => t.cardId.equals(card),
))
revision.id!,
];
test('a person with a public profile publishes a studyset with '
'everything inside it as the first release of its own repository, '
'found under their handle by its name', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
final published = await publish(
alice,
content.root,
notes: 'The first lessons.',
);
expect(published.location, '$domain/@alice/japanese');
expect(published.studysetId, content.root);
final release = published.releases.single;
expect(release.version, 1);
expect(release.notes, 'The first lessons.');
expect(release.license, 'CC-BY-SA-4.0');
expect(
[
release.studysetCount,
release.cardCount,
release.noteCount,
release.pathCount,
release.pdfCount,
],
[2, 2, 1, 1, 1],
);
final kanji = content.kanji.uuid;
final path = 'paths/${content.path.uuid}';
expect(
(await git(published.publicId, [
'ls-tree',
'-r',
'--name-only',
'v1',
])).split('\n').toSet(),
{
'studyset.json',
'cards/${content.word.uuid}.json',
'notes/${content.note.uuid}.json',
'$path/path.json',
'$path/steps/${content.step.uuid}.json',
'$kanji/studyset.json',
'$kanji/cards/${content.pictured.uuid}.json',
'$kanji/pdfs/${content.book.uuid}.json',
'files.json',
'files/${content.image}',
'files/${content.pdf}',
},
);
expect(
await git(published.publicId, ['show', 'v1:files/${content.image}']),
'an image',
);
expect(
await git(published.publicId, [
'log',
'-1',
'--format=%an <%ae>|%cn|%B',
'v1',
]),
'ALICE <alice@$domain>|$domain|v1\n\nThe first lessons.',
);
// The handle is fixed from the first publish on.
expect(
(await endpoints.profile.mine(signedIn(alice)))!.handleFixed,
isTrue,
);
});
test(
'an item is a file of its id, position, tags by name and payload '
"as synced, keys sorted, without what is only its author's",
() async {
await profile(alice, 'alice');
final content = await japanese(alice);
final published = await publish(alice, content.root);
final word = await git(published.publicId, [
'show',
'v1:cards/${content.word.uuid}.json',
]);
expect(
word,
const JsonEncoder.withIndent(' ').convert({
'id': content.word.uuid,
'payload': {
'back': 'a word',
'front': '日本',
'schemaVersion': 8,
'type': 'vocabulary',
},
'position': 'a0',
'tags': ['JLPT-N5'],
}),
);
final book = await fileAt(
published,
'v1',
'${content.kanji.uuid}/pdfs/${content.book.uuid}.json',
);
expect((book['payload']! as Map).containsKey('deadline'), isFalse);
// The studysets' own study options are a suggestion to learners.
final root = await fileAt(published, 'v1', 'studyset.json');
expect(root['payload'], containsPair('study', {'newPerDay': 10}));
expect(root.containsKey('position'), isFalse);
final files = await fileAt(published, 'v1', 'files.json');
expect(files.length, 2);
},
);
test('a later release is a commit on the one before, which stays as it '
'was; nothing changed, nothing is released', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
await publish(alice, content.root);
await expectLater(
publish(alice, content.root),
refused(PublishRefusal.nothingChanged),
);
await revise(
alice,
content.word,
await revisionsOf(content.word),
'日本語',
);
await asPerson(
alice,
(transaction) => Card.db.deleteWhere(
session,
where: (t) => t.id.equals(content.pictured),
transaction: transaction,
),
);
final published = await publish(alice, content.root);
expect([for (final r in published.releases) r.version], [2, 1]);
expect(published.releases.first.cardCount, 1);
expect(
await git(published.publicId, ['rev-list', '--count', 'v2']),
'2',
);
final card = 'cards/${content.word.uuid}.json';
expect(
(await fileAt(published, 'v2', card))['payload'],
containsPair('front', '日本語'),
);
expect(
(await fileAt(published, 'v1', card))['payload'],
containsPair('front', '日本'),
);
final pictured =
'${content.kanji.uuid}/cards/${content.pictured.uuid}.json';
expect(
await git(published.publicId, ['ls-tree', '-r', '--name-only', 'v2']),
isNot(contains(pictured)),
);
expect(
await git(published.publicId, ['ls-tree', '-r', '--name-only', 'v1']),
contains(pictured),
);
});
test(
'a version git has but the database lost is never given again',
() async {
await profile(alice, 'alice');
final content = await japanese(alice);
final first = await publish(alice, content.root);
await StudysetRelease.db.deleteWhere(session, where: (t) => t.id > 0);
await revise(
alice,
content.word,
await revisionsOf(content.word),
'x',
);
final published = await publish(alice, content.root);
expect(published.releases.single.version, 2);
expect(published.publicId, first.publicId);
},
);
test('only under licences that allow passing it on, every studyset '
'inside included; nothing published otherwise', () async {
await profile(alice, 'alice');
final unlicensed = await japanese(alice, license: null);
await expectLater(
publish(alice, unlicensed.root),
refused(PublishRefusal.licenseNotOpen, [
unlicensed.root,
unlicensed.kanji,
]),
);
final reserved = await japanese(
alice,
license: 'CC-BY-NC-ND-4.0',
kanjiLicense: 'All Rights Reserved',
);
await expectLater(
publish(alice, reserved.root, slug: 'reserved'),
refused(PublishRefusal.licenseNotOpen, [reserved.kanji]),
);
expect(await PublishedStudyset.db.count(session), 0);
expect(repositoriesFolder.listSync(), isEmpty);
// "No derivatives" allows passing it on as it is.
final verbatim = await japanese(alice, license: 'CC-BY-NC-ND-4.0');
final published = await publish(alice, verbatim.root, slug: 'nd');
expect(published.releases.single.license, 'CC-BY-NC-ND-4.0');
});
test('a card with a conflict to settle is named, and nothing is '
'published', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
final first = await revisionsOf(content.word);
await revise(alice, content.word, first, 'one device');
await revise(alice, content.word, first, 'another device');
await expectLater(
publish(alice, content.root),
refused(PublishRefusal.conflicts, [content.word]),
);
expect(await PublishedStudyset.db.count(session), 0);
});
test('files the content names must have reached the instance', () async {
await profile(alice, 'alice');
final content = await japanese(alice, stored: false);
await expectLater(
publish(alice, content.root),
refused(PublishRefusal.filesMissing),
);
expect(repositoriesFolder.listSync(), isEmpty);
});
test('a studyset being deleted is left out with everything inside '
'it', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
// Blank, as a device's deletion leaves it on the server (the app's
// sync tests check that). Written in SQL: the sync engine (checked
// with v0.0.10) stores a ByteData the server updates as its JSON
// text, `decode('…', 'base64')`, not as its bytes.
await session.db.unsafeExecute(
'UPDATE "studyset" SET "payload" = \'\'::bytea WHERE "id" = @id',
parameters: QueryParameters.named({'id': content.kanji.uuid}),
);
final published = await publish(alice, content.root);
expect(
await git(published.publicId, ['ls-tree', '--name-only', 'v1']),
isNot(contains(content.kanji.uuid)),
);
expect(published.releases.single.cardCount, 1);
});
test('a studyset waiting in Recently deleted is left out with '
'everything inside it, and not published itself', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
// Set as a device's deletion sets it; in SQL, as the test above
// writes.
Future<void> delete(UuidValue studyset) => session.db.unsafeExecute(
'UPDATE "studyset" SET "deletedAt" = now() WHERE "id" = @id',
parameters: QueryParameters.named({'id': studyset.uuid}),
);
await delete(content.kanji);
final published = await publish(alice, content.root);
expect(
await git(published.publicId, ['ls-tree', '--name-only', 'v1']),
isNot(contains(content.kanji.uuid)),
);
expect(published.releases.single.cardCount, 1);
await delete(content.root);
await expectLater(
publish(alice, content.root),
throwsA(isA<NotFoundException>()),
);
});
test('its name is lower-case letters, digits and hyphens, one per '
'studyset of its person, and fixed from the first publish', () async {
await profile(alice, 'alice');
await profile(bob, 'bob');
final content = await japanese(alice);
for (final slug in ['Japanese', '-japanese', 'に', 'a' * 51, '']) {
await expectLater(
publish(alice, content.root, slug: slug),
refused(PublishRefusal.slugInvalid),
reason: slug,
);
}
await publish(alice, content.root);
final another = await japanese(alice);
await expectLater(
publish(alice, another.root),
refused(PublishRefusal.slugTaken),
);
await expectLater(
publish(alice, content.root, slug: 'nihongo'),
refused(PublishRefusal.slugFixed),
);
// Another person's name is theirs.
final bobs = await japanese(bob);
expect(
(await publish(bob, bobs.root)).location,
'$domain/@bob/japanese',
);
});
test('notes are at most 5,000 characters', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
await expectLater(
publish(alice, content.root, notes: 'x' * 5001),
refused(PublishRefusal.notesTooLong),
);
});
group('A copy of a published studyset', () {
/// Where Alice's Japanese, published under [license], is, as a copy
/// of it names it.
Future<Map<String, Object?>> original(String license) async {
final published = await publish(
alice,
(await japanese(alice, license: license)).root,
);
return {
'publication': published.publicId.uuid,
'item': 'root',
'version': 1,
'location': published.location,
};
}
test('is published as based on the original', () async {
await profile(alice, 'alice');
await profile(bob, 'bob');
final origin = await original('CC-BY-4.0');
final copy = await japanese(
bob,
license: 'CC-BY-4.0',
copyOf: origin,
);
final published = await publish(bob, copy.root, slug: 'nihongo');
final entry = (await endpoints.explore.entries(signedIn(bob), [
published.publicId,
])).single;
expect(entry.basedOn, origin['location']);
});
test('is refused under "no derivatives"', () async {
await profile(alice, 'alice');
await profile(bob, 'bob');
final copy = await japanese(
bob,
license: 'CC-BY-NC-ND-4.0',
copyOf: await original('CC-BY-NC-ND-4.0'),
);
await expectLater(
publish(bob, copy.root),
refused(PublishRefusal.forkForbidden),
);
});
test('keeps a ShareAlike licence, in every studyset of it', () async {
await profile(alice, 'alice');
await profile(bob, 'bob');
final origin = await original('CC-BY-SA-4.0');
final relicensed = await japanese(
bob,
license: 'CC-BY-4.0',
copyOf: origin,
);
await expectLater(
publish(bob, relicensed.root),
throwsA(
isA<PublishRefusedException>()
.having(
(e) => e.reason,
'reason',
PublishRefusal.licenseMustStay,
)
.having((e) => e.license, 'license', 'CC-BY-SA-4.0'),
),
);
final mixed = await japanese(
bob,
kanjiLicense: 'CC0-1.0',
copyOf: origin,
);
await expectLater(
publish(bob, mixed.root, slug: 'mixed'),
refused(PublishRefusal.licenseMustStay),
);
final kept = await japanese(bob, copyOf: origin);
expect(
(await publish(bob, kept.root, slug: 'kept')).releases,
hasLength(1),
);
});
test("is its author's to publish when they copied their own, and "
"one of another instance's goes with where that is", () async {
await profile(alice, 'alice');
final mine = await japanese(
alice,
license: 'CC-BY-4.0',
copyOf: await original('CC-BY-NC-ND-4.0'),
);
expect(
(await publish(alice, mine.root, slug: 'again')).releases,
hasLength(1),
);
final elsewhere = await japanese(
alice,
copyOf: {
'publication': const Uuid().v4obj().uuid,
'item': 'root',
'version': 3,
'location': 'learn.example.org/@carol/japanese',
},
);
final published = await publish(
alice,
elsewhere.root,
slug: 'elsewhere',
);
final entry = (await endpoints.explore.entries(signedIn(alice), [
published.publicId,
])).single;
expect(entry.basedOn, 'learn.example.org/@carol/japanese');
});
});
test('one release of a studyset at a time', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
final outcomes = await Future.wait([
for (var i = 0; i < 2; i++)
publish(alice, content.root).then<Object>(
(published) => published.releases.single.version,
onError: (Object error) =>
(error as PublishRefusedException).reason,
),
]);
expect(outcomes.toSet(), {1, PublishRefusal.busy});
});
group('Refused', () {
test('without signing in, or with an anonymous account', () async {
final content = await japanese(alice);
final anonymous = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
alice.uuid,
{},
),
);
await expectLater(
endpoints.publish.publish(sessionBuilder, content.root, 'a', ''),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
await expectLater(
endpoints.publish.publish(anonymous, content.root, 'a', ''),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
await expectLater(
endpoints.publish.publication(anonymous, content.root),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
});
test('without a public profile: a release names its author', () async {
final content = await japanese(alice);
await expectLater(
publish(alice, content.root),
refused(PublishRefusal.noProfile),
);
});
test("for another person's studyset, which can't be told from one "
"that doesn't exist", () async {
await profile(alice, 'alice');
await profile(bob, 'bob');
final content = await japanese(alice);
await publish(alice, content.root);
for (final studyset in [content.root, const Uuid().v7obj()]) {
await expectLater(
publish(bob, studyset),
throwsA(isA<NotFoundException>()),
);
}
expect(
await endpoints.publish.publication(signedIn(bob), content.root),
isNull,
);
expect(await PublishedStudyset.db.count(session), 1);
});
});
test(
'a publication is null until published, then lists its releases',
() async {
await profile(alice, 'alice');
final content = await japanese(alice);
expect(
await endpoints.publish.publication(
signedIn(alice),
content.root,
),
isNull,
);
final published = await publish(alice, content.root);
expect(
(await endpoints.publish.publication(
signedIn(alice),
content.root,
))!.toJson(),
published.toJson(),
);
},
);
test("the studysets out on Explore are the person's published ones "
'not withdrawn', () async {
await profile(alice, 'alice');
await profile(bob, 'bob');
final alices = await japanese(alice);
final bobs = await japanese(bob);
expect(await endpoints.publish.published(signedIn(alice)), isEmpty);
await publish(alice, alices.root);
await publish(bob, bobs.root);
await endpoints.publish.withdraw(signedIn(bob), bobs.root);
expect(await endpoints.publish.published(signedIn(alice)), [
alices.root,
]);
expect(await endpoints.publish.published(signedIn(bob)), isEmpty);
});
test('deleting a person deletes their published studysets and their '
'repositories', () async {
useServerAuth();
final carol = (await AuthServices.instance.authUsers.create(session))
.id;
people.add(carol);
await profile(carol, 'carol');
final content = await japanese(carol);
final published = await publish(carol, content.root);
expect(repositoryOf(published.publicId).directory.existsSync(), isTrue);
await deletePerson(session, carol);
expect(await PublishedStudyset.db.count(session), 0);
expect(await StudysetRelease.db.count(session), 0);
expect(
repositoryOf(published.publicId).directory.existsSync(),
isFalse,
);
});
},
// The sync engine's database, as the running server has it: deleted rows
// are tombstones it hides. Writing through it takes transactions of its
// own, outside the one a test would be rolled back in, so the tests
// delete what they made instead.
databaseInterceptor: (session, inner) {
final database = offlineSyncDatabaseInterceptor(session, inner);
if (database is OfflineSyncDatabase) {
_syncDatabases[session] = database;
}
return database;
},
rollbackDatabase: RollbackDatabase.disabled,
);
}
/// The sync engine's database of each session, as the test pod's own
/// database wraps it.
final _syncDatabases = Expando<OfflineSyncDatabase>();
/// [json] as a synced payload.
ByteData _json(Map<String, Object?> json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
/// The ids of what `japanese` writes, and the hashes of its files.
final class _Japanese {
_Japanese({
required this.root,
required this.kanji,
required this.word,
required this.pictured,
required this.note,
required this.path,
required this.step,
required this.book,
required this.image,
required this.pdf,
});
final UuidValue root;
final UuidValue kanji;
final UuidValue word;
final UuidValue pictured;
final UuidValue note;
final UuidValue path;
final UuidValue step;
final UuidValue book;
final String image;
final String pdf;
}