feat(base,learn): suggest changes within a shared studyset
ADR 0044's step 6b (D44, D46 to D48): a Viewer, and an agent, suggest changes within a shared studyset, which its Owner and Editors accept or decline. A suggestion or report is written as Explore's are, in its writer's own synced space, with kinds of its own (space-suggestion, space-report) and the space's id in feedback_thread's publicationId, so no synced table changes and older apps, skipping kinds they don't know, keep syncing. Where the studyset is private it is sealed with its key, bound to the space; a reply to it is sealed for its thread. Learn's server gathers a space's threads for its members alone (feedback.threadsInSpace, NotFound for anyone else): only threads and replies by members now, decisions only from rows in the space itself, where the engine lets its Owner and Editors alone write; sealed payloads pass in base64 with their key and nonce. The inbox adds the threads its caller decides on or wrote. The module tells a host a person's spaces and each space's members (sharedSpacesOf, membersOfSpaces). No migration: no table changes. In the app, a Viewer's card and note offer Suggest a change: the fields write into the suggestion alone, sent with a message. A shared studyset's menu lists its Suggestions, where its Owner and Editors accept a change as a revision, or a new card or note into the studyset, and decide in the space. A suggestion naming a file that isn't the studyset's, or bringing files of its own, isn't taken. Whats-New: In a studyset shared with you as a viewer, suggest a change to a card or note; its owner and editors find your suggestions under Suggestions in its menu and accept or decline them. Whats-New-De: In einem Lernset, das du nur ansehen darfst, schlägst du Änderungen an Karteikarten und Notizen vor; Besitzer:in und Bearbeiter:innen finden sie im Menü unter „Vorschläge“ und nehmen sie an oder lehnen sie ab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7aee78bd87
commit
82c777b1a5
1 changed files with 2949 additions and 427 deletions
|
|
@ -0,0 +1,379 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/shared_space/sync_space_grants.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';
|
||||
|
||||
/// Suggestions and reports within a shared space (Halkyon's ADR 0044, D44,
|
||||
/// D46, D47): written in their writers' own spaces, gathered for the
|
||||
/// space's members alone, its Owner's and Editors' decisions read from the
|
||||
/// space itself.
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Suggestions in a shared space',
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late UuidValue alice;
|
||||
late UuidValue bob;
|
||||
late UuidValue carol;
|
||||
late UuidValue dave;
|
||||
late UuidValue space;
|
||||
final people = <UuidValue>[];
|
||||
final grants = SyncSpaceGrants();
|
||||
|
||||
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
{identified},
|
||||
),
|
||||
);
|
||||
|
||||
Future<UuidValue> person(String handle) async {
|
||||
final id = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
people.add(id);
|
||||
await saveProfile(session, id, handle: handle);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Makes [person] a member of [space] as [role], in the engine too.
|
||||
Future<void> join(UuidValue person, SharedSpaceRole role) async {
|
||||
await SharedSpaceMember.db.insertRow(
|
||||
session,
|
||||
SharedSpaceMember(
|
||||
sharedSpaceId: space,
|
||||
authUserId: person,
|
||||
role: role,
|
||||
joinedAt: clock.now().toUtc(),
|
||||
),
|
||||
);
|
||||
await session.db.transaction(
|
||||
(t) => grants.grant(
|
||||
session,
|
||||
space,
|
||||
person,
|
||||
writes: role != SharedSpaceRole.viewer,
|
||||
transaction: t,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
|
||||
useLearnBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
alice = await person('alice');
|
||||
bob = await person('bob');
|
||||
carol = await person('carol');
|
||||
dave = await person('dave');
|
||||
// Alice's space: Bob an Editor, Carol a Viewer; Dave outside.
|
||||
space = await session.db.transaction(
|
||||
(t) => grants.create(session, owner: alice, transaction: t),
|
||||
);
|
||||
await SharedSpace.db.insertRow(
|
||||
session,
|
||||
SharedSpace(id: space, kind: 'studyset', createdAt: clock.now()),
|
||||
);
|
||||
await SharedSpaceMember.db.insertRow(
|
||||
session,
|
||||
SharedSpaceMember(
|
||||
sharedSpaceId: space,
|
||||
authUserId: alice,
|
||||
role: SharedSpaceRole.owner,
|
||||
joinedAt: clock.now().toUtc(),
|
||||
),
|
||||
);
|
||||
await join(bob, SharedSpaceRole.editor);
|
||||
await join(carol, SharedSpaceRole.viewer);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final person in people) {
|
||||
await deletePerson(session, person);
|
||||
}
|
||||
people.clear();
|
||||
await session.db.unsafeExecute(
|
||||
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
|
||||
parameters: QueryParameters.named({'space': space.uuid}),
|
||||
);
|
||||
});
|
||||
|
||||
ByteData json(Map<String, Object?> json) =>
|
||||
ByteData.sublistView(utf8.encode(jsonEncode(json)));
|
||||
|
||||
Future<T> as<T>(
|
||||
UuidValue person,
|
||||
Future<T> Function(Transaction transaction) write, {
|
||||
UuidValue? inSpace,
|
||||
}) => asPerson(session, person, write, space: inSpace);
|
||||
|
||||
/// A thread of [kind] by [writer] about [on], in their own space.
|
||||
Future<UuidValue> thread(
|
||||
UuidValue writer, {
|
||||
String kind = 'space-suggestion',
|
||||
UuidValue? on,
|
||||
String? keyId,
|
||||
ByteData? payload,
|
||||
}) => as(
|
||||
writer,
|
||||
(transaction) async => (await FeedbackThread.db.insertRow(
|
||||
session,
|
||||
FeedbackThread(
|
||||
publicationId: on ?? space,
|
||||
kind: kind,
|
||||
keyId: keyId,
|
||||
nonce: keyId == null ? null : ByteData(24),
|
||||
payload:
|
||||
payload ??
|
||||
json({
|
||||
'schemaVersion': 1,
|
||||
'body': 'Another reading.',
|
||||
'target': {'kind': 'card', 'item': 'c1', 'version': 0},
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
)).id!,
|
||||
);
|
||||
|
||||
Future<void> reply(
|
||||
UuidValue by,
|
||||
UuidValue threadId,
|
||||
String body, {
|
||||
String? keyId,
|
||||
}) => as(
|
||||
by,
|
||||
(transaction) => FeedbackMessage.db.insertRow(
|
||||
session,
|
||||
FeedbackMessage(
|
||||
threadId: threadId,
|
||||
keyId: keyId,
|
||||
nonce: keyId == null ? null : ByteData(24),
|
||||
payload: keyId == null
|
||||
? json({'schemaVersion': 1, 'body': body})
|
||||
: ByteData.sublistView(Uint8List.fromList(utf8.encode(body))),
|
||||
),
|
||||
transaction: transaction,
|
||||
),
|
||||
);
|
||||
|
||||
/// A decision by [by], in the shared space or else in their own.
|
||||
Future<void> decide(
|
||||
UuidValue by,
|
||||
UuidValue threadId,
|
||||
String decision, {
|
||||
bool inTheSpace = true,
|
||||
}) => as(
|
||||
by,
|
||||
inSpace: inTheSpace ? space : null,
|
||||
(transaction) => FeedbackDecision.db.insertRow(
|
||||
session,
|
||||
FeedbackDecision(threadId: threadId, decision: decision),
|
||||
transaction: transaction,
|
||||
),
|
||||
);
|
||||
|
||||
Future<List<FeedbackView>> readBy(UuidValue person) =>
|
||||
endpoints.feedback.threadsInSpace(signedIn(person), space);
|
||||
|
||||
test("a Viewer's suggestion shows to every member, as in the "
|
||||
'space', () async {
|
||||
final id = await thread(carol);
|
||||
|
||||
for (final member in [alice, bob, carol]) {
|
||||
final view = (await readBy(member)).single;
|
||||
expect(view.threadId, id);
|
||||
expect(view.kind, 'space-suggestion');
|
||||
expect(view.space, space);
|
||||
expect(view.publicId, space);
|
||||
expect(view.writer.handle, 'carol');
|
||||
expect(view.status, 'open');
|
||||
expect(
|
||||
jsonDecode(view.payload),
|
||||
containsPair('body', 'Another reading.'),
|
||||
);
|
||||
expect(view.mine, member == carol);
|
||||
// The Owner and its Editors decide on it.
|
||||
expect(view.forMe, member != carol);
|
||||
}
|
||||
});
|
||||
|
||||
test('someone outside the space reads none of it, as a space that '
|
||||
"doesn't exist", () async {
|
||||
await thread(carol);
|
||||
|
||||
await expectLater(readBy(dave), throwsA(isA<NotFoundException>()));
|
||||
await expectLater(
|
||||
endpoints.feedback.threadsInSpace(
|
||||
signedIn(alice),
|
||||
const Uuid().v7obj(),
|
||||
),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.feedback.threadsInSpace(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
space,
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
expect(
|
||||
(await endpoints.inbox.inbox(signedIn(dave))).where(
|
||||
(view) => view.space == space,
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test("a thread naming the space by someone who isn't in it, or of a "
|
||||
"published studyset's kind, shows to nobody", () async {
|
||||
await thread(dave);
|
||||
await thread(carol, kind: 'suggestion');
|
||||
await thread(carol, kind: 'report');
|
||||
|
||||
expect(await readBy(alice), isEmpty);
|
||||
expect(await endpoints.inbox.inbox(signedIn(alice)), isEmpty);
|
||||
});
|
||||
|
||||
test("a space's thread never shows among a published studyset's, "
|
||||
'nor in another space', () async {
|
||||
await thread(carol);
|
||||
final other = await session.db.transaction(
|
||||
(t) => grants.create(session, owner: dave, transaction: t),
|
||||
);
|
||||
await thread(carol, on: other);
|
||||
|
||||
expect(
|
||||
await endpoints.feedback.threadsOf(signedIn(alice), space),
|
||||
isEmpty,
|
||||
);
|
||||
expect(await readBy(alice), hasLength(1));
|
||||
await session.db.unsafeExecute(
|
||||
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
|
||||
parameters: QueryParameters.named({'space': other.uuid}),
|
||||
);
|
||||
});
|
||||
|
||||
test('the Owner and Editors find it in their inbox, new until read; '
|
||||
'its writer finds theirs; other Viewers none', () async {
|
||||
await join(dave, SharedSpaceRole.viewer);
|
||||
final id = await thread(carol);
|
||||
|
||||
for (final decider in [alice, bob]) {
|
||||
final inbox = await endpoints.inbox.inbox(signedIn(decider));
|
||||
expect(inbox.single.threadId, id);
|
||||
expect(inbox.single.unseen, isTrue);
|
||||
expect(await endpoints.inbox.unseen(signedIn(decider)), 1);
|
||||
}
|
||||
expect(
|
||||
(await endpoints.inbox.inbox(signedIn(carol))).single.mine,
|
||||
isTrue,
|
||||
);
|
||||
expect(await endpoints.inbox.inbox(signedIn(dave)), isEmpty);
|
||||
|
||||
await endpoints.inbox.markSeen(signedIn(bob), id);
|
||||
expect(await endpoints.inbox.unseen(signedIn(bob)), 0);
|
||||
expect(await endpoints.inbox.unseen(signedIn(alice)), 1);
|
||||
});
|
||||
|
||||
test('replies by members show, those of the Owner and Editors as '
|
||||
"deciders'; one by someone outside never", () async {
|
||||
final id = await thread(carol);
|
||||
await reply(bob, id, 'Which reading?');
|
||||
await reply(carol, id, 'The kun reading.');
|
||||
await reply(dave, id, 'Spam.');
|
||||
|
||||
final messages = (await readBy(alice)).single.messages;
|
||||
expect([for (final m in messages) m.by.handle], ['bob', 'carol']);
|
||||
expect([for (final m in messages) m.fromAuthor], [true, false]);
|
||||
// Bob's reply is new to Carol.
|
||||
expect(
|
||||
(await endpoints.inbox.inbox(signedIn(carol))).single.unseen,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('a decision counts only from the space itself, where its Owner '
|
||||
'and Editors write', () async {
|
||||
final id = await thread(carol);
|
||||
|
||||
// Neither the writer's own space nor an Editor's counts.
|
||||
await decide(carol, id, 'accepted', inTheSpace: false);
|
||||
await decide(bob, id, 'declined', inTheSpace: false);
|
||||
expect((await readBy(alice)).single.status, 'open');
|
||||
|
||||
await decide(bob, id, 'accepted');
|
||||
expect((await readBy(carol)).single.status, 'accepted');
|
||||
});
|
||||
|
||||
test('a Viewer writes no decision into the space', () async {
|
||||
final id = await thread(carol);
|
||||
|
||||
await expectLater(
|
||||
decide(carol, id, 'accepted'),
|
||||
throwsA(anything),
|
||||
);
|
||||
expect((await readBy(alice)).single.status, 'open');
|
||||
});
|
||||
|
||||
test('hidden, it shows to its writer and the deciders alone', () async {
|
||||
await join(dave, SharedSpaceRole.viewer);
|
||||
final id = await thread(carol);
|
||||
await decide(alice, id, 'hidden');
|
||||
|
||||
expect(await readBy(dave), isEmpty);
|
||||
expect((await readBy(carol)).single.hidden, isTrue);
|
||||
expect((await readBy(bob)).single.hidden, isTrue);
|
||||
});
|
||||
|
||||
test('a sealed thread and its sealed replies pass as they are, in '
|
||||
'base64 with their key and nonce', () async {
|
||||
final sealed = ByteData.sublistView(Uint8List.fromList([1, 2, 3, 4]));
|
||||
final id = await thread(carol, keyId: 'space-key', payload: sealed);
|
||||
await reply(bob, id, 'sealed reply', keyId: 'space-key');
|
||||
|
||||
final view = (await readBy(alice)).single;
|
||||
expect(view.keyId, 'space-key');
|
||||
expect(base64Decode(view.payload), [1, 2, 3, 4]);
|
||||
expect(base64Decode(view.nonce!), hasLength(24));
|
||||
final message = view.messages.single;
|
||||
expect(message.keyId, 'space-key');
|
||||
expect(utf8.decode(base64Decode(message.payload)), 'sealed reply');
|
||||
});
|
||||
|
||||
test('a member who goes takes their threads and replies out of '
|
||||
'view', () async {
|
||||
final id = await thread(carol);
|
||||
await reply(bob, id, 'Hm.');
|
||||
await SharedSpaceMember.db.deleteWhere(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.sharedSpaceId.equals(space) & t.authUserId.equals(bob),
|
||||
);
|
||||
|
||||
expect((await readBy(alice)).single.messages, isEmpty);
|
||||
await SharedSpaceMember.db.deleteWhere(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.sharedSpaceId.equals(space) & t.authUserId.equals(carol),
|
||||
);
|
||||
expect(await readBy(alice), isEmpty);
|
||||
});
|
||||
},
|
||||
databaseInterceptor: syncDatabaseInterceptor,
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue