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>
951 lines
32 KiB
Dart
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;
|
|
}
|