chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its assets (maps, Anki fixtures, icons), as they are; paths are fixed in the commits that follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
11abd9afce
commit
649499be23
1 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,830 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/discussion/discussion_rules.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 '../support/sync_database.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
/// Rich text as the app's editor writes it, holding [text].
|
||||
String rich(String text) => jsonEncode({
|
||||
'formatVersion': 1,
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Discussions',
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late UuidValue alice;
|
||||
late UuidValue bob;
|
||||
late UuidValue carol;
|
||||
final people = <UuidValue>[];
|
||||
|
||||
/// A person signed in with an identity, or none.
|
||||
TestSessionBuilder signedIn(UuidValue person, {bool identity = true}) =>
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
identity ? {identified} : {},
|
||||
),
|
||||
);
|
||||
|
||||
/// A person, with an identity unless [anonymous], and a public profile
|
||||
/// under [handle] unless null.
|
||||
Future<UuidValue> person(String? handle, {bool anonymous = false}) async {
|
||||
final id = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: anonymous ? const {} : {identified},
|
||||
)).id;
|
||||
people.add(id);
|
||||
if (handle != null) {
|
||||
await endpoints.profile.save(signedIn(id), handle, '', '', []);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
|
||||
useLearnBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
repositoriesFolder = await Directory.systemTemp.createTemp('repos-');
|
||||
alice = await person('alice');
|
||||
bob = await person('bob');
|
||||
carol = await person('carol');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final person in people) {
|
||||
await deletePerson(session, person);
|
||||
}
|
||||
people.clear();
|
||||
await repositoriesFolder.delete(recursive: true);
|
||||
repositoriesFolder = null;
|
||||
});
|
||||
|
||||
ByteData json(Map<String, Object?> json) =>
|
||||
ByteData.sublistView(utf8.encode(jsonEncode(json)));
|
||||
|
||||
late UuidValue root;
|
||||
late UuidValue grammar;
|
||||
late UuidValue particles;
|
||||
late UuidValue kanji;
|
||||
late UuidValue card;
|
||||
|
||||
/// Alice's Japanese, published: Grammar with Particles inside, holding
|
||||
/// a card, and Kanji beside it. Returns its public id.
|
||||
Future<UuidValue> japanese() async {
|
||||
await asPerson(session, alice, (transaction) async {
|
||||
Future<UuidValue> studyset(String title, {UuidValue? parent}) async =>
|
||||
(await Studyset.db.insertRow(
|
||||
session,
|
||||
Studyset(
|
||||
parentId: parent,
|
||||
position: 'a0',
|
||||
payload: json({
|
||||
'schemaVersion': 5,
|
||||
'title': title,
|
||||
'license': 'CC-BY-SA-4.0',
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
root = await studyset('Japanese');
|
||||
grammar = await studyset('Grammar', parent: root);
|
||||
particles = await studyset('Particles', parent: grammar);
|
||||
kanji = await studyset('Kanji', parent: root);
|
||||
card = (await Card.db.insertRow(
|
||||
session,
|
||||
Card(studysetId: particles, position: 'a0'),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
await CardRevision.db.insertRow(
|
||||
session,
|
||||
CardRevision(
|
||||
cardId: card,
|
||||
parents: [],
|
||||
authorId: alice,
|
||||
payload: json({'schemaVersion': 9, 'type': 'frontBack'}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
return (await endpoints.publish.publish(
|
||||
signedIn(alice),
|
||||
root,
|
||||
'japanese',
|
||||
'',
|
||||
)).publicId;
|
||||
}
|
||||
|
||||
DiscussionTarget about(String kind, UuidValue item, {int version = 1}) =>
|
||||
DiscussionTarget(kind: kind, item: item.uuid, version: version);
|
||||
|
||||
Future<UuidValue> ask(
|
||||
UuidValue by,
|
||||
UuidValue publication, {
|
||||
DiscussionTarget? target,
|
||||
String title = 'Meaning of は here?',
|
||||
String body = 'Why は and not が?',
|
||||
}) => endpoints.discussion.ask(
|
||||
signedIn(by),
|
||||
publication,
|
||||
target ?? about('card', card),
|
||||
title,
|
||||
rich(body),
|
||||
);
|
||||
|
||||
Future<DiscussionView> read(UuidValue by, UuidValue question) =>
|
||||
endpoints.discussions.read(signedIn(by), question);
|
||||
|
||||
Matcher refused(DiscussionRefusal reason) => throwsA(
|
||||
isA<DiscussionRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
reason,
|
||||
),
|
||||
);
|
||||
|
||||
group('asking', () {
|
||||
test('a person with an identity and a public profile asks about an '
|
||||
'item of the release; everyone signed in reads it, its writer '
|
||||
'named', () async {
|
||||
final publication = await japanese();
|
||||
|
||||
final id = await ask(bob, publication);
|
||||
|
||||
final anonymous = await person(null, anonymous: true);
|
||||
final [summary] = await endpoints.discussions.list(
|
||||
signedIn(anonymous, identity: false),
|
||||
publication,
|
||||
);
|
||||
expect(summary.questionId, id);
|
||||
expect(summary.title, 'Meaning of は here?');
|
||||
expect(summary.asker?.handle, 'bob');
|
||||
expect(summary.askerIsAuthor, isFalse);
|
||||
expect(summary.target.toJson(), about('card', card).toJson());
|
||||
expect(summary.studysetTitle, 'Japanese');
|
||||
expect(summary.answers, 0);
|
||||
final view = await read(carol, id);
|
||||
expect(view.question.title, 'Meaning of は here?');
|
||||
expect(view.question.body, rich('Why は and not が?'));
|
||||
expect(view.question.mine, isFalse);
|
||||
expect(view.location, endsWith('/@alice/japanese'));
|
||||
expect(view.asker, isFalse);
|
||||
expect(view.author, isFalse);
|
||||
expect(view.refusal, isNull);
|
||||
expect((await read(bob, id)).asker, isTrue);
|
||||
expect((await read(alice, id)).author, isTrue);
|
||||
});
|
||||
|
||||
test('a topic lists what is asked within it and its topics, and '
|
||||
'nothing else; the published studyset lists everything', () async {
|
||||
final publication = await japanese();
|
||||
final onCard = await ask(bob, publication);
|
||||
final onGrammar = await ask(
|
||||
bob,
|
||||
publication,
|
||||
target: about('studyset', grammar),
|
||||
);
|
||||
final onKanji = await ask(
|
||||
carol,
|
||||
publication,
|
||||
target: about('studyset', kanji),
|
||||
);
|
||||
final onRoot = await ask(
|
||||
carol,
|
||||
publication,
|
||||
target: about('studyset', root),
|
||||
);
|
||||
|
||||
Future<Set<UuidValue>> within(UuidValue? topic) async => {
|
||||
for (final summary in await endpoints.discussions.list(
|
||||
signedIn(carol),
|
||||
publication,
|
||||
topic: topic?.uuid,
|
||||
))
|
||||
summary.questionId,
|
||||
};
|
||||
expect(await within(grammar), {onCard, onGrammar});
|
||||
expect(await within(particles), {onCard});
|
||||
expect(await within(kanji), {onKanji});
|
||||
expect(await within(root), {onCard, onGrammar, onKanji, onRoot});
|
||||
expect(await within(null), {onCard, onGrammar, onKanji, onRoot});
|
||||
expect(
|
||||
[
|
||||
for (final summary in await endpoints.discussions.list(
|
||||
signedIn(carol),
|
||||
publication,
|
||||
item: about('card', card),
|
||||
))
|
||||
summary.questionId,
|
||||
],
|
||||
[onCard],
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'a question about the studyset as a whole may name no item',
|
||||
() async {
|
||||
final publication = await japanese();
|
||||
|
||||
final id = await ask(
|
||||
bob,
|
||||
publication,
|
||||
target: DiscussionTarget(kind: 'studyset', item: '', version: 1),
|
||||
);
|
||||
|
||||
expect((await read(carol, id)).target.item, root.uuid);
|
||||
},
|
||||
);
|
||||
|
||||
test('finds questions by what their titles say, wildcards as '
|
||||
'themselves', () async {
|
||||
final publication = await japanese();
|
||||
final wa = await ask(bob, publication, title: 'Meaning of は');
|
||||
await ask(bob, publication, title: 'Reading of 100%');
|
||||
|
||||
Future<List<UuidValue>> search(String query) async => [
|
||||
for (final summary in await endpoints.discussions.list(
|
||||
signedIn(carol),
|
||||
publication,
|
||||
query: query,
|
||||
))
|
||||
summary.questionId,
|
||||
];
|
||||
expect(await search('meaning'), [wa]);
|
||||
expect(await search('%'), hasLength(1));
|
||||
expect(await search('_'), isEmpty);
|
||||
});
|
||||
|
||||
test('needs an identity to write, and signing in to read', () async {
|
||||
final publication = await japanese();
|
||||
|
||||
await expectLater(
|
||||
endpoints.discussion.ask(
|
||||
signedIn(bob, identity: false),
|
||||
publication,
|
||||
about('card', card),
|
||||
'Why?',
|
||||
rich('Why?'),
|
||||
),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.discussions.list(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
publication,
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a person without a public profile, or one the author '
|
||||
'blocked, and says so to them when they read', () async {
|
||||
final publication = await japanese();
|
||||
final dave = await person(null);
|
||||
final question = await ask(bob, publication);
|
||||
|
||||
await expectLater(
|
||||
ask(dave, publication),
|
||||
refused(DiscussionRefusal.noProfile),
|
||||
);
|
||||
expect(
|
||||
(await read(dave, question)).refusal,
|
||||
DiscussionRefusal.noProfile,
|
||||
);
|
||||
await endpoints.inbox.block(signedIn(alice), 'carol');
|
||||
await expectLater(
|
||||
ask(carol, publication),
|
||||
refused(DiscussionRefusal.blocked),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.discussion.answer(signedIn(carol), question, rich('No')),
|
||||
refused(DiscussionRefusal.blocked),
|
||||
);
|
||||
expect(
|
||||
(await read(carol, question)).refusal,
|
||||
DiscussionRefusal.blocked,
|
||||
);
|
||||
final anonymous = await person(null, anonymous: true);
|
||||
expect(
|
||||
(await endpoints.discussions.read(
|
||||
signedIn(anonymous, identity: false),
|
||||
question,
|
||||
)).refusal,
|
||||
DiscussionRefusal.noIdentity,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a question about what the release lacks, or in a '
|
||||
'release that does not exist; a studyset never published is '
|
||||
'missing', () async {
|
||||
final publication = await japanese();
|
||||
|
||||
await expectLater(
|
||||
ask(bob, publication, target: about('card', grammar)),
|
||||
refused(DiscussionRefusal.notInRelease),
|
||||
);
|
||||
await expectLater(
|
||||
ask(bob, publication, target: about('card', card, version: 2)),
|
||||
refused(DiscussionRefusal.notInRelease),
|
||||
);
|
||||
await expectLater(
|
||||
ask(bob, publication, target: about('chapter', card)),
|
||||
refused(DiscussionRefusal.notInRelease),
|
||||
);
|
||||
await expectLater(
|
||||
ask(bob, const Uuid().v4obj()),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.discussions.list(signedIn(bob), const Uuid().v4obj()),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a studyset withdrawn keeps its discussions readable, but takes '
|
||||
'no new questions; answers go on', () async {
|
||||
final publication = await japanese();
|
||||
final question = await ask(bob, publication);
|
||||
|
||||
await endpoints.publish.withdraw(signedIn(alice), root);
|
||||
|
||||
await expectLater(
|
||||
ask(bob, publication),
|
||||
refused(DiscussionRefusal.withdrawn),
|
||||
);
|
||||
expect(
|
||||
await endpoints.discussions.list(signedIn(carol), publication),
|
||||
hasLength(1),
|
||||
);
|
||||
await endpoints.discussion.answer(
|
||||
signedIn(carol),
|
||||
question,
|
||||
rich('Topic marker.'),
|
||||
);
|
||||
expect((await read(bob, question)).answers, hasLength(1));
|
||||
});
|
||||
|
||||
test('refuses an empty or too long title, text or comment, and more '
|
||||
'posts in an hour than allowed', () async {
|
||||
final publication = await japanese();
|
||||
|
||||
await expectLater(
|
||||
ask(bob, publication, title: ' '),
|
||||
refused(DiscussionRefusal.empty),
|
||||
);
|
||||
await expectLater(
|
||||
ask(bob, publication, title: 'x' * (maxTitleLength + 1)),
|
||||
refused(DiscussionRefusal.tooLong),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.discussion.ask(
|
||||
signedIn(bob),
|
||||
publication,
|
||||
about('card', card),
|
||||
'Why?',
|
||||
'',
|
||||
),
|
||||
refused(DiscussionRefusal.empty),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.discussion.ask(
|
||||
signedIn(bob),
|
||||
publication,
|
||||
about('card', card),
|
||||
'Why?',
|
||||
'not rich text',
|
||||
),
|
||||
refused(DiscussionRefusal.empty),
|
||||
);
|
||||
await expectLater(
|
||||
ask(bob, publication, body: 'x' * maxBodyBytes),
|
||||
refused(DiscussionRefusal.tooLong),
|
||||
);
|
||||
final question = await ask(bob, publication);
|
||||
await expectLater(
|
||||
endpoints.discussion.comment(
|
||||
signedIn(carol),
|
||||
question,
|
||||
'x' * (maxCommentLength + 1),
|
||||
),
|
||||
refused(DiscussionRefusal.tooLong),
|
||||
);
|
||||
for (var post = 1; post < maxPostsAnHour; post++) {
|
||||
await endpoints.discussion.comment(
|
||||
signedIn(bob),
|
||||
question,
|
||||
'$post',
|
||||
);
|
||||
}
|
||||
await expectLater(
|
||||
endpoints.discussion.comment(signedIn(bob), question, 'more'),
|
||||
refused(DiscussionRefusal.tooMany),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('answering', () {
|
||||
test('anyone with a profile answers and comments; answers come oldest '
|
||||
'first, the accepted one first, comments beneath what they are '
|
||||
'on', () async {
|
||||
final publication = await japanese();
|
||||
final question = await ask(bob, publication);
|
||||
final first = await endpoints.discussion.answer(
|
||||
signedIn(carol),
|
||||
question,
|
||||
rich('Topic marker.'),
|
||||
);
|
||||
final second = await endpoints.discussion.answer(
|
||||
signedIn(alice),
|
||||
question,
|
||||
rich('は marks the topic; が the subject.'),
|
||||
);
|
||||
final onQuestion = await endpoints.discussion.comment(
|
||||
signedIn(carol),
|
||||
question,
|
||||
'Good question.',
|
||||
);
|
||||
final onAnswer = await endpoints.discussion.comment(
|
||||
signedIn(bob),
|
||||
second,
|
||||
'Thanks!',
|
||||
);
|
||||
|
||||
var view = await read(carol, question);
|
||||
expect(
|
||||
[for (final answer in view.answers) answer.id],
|
||||
[
|
||||
first,
|
||||
second,
|
||||
],
|
||||
);
|
||||
expect(view.answers.last.fromAuthor, isTrue);
|
||||
expect(view.answers.first.mine, isTrue);
|
||||
expect(
|
||||
[
|
||||
for (final comment in view.comments)
|
||||
(comment.id, comment.parentId),
|
||||
],
|
||||
[(onQuestion, question), (onAnswer, second)],
|
||||
);
|
||||
expect(view.comments.first.body, 'Good question.');
|
||||
|
||||
await endpoints.discussion.accept(signedIn(bob), question, second);
|
||||
view = await read(carol, question);
|
||||
expect(view.acceptedAnswerId, second);
|
||||
expect(
|
||||
[for (final answer in view.answers) answer.id],
|
||||
[
|
||||
second,
|
||||
first,
|
||||
],
|
||||
);
|
||||
final [summary] = await endpoints.discussions.list(
|
||||
signedIn(carol),
|
||||
publication,
|
||||
);
|
||||
expect(summary.answers, 2);
|
||||
expect(summary.accepted, isTrue);
|
||||
|
||||
await endpoints.discussion.accept(signedIn(bob), question, null);
|
||||
expect((await read(carol, question)).acceptedAnswerId, isNull);
|
||||
});
|
||||
|
||||
test('only the asker accepts, only an answer of the question; '
|
||||
'anything else is as missing', () async {
|
||||
final publication = await japanese();
|
||||
final question = await ask(bob, publication);
|
||||
final answer = await endpoints.discussion.answer(
|
||||
signedIn(carol),
|
||||
question,
|
||||
rich('Topic marker.'),
|
||||
);
|
||||
final other = await ask(carol, publication);
|
||||
final elsewhere = await endpoints.discussion.answer(
|
||||
signedIn(bob),
|
||||
other,
|
||||
rich('Elsewhere.'),
|
||||
);
|
||||
|
||||
for (final (by, on, accepted) in [
|
||||
(alice, question, answer),
|
||||
(carol, question, answer),
|
||||
(bob, question, elsewhere),
|
||||
(bob, question, question),
|
||||
(bob, answer, answer),
|
||||
]) {
|
||||
await expectLater(
|
||||
endpoints.discussion.accept(signedIn(by), on, accepted),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
}
|
||||
expect((await read(bob, question)).acceptedAnswerId, isNull);
|
||||
});
|
||||
|
||||
test('a comment goes on a question or an answer, an answer on a '
|
||||
'question; anything else is as missing', () async {
|
||||
final publication = await japanese();
|
||||
final question = await ask(bob, publication);
|
||||
final comment = await endpoints.discussion.comment(
|
||||
signedIn(carol),
|
||||
question,
|
||||
'Hm.',
|
||||
);
|
||||
final answer = await endpoints.discussion.answer(
|
||||
signedIn(carol),
|
||||
question,
|
||||
rich('Topic marker.'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.discussion.comment(signedIn(bob), comment, 'Re: hm.'),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
for (final on in [answer, comment, const Uuid().v7obj()]) {
|
||||
await expectLater(
|
||||
endpoints.discussion.answer(signedIn(bob), on, rich('No.')),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('editing and deleting', () {
|
||||
test('people edit what they wrote, marked edited; what others wrote '
|
||||
'is as missing', () async {
|
||||
final publication = await japanese();
|
||||
final question = await ask(bob, publication);
|
||||
final comment = await endpoints.discussion.comment(
|
||||
signedIn(carol),
|
||||
question,
|
||||
'Hm.',
|
||||
);
|
||||
|
||||
await endpoints.discussion.edit(
|
||||
signedIn(bob),
|
||||
question,
|
||||
rich('Why は here?'),
|
||||
title: 'は or が?',
|
||||
);
|
||||
await endpoints.discussion.edit(signedIn(carol), comment, 'Hmm.');
|
||||
|
||||
final view = await read(alice, question);
|
||||
expect(view.question.title, 'は or が?');
|
||||
expect(view.question.body, rich('Why は here?'));
|
||||
expect(view.question.editedAt, isNotNull);
|
||||
expect(view.comments.single.body, 'Hmm.');
|
||||
for (final (by, post) in [(carol, question), (bob, comment)]) {
|
||||
await expectLater(
|
||||
endpoints.discussion.edit(signedIn(by), post, rich('Mine now')),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
}
|
||||
await expectLater(
|
||||
endpoints.discussion.delete(signedIn(carol), question),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('deleting a post nobody else replied to removes it; with '
|
||||
"others' replies it stays, its title, text and writer gone, the "
|
||||
'replies kept', () async {
|
||||
final publication = await japanese();
|
||||
final lonely = await ask(bob, publication, title: 'Alone');
|
||||
final answered = await ask(bob, publication, title: 'Answered');
|
||||
await endpoints.discussion.comment(signedIn(bob), lonely, 'Bump.');
|
||||
final answer = await endpoints.discussion.answer(
|
||||
signedIn(carol),
|
||||
answered,
|
||||
rich('Topic marker.'),
|
||||
);
|
||||
|
||||
await endpoints.discussion.delete(signedIn(bob), lonely);
|
||||
await endpoints.discussion.delete(signedIn(bob), answered);
|
||||
|
||||
final [summary] = await endpoints.discussions.list(
|
||||
signedIn(carol),
|
||||
publication,
|
||||
);
|
||||
expect(summary.questionId, answered);
|
||||
expect(summary.deleted, isTrue);
|
||||
expect(summary.title, isEmpty);
|
||||
expect(summary.asker, isNull);
|
||||
final view = await read(carol, answered);
|
||||
expect(view.question.body, isEmpty);
|
||||
expect(view.question.by, isNull);
|
||||
expect(view.answers.single.id, answer);
|
||||
await expectLater(
|
||||
read(carol, lonely),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
// A deleted question takes no new answers.
|
||||
await expectLater(
|
||||
endpoints.discussion.answer(signedIn(carol), answered, rich('?')),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test("deleting a person keeps others' answers under their questions, "
|
||||
'and removes the rest of what they wrote', () async {
|
||||
final publication = await japanese();
|
||||
final dave = await person('dave');
|
||||
final answered = await ask(dave, publication, title: 'Answered');
|
||||
final lonely = await ask(dave, publication, title: 'Alone');
|
||||
final answer = await endpoints.discussion.answer(
|
||||
signedIn(carol),
|
||||
answered,
|
||||
rich('Topic marker.'),
|
||||
);
|
||||
await endpoints.discussion.answer(
|
||||
signedIn(dave),
|
||||
answered,
|
||||
rich('Thanks, I see.'),
|
||||
);
|
||||
await endpoints.discussion.markSeen(signedIn(dave), answered);
|
||||
|
||||
await deletePerson(session, dave);
|
||||
people.remove(dave);
|
||||
|
||||
final view = await read(carol, answered);
|
||||
expect(view.question.deleted, isTrue);
|
||||
expect(view.question.title, isEmpty);
|
||||
expect([for (final answer in view.answers) answer.id], [answer]);
|
||||
await expectLater(
|
||||
read(carol, lonely),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
expect(
|
||||
await DiscussionPost.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(dave),
|
||||
),
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await DiscussionSeen.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(dave),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"deleting the author's studyset takes its discussions with it",
|
||||
() async {
|
||||
final publication = await japanese();
|
||||
await ask(bob, publication);
|
||||
|
||||
await deletePerson(session, alice);
|
||||
people.remove(alice);
|
||||
|
||||
expect(await DiscussionPost.db.count(session), 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group("the author's say", () {
|
||||
test('the author hides posts on their studysets: then only their '
|
||||
'writer and the author see them; nobody else hides', () async {
|
||||
final publication = await japanese();
|
||||
final question = await ask(bob, publication);
|
||||
final answer = await endpoints.discussion.answer(
|
||||
signedIn(carol),
|
||||
question,
|
||||
rich('Spam.'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.discussion.hide(signedIn(bob), answer, true),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
await endpoints.discussion.hide(signedIn(alice), answer, true);
|
||||
|
||||
expect((await read(bob, question)).answers, isEmpty);
|
||||
expect((await read(carol, question)).answers.single.hidden, isTrue);
|
||||
expect((await read(alice, question)).answers.single.hidden, isTrue);
|
||||
|
||||
await endpoints.discussion.hide(signedIn(alice), question, true);
|
||||
await expectLater(
|
||||
read(carol, question),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
expect(
|
||||
await endpoints.discussions.list(signedIn(carol), publication),
|
||||
isEmpty,
|
||||
);
|
||||
expect((await read(bob, question)).question.hidden, isTrue);
|
||||
|
||||
await endpoints.discussion.hide(signedIn(alice), question, false);
|
||||
await endpoints.discussion.hide(signedIn(alice), answer, false);
|
||||
expect((await read(carol, question)).answers, hasLength(1));
|
||||
});
|
||||
|
||||
test(
|
||||
'what a person the author blocked wrote shows to them alone',
|
||||
() async {
|
||||
final publication = await japanese();
|
||||
final question = await ask(bob, publication);
|
||||
await endpoints.discussion.answer(
|
||||
signedIn(carol),
|
||||
question,
|
||||
rich('Spam.'),
|
||||
);
|
||||
final theirs = await ask(carol, publication);
|
||||
|
||||
await endpoints.inbox.block(signedIn(alice), 'carol');
|
||||
|
||||
expect((await read(bob, question)).answers, isEmpty);
|
||||
expect((await read(carol, question)).answers, hasLength(1));
|
||||
await expectLater(
|
||||
read(bob, theirs),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
expect(
|
||||
[
|
||||
for (final summary in await endpoints.discussions.list(
|
||||
signedIn(bob),
|
||||
publication,
|
||||
))
|
||||
summary.questionId,
|
||||
],
|
||||
[question],
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('the inbox', () {
|
||||
test('holds the questions on the person’s studysets and those they '
|
||||
'wrote in, new until read, again when others write', () async {
|
||||
final publication = await japanese();
|
||||
final question = await ask(bob, publication);
|
||||
|
||||
var [inbox] = await endpoints.discussion.inbox(signedIn(alice));
|
||||
expect(inbox.questionId, question);
|
||||
expect(inbox.unseen, isTrue);
|
||||
expect(await endpoints.discussion.unseen(signedIn(alice)), 1);
|
||||
expect(await endpoints.discussion.inbox(signedIn(carol)), isEmpty);
|
||||
[inbox] = await endpoints.discussion.inbox(signedIn(bob));
|
||||
expect(inbox.unseen, isFalse, reason: 'Bob wrote all of it');
|
||||
|
||||
await endpoints.discussion.markSeen(signedIn(alice), question);
|
||||
expect(await endpoints.discussion.unseen(signedIn(alice)), 0);
|
||||
|
||||
final answer = await endpoints.discussion.answer(
|
||||
signedIn(carol),
|
||||
question,
|
||||
rich('Topic marker.'),
|
||||
);
|
||||
expect(await endpoints.discussion.unseen(signedIn(alice)), 1);
|
||||
expect(await endpoints.discussion.unseen(signedIn(bob)), 1);
|
||||
expect(await endpoints.discussion.unseen(signedIn(carol)), 0);
|
||||
|
||||
await endpoints.discussion.markSeen(signedIn(carol), question);
|
||||
await endpoints.discussion.accept(signedIn(bob), question, answer);
|
||||
expect(
|
||||
await endpoints.discussion.unseen(signedIn(carol)),
|
||||
1,
|
||||
reason: 'her answer was accepted',
|
||||
);
|
||||
});
|
||||
|
||||
test('needs an identity', () async {
|
||||
await expectLater(
|
||||
endpoints.discussion.inbox(signedIn(bob, identity: false)),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
databaseInterceptor: syncDatabaseInterceptor,
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue