halkyon/halkyon_learn_server/test/integration/publish/publish_test.dart
Tizian 「ティツィアーン」 bc3e8d9ac3
feat(library): who made a studyset, and whether it is public
Each card's foot now says who made the studyset: "By you", or for a copy
of a published studyset its author's handle. Beside the star, a lock or a
globe says whether it is out on Explore, and opens the Publish sheet.

The instance says which of the person's studysets are published and not
withdrawn (`PublishEndpoint.published`, additive: older apps don't call
it), and the app remembers that in the person's folder, so the library
shows it offline too; publishing, withdrawing and looking one up keep it
current.

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-10 09:46:47 +02:00

951 lines
32 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('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;
}