feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
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>
This commit is contained in:
parent
7f6147d689
commit
f635b0816a
1 changed files with 20287 additions and 77 deletions
811
halkyon_learn_server/test/integration/publish/publish_test.dart
Normal file
811
halkyon_learn_server/test/integration/publish/publish_test.dart
Normal file
|
|
@ -0,0 +1,811 @@
|
|||
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,
|
||||
}) 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},
|
||||
}),
|
||||
),
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
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('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;
|
||||
}
|
||||
Loading…
Reference in a new issue