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
17 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,664 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/file_limits.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/file_pruning.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Files',
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late UuidValue alice;
|
||||
late UuidValue bob;
|
||||
final image = utf8.encode('an image');
|
||||
final imageHash = sha256.convert(image).toString();
|
||||
|
||||
/// The spaces a test made, which it deletes again with their files.
|
||||
final spaces = <UuidValue>{};
|
||||
|
||||
setUp(() {
|
||||
// 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();
|
||||
alice = const Uuid().v7obj();
|
||||
bob = const Uuid().v7obj();
|
||||
spaces.addAll([alice, bob]);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
fileLimits = null;
|
||||
await FileAllowance.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet(spaces),
|
||||
);
|
||||
for (final space in spaces) {
|
||||
await session.db.unsafeExecute(
|
||||
'DELETE FROM "serverpod_cloud_storage" WHERE "path" LIKE @prefix',
|
||||
parameters: QueryParameters.named({
|
||||
'prefix': '${assetPath(space, '')}%',
|
||||
}),
|
||||
);
|
||||
await session.db.unsafeExecute(
|
||||
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
|
||||
parameters: QueryParameters.named({'space': space.uuid}),
|
||||
);
|
||||
}
|
||||
spaces.clear();
|
||||
});
|
||||
|
||||
TestSessionBuilder signedIn(
|
||||
UuidValue person, {
|
||||
Set<Scope> scopes = const {},
|
||||
}) => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
scopes,
|
||||
),
|
||||
);
|
||||
|
||||
/// Adds [bytes] as [person], as the app does: asks how to upload them,
|
||||
/// uploads them unless the server has them, and finishes the upload.
|
||||
Future<bool> add(
|
||||
UuidValue person,
|
||||
List<int> bytes, {
|
||||
String? as,
|
||||
Set<Scope> scopes = const {},
|
||||
}) async {
|
||||
final hash = as ?? sha256.convert(bytes).toString();
|
||||
final description = await endpoints.asset.startUpload(
|
||||
signedIn(person, scopes: scopes),
|
||||
hash,
|
||||
bytes.length,
|
||||
);
|
||||
if (description != null) {
|
||||
await _upload(session, description, bytes);
|
||||
}
|
||||
return await endpoints.asset.finishUpload(
|
||||
signedIn(person, scopes: scopes),
|
||||
hash,
|
||||
);
|
||||
}
|
||||
|
||||
test('refuses callers who are not signed in', () async {
|
||||
final nobody = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
final unauthenticated = throwsA(
|
||||
isA<ServerpodUnauthenticatedException>(),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.asset.startUpload(nobody, imageHash, image.length),
|
||||
unauthenticated,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.asset.finishUpload(nobody, imageHash),
|
||||
unauthenticated,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.asset.download(nobody, const Uuid().v7obj()),
|
||||
unauthenticated,
|
||||
);
|
||||
});
|
||||
|
||||
test('stores a file once and serves it by its asset row', () async {
|
||||
final asset = await _assetIn(session, alice, imageHash);
|
||||
|
||||
expect(await add(alice, image), isTrue);
|
||||
|
||||
expect(
|
||||
await endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
imageHash,
|
||||
image.length,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
Uint8List.sublistView(
|
||||
await endpoints.asset.download(signedIn(alice), asset),
|
||||
),
|
||||
image,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps no file whose content has another hash', () async {
|
||||
final asset = await _assetIn(session, alice, imageHash);
|
||||
|
||||
expect(
|
||||
await add(alice, utf8.encode('not that image'), as: imageHash),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.asset.download(signedIn(alice), asset),
|
||||
_notFound,
|
||||
);
|
||||
expect(
|
||||
await endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
imageHash,
|
||||
image.length,
|
||||
),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps a file over 20 MB only when it is a PDF, which may take '
|
||||
'up to 50 MB', () async {
|
||||
const size = AssetEndpoint.maxOtherBytes + 1;
|
||||
final pdf = Uint8List(size)
|
||||
..setAll(0, utf8.encode('%PDF-1.7'))
|
||||
..fillRange(8, size, 32);
|
||||
final other = Uint8List(size)..fillRange(0, size, 32);
|
||||
final otherHash = sha256.convert(other).toString();
|
||||
|
||||
expect(await add(alice, pdf), isTrue);
|
||||
expect(await add(alice, other), isFalse);
|
||||
|
||||
// What isn't a PDF is gone: the server asks for it anew.
|
||||
expect(
|
||||
await endpoints.asset.startUpload(signedIn(alice), otherHash, size),
|
||||
isNotNull,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
otherHash,
|
||||
AssetEndpoint.maxBytes + 1,
|
||||
),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('finishes no upload that never came', () async {
|
||||
expect(
|
||||
await endpoints.asset.finishUpload(signedIn(alice), imageHash),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses what is no SHA-256, and sizes out of range', () async {
|
||||
for (final hash in [
|
||||
'',
|
||||
'abc',
|
||||
imageHash.toUpperCase(),
|
||||
'$imageHash 0',
|
||||
]) {
|
||||
await expectLater(
|
||||
endpoints.asset.startUpload(signedIn(alice), hash, 1),
|
||||
throwsArgumentError,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.asset.finishUpload(signedIn(alice), hash),
|
||||
throwsArgumentError,
|
||||
);
|
||||
}
|
||||
for (final size in [0, -1, AssetEndpoint.maxBytes + 1]) {
|
||||
await expectLater(
|
||||
endpoints.asset.startUpload(signedIn(alice), imageHash, size),
|
||||
throwsArgumentError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"serves nobody else's file, exactly like a missing one",
|
||||
() async {
|
||||
final asset = await _assetIn(session, alice, imageHash);
|
||||
await add(alice, image);
|
||||
final notUploaded = await _assetIn(
|
||||
session,
|
||||
alice,
|
||||
sha256.convert([1]).toString(),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.asset.download(signedIn(bob), asset),
|
||||
_notFound,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.asset.download(signedIn(alice), notUploaded),
|
||||
_notFound,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.asset.download(signedIn(alice), const Uuid().v7obj()),
|
||||
_notFound,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"keeps every person's files apart: the same file from someone else "
|
||||
'neither shows nor replaces yours',
|
||||
() async {
|
||||
final asset = await _assetIn(session, alice, imageHash);
|
||||
await add(alice, image);
|
||||
|
||||
// Bob is asked for the file as if nobody had it.
|
||||
expect(
|
||||
await endpoints.asset.startUpload(
|
||||
signedIn(bob),
|
||||
imageHash,
|
||||
image.length,
|
||||
),
|
||||
isNotNull,
|
||||
);
|
||||
expect(
|
||||
await add(bob, utf8.encode('not that image'), as: imageHash),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
expect(
|
||||
Uint8List.sublistView(
|
||||
await endpoints.asset.download(signedIn(alice), asset),
|
||||
),
|
||||
image,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"serves a shared space's files to its members, and only them",
|
||||
() async {
|
||||
final carol = const Uuid().v7obj();
|
||||
final spaces = OfflineSyncSpaces(session);
|
||||
final shared = await spaces.createFor(carol);
|
||||
await spaces.grant(
|
||||
user: alice,
|
||||
space: shared,
|
||||
role: OfflineSyncSpaceRole.readOnly,
|
||||
);
|
||||
final asset = await _assetIn(
|
||||
session,
|
||||
carol,
|
||||
imageHash,
|
||||
space: shared,
|
||||
);
|
||||
await session.storage.storeFile(
|
||||
storageId: assetStorage,
|
||||
path: assetPath(shared, imageHash),
|
||||
byteData: ByteData.sublistView(image),
|
||||
);
|
||||
|
||||
expect(
|
||||
Uint8List.sublistView(
|
||||
await endpoints.asset.download(signedIn(alice), asset),
|
||||
),
|
||||
image,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.asset.download(signedIn(bob), asset),
|
||||
_notFound,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('serves no deleted asset', () async {
|
||||
final asset = await _assetIn(session, alice, imageHash);
|
||||
await add(alice, image);
|
||||
|
||||
await _deleteAsset(session, alice, asset);
|
||||
|
||||
await expectLater(
|
||||
endpoints.asset.download(signedIn(alice), asset),
|
||||
_notFound,
|
||||
);
|
||||
});
|
||||
|
||||
group('room', () {
|
||||
setUp(() {
|
||||
fileLimits = const FileLimits(
|
||||
anonymousBytes: 10,
|
||||
identifiedBytes: 20,
|
||||
instanceBytes: 1000,
|
||||
);
|
||||
});
|
||||
|
||||
Matcher refused({required bool instanceFull, required int used}) =>
|
||||
throwsA(
|
||||
isA<FileLimitExceededException>()
|
||||
.having((e) => e.instanceFull, 'instanceFull', instanceFull)
|
||||
.having((e) => e.usedBytes, 'usedBytes', used),
|
||||
);
|
||||
|
||||
test('an anonymous person keeps no more than their room', () async {
|
||||
expect(await add(alice, List.filled(8, 1)), isTrue);
|
||||
|
||||
await expectLater(
|
||||
endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
sha256.convert([2, 2, 2]).toString(),
|
||||
3,
|
||||
),
|
||||
refused(instanceFull: false, used: 8),
|
||||
);
|
||||
});
|
||||
|
||||
test('an identified person keeps more', () async {
|
||||
expect(
|
||||
await add(alice, List.filled(15, 1), scopes: {identified}),
|
||||
isTrue,
|
||||
);
|
||||
await expectLater(
|
||||
add(bob, List.filled(15, 1)),
|
||||
refused(instanceFull: false, used: 0),
|
||||
);
|
||||
});
|
||||
|
||||
test('an allowance gives one person more, and nobody else', () async {
|
||||
await FileAllowance.db.insertRow(
|
||||
session,
|
||||
FileAllowance(authUserId: alice, maxBytes: 40),
|
||||
);
|
||||
|
||||
expect(await add(alice, List.filled(30, 1)), isTrue);
|
||||
await expectLater(
|
||||
add(bob, List.filled(30, 2)),
|
||||
refused(instanceFull: false, used: 0),
|
||||
);
|
||||
});
|
||||
|
||||
test('the instance keeps no more than its room', () async {
|
||||
// Whatever other tests left behind counts too: the room is set
|
||||
// from what the instance holds now.
|
||||
fileLimits = FileLimits(
|
||||
anonymousBytes: 10,
|
||||
identifiedBytes: 20,
|
||||
instanceBytes: await _instanceBytes(session) + 12,
|
||||
);
|
||||
expect(await add(alice, List.filled(8, 1)), isTrue);
|
||||
|
||||
await expectLater(
|
||||
add(bob, List.filled(5, 2)),
|
||||
refused(instanceFull: true, used: 0),
|
||||
);
|
||||
});
|
||||
|
||||
test('uploads started side by side are checked again as they '
|
||||
'finish', () async {
|
||||
final first = List.filled(6, 1);
|
||||
final second = List.filled(6, 2);
|
||||
String hash(List<int> bytes) => sha256.convert(bytes).toString();
|
||||
// Both started before either arrives: each fits on its own.
|
||||
final descriptions = [
|
||||
for (final bytes in [first, second])
|
||||
(await endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
hash(bytes),
|
||||
bytes.length,
|
||||
))!,
|
||||
];
|
||||
await _upload(session, descriptions[0], first);
|
||||
await _upload(session, descriptions[1], second);
|
||||
|
||||
expect(
|
||||
await endpoints.asset.finishUpload(signedIn(alice), hash(first)),
|
||||
isTrue,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.asset.finishUpload(signedIn(alice), hash(second)),
|
||||
refused(instanceFull: false, used: 12),
|
||||
);
|
||||
expect(
|
||||
await session.storage.fileExists(
|
||||
storageId: assetStorage,
|
||||
path: assetPath(alice, hash(second)),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('uploads never finished take room from the next', () async {
|
||||
final unfinished = List.filled(6, 1);
|
||||
await _upload(
|
||||
session,
|
||||
(await endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
sha256.convert(unfinished).toString(),
|
||||
unfinished.length,
|
||||
))!,
|
||||
unfinished,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
sha256.convert(List.filled(6, 2)).toString(),
|
||||
6,
|
||||
),
|
||||
refused(instanceFull: false, used: 6),
|
||||
);
|
||||
});
|
||||
|
||||
test('tells how much room the files take, and may take', () async {
|
||||
await add(alice, List.filled(4, 1));
|
||||
|
||||
final usage = await endpoints.asset.usage(signedIn(alice));
|
||||
expect((usage.usedBytes, usage.limitBytes), (4, 10));
|
||||
|
||||
fileLimits = FileLimits(
|
||||
anonymousBytes: 10,
|
||||
identifiedBytes: 20,
|
||||
instanceBytes: await _instanceBytes(session) + 2,
|
||||
);
|
||||
final tight = await endpoints.asset.usage(signedIn(alice));
|
||||
expect((tight.usedBytes, tight.limitBytes), (4, 6));
|
||||
});
|
||||
|
||||
test('refuses callers who are not signed in', () async {
|
||||
await expectLater(
|
||||
endpoints.asset.usage(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('pruning', () {
|
||||
final now = DateTime.now().toUtc();
|
||||
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
|
||||
|
||||
Future<bool> stored(UuidValue space, String hash) async =>
|
||||
await CloudStorageEntry.db.count(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.storageId.equals(assetStorage) &
|
||||
t.path.equals(assetPath(space, hash)),
|
||||
) >
|
||||
0;
|
||||
|
||||
test(
|
||||
'deletes the files no asset refers to any more, a week after they '
|
||||
'were last claimed',
|
||||
() async {
|
||||
final kept = utf8.encode('kept');
|
||||
final deleted = utf8.encode('deleted');
|
||||
final recent = utf8.encode('recently uploaded');
|
||||
final unfinished = utf8.encode('never finished');
|
||||
final bobs = utf8.encode("bob's");
|
||||
String hash(List<int> bytes) => sha256.convert(bytes).toString();
|
||||
// Whatever an earlier, aborted run left behind goes first.
|
||||
await pruneUnreferencedFiles(session, now: now);
|
||||
|
||||
await _assetIn(session, alice, hash(kept));
|
||||
await add(alice, kept);
|
||||
final gone = await _assetIn(session, alice, hash(deleted));
|
||||
await add(alice, deleted);
|
||||
await _deleteAsset(session, alice, gone);
|
||||
await add(alice, recent);
|
||||
await _upload(
|
||||
session,
|
||||
(await endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
hash(unfinished),
|
||||
unfinished.length,
|
||||
))!,
|
||||
unfinished,
|
||||
);
|
||||
await _assetIn(session, alice, hash(unfinished));
|
||||
await add(bob, bobs);
|
||||
for (final (space, bytes) in [
|
||||
(alice, kept),
|
||||
(alice, deleted),
|
||||
(alice, unfinished),
|
||||
(bob, bobs),
|
||||
]) {
|
||||
await _claimed(session, space, hash(bytes), daysAgo(8));
|
||||
}
|
||||
await _claimed(session, alice, hash(recent), daysAgo(6));
|
||||
|
||||
expect(await pruneUnreferencedFiles(session, now: now), 3);
|
||||
|
||||
expect(await stored(alice, hash(kept)), isTrue);
|
||||
expect(await stored(alice, hash(recent)), isTrue);
|
||||
expect(await stored(alice, hash(deleted)), isFalse);
|
||||
expect(await stored(alice, hash(unfinished)), isFalse);
|
||||
expect(await stored(bob, hash(bobs)), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps a file asked for again', () async {
|
||||
await add(alice, image);
|
||||
await _claimed(session, alice, imageHash, daysAgo(30));
|
||||
|
||||
expect(
|
||||
await endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
imageHash,
|
||||
image.length,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
|
||||
expect(await pruneUnreferencedFiles(session, now: now), 0);
|
||||
expect(await stored(alice, imageHash), isTrue);
|
||||
});
|
||||
});
|
||||
},
|
||||
// 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>();
|
||||
|
||||
final _notFound = throwsA(
|
||||
isA<NotFoundException>().having((e) => e.what, 'what', 'file'),
|
||||
);
|
||||
|
||||
/// An asset row for the file with [hash], written as [person] would through
|
||||
/// the sync engine, in their personal space or in [space]; returns its id.
|
||||
Future<UuidValue> _assetIn(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
String hash, {
|
||||
UuidValue? space,
|
||||
}) => _syncDatabases[session]!.transactionForUser(
|
||||
person,
|
||||
spaceId: space,
|
||||
(transaction) async => (await Asset.db.insertRow(
|
||||
session,
|
||||
Asset(
|
||||
sha256: hash,
|
||||
mimeType: 'image/png',
|
||||
byteSize: 1,
|
||||
payload: ByteData(0),
|
||||
),
|
||||
transaction: transaction,
|
||||
)).id!,
|
||||
);
|
||||
|
||||
/// Deletes the asset row [asset] as [person] would through the sync engine.
|
||||
Future<void> _deleteAsset(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
UuidValue asset,
|
||||
) => _syncDatabases[session]!.transactionForUser(
|
||||
person,
|
||||
(transaction) => Asset.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.equals(asset),
|
||||
transaction: transaction,
|
||||
),
|
||||
);
|
||||
|
||||
/// Uploads [bytes] as the upload [description] says, standing in for the
|
||||
/// app's HTTP upload: the server's upload route stores the file unverified at
|
||||
/// the path the description names, if its key is the one issued.
|
||||
Future<void> _upload(
|
||||
Session session,
|
||||
String description,
|
||||
List<int> bytes,
|
||||
) async {
|
||||
final url = Uri.parse(
|
||||
(jsonDecode(description) as Map<String, Object?>)['url']! as String,
|
||||
);
|
||||
final storageId = url.queryParameters['storage']!;
|
||||
final path = url.queryParameters['path']!;
|
||||
final issued = await session.db.unsafeQuery(
|
||||
'SELECT 1 FROM "serverpod_cloud_storage_direct_upload" '
|
||||
'WHERE "storageId" = @storage AND "path" = @path AND "authKey" = @key',
|
||||
parameters: QueryParameters.named({
|
||||
'storage': storageId,
|
||||
'path': path,
|
||||
'key': url.queryParameters['key'],
|
||||
}),
|
||||
);
|
||||
expect(issued, isNotEmpty, reason: 'the upload was authorized');
|
||||
final storage =
|
||||
Serverpod.instance.storage[storageId]! as DatabaseCloudStorage;
|
||||
await storage.storeUnverifiedFile(
|
||||
session: session,
|
||||
path: path,
|
||||
byteData: ByteData.sublistView(Uint8List.fromList(bytes)),
|
||||
);
|
||||
}
|
||||
|
||||
/// What the stored files of everyone take, in bytes.
|
||||
Future<int> _instanceBytes(Session session) async =>
|
||||
(await session.db.unsafeQuery(
|
||||
'SELECT COALESCE(SUM(octet_length("byteData")), 0)::bigint '
|
||||
'FROM "serverpod_cloud_storage" WHERE "path" LIKE \'spaces/%\'',
|
||||
)).first.first!
|
||||
as int;
|
||||
|
||||
/// Makes the stored file with [hash] of [space] last claimed [at].
|
||||
Future<void> _claimed(
|
||||
Session session,
|
||||
UuidValue space,
|
||||
String hash,
|
||||
DateTime at,
|
||||
) => CloudStorageEntry.db.updateWhere(
|
||||
session,
|
||||
columnValues: (t) => [t.addedTime(at)],
|
||||
where: (t) =>
|
||||
t.storageId.equals(assetStorage) & t.path.equals(assetPath(space, hash)),
|
||||
);
|
||||
|
|
@ -0,0 +1,949 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/assistant_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/connector_http.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/connector_mcp.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/connector_oauth.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.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';
|
||||
|
||||
/// Where Claude's custom connectors send people back to.
|
||||
const _callback = 'https://claude.ai/api/mcp/auth_callback';
|
||||
|
||||
/// A PKCE verifier, and its S256 challenge.
|
||||
const _verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||
final _challenge = base64UrlEncode(
|
||||
sha256.convert(ascii.encode(_verifier)).bytes,
|
||||
).replaceAll('=', '');
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'The assistant connector',
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late UuidValue alice;
|
||||
late UuidValue bob;
|
||||
final connector = Connector(
|
||||
base: 'https://learn.example.org',
|
||||
instance: 'learn.example.org',
|
||||
);
|
||||
|
||||
/// The people a test made, whose spaces it deletes again.
|
||||
final people = <UuidValue>{};
|
||||
|
||||
setUp(() {
|
||||
// The test pod is built without the generated Serverpod class, which
|
||||
// sets up sync for the running server.
|
||||
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
|
||||
useLearnBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
alice = const Uuid().v7obj();
|
||||
bob = const Uuid().v7obj();
|
||||
people.addAll([alice, bob]);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
// The clients cascade into connections, grants and tokens.
|
||||
await AssistantClient.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => Constant.bool(true),
|
||||
);
|
||||
await AssistantConnectCode.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet(people),
|
||||
);
|
||||
for (final person in people) {
|
||||
await session.db.unsafeExecute(
|
||||
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
|
||||
parameters: QueryParameters.named({'space': person.uuid}),
|
||||
);
|
||||
}
|
||||
people.clear();
|
||||
});
|
||||
|
||||
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
/// Registers a client as Claude does; its client_id.
|
||||
Future<String> register({String method = 'none'}) async {
|
||||
final response = await connector.register(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'client_name': 'Claude',
|
||||
'redirect_uris': [_callback],
|
||||
'grant_types': ['authorization_code', 'refresh_token'],
|
||||
'response_types': ['code'],
|
||||
'token_endpoint_auth_method': method,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(response.status, 201, reason: response.body);
|
||||
return (jsonDecode(response.body) as Map)['client_id'] as String;
|
||||
}
|
||||
|
||||
Map<String, String> request(String clientId) => {
|
||||
'response_type': 'code',
|
||||
'client_id': clientId,
|
||||
'redirect_uri': _callback,
|
||||
'state': 'xyz',
|
||||
'code_challenge': _challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'scope': readScope,
|
||||
'resource': connector.resource,
|
||||
};
|
||||
|
||||
/// Types [code] on the authorization page of [clientId].
|
||||
Future<ConnectorResponse> typeCode(
|
||||
String clientId,
|
||||
String code, {
|
||||
Map<String, String>? fields,
|
||||
}) => connector.authorize(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: Uri(
|
||||
queryParameters: {
|
||||
...fields ?? request(clientId),
|
||||
'connect_code': code,
|
||||
},
|
||||
).query,
|
||||
),
|
||||
);
|
||||
|
||||
/// Connects [person]'s account to [clientId] with a code from their
|
||||
/// app; the authorization code the client gets back.
|
||||
Future<String> connect(UuidValue person, String clientId) async {
|
||||
final code = await endpoints.assistant.connectCode(signedIn(person));
|
||||
final response = await typeCode(clientId, code.code);
|
||||
expect(response.status, 302, reason: response.body);
|
||||
return Uri.parse(
|
||||
response.headers['Location']!,
|
||||
).queryParameters['code']!;
|
||||
}
|
||||
|
||||
Future<ConnectorResponse> exchange(
|
||||
Map<String, String> form, {
|
||||
Map<String, String> headers = const {},
|
||||
}) => connector.token(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...headers,
|
||||
},
|
||||
body: Uri(queryParameters: form).query,
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, String> codeForm(String clientId, String code) => {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': _callback,
|
||||
'client_id': clientId,
|
||||
'code_verifier': _verifier,
|
||||
'resource': connector.resource,
|
||||
};
|
||||
|
||||
/// The tokens [person] gives a newly registered client.
|
||||
Future<Map<String, Object?>> tokensOf(
|
||||
UuidValue person, {
|
||||
String? clientId,
|
||||
}) async {
|
||||
final client = clientId ?? await register();
|
||||
final response = await exchange(
|
||||
codeForm(client, await connect(person, client)),
|
||||
);
|
||||
expect(response.status, 200, reason: response.body);
|
||||
return jsonDecode(response.body) as Map<String, Object?>;
|
||||
}
|
||||
|
||||
Future<ConnectorResponse> mcp(
|
||||
Object? token,
|
||||
Map<String, Object?> message, {
|
||||
String method = 'POST',
|
||||
Map<String, String> headers = const {},
|
||||
}) => answerMcpRequest(
|
||||
session,
|
||||
connectorRequest(
|
||||
method,
|
||||
headers: {
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: jsonEncode(message),
|
||||
),
|
||||
connector,
|
||||
);
|
||||
|
||||
/// The text tool [name] answers with [arguments], for [token].
|
||||
Future<String> call(
|
||||
Object? token,
|
||||
String name, [
|
||||
Map<String, Object?> arguments = const {},
|
||||
]) async {
|
||||
final response = await mcp(token, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'tools/call',
|
||||
'params': {'name': name, 'arguments': arguments},
|
||||
});
|
||||
expect(response.status, 200, reason: response.body);
|
||||
final result = (jsonDecode(response.body) as Map)['result'] as Map;
|
||||
final text = ((result['content'] as List).single as Map)['text'];
|
||||
return result['isError'] == true ? 'ERROR: $text' : text as String;
|
||||
}
|
||||
|
||||
group('Metadata', () {
|
||||
test('names the MCP endpoint and the instance as its authorization '
|
||||
'server', () {
|
||||
final resource = jsonDecode(connector.resourceMetadata().body) as Map;
|
||||
final server = jsonDecode(connector.serverMetadata().body) as Map;
|
||||
|
||||
expect(resource['resource'], 'https://learn.example.org/mcp');
|
||||
expect(resource['authorization_servers'], [
|
||||
'https://learn.example.org',
|
||||
]);
|
||||
expect(server['issuer'], 'https://learn.example.org');
|
||||
expect(server['code_challenge_methods_supported'], ['S256']);
|
||||
expect(
|
||||
server['authorization_response_iss_parameter_supported'],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('the instance says where its connector is', () async {
|
||||
final info = await endpoints.instance.info(sessionBuilder);
|
||||
|
||||
expect(info.assistantConnectorUrl, endsWith('/mcp'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Registering', () {
|
||||
test('keeps the name and the redirect URIs, and gives a public '
|
||||
'client no secret', () async {
|
||||
final id = await register();
|
||||
|
||||
final client = await AssistantClient.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.clientId.equals(id),
|
||||
);
|
||||
expect(client!.name, 'Claude');
|
||||
expect(client.redirectUris, [_callback]);
|
||||
expect(client.secretHash, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'refuses plain http but on localhost, and no redirect URIs',
|
||||
() async {
|
||||
Future<int> status(Object? redirects) async =>
|
||||
(await connector.register(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
body: jsonEncode({'redirect_uris': redirects}),
|
||||
),
|
||||
)).status;
|
||||
|
||||
expect(await status(['http://claude.ai/callback']), 400);
|
||||
expect(await status(['https://claude.ai/a#b']), 400);
|
||||
expect(await status(<String>[]), 400);
|
||||
expect(await status(null), 400);
|
||||
expect(await status(['http://localhost:4567/callback']), 201);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('at most 200 clients register in an hour', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
await AssistantClient.db.insert(session, [
|
||||
for (var i = 0; i < maxRegistrationsPerHour; i++)
|
||||
AssistantClient(
|
||||
clientId: 'flood-$i',
|
||||
name: 'Flood',
|
||||
redirectUris: [_callback],
|
||||
createdAt: now,
|
||||
),
|
||||
]);
|
||||
|
||||
final response = await connector.register(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
body: jsonEncode({
|
||||
'redirect_uris': [_callback],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status, 429);
|
||||
});
|
||||
|
||||
group('Single use, also when requests race', () {
|
||||
test('a connect code', () async {
|
||||
final client = await register();
|
||||
final code = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
|
||||
final answers = await Future.wait([
|
||||
typeCode(client, code.code),
|
||||
typeCode(client, code.code),
|
||||
]);
|
||||
|
||||
expect([for (final answer in answers) answer.status]..sort(), [
|
||||
200,
|
||||
302,
|
||||
]);
|
||||
expect(await AssistantGrant.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('an authorization code', () async {
|
||||
final client = await register();
|
||||
final code = await connect(alice, client);
|
||||
|
||||
final answers = await Future.wait([
|
||||
exchange(codeForm(client, code)),
|
||||
exchange(codeForm(client, code)),
|
||||
]);
|
||||
|
||||
expect([for (final answer in answers) answer.status]..sort(), [
|
||||
200,
|
||||
400,
|
||||
]);
|
||||
expect(await AssistantToken.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('a refresh token', () async {
|
||||
final client = await register();
|
||||
final tokens = await tokensOf(alice, clientId: client);
|
||||
final refresh = {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': '${tokens['refresh_token']}',
|
||||
'client_id': client,
|
||||
};
|
||||
|
||||
final answers = await Future.wait([
|
||||
exchange(refresh),
|
||||
exchange(refresh),
|
||||
]);
|
||||
|
||||
expect([for (final answer in answers) answer.status]..sort(), [
|
||||
200,
|
||||
400,
|
||||
]);
|
||||
expect(await AssistantToken.db.count(session), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('Connecting by code', () {
|
||||
test('a code needs a signed-in person', () async {
|
||||
await expectLater(
|
||||
endpoints.assistant.connectCode(sessionBuilder),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a code is eight characters, kept only as a hash, and a new one '
|
||||
'replaces the last', () async {
|
||||
final first = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
final second = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
|
||||
expect(second.code, matches(RegExp(r'^[0-9A-Z]{4}-[0-9A-Z]{4}$')));
|
||||
final kept = await AssistantConnectCode.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(alice),
|
||||
);
|
||||
expect(kept, hasLength(1));
|
||||
expect(kept.single.codeHash, isNot(contains(second.code)));
|
||||
final client = await register();
|
||||
expect((await typeCode(client, first.code)).status, 200);
|
||||
expect((await typeCode(client, second.code)).status, 302);
|
||||
});
|
||||
|
||||
test('the page names the client and where it returns to, and may not '
|
||||
'be framed', () async {
|
||||
final client = await register();
|
||||
|
||||
final page = await connector.authorize(
|
||||
session,
|
||||
connectorRequest('GET', query: request(client)),
|
||||
);
|
||||
|
||||
expect(page.status, 200);
|
||||
expect(page.body, contains('Connect Claude to Halkyon Learn'));
|
||||
expect(page.body, contains('<strong>claude.ai</strong>'));
|
||||
expect(
|
||||
page.headers['Content-Security-Policy'],
|
||||
contains("frame-ancestors 'none'"),
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown client or a redirect URI it didn't register gets a "
|
||||
'page, never a redirect', () async {
|
||||
final client = await register();
|
||||
|
||||
final unknown = await connector.authorize(
|
||||
session,
|
||||
connectorRequest('GET', query: request('nobody')),
|
||||
);
|
||||
final elsewhere = await connector.authorize(
|
||||
session,
|
||||
connectorRequest(
|
||||
'GET',
|
||||
query: {
|
||||
...request(client),
|
||||
'redirect_uri': 'https://evil.example/',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
for (final response in [unknown, elsewhere]) {
|
||||
expect(response.status, 400);
|
||||
expect(response.headers, isNot(contains('Location')));
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'a wrong code connects nothing; the right one, typed loosely, '
|
||||
'connects and sends the person back with a code and the issuer',
|
||||
() async {
|
||||
final client = await register();
|
||||
final code = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
|
||||
final wrong = await typeCode(client, 'AAAA-AAAA');
|
||||
expect(wrong.status, 200);
|
||||
expect(wrong.body, contains("That code doesn't work"));
|
||||
expect(await AssistantConnection.db.count(session), 0);
|
||||
|
||||
final right = await typeCode(
|
||||
client,
|
||||
code.code.toLowerCase().replaceAll('-', ' '),
|
||||
);
|
||||
expect(right.status, 302);
|
||||
final back = Uri.parse(right.headers['Location']!);
|
||||
expect('${back.replace(query: '')}', '$_callback?');
|
||||
expect(back.queryParameters['code'], isNotEmpty);
|
||||
expect(back.queryParameters['state'], 'xyz');
|
||||
expect(back.queryParameters['iss'], 'https://learn.example.org');
|
||||
final connections = await endpoints.assistant.connections(
|
||||
signedIn(alice),
|
||||
);
|
||||
expect(connections.single.name, 'Claude');
|
||||
expect(connections.single.host, 'claude.ai');
|
||||
},
|
||||
);
|
||||
|
||||
test('a code works once, and for ten minutes', () async {
|
||||
final client = await register();
|
||||
final used = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
expect((await typeCode(client, used.code)).status, 302);
|
||||
expect((await typeCode(client, used.code)).status, 200);
|
||||
|
||||
final old = await endpoints.assistant.connectCode(signedIn(bob));
|
||||
final later = await withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
||||
() => typeCode(client, old.code),
|
||||
);
|
||||
expect(later.status, 200);
|
||||
});
|
||||
|
||||
test('without PKCE, or for another resource, the client gets an '
|
||||
'error back', () async {
|
||||
final client = await register();
|
||||
final code = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
|
||||
final plain = await typeCode(
|
||||
client,
|
||||
code.code,
|
||||
fields: {...request(client), 'code_challenge_method': 'plain'},
|
||||
);
|
||||
final other = await typeCode(
|
||||
client,
|
||||
code.code,
|
||||
fields: {...request(client), 'resource': 'https://other.example/'},
|
||||
);
|
||||
|
||||
expect(
|
||||
Uri.parse(plain.headers['Location']!).queryParameters['error'],
|
||||
'invalid_request',
|
||||
);
|
||||
expect(
|
||||
Uri.parse(other.headers['Location']!).queryParameters['error'],
|
||||
'invalid_target',
|
||||
);
|
||||
expect(await AssistantConnection.db.count(session), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('Tokens', () {
|
||||
test('a code and its verifier give bearer tokens, kept only as '
|
||||
'hashes', () async {
|
||||
final tokens = await tokensOf(alice);
|
||||
|
||||
expect(tokens['token_type'], 'Bearer');
|
||||
expect(tokens['expires_in'], 3600);
|
||||
expect(tokens['scope'], readScope);
|
||||
final kept = await AssistantToken.db.find(session);
|
||||
expect(kept, hasLength(1));
|
||||
expect(kept.single.accessHash, isNot(tokens['access_token']));
|
||||
expect(kept.single.resource, connector.resource);
|
||||
});
|
||||
|
||||
test('a wrong verifier spends the code', () async {
|
||||
final client = await register();
|
||||
final code = await connect(alice, client);
|
||||
|
||||
final wrong = await exchange({
|
||||
...codeForm(client, code),
|
||||
'code_verifier': 'x' * 43,
|
||||
});
|
||||
final again = await exchange(codeForm(client, code));
|
||||
|
||||
expect(wrong.status, 400);
|
||||
expect((jsonDecode(wrong.body) as Map)['error'], 'invalid_grant');
|
||||
expect(again.status, 400);
|
||||
});
|
||||
|
||||
test("another client can't use the code", () async {
|
||||
final client = await register();
|
||||
final other = await register();
|
||||
final code = await connect(alice, client);
|
||||
|
||||
final response = await exchange(codeForm(other, code));
|
||||
|
||||
expect(response.status, 400);
|
||||
});
|
||||
|
||||
test('a refresh gives a new pair and spends the old refresh '
|
||||
'token', () async {
|
||||
final client = await register();
|
||||
final first = await tokensOf(alice, clientId: client);
|
||||
final refresh = {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': '${first['refresh_token']}',
|
||||
'client_id': client,
|
||||
};
|
||||
|
||||
final renewed = await exchange(refresh);
|
||||
final reused = await exchange(refresh);
|
||||
|
||||
expect(renewed.status, 200);
|
||||
final second = jsonDecode(renewed.body) as Map;
|
||||
expect(second['access_token'], isNot(first['access_token']));
|
||||
expect(reused.status, 400);
|
||||
expect(await call(second['access_token'], 'list_studysets'), '[]');
|
||||
expect(
|
||||
(await mcp(first['access_token'], {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'ping',
|
||||
})).status,
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
test('a client registered with a secret needs it, in the form or '
|
||||
'as Basic authorization', () async {
|
||||
final response = await connector.register(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
body: jsonEncode({
|
||||
'client_name': 'Claude',
|
||||
'redirect_uris': [_callback],
|
||||
'token_endpoint_auth_method': 'client_secret_basic',
|
||||
}),
|
||||
),
|
||||
);
|
||||
final registered = jsonDecode(response.body) as Map;
|
||||
final id = registered['client_id'] as String;
|
||||
final secret = registered['client_secret'] as String;
|
||||
|
||||
final without = await exchange(
|
||||
codeForm(id, await connect(alice, id)),
|
||||
);
|
||||
final basic = await exchange(
|
||||
codeForm(id, await connect(alice, id)),
|
||||
headers: {
|
||||
'Authorization':
|
||||
'Basic ${base64.encode(utf8.encode('$id:$secret'))}',
|
||||
},
|
||||
);
|
||||
|
||||
expect(without.status, 401);
|
||||
expect(basic.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
group('The MCP endpoint', () {
|
||||
/// 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);
|
||||
|
||||
/// Gives [person] a studyset [title] holding cards with [fronts],
|
||||
/// each card's back the front's English, a note, and a review of
|
||||
/// the first card, deleted at [deletedAt] if given; its id.
|
||||
Future<UuidValue> studyset(
|
||||
UuidValue person,
|
||||
String title, {
|
||||
List<(String, String)> cards = const [],
|
||||
UuidValue? parent,
|
||||
DateTime? deletedAt,
|
||||
}) => asPerson(person, (transaction) async {
|
||||
final id = (await Studyset.db.insertRow(
|
||||
session,
|
||||
Studyset(
|
||||
parentId: parent,
|
||||
deletedAt: deletedAt,
|
||||
payload: _json({
|
||||
'schemaVersion': 4,
|
||||
'title': title,
|
||||
'description': _rich('About $title'),
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
var position = 0;
|
||||
for (final (front, back) in cards) {
|
||||
final card = (await Card.db.insertRow(
|
||||
session,
|
||||
Card(studysetId: id, position: 'a${position++}'),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
await CardRevision.db.insertRow(
|
||||
session,
|
||||
CardRevision(
|
||||
cardId: card,
|
||||
parents: [],
|
||||
authorId: person,
|
||||
payload: _json({
|
||||
'schemaVersion': 9,
|
||||
'type': 'frontBack',
|
||||
'front': _rich(front),
|
||||
'back': _rich(back),
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
if (position == 1) {
|
||||
await ReviewLog.db.insertRow(
|
||||
session,
|
||||
ReviewLog(
|
||||
cardId: card,
|
||||
rating: 3,
|
||||
reviewedAt: DateTime.now().toUtc(),
|
||||
durationMs: 2000,
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
final note = (await Note.db.insertRow(
|
||||
session,
|
||||
Note(studysetId: id, position: 'a0'),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
await NoteRevision.db.insertRow(
|
||||
session,
|
||||
NoteRevision(
|
||||
noteId: note,
|
||||
parents: [],
|
||||
authorId: person,
|
||||
payload: _json({
|
||||
'schemaVersion': 1,
|
||||
'title': 'Particles',
|
||||
'body': _rich('は marks the topic.'),
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
return id;
|
||||
});
|
||||
|
||||
test('without a valid token, says where to get one', () async {
|
||||
final response = await mcp('nonsense', {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'ping',
|
||||
});
|
||||
|
||||
expect(response.status, 401);
|
||||
expect(
|
||||
response.headers['WWW-Authenticate'],
|
||||
contains(
|
||||
'resource_metadata="https://learn.example.org/.well-known/'
|
||||
'oauth-protected-resource"',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('initializes, takes notifications, and lists five tools, all '
|
||||
'only reading', () async {
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
|
||||
final initialized = await mcp(token, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'initialize',
|
||||
'params': {'protocolVersion': '2025-06-18'},
|
||||
});
|
||||
final notified = await mcp(token, {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'notifications/initialized',
|
||||
});
|
||||
final listed = await mcp(token, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 2,
|
||||
'method': 'tools/list',
|
||||
});
|
||||
|
||||
expect(initialized.status, 200);
|
||||
expect(notified.status, 202);
|
||||
final tools =
|
||||
((jsonDecode(listed.body) as Map)['result'] as Map)['tools']
|
||||
as List;
|
||||
expect(
|
||||
[for (final tool in tools) (tool as Map)['name']],
|
||||
[
|
||||
'list_studysets',
|
||||
'read_studyset',
|
||||
'search_cards',
|
||||
'read_note',
|
||||
'study_progress',
|
||||
],
|
||||
);
|
||||
for (final tool in tools) {
|
||||
expect(
|
||||
(tool as Map)['annotations'],
|
||||
containsPair('readOnlyHint', true),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('leaves out a studyset waiting in Recently deleted, and what '
|
||||
'is inside it', () async {
|
||||
final japanese = await studyset(
|
||||
alice,
|
||||
'Japanese',
|
||||
cards: [('火', 'fire')],
|
||||
deletedAt: DateTime.utc(2026, 10, 12),
|
||||
);
|
||||
await studyset(alice, 'Kanji', parent: japanese);
|
||||
await studyset(alice, 'Greek');
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
|
||||
final listed =
|
||||
jsonDecode(await call(token, 'list_studysets')) as List;
|
||||
expect([for (final s in listed) (s as Map)['title']], ['Greek']);
|
||||
final found = jsonDecode(
|
||||
await call(token, 'search_cards', {'query': 'fire'}),
|
||||
) as List;
|
||||
expect(found, isEmpty);
|
||||
});
|
||||
|
||||
test("reads the person's studysets, cards, notes and progress, and "
|
||||
"none of anyone else's", () async {
|
||||
final japanese = await studyset(
|
||||
alice,
|
||||
'Japanese',
|
||||
cards: [('火', 'fire'), ('水', 'water')],
|
||||
);
|
||||
await studyset(alice, 'Kanji', parent: japanese);
|
||||
final secret = await studyset(bob, "Bob's diary");
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
|
||||
final listed =
|
||||
jsonDecode(await call(token, 'list_studysets')) as List;
|
||||
expect(
|
||||
[for (final s in listed) (s as Map)['title']],
|
||||
[
|
||||
'Japanese',
|
||||
'Kanji',
|
||||
],
|
||||
);
|
||||
expect((listed.first as Map)['cards'], 2);
|
||||
expect((listed.last as Map)['inside'], 'Japanese');
|
||||
|
||||
final read = jsonDecode(
|
||||
await call(token, 'read_studyset', {
|
||||
'studyset_id': japanese.uuid,
|
||||
}),
|
||||
) as Map;
|
||||
expect(read['description'], 'About Japanese');
|
||||
expect(
|
||||
[for (final c in read['cards'] as List) (c as Map)['back']],
|
||||
[
|
||||
'fire',
|
||||
'water',
|
||||
],
|
||||
);
|
||||
final note = (read['notes'] as List).single as Map;
|
||||
expect(note['title'], 'Particles');
|
||||
|
||||
final found = jsonDecode(
|
||||
await call(token, 'search_cards', {'query': 'WAT'}),
|
||||
) as List;
|
||||
expect((found.single as Map)['front'], '水');
|
||||
|
||||
final text = jsonDecode(
|
||||
await call(token, 'read_note', {'note_id': note['id']}),
|
||||
) as Map;
|
||||
expect(text['text'], 'は marks the topic.');
|
||||
|
||||
final progress = jsonDecode(
|
||||
await call(token, 'study_progress', {
|
||||
'studyset_id': japanese.uuid,
|
||||
}),
|
||||
) as Map;
|
||||
expect(progress['cards'], 2);
|
||||
expect(progress['cards_studied'], 1);
|
||||
expect(progress['reviews_last_7_days'], 1);
|
||||
|
||||
expect(
|
||||
await call(token, 'read_studyset', {'studyset_id': secret.uuid}),
|
||||
startsWith('ERROR: No studyset'),
|
||||
);
|
||||
});
|
||||
|
||||
test('disconnecting ends the tokens at once', () async {
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
final connection = (await endpoints.assistant.connections(
|
||||
signedIn(alice),
|
||||
)).single;
|
||||
|
||||
await expectLater(
|
||||
endpoints.assistant.disconnect(signedIn(bob), connection.id),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
await endpoints.assistant.disconnect(signedIn(alice), connection.id);
|
||||
|
||||
expect(
|
||||
(await mcp(token, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'ping',
|
||||
})).status,
|
||||
401,
|
||||
);
|
||||
expect(await AssistantToken.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('an access token works for an hour', () async {
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
|
||||
final later = await withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 61))),
|
||||
() => mcp(token, {'jsonrpc': '2.0', 'id': 1, 'method': 'ping'}),
|
||||
);
|
||||
|
||||
expect(later.status, 401);
|
||||
});
|
||||
|
||||
test('takes only POST, and no page from a plain-http origin', () async {
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
|
||||
final get = await mcp(token, {}, method: 'GET');
|
||||
final origin = await mcp(
|
||||
token,
|
||||
{'jsonrpc': '2.0', 'id': 1, 'method': 'ping'},
|
||||
headers: {'Origin': 'http://evil.example'},
|
||||
);
|
||||
|
||||
expect(get.status, 405);
|
||||
expect(get.headers['Allow'], 'POST');
|
||||
expect(origin.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
test('pruning forgets expired codes and tokens, and clients nobody '
|
||||
'connected within a week', () async {
|
||||
final connected = await register();
|
||||
await tokensOf(alice, clientId: connected);
|
||||
await register();
|
||||
await endpoints.assistant.connectCode(signedIn(bob));
|
||||
|
||||
final forgotten = await forgetExpiredConnectorSecrets(
|
||||
session,
|
||||
now: DateTime.now().toUtc().add(const Duration(days: 31)),
|
||||
);
|
||||
|
||||
expect(forgotten, 3);
|
||||
expect(await AssistantClient.db.count(session), 1);
|
||||
expect(await AssistantConnection.db.count(session), 1);
|
||||
expect(await AssistantConnectCode.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('deleting the account deletes its connections and codes', () async {
|
||||
useLearnBase(sessionBuilder);
|
||||
final carol = (await AuthServices.instance.authUsers.create(session))
|
||||
.id;
|
||||
people.add(carol);
|
||||
await tokensOf(carol);
|
||||
await endpoints.assistant.connectCode(signedIn(carol));
|
||||
|
||||
await deletePerson(session, carol);
|
||||
|
||||
expect(await AssistantConnection.db.count(session), 0);
|
||||
expect(await AssistantToken.db.count(session), 0);
|
||||
expect(await AssistantConnectCode.db.count(session), 0);
|
||||
});
|
||||
},
|
||||
// The sync engine's database, as the running server has it (see
|
||||
// publish_test.dart): 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.
|
||||
final _syncDatabases = Expando<OfflineSyncDatabase>();
|
||||
|
||||
/// [json] as a synced payload.
|
||||
ByteData _json(Map<String, Object?> json) =>
|
||||
ByteData.sublistView(utf8.encode(jsonEncode(json)));
|
||||
|
||||
/// Rich content holding one paragraph of [text], as the app stores it.
|
||||
Map<String, Object?> _rich(String text) => {
|
||||
'formatVersion': 1,
|
||||
'editor': {
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/auth_fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
// The account endpoint, its refusals and the order of a deletion are
|
||||
// halkyon_base's, tested there (ADR 0014); here, that Learn's own data of a
|
||||
// person goes with them, through the hook Learn configures, and nobody
|
||||
// else's.
|
||||
withServerpod('Deleting an account', (sessionBuilder, _) {
|
||||
late Session session;
|
||||
late UuidValue anna;
|
||||
late UuidValue annasStudyset;
|
||||
late UuidValue ben;
|
||||
late UuidValue bensStudyset;
|
||||
|
||||
setUp(() async {
|
||||
useLearnBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
anna = await _newAnonymousPerson(session);
|
||||
await signInNewDevice(session, anna);
|
||||
annasStudyset = await _studysetOf(session, anna);
|
||||
ben = await _newAnonymousPerson(session);
|
||||
await signInNewDevice(session, ben);
|
||||
bensStudyset = await _studysetOf(session, ben);
|
||||
});
|
||||
|
||||
test('deletes the person with everything they have, and nobody '
|
||||
"else's", () async {
|
||||
final annasFile = await _fileOf(session, anna);
|
||||
final bensFile = await _fileOf(session, ben);
|
||||
await FileAllowance.db.insert(session, [
|
||||
FileAllowance(authUserId: anna, maxBytes: 1),
|
||||
FileAllowance(authUserId: ben, maxBytes: 1),
|
||||
]);
|
||||
|
||||
await deletePerson(session, anna);
|
||||
|
||||
expect(await AuthUser.db.findById(session, anna), isNull);
|
||||
expect(await _sessionsOf(session, anna), 0);
|
||||
expect(await _isAnonymous(session, anna), isFalse);
|
||||
expect(await _studysetExists(session, annasStudyset), isFalse);
|
||||
expect(await _fileExists(session, annasFile), isFalse);
|
||||
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
expect(await _sessionsOf(session, ben), 1);
|
||||
expect(await _studysetExists(session, bensStudyset), isTrue);
|
||||
expect(await _fileExists(session, bensFile), isTrue);
|
||||
expect(
|
||||
[
|
||||
for (final allowance in await FileAllowance.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet({anna, ben}),
|
||||
))
|
||||
allowance.authUserId,
|
||||
],
|
||||
[ben],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// halkyon_base's pruning, with Learn's deletion as it runs on the server.
|
||||
withServerpod('Pruning abandoned anonymous people', (sessionBuilder, _) {
|
||||
late Session session;
|
||||
final now = DateTime.now().toUtc();
|
||||
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
|
||||
|
||||
setUp(() {
|
||||
useLearnBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
});
|
||||
|
||||
test(
|
||||
'deletes anonymous people unused for 90 days, and only them',
|
||||
() async {
|
||||
final unused = await _newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
await _lastUsed(session, unused, daysAgo(95));
|
||||
final neverSignedIn = await _newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
final recentlyUsed = await _newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
await _lastUsed(session, recentlyUsed, daysAgo(10));
|
||||
final newcomer = await _newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(5),
|
||||
);
|
||||
final identifiedPerson = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
await _lastUsed(session, identifiedPerson, daysAgo(365));
|
||||
|
||||
final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
|
||||
|
||||
expect(pruned, 2);
|
||||
expect(await AuthUser.db.findById(session, unused), isNull);
|
||||
expect(await AuthUser.db.findById(session, neverSignedIn), isNull);
|
||||
for (final kept in [recentlyUsed, newcomer, identifiedPerson]) {
|
||||
expect(await AuthUser.db.findById(session, kept), isNotNull);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A person as the app's first launch creates them: anonymous.
|
||||
Future<UuidValue> _newAnonymousPerson(
|
||||
Session session, {
|
||||
DateTime? createdAt,
|
||||
}) async {
|
||||
final person = (await AuthServices.instance.authUsers.create(session)).id;
|
||||
await AnonymousAccount.db.insertRow(
|
||||
session,
|
||||
AnonymousAccount(authUserId: person, createdAt: createdAt),
|
||||
);
|
||||
return person;
|
||||
}
|
||||
|
||||
/// Signs [person] in on a device last used at [at].
|
||||
Future<void> _lastUsed(Session session, UuidValue person, DateTime at) async {
|
||||
final device = await signInNewDevice(session, person);
|
||||
final row = await ServerSideSession.db.findById(session, device);
|
||||
await ServerSideSession.db.updateRow(session, row!.copyWith(lastUsedAt: at));
|
||||
}
|
||||
|
||||
/// A studyset in [person]'s personal space, written the way the sync engine
|
||||
/// stores it; returns its id.
|
||||
Future<UuidValue> _studysetOf(Session session, UuidValue person) async {
|
||||
// INSERT INTO "offline_sync_spaces" ("uuidSpaceId") VALUES ($1) RETURNING "id"
|
||||
final space = await session.db.unsafeQuery(
|
||||
'INSERT INTO "offline_sync_spaces" ("uuidSpaceId") '
|
||||
'VALUES (@person::uuid) RETURNING "id"',
|
||||
parameters: QueryParameters.named({'person': person.uuid}),
|
||||
);
|
||||
// INSERT INTO "studyset" ("spaceId", "payload") VALUES ($1, $2) RETURNING "id"
|
||||
final studyset = await session.db.unsafeQuery(
|
||||
'INSERT INTO "studyset" ("spaceId", "payload") '
|
||||
r"VALUES (@space, '\x7b7d'::bytea) RETURNING "
|
||||
'"id"',
|
||||
parameters: QueryParameters.named({'space': space.first.first}),
|
||||
);
|
||||
return UuidValue.withValidation(studyset.first.first.toString());
|
||||
}
|
||||
|
||||
/// A file stored in [person]'s space, as an upload leaves it; returns its
|
||||
/// path.
|
||||
Future<String> _fileOf(Session session, UuidValue person) async {
|
||||
final path = assetPath(person, '0' * 64);
|
||||
await session.storage.storeFile(
|
||||
storageId: assetStorage,
|
||||
path: path,
|
||||
byteData: ByteData(1),
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
Future<bool> _fileExists(Session session, String path) =>
|
||||
session.storage.fileExists(storageId: assetStorage, path: path);
|
||||
|
||||
Future<bool> _studysetExists(Session session, UuidValue id) async =>
|
||||
(await session.db.unsafeQuery(
|
||||
'SELECT 1 FROM "studyset" WHERE "id" = @id::uuid',
|
||||
parameters: QueryParameters.named({'id': id.uuid}),
|
||||
)).isNotEmpty;
|
||||
|
||||
Future<int> _sessionsOf(Session session, UuidValue person) =>
|
||||
ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
);
|
||||
|
||||
Future<bool> _isAnonymous(Session session, UuidValue person) async =>
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
) >
|
||||
0;
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/auth_fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod('Your devices', (sessionBuilder, endpoints) {
|
||||
late UuidValue alice;
|
||||
late UuidValue aliceLaptop;
|
||||
late UuidValue alicePhone;
|
||||
late UuidValue bob;
|
||||
late UuidValue bobPhone;
|
||||
|
||||
setUp(() async {
|
||||
useLearnBase(sessionBuilder);
|
||||
final session = sessionBuilder.build();
|
||||
final authUsers = AuthServices.instance.authUsers;
|
||||
alice = (await authUsers.create(session, scopes: {identified})).id;
|
||||
bob = (await authUsers.create(session, scopes: {identified})).id;
|
||||
aliceLaptop = await signInNewDevice(session, alice, scopes: {identified});
|
||||
alicePhone = await signInNewDevice(session, alice, scopes: {identified});
|
||||
bobPhone = await signInNewDevice(session, bob, scopes: {identified});
|
||||
});
|
||||
|
||||
/// A caller signed in as [person] on [device], identified unless [scopes]
|
||||
/// says otherwise.
|
||||
TestSessionBuilder signedIn(
|
||||
UuidValue person,
|
||||
UuidValue device, {
|
||||
Set<Scope>? scopes,
|
||||
}) => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
scopes ?? {identified},
|
||||
authId: device.uuid,
|
||||
),
|
||||
);
|
||||
|
||||
test('refuses anonymous people', () async {
|
||||
await expectLater(
|
||||
endpoints.devices.list(signedIn(alice, aliceLaptop, scopes: {})),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses callers who are not signed in', () async {
|
||||
await expectLater(
|
||||
endpoints.devices.list(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('lists only your own devices, and marks the one asking', () async {
|
||||
final devices = await endpoints.devices.list(
|
||||
signedIn(alice, aliceLaptop),
|
||||
);
|
||||
|
||||
expect(
|
||||
devices.map((device) => device.sessionId),
|
||||
unorderedEquals([aliceLaptop, alicePhone]),
|
||||
);
|
||||
expect(
|
||||
devices.singleWhere((device) => device.isThisDevice).sessionId,
|
||||
aliceLaptop,
|
||||
);
|
||||
});
|
||||
|
||||
test('signs out one of your devices', () async {
|
||||
await endpoints.devices.signOut(signedIn(alice, aliceLaptop), alicePhone);
|
||||
|
||||
final devices = await endpoints.devices.list(
|
||||
signedIn(alice, aliceLaptop),
|
||||
);
|
||||
expect(devices.map((device) => device.sessionId), [aliceLaptop]);
|
||||
});
|
||||
|
||||
test(
|
||||
"refuses to sign out someone else's device, exactly like a missing one",
|
||||
() async {
|
||||
final notFound = throwsA(
|
||||
isA<NotFoundException>().having((e) => e.what, 'what', 'device'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.devices.signOut(signedIn(alice, aliceLaptop), bobPhone),
|
||||
notFound,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.devices.signOut(
|
||||
signedIn(alice, aliceLaptop),
|
||||
const Uuid().v4obj(),
|
||||
),
|
||||
notFound,
|
||||
);
|
||||
|
||||
final bobsDevices = await endpoints.devices.list(
|
||||
signedIn(bob, bobPhone),
|
||||
);
|
||||
expect(bobsDevices.map((device) => device.sessionId), [bobPhone]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_learn_server/src/dictionary/device_dictionaries.dart';
|
||||
import 'package:halkyon_learn_server/src/dictionary/dictionary_loader.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
/// A dictionary file's header, as the curator writes it.
|
||||
Map<String, Object?> _header() => {
|
||||
'format': 'halkyon-dictionary',
|
||||
'formatVersion': 1,
|
||||
'source': 'jmdict',
|
||||
'kind': 'words',
|
||||
'sourceVersion': '3.6.2 (2026-10-05)',
|
||||
'language': 'ja',
|
||||
'glossLanguage': 'en',
|
||||
'license': 'CC-BY-SA-4.0',
|
||||
'credits': [
|
||||
{
|
||||
'name': 'JMdict',
|
||||
'by': 'EDRDG',
|
||||
'license': 'CC-BY-SA-4.0',
|
||||
'url': 'https://www.edrdg.org/edrdg/licence.html',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// An entry, as the curator writes it.
|
||||
Map<String, Object?> _entry(
|
||||
String id,
|
||||
String headword, {
|
||||
String? reading,
|
||||
List<String> labels = const [],
|
||||
required int rank,
|
||||
required List<String> keys,
|
||||
}) => {
|
||||
'id': id,
|
||||
'headword': headword,
|
||||
'reading': ?reading,
|
||||
'labels': labels,
|
||||
'rank': rank,
|
||||
'keys': keys,
|
||||
'entry': {
|
||||
'senses': [
|
||||
{'glosses': keys.last.split(', ')},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Dictionaries for devices',
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late Directory folder;
|
||||
late Directory exports;
|
||||
late DeviceDictionaries devices;
|
||||
|
||||
setUp(() async {
|
||||
session = sessionBuilder.build();
|
||||
folder = Directory.systemTemp.createTempSync('dictionaries');
|
||||
exports = Directory.systemTemp.createTempSync('exports');
|
||||
devices = DeviceDictionaries.of(session.serverpod)
|
||||
..dictionaries = folder
|
||||
..exports = exports
|
||||
..inSession = ((work) => work(session));
|
||||
final file = File('${folder.path}/jmdict-en.jsonl')
|
||||
..writeAsStringSync(
|
||||
[
|
||||
jsonEncode(_header()),
|
||||
jsonEncode(
|
||||
_entry(
|
||||
'1358280',
|
||||
'食べる',
|
||||
reading: 'たべる',
|
||||
labels: ['common', 'jlpt:n5'],
|
||||
rank: 100368,
|
||||
keys: ['食べる', 'たべる', 'taberu', 'to eat', 'eat'],
|
||||
),
|
||||
),
|
||||
jsonEncode(
|
||||
_entry(
|
||||
'1080510',
|
||||
'テレビ',
|
||||
labels: ['common', 'jlpt:n5'],
|
||||
rank: 100410,
|
||||
keys: ['テレビ', 'てれび', 'terebi', 'television', 'tv'],
|
||||
),
|
||||
),
|
||||
].join('\n'),
|
||||
);
|
||||
await loadDictionaryFile(session, file);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await DictionaryEntry.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await DictionaryKey.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await DictionarySource.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
folder.deleteSync(recursive: true);
|
||||
exports.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
final signedIn = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
/// The curator's IPADIC pack, in the dictionaries' folder.
|
||||
File ipadic() =>
|
||||
File('${folder.path}/mecab-ipadic.tar.gz')
|
||||
..writeAsBytesSync(gzip.encode(utf8.encode('IPADIC, packed')));
|
||||
|
||||
test('lists, for Japanese, IPADIC and JMdict as this instance holds it, '
|
||||
'each with where to get it, its hash and size, once both are '
|
||||
'ready', () async {
|
||||
final pack = ipadic();
|
||||
expect(
|
||||
await endpoints.dictionary.deviceFiles(signedIn, 'ja'),
|
||||
isEmpty,
|
||||
reason: 'the export is still being made',
|
||||
);
|
||||
|
||||
await devices.prepare(session);
|
||||
final files = await endpoints.dictionary.deviceFiles(signedIn, 'ja');
|
||||
|
||||
expect(
|
||||
[for (final file in files) file.name],
|
||||
[
|
||||
'mecab-ipadic',
|
||||
'jmdict-en',
|
||||
],
|
||||
);
|
||||
final [ipadicFile, jmdictFile] = files;
|
||||
expect(
|
||||
ipadicFile.sha256,
|
||||
sha256.convert(pack.readAsBytesSync()).toString(),
|
||||
);
|
||||
expect(ipadicFile.size, pack.lengthSync());
|
||||
expect(
|
||||
ipadicFile.url,
|
||||
endsWith(
|
||||
'/device-dictionaries/mecab-ipadic-'
|
||||
'${ipadicFile.sha256.substring(0, 16)}.tar.gz',
|
||||
),
|
||||
);
|
||||
final served = devices.servedFile(jmdictFile.url.split('/').last)!;
|
||||
expect(
|
||||
jmdictFile.sha256,
|
||||
sha256.convert(served.readAsBytesSync()).toString(),
|
||||
);
|
||||
});
|
||||
|
||||
test('the export holds every entry as the instance finds and versions '
|
||||
'it: its keys, labels, version and the entry as cards take it, '
|
||||
'after a header with the credits', () async {
|
||||
ipadic();
|
||||
await devices.prepare(session);
|
||||
final file = (await endpoints.dictionary.deviceFiles(
|
||||
signedIn,
|
||||
'ja',
|
||||
)).last;
|
||||
|
||||
final lines = utf8
|
||||
.decode(
|
||||
gzip.decode(
|
||||
devices.servedFile(file.url.split('/').last)!.readAsBytesSync(),
|
||||
),
|
||||
)
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => jsonDecode(line) as Map<String, Object?>)
|
||||
.toList();
|
||||
final [header, ...entries] = lines;
|
||||
expect(header['format'], deviceExportFormat);
|
||||
expect(header['source'], 'jmdict');
|
||||
expect(header['language'], 'ja');
|
||||
expect(
|
||||
(header['credits']! as List).single,
|
||||
containsPair('name', 'JMdict'),
|
||||
);
|
||||
expect(entries, hasLength(2));
|
||||
final taberu = entries.singleWhere((entry) => entry['id'] == '1358280');
|
||||
expect(taberu['headword'], '食べる');
|
||||
expect(taberu['reading'], 'たべる');
|
||||
expect(taberu['labels'], ['common', 'jlpt:n5']);
|
||||
expect(taberu['keys'], containsAll(['食べる', 'たべる']));
|
||||
expect(taberu['version'], matches(RegExp(r'^[0-9a-f]{16}$')));
|
||||
final stored = await DictionaryEntry.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.sourceId.equals('1358280'),
|
||||
);
|
||||
expect(taberu['version'], stored!.version);
|
||||
expect(taberu['entry'], stored.entry);
|
||||
});
|
||||
|
||||
test('lists nothing for other languages, nor without IPADIC, as one '
|
||||
'file is no use without the other', () async {
|
||||
await devices.prepare(session);
|
||||
|
||||
expect(await endpoints.dictionary.deviceFiles(signedIn, 'ja'), isEmpty);
|
||||
ipadic();
|
||||
expect(await endpoints.dictionary.deviceFiles(signedIn, 'de'), isEmpty);
|
||||
expect(
|
||||
await endpoints.dictionary.deviceFiles(signedIn, 'ja-JP'),
|
||||
hasLength(2),
|
||||
);
|
||||
});
|
||||
|
||||
test('serves no file but those listed', () async {
|
||||
ipadic();
|
||||
await devices.prepare(session);
|
||||
await endpoints.dictionary.deviceFiles(signedIn, 'ja');
|
||||
|
||||
for (final name in [
|
||||
'mecab-ipadic.tar.gz',
|
||||
'jmdict-en.jsonl',
|
||||
'..%2Fpasswords.yaml',
|
||||
'',
|
||||
]) {
|
||||
expect(devices.servedFile(name), isNull, reason: name);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses to list them to nobody signed in', () async {
|
||||
await expectLater(
|
||||
endpoints.dictionary.deviceFiles(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
'ja',
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,909 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_learn_server/src/dictionary/dictionary_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/dictionary/dictionary_loader.dart';
|
||||
import 'package:halkyon_learn_server/src/dictionary/dictionary_search.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
/// The header of a dictionary file of JMdict at [version], or of another
|
||||
/// [source] of another [kind].
|
||||
Map<String, Object?> header({
|
||||
String version = '3.6.2 (2026-10-05)',
|
||||
String source = 'jmdict',
|
||||
String? kind = 'words',
|
||||
}) => {
|
||||
'format': 'halkyon-dictionary',
|
||||
'formatVersion': 1,
|
||||
'source': source,
|
||||
'kind': ?kind,
|
||||
'sourceVersion': version,
|
||||
'language': 'ja',
|
||||
'glossLanguage': 'en',
|
||||
'license': 'CC-BY-SA-4.0',
|
||||
'credits': [
|
||||
{
|
||||
'name': 'JMdict',
|
||||
'by': 'EDRDG',
|
||||
'license': 'CC-BY-SA-4.0',
|
||||
'url': 'https://www.edrdg.org/edrdg/licence.html',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// An entry as the curator writes it.
|
||||
Map<String, Object?> entry(
|
||||
String id,
|
||||
String headword, {
|
||||
String? reading,
|
||||
List<String> labels = const [],
|
||||
required int rank,
|
||||
required List<String> keys,
|
||||
List<String> looseKeys = const [],
|
||||
}) => {
|
||||
'id': id,
|
||||
'headword': headword,
|
||||
'reading': ?reading,
|
||||
'labels': labels,
|
||||
'rank': rank,
|
||||
'keys': keys,
|
||||
'looseKeys': ?(looseKeys.isEmpty ? null : looseKeys),
|
||||
'entry': {
|
||||
'senses': [
|
||||
{'glosses': keys.last.split(', ')},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final taberu = entry(
|
||||
'1358280',
|
||||
'食べる',
|
||||
reading: 'たべる',
|
||||
labels: ['common', 'jlpt:n5'],
|
||||
rank: 100368,
|
||||
keys: ['食べる', 'たべる', 'taberu', 'to eat', 'eat'],
|
||||
);
|
||||
final terebi = entry(
|
||||
'1080510',
|
||||
'テレビ',
|
||||
labels: ['common', 'jlpt:n5'],
|
||||
rank: 100410,
|
||||
keys: ['テレビ', 'てれび', 'terebi', 'television', 'tv'],
|
||||
);
|
||||
final toukyou = entry(
|
||||
'1447690',
|
||||
'東京',
|
||||
reading: 'とうきょう',
|
||||
labels: ['common'],
|
||||
rank: 600000,
|
||||
keys: ['東京', 'とうきょう', 'toukyou', 'tokyo'],
|
||||
);
|
||||
// Ranks before 寿司, but "sushi" is only its long vowel typed once.
|
||||
final suushi = entry(
|
||||
'1356640',
|
||||
'数詞',
|
||||
reading: 'すうし',
|
||||
rank: 500000,
|
||||
keys: ['数詞', 'すうし', 'suushi', 'numeral'],
|
||||
looseKeys: ['sushi'],
|
||||
);
|
||||
final sushi = entry(
|
||||
'1595650',
|
||||
'寿司',
|
||||
reading: 'すし',
|
||||
rank: 600000,
|
||||
keys: ['寿司', 'すし', 'sushi'],
|
||||
);
|
||||
// A kanji, as KANJIDIC2's file gives it.
|
||||
final shoku = entry(
|
||||
'食',
|
||||
'食',
|
||||
labels: ['jlpt:n5', 'grade:2'],
|
||||
rank: 10328,
|
||||
keys: ['食', 'ショク', 'しょく', 'shoku', 'たべる', 'taberu', 'eat'],
|
||||
);
|
||||
// Ranks first, but "eat" only starts one of its keys.
|
||||
final kuidaore = entry(
|
||||
'9000001',
|
||||
'食い倒れ',
|
||||
reading: 'くいだおれ',
|
||||
rank: 50,
|
||||
keys: ['食い倒れ', 'くいだおれ', 'kuidaore', 'eating oneself into ruin'],
|
||||
);
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Dictionaries',
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late Directory folder;
|
||||
|
||||
setUp(() {
|
||||
session = sessionBuilder.build();
|
||||
folder = Directory.systemTemp.createTempSync('dictionaries');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await DictionaryEntry.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await DictionaryKey.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await DictionarySearchKey.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id > 0,
|
||||
);
|
||||
await DictionarySource.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id > 0,
|
||||
);
|
||||
folder.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
/// A dictionary file named [name] in the folder, of [entries].
|
||||
File write(
|
||||
List<Map<String, Object?>> entries, {
|
||||
String name = 'jmdict-en.jsonl',
|
||||
Map<String, Object?>? head,
|
||||
}) {
|
||||
final text = [
|
||||
jsonEncode(head ?? header()),
|
||||
for (final entry in entries) jsonEncode(entry),
|
||||
].join('\n');
|
||||
final file = File('${folder.path}/$name');
|
||||
name.endsWith('.gz')
|
||||
? file.writeAsBytesSync(gzip.encode(utf8.encode(text)))
|
||||
: file.writeAsStringSync(text);
|
||||
return file;
|
||||
}
|
||||
|
||||
final signedIn = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
Future<List<String>> search(String query) async => [
|
||||
for (final hit in await endpoints.dictionary.search(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
query,
|
||||
))
|
||||
hit.headword,
|
||||
];
|
||||
|
||||
Future<List<String>> searchFirst(String query, int limit) async => [
|
||||
for (final hit in await endpoints.dictionary.search(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
query,
|
||||
limit: limit,
|
||||
))
|
||||
hit.headword,
|
||||
];
|
||||
|
||||
test('a dictionary file loads with its credits, and again only once '
|
||||
'it changed, replacing what it held', () async {
|
||||
final file = write([taberu, terebi, toukyou]);
|
||||
|
||||
expect(await loadDictionaryFile(session, file), isTrue);
|
||||
expect(await DictionaryEntry.db.count(session), 3);
|
||||
var source = (await DictionarySource.db.find(session)).single;
|
||||
expect(source.entryCount, 3);
|
||||
expect(source.credits.single.name, 'JMdict');
|
||||
|
||||
expect(await loadDictionaryFile(session, file), isFalse);
|
||||
|
||||
write([taberu, toukyou], head: header(version: '3.6.3 (2026-11-01)'));
|
||||
expect(await loadDictionaryFile(session, file), isTrue);
|
||||
source = (await DictionarySource.db.find(session)).single;
|
||||
expect(source.sourceVersion, '3.6.3 (2026-11-01)');
|
||||
expect(await search('terebi'), isEmpty);
|
||||
expect(await search('taberu'), ['食べる']);
|
||||
});
|
||||
|
||||
test('a dictionary says what it holds; a file from before kinds holds '
|
||||
'words', () async {
|
||||
await loadDictionaryFile(
|
||||
session,
|
||||
write([taberu], head: header(kind: null)),
|
||||
);
|
||||
await loadDictionaryFile(
|
||||
session,
|
||||
write(
|
||||
[shoku],
|
||||
name: 'kanjidic-en.jsonl',
|
||||
head: header(source: 'kanjidic', kind: 'characters'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
{
|
||||
for (final source in await endpoints.dictionary.dictionaries(
|
||||
signedIn,
|
||||
))
|
||||
source.source: source.kind,
|
||||
},
|
||||
{'jmdict': 'words', 'kanjidic': 'characters'},
|
||||
);
|
||||
});
|
||||
|
||||
test("a dictionary's search and labels never reach another's entries "
|
||||
'of the same language', () async {
|
||||
await loadDictionaryFile(session, write([taberu]));
|
||||
await loadDictionaryFile(
|
||||
session,
|
||||
write(
|
||||
[shoku],
|
||||
name: 'kanjidic-en.jsonl',
|
||||
head: header(source: 'kanjidic', kind: 'characters'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(await search('taberu'), ['食べる']);
|
||||
expect(
|
||||
[
|
||||
for (final hit in await endpoints.dictionary.search(
|
||||
signedIn,
|
||||
'kanjidic',
|
||||
'en',
|
||||
'taberu',
|
||||
))
|
||||
hit.headword,
|
||||
],
|
||||
['食'],
|
||||
);
|
||||
expect(
|
||||
await endpoints.dictionary.countLabelled(
|
||||
signedIn,
|
||||
'kanjidic',
|
||||
'en',
|
||||
'jlpt:n5',
|
||||
),
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
[
|
||||
for (final hit in await endpoints.dictionary.labelled(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
'jlpt:n5',
|
||||
))
|
||||
hit.headword,
|
||||
],
|
||||
['食べる'],
|
||||
);
|
||||
});
|
||||
|
||||
test("a dictionary the instance doesn't hold finds nothing", () async {
|
||||
await loadDictionaryFile(session, write([taberu]));
|
||||
|
||||
expect(
|
||||
await endpoints.dictionary.search(signedIn, 'jmdict', 'de', 'taberu'),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
await endpoints.dictionary.search(signedIn, 'other', 'en', 'taberu'),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('the folder loads every file it can read, gzipped too, and '
|
||||
"leaves out one it can't", () async {
|
||||
write([taberu], name: 'jmdict-en.jsonl.gz');
|
||||
write([], name: 'broken.jsonl', head: {'format': 'something else'});
|
||||
|
||||
await loadDictionaries(session, folder);
|
||||
|
||||
expect(await search('taberu'), ['食べる']);
|
||||
});
|
||||
|
||||
test('a search finds a word by its kanji, kana (katakana too), romaji '
|
||||
'with long vowels as typed, and its meanings', () async {
|
||||
await loadDictionaryFile(session, write([taberu, terebi, toukyou]));
|
||||
|
||||
expect(await search('食べる'), ['食べる']);
|
||||
expect(await search('たべ'), ['食べる']);
|
||||
expect(await search('タベル'), ['食べる']);
|
||||
expect(await search('TABERU'), ['食べる']);
|
||||
expect(await search('tokyo'), ['東京']);
|
||||
expect(await search('tōkyō'), ['東京']);
|
||||
expect(await search('television'), ['テレビ']);
|
||||
expect(await search(''), isEmpty);
|
||||
});
|
||||
|
||||
test('a key that is the query comes before one that starts with it, '
|
||||
'whatever their order otherwise', () async {
|
||||
await loadDictionaryFile(session, write([taberu, kuidaore]));
|
||||
|
||||
expect(await search('eat'), ['食べる', '食い倒れ']);
|
||||
});
|
||||
|
||||
test('a loose key, a spelling not quite right, comes after the '
|
||||
'right ones', () async {
|
||||
await loadDictionaryFile(session, write([suushi, sushi]));
|
||||
|
||||
expect(await search('sushi'), ['寿司', '数詞']);
|
||||
expect(await search('numeral'), ['数詞']);
|
||||
});
|
||||
|
||||
test('the best matches come first even when the rest would fill the '
|
||||
'list: an exact key before a loose one ranked higher', () async {
|
||||
await loadDictionaryFile(session, write([suushi, sushi]));
|
||||
|
||||
expect(await searchFirst('sushi', 1), ['寿司']);
|
||||
});
|
||||
|
||||
test('long vowels with macrons find words written with おお or ー, and '
|
||||
'with the vowel once', () async {
|
||||
final ookii = entry(
|
||||
'1',
|
||||
'大きい',
|
||||
reading: 'おおきい',
|
||||
rank: 10,
|
||||
keys: ['大きい', 'おおきい', 'ookii', 'big'],
|
||||
looseKeys: ['okii'],
|
||||
);
|
||||
final koohii = entry(
|
||||
'2',
|
||||
'コーヒー',
|
||||
rank: 20,
|
||||
keys: ['コーヒー', 'こーひー', 'koohii', 'coffee'],
|
||||
looseKeys: ['kohi'],
|
||||
);
|
||||
await loadDictionaryFile(session, write([ookii, koohii]));
|
||||
|
||||
expect(await search('ōkii'), ['大きい']);
|
||||
expect(await search('kōhī'), ['コーヒー']);
|
||||
});
|
||||
|
||||
test("ん typed as n' or nn, and full-width letters, find their "
|
||||
'words', () async {
|
||||
final kinen = entry(
|
||||
'1',
|
||||
'禁煙',
|
||||
reading: 'きんえん',
|
||||
rank: 10,
|
||||
keys: ['禁煙', 'きんえん', 'kinen', 'no smoking'],
|
||||
);
|
||||
final honya = entry(
|
||||
'2',
|
||||
'本屋',
|
||||
reading: 'ほんや',
|
||||
rank: 20,
|
||||
keys: ['本屋', 'ほんや', 'honya', 'bookshop'],
|
||||
);
|
||||
final tshirt = entry(
|
||||
'3',
|
||||
'Tシャツ',
|
||||
rank: 30,
|
||||
keys: ['tシャツ', 'てぃーしゃつ', 't-shirt'],
|
||||
);
|
||||
await loadDictionaryFile(session, write([kinen, honya, tshirt]));
|
||||
|
||||
expect(await search("kin'en"), ['禁煙']);
|
||||
expect(await search('honnya'), ['本屋']);
|
||||
expect(await search('Tシャツ'), ['Tシャツ']);
|
||||
expect(await search('Tシャツ'), ['Tシャツ']);
|
||||
});
|
||||
|
||||
test("the query's wildcards and bars are taken as they are", () async {
|
||||
await loadDictionaryFile(session, write([taberu, terebi]));
|
||||
|
||||
expect(await search('%'), isEmpty);
|
||||
expect(await search('_'), isEmpty);
|
||||
expect(await search('|'), isEmpty);
|
||||
});
|
||||
|
||||
test('a search finds a key by its first character, its first two, '
|
||||
'three or more, a character beyond 16 bits counting as one, and '
|
||||
'a key shorter than the query never', () async {
|
||||
final shikaru = entry(
|
||||
'1590120',
|
||||
'𠮟る',
|
||||
reading: 'しかる',
|
||||
rank: 120000,
|
||||
keys: ['𠮟る', 'しかる', 'shikaru', 'to scold', 'scold'],
|
||||
);
|
||||
await loadDictionaryFile(session, write([taberu, terebi, shikaru]));
|
||||
|
||||
expect(await search('𠮟'), ['𠮟る']);
|
||||
expect(await search('𠮟る'), ['𠮟る']);
|
||||
expect(await search('t'), ['食べる', 'テレビ', '𠮟る']);
|
||||
expect(await search('te'), ['テレビ']);
|
||||
expect(await search('tel'), ['テレビ']);
|
||||
expect(await search('tele'), ['テレビ']);
|
||||
expect(await search('televisions'), isEmpty);
|
||||
expect(await search('scold'), ['𠮟る']);
|
||||
});
|
||||
|
||||
test('search keys go with their dictionary: replaced with it, and a '
|
||||
'dictionary loaded before they had rows is searched as before '
|
||||
'until it loads again', () async {
|
||||
final file = write([taberu, terebi]);
|
||||
await loadDictionaryFile(session, file);
|
||||
// Every key and loose key, "to eat" with its space too.
|
||||
expect(await DictionarySearchKey.db.count(session), 10);
|
||||
|
||||
write([terebi], head: header(version: '3.6.3 (2026-11-01)'));
|
||||
await loadDictionaryFile(session, file);
|
||||
expect(await search('taberu'), isEmpty);
|
||||
expect(await DictionarySearchKey.db.count(session), 5);
|
||||
|
||||
await DictionarySearchKey.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id > 0,
|
||||
);
|
||||
expect(await search('tele'), ['テレビ']);
|
||||
expect(await loadDictionaryFile(session, file), isTrue);
|
||||
expect(await DictionarySearchKey.db.count(session), 5);
|
||||
expect(await search('tele'), ['テレビ']);
|
||||
expect(await loadDictionaryFile(session, file), isFalse);
|
||||
});
|
||||
|
||||
test('loads asked for at once leave the dictionary loaded once, '
|
||||
'whole', () async {
|
||||
write([taberu, terebi]);
|
||||
|
||||
await Future.wait([
|
||||
loadDictionaries(session, folder),
|
||||
loadDictionaries(session, folder),
|
||||
]);
|
||||
|
||||
expect(await DictionaryEntry.db.count(session), 2);
|
||||
expect(await DictionarySource.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('a dictionary whose file is gone is unloaded, never while a file '
|
||||
"can't be read", () async {
|
||||
write([taberu]);
|
||||
write(
|
||||
[terebi],
|
||||
name: 'other-en.jsonl',
|
||||
head: header(source: 'other'),
|
||||
);
|
||||
await loadDictionaries(session, folder);
|
||||
expect(await DictionarySource.db.count(session), 2);
|
||||
|
||||
File('${folder.path}/other-en.jsonl').deleteSync();
|
||||
File('${folder.path}/broken.jsonl').writeAsStringSync('not json');
|
||||
await loadDictionaries(session, folder);
|
||||
expect(await DictionarySource.db.count(session), 2);
|
||||
|
||||
File('${folder.path}/broken.jsonl').deleteSync();
|
||||
await loadDictionaries(session, folder);
|
||||
expect(
|
||||
[
|
||||
for (final source in await DictionarySource.db.find(session))
|
||||
source.source,
|
||||
],
|
||||
['jmdict'],
|
||||
);
|
||||
expect(
|
||||
await DictionaryEntry.db.count(
|
||||
session,
|
||||
where: (t) => t.source.equals('other'),
|
||||
),
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await DictionaryKey.db.count(
|
||||
session,
|
||||
where: (t) => t.source.equals('other'),
|
||||
),
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await DictionarySearchKey.db.count(
|
||||
session,
|
||||
where: (t) => t.source.equals('other'),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('two files of one source are refused, and neither loads, as '
|
||||
'which one is meant is unclear', () async {
|
||||
write([taberu], name: 'jmdict-en-old.jsonl');
|
||||
write([terebi]);
|
||||
|
||||
await loadDictionaries(session, folder);
|
||||
|
||||
expect(await DictionaryEntry.db.count(session), 0);
|
||||
});
|
||||
|
||||
test("a folder that can't be read is logged, never thrown: the "
|
||||
'server goes on', () async {
|
||||
final locked = Directory('${folder.path}/locked')..createSync();
|
||||
Process.runSync('chmod', ['000', locked.path]);
|
||||
try {
|
||||
await expectLater(loadDictionaries(session, locked), completes);
|
||||
} finally {
|
||||
Process.runSync('chmod', ['700', locked.path]);
|
||||
}
|
||||
});
|
||||
|
||||
test('pages of a label never repeat or skip entries that rank the '
|
||||
'same', () async {
|
||||
await loadDictionaryFile(
|
||||
session,
|
||||
write([
|
||||
for (var i = 0; i < 40; i++)
|
||||
entry(
|
||||
'$i',
|
||||
'w$i',
|
||||
labels: ['common'],
|
||||
rank: 600000,
|
||||
keys: ['w$i'],
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
final seen = [
|
||||
for (var offset = 0; offset < 40; offset += 7)
|
||||
for (final hit in await endpoints.dictionary.labelled(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
'common',
|
||||
offset: offset,
|
||||
limit: 7,
|
||||
))
|
||||
hit.sourceId,
|
||||
];
|
||||
expect(seen, hasLength(40));
|
||||
expect(seen.toSet(), hasLength(40));
|
||||
});
|
||||
|
||||
test("a label picks its entries, in the dictionary's order, a page at "
|
||||
'a time', () async {
|
||||
await loadDictionaryFile(session, write([toukyou, terebi, taberu]));
|
||||
|
||||
expect(
|
||||
await endpoints.dictionary.countLabelled(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
'jlpt:n5',
|
||||
),
|
||||
2,
|
||||
);
|
||||
final pages = [
|
||||
for (final offset in [0, 1, 2])
|
||||
[
|
||||
for (final hit in await endpoints.dictionary.labelled(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
'jlpt:n5',
|
||||
offset: offset,
|
||||
limit: 1,
|
||||
))
|
||||
hit.headword,
|
||||
],
|
||||
];
|
||||
expect(pages, [
|
||||
['食べる'],
|
||||
['テレビ'],
|
||||
<String>[],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a hit carries what a card needs: its source and id, labels and '
|
||||
'the entry', () async {
|
||||
await loadDictionaryFile(session, write([taberu]));
|
||||
|
||||
final hit = (await endpoints.dictionary.search(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
'taberu',
|
||||
)).single;
|
||||
|
||||
expect((hit.source, hit.sourceId), ('jmdict', '1358280'));
|
||||
expect(hit.reading, 'たべる');
|
||||
expect(hit.labels, ['common', 'jlpt:n5']);
|
||||
expect(jsonDecode(hit.entry), isA<Map<String, Object?>>());
|
||||
});
|
||||
|
||||
test("a hit carries its entry's version, which changes with what a "
|
||||
'card takes of it', () async {
|
||||
Future<String?> version() async => (await endpoints.dictionary.search(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
'taberu',
|
||||
)).single.version;
|
||||
await loadDictionaryFile(session, write([taberu]));
|
||||
final before = await version();
|
||||
|
||||
final corrected = write([
|
||||
{
|
||||
...taberu,
|
||||
'entry': {
|
||||
'senses': [
|
||||
{
|
||||
'glosses': ['to eat', 'to consume'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
], head: header(version: '3.6.3 (2026-11-01)'));
|
||||
await loadDictionaryFile(session, corrected);
|
||||
final after = await version();
|
||||
|
||||
expect(before, hasLength(16));
|
||||
expect(after, isNot(before));
|
||||
expect(entryVersion('食べる', 'たべる', '{}'), hasLength(16));
|
||||
});
|
||||
|
||||
test('changed: of the entries named, those whose version is another '
|
||||
'now; none gone from the source', () async {
|
||||
await loadDictionaryFile(session, write([taberu, terebi]));
|
||||
final versions = {
|
||||
for (final hit in await endpoints.dictionary.labelled(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
'jlpt:n5',
|
||||
))
|
||||
hit.sourceId: hit.version!,
|
||||
};
|
||||
|
||||
Future<List<String>> changed(Map<String, String> asked) async => [
|
||||
for (final hit in await endpoints.dictionary.changed(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
asked,
|
||||
))
|
||||
hit.sourceId,
|
||||
];
|
||||
|
||||
expect(await changed(versions), isEmpty);
|
||||
expect(
|
||||
await changed({...versions, '1358280': 'an older one'}),
|
||||
['1358280'],
|
||||
);
|
||||
expect(await changed({'404': 'gone'}), isEmpty);
|
||||
expect(await changed({}), isEmpty);
|
||||
expect(
|
||||
() => changed({
|
||||
for (var i = 0; i <= DictionaryEndpoint.maxCompared; i++) '$i': 'v',
|
||||
}),
|
||||
throwsA(anything),
|
||||
);
|
||||
});
|
||||
|
||||
test('entries: those named, in the order named; none gone from the '
|
||||
'source, nor from another dictionary', () async {
|
||||
await loadDictionaryFile(session, write([taberu, terebi]));
|
||||
final ids = [
|
||||
for (final hit in await endpoints.dictionary.labelled(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
'jlpt:n5',
|
||||
))
|
||||
hit.sourceId,
|
||||
];
|
||||
|
||||
Future<List<String>> entries(List<String> asked) async => [
|
||||
for (final hit in await endpoints.dictionary.entries(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
asked,
|
||||
))
|
||||
hit.sourceId,
|
||||
];
|
||||
|
||||
expect(await entries(ids.reversed.toList()), ids.reversed);
|
||||
expect(await entries([ids.first, '404']), [ids.first]);
|
||||
expect(
|
||||
await endpoints.dictionary.entries(signedIn, 'jmdict', 'de', ids),
|
||||
isEmpty,
|
||||
);
|
||||
expect(await entries([]), isEmpty);
|
||||
expect(
|
||||
() => entries([
|
||||
for (var i = 0; i <= DictionaryEndpoint.maxCompared; i++) '$i',
|
||||
]),
|
||||
throwsA(anything),
|
||||
);
|
||||
});
|
||||
|
||||
test('withKeys: the entries with a key that is one asked for, each '
|
||||
'with that key; a key only starting one finds nothing, nor does '
|
||||
"another dictionary's entry", () async {
|
||||
await loadDictionaryFile(
|
||||
session,
|
||||
write([taberu, terebi, toukyou, kuidaore]),
|
||||
);
|
||||
|
||||
Future<List<(String, String)>> withKeys(
|
||||
List<String> keys, {
|
||||
String glossLanguage = 'en',
|
||||
}) async => [
|
||||
for (final found in await endpoints.dictionary.withKeys(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
glossLanguage,
|
||||
keys,
|
||||
))
|
||||
(found.key, found.hit.headword),
|
||||
];
|
||||
|
||||
expect(
|
||||
(await withKeys([
|
||||
'食べる',
|
||||
'たべ',
|
||||
'テレビ',
|
||||
'てれび',
|
||||
'東京',
|
||||
'EAT',
|
||||
'nothing',
|
||||
'',
|
||||
'a|b',
|
||||
])).toSet(),
|
||||
{
|
||||
('食べる', '食べる'),
|
||||
('テレビ', 'テレビ'),
|
||||
('てれび', 'テレビ'),
|
||||
('東京', '東京'),
|
||||
('eat', '食べる'),
|
||||
},
|
||||
);
|
||||
expect(await withKeys(['食べる'], glossLanguage: 'de'), isEmpty);
|
||||
expect(await withKeys([]), isEmpty);
|
||||
expect(
|
||||
() => withKeys([
|
||||
for (var i = 0; i <= DictionaryEndpoint.maxKeys; i++) '$i',
|
||||
]),
|
||||
throwsA(anything),
|
||||
);
|
||||
});
|
||||
|
||||
test('withKeys finds an entry that names its text keys by them alone: '
|
||||
'a German word by its forms, never by its English meanings, which '
|
||||
'would match English words in German text', () async {
|
||||
await loadDictionaryFile(
|
||||
session,
|
||||
write(
|
||||
[
|
||||
{
|
||||
...entry(
|
||||
'Haus/noun',
|
||||
'Haus',
|
||||
rank: 5584,
|
||||
keys: ['haus', 'häuser', 'house', 'house, building'],
|
||||
),
|
||||
'textKeys': ['haus', 'häuser', 'häusern'],
|
||||
},
|
||||
],
|
||||
name: 'wiktionary-de-en.jsonl',
|
||||
head: {
|
||||
...header(source: 'wiktionary-de'),
|
||||
'language': 'de',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final found = await endpoints.dictionary.withKeys(
|
||||
signedIn,
|
||||
'wiktionary-de',
|
||||
'en',
|
||||
['Häusern', 'house', 'haus'],
|
||||
);
|
||||
|
||||
expect({for (final hit in found) hit.key}, {'häusern', 'haus'});
|
||||
// A search still finds it by its meaning.
|
||||
expect(
|
||||
[
|
||||
for (final hit in await endpoints.dictionary.search(
|
||||
signedIn,
|
||||
'wiktionary-de',
|
||||
'en',
|
||||
'house',
|
||||
))
|
||||
hit.headword,
|
||||
],
|
||||
['Haus'],
|
||||
);
|
||||
});
|
||||
|
||||
test('withKeys finds the keys loaded with their entries, and no more '
|
||||
'once a load replaced them; a dictionary loaded before keys had '
|
||||
'rows loads again', () async {
|
||||
final file = write([taberu, terebi]);
|
||||
await loadDictionaryFile(session, file);
|
||||
expect(
|
||||
await DictionaryKey.db.count(session),
|
||||
// 食べる, たべる, taberu, eat; テレビ, てれび, terebi, television,
|
||||
// tv: "to eat" has a space.
|
||||
9,
|
||||
);
|
||||
|
||||
write([terebi], head: header(version: '3.6.3 (2026-11-01)'));
|
||||
await loadDictionaryFile(session, file);
|
||||
expect(
|
||||
await endpoints.dictionary.withKeys(signedIn, 'jmdict', 'en', [
|
||||
'食べる',
|
||||
]),
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
await DictionaryKey.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
expect(await loadDictionaryFile(session, file), isTrue);
|
||||
expect(await DictionaryKey.db.count(session), 5);
|
||||
expect(await loadDictionaryFile(session, file), isFalse);
|
||||
});
|
||||
|
||||
test('withKeys: for a key many entries have, the first few in the '
|
||||
"dictionary's order", () async {
|
||||
await loadDictionaryFile(
|
||||
session,
|
||||
write([
|
||||
for (var i = 0; i < DictionaryEndpoint.maxPerKey + 2; i++)
|
||||
entry(
|
||||
'${2000 + i}',
|
||||
'はし$i',
|
||||
rank: 100 - i,
|
||||
keys: ['はし$i', 'はし', 'hashi'],
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
final found = await endpoints.dictionary.withKeys(
|
||||
signedIn,
|
||||
'jmdict',
|
||||
'en',
|
||||
['はし'],
|
||||
);
|
||||
|
||||
expect(found, hasLength(DictionaryEndpoint.maxPerKey));
|
||||
expect(
|
||||
found.first.hit.headword,
|
||||
'はし${DictionaryEndpoint.maxPerKey + 1}',
|
||||
);
|
||||
expect(found.map((each) => each.key).toSet(), {'はし'});
|
||||
});
|
||||
|
||||
test('the dictionaries are for those signed in', () {
|
||||
expect(
|
||||
() => endpoints.dictionary.search(
|
||||
sessionBuilder,
|
||||
'jmdict',
|
||||
'en',
|
||||
'x',
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
expect(
|
||||
() => endpoints.dictionary.withKeys(sessionBuilder, 'jmdict', 'en', [
|
||||
'食べる',
|
||||
]),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('a query looks for itself, and in Japanese for its hiragana and '
|
||||
'its long vowels written out', () {
|
||||
expect(
|
||||
queryVariants('ja', ' Tōkyō '),
|
||||
containsAll(['tōkyō', 'toukyou', 'tookyoo', 'tokyo']),
|
||||
);
|
||||
expect(queryVariants('ja', 'タベル'), ['タベル', 'たべる']);
|
||||
expect(queryVariants('en', 'Tōkyō'), ['tōkyō']);
|
||||
expect(queryVariants('ja', ' '), isEmpty);
|
||||
expect(likeEscaped(r'50%_a|b\c'), r'50\%\_a b\\c');
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,580 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.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_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/sync_database.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Explore',
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late UuidValue alice;
|
||||
late UuidValue bob;
|
||||
|
||||
/// The people a test made, whose spaces, profiles and publications it
|
||||
/// deletes again.
|
||||
final people = <UuidValue>{};
|
||||
|
||||
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
{identified},
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> profile(UuidValue person, String handle, String name) =>
|
||||
endpoints.profile.save(signedIn(person), handle, name, '', []);
|
||||
|
||||
setUp(() async {
|
||||
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]);
|
||||
await profile(alice, 'alice', 'Alice Liddell');
|
||||
await profile(bob, 'bob', 'Robert');
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
/// Someone signed in without an identity.
|
||||
final anonymous = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
Future<List<ExploreEntry>> list([ExploreQuery? query]) =>
|
||||
endpoints.explore.list(anonymous, query ?? ExploreQuery());
|
||||
|
||||
List<String> titles(List<ExploreEntry> entries) => [
|
||||
for (final entry in entries) entry.title,
|
||||
];
|
||||
|
||||
/// A studyset of [person] titled [title] in [frontLanguage] under
|
||||
/// CC BY 4.0, holding a card showing an image, and a learning path
|
||||
/// when [withPath]; returns its id.
|
||||
Future<UuidValue> studyset(
|
||||
UuidValue person,
|
||||
String title, {
|
||||
String? frontLanguage,
|
||||
bool withPath = false,
|
||||
}) async {
|
||||
final image = utf8.encode('the image of $title');
|
||||
final hash = sha256.convert(image).toString();
|
||||
final id = await asPerson(session, person, (transaction) async {
|
||||
final studyset = (await Studyset.db.insertRow(
|
||||
session,
|
||||
Studyset(
|
||||
payload: _json({
|
||||
'schemaVersion': 4,
|
||||
'title': title,
|
||||
'secondaryTitle': 'for beginners',
|
||||
'description': {'document': 'about $title'},
|
||||
'license': 'CC-BY-4.0',
|
||||
'licenseHolder': 'Alice',
|
||||
'licenseYear': 2026,
|
||||
'frontLanguage': ?frontLanguage,
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
final asset = (await Asset.db.insertRow(
|
||||
session,
|
||||
Asset(
|
||||
sha256: hash,
|
||||
mimeType: 'image/png',
|
||||
byteSize: image.length,
|
||||
payload: _json({'schemaVersion': 1, 'fileName': 'a.png'}),
|
||||
),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
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': {'image': 'asset:${asset.uuid}'},
|
||||
'back': title,
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
if (withPath) {
|
||||
await LearningPath.db.insertRow(
|
||||
session,
|
||||
LearningPath(
|
||||
studysetId: studyset,
|
||||
position: 'a0',
|
||||
payload: _json({'schemaVersion': 1, 'title': 'A path'}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
return studyset;
|
||||
});
|
||||
await session.storage.storeFile(
|
||||
storageId: assetStorage,
|
||||
path: assetPath(person, hash),
|
||||
byteData: ByteData.sublistView(Uint8List.fromList(image)),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<Publication> publish(
|
||||
UuidValue person,
|
||||
UuidValue studyset,
|
||||
String slug, {
|
||||
String notes = '',
|
||||
}) => endpoints.publish.publish(signedIn(person), studyset, slug, notes);
|
||||
|
||||
/// Gives [studyset] of [person] another card, so its next release has
|
||||
/// something new.
|
||||
Future<void> addCard(UuidValue person, UuidValue studyset) =>
|
||||
asPerson(session, person, (transaction) async {
|
||||
final card = (await Card.db.insertRow(
|
||||
session,
|
||||
Card(studysetId: studyset, position: 'a1'),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
await CardRevision.db.insertRow(
|
||||
session,
|
||||
CardRevision(
|
||||
cardId: card,
|
||||
parents: [],
|
||||
authorId: person,
|
||||
payload: _json({'schemaVersion': 8, 'type': 'vocabulary'}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
|
||||
test('lists published studysets, the newest release first, with '
|
||||
'their author, where they are and what they hold', () async {
|
||||
final japanese = await studyset(alice, 'Japanese');
|
||||
await publish(alice, japanese, 'japanese');
|
||||
await publish(bob, await studyset(bob, 'Latin'), 'latin');
|
||||
|
||||
expect(titles(await list()), ['Latin', 'Japanese']);
|
||||
|
||||
await addCard(alice, japanese);
|
||||
await publish(alice, japanese, 'japanese', notes: 'One more card.');
|
||||
final entries = await list();
|
||||
expect(titles(entries), ['Japanese', 'Latin']);
|
||||
final entry = entries.first;
|
||||
expect(entry.version, 2);
|
||||
expect(entry.notes, 'One more card.');
|
||||
expect(entry.location, endsWith('/@alice/japanese'));
|
||||
expect(entry.handle, 'alice');
|
||||
expect(entry.displayName, 'Alice Liddell');
|
||||
expect(entry.secondaryTitle, 'for beginners');
|
||||
expect(jsonDecode(entry.description!), {'document': 'about Japanese'});
|
||||
expect(entry.license, 'CC-BY-4.0');
|
||||
expect((entry.licenseHolder, entry.licenseYear), ('Alice', 2026));
|
||||
expect((entry.studysetCount, entry.cardCount), (1, 2));
|
||||
});
|
||||
|
||||
test('finds by title or author, whatever their case; what is typed is '
|
||||
'only itself', () async {
|
||||
await publish(alice, await studyset(alice, 'Japanese'), 'japanese');
|
||||
await publish(bob, await studyset(bob, 'Latin 100%'), 'latin');
|
||||
|
||||
Future<List<String>> search(String text) async =>
|
||||
titles(await list(ExploreQuery(text: text)));
|
||||
expect(await search('japan'), ['Japanese']);
|
||||
expect(await search('ROBERT'), ['Latin 100%']);
|
||||
expect(await search('alice'), ['Japanese']);
|
||||
expect(await search('100%'), ['Latin 100%']);
|
||||
expect(await search('_'), isEmpty);
|
||||
});
|
||||
|
||||
test('a language takes its regional ones; kinds of content must be '
|
||||
'there', () async {
|
||||
await publish(
|
||||
alice,
|
||||
await studyset(alice, 'Japanese', frontLanguage: 'ja-JP'),
|
||||
'japanese',
|
||||
);
|
||||
await publish(
|
||||
bob,
|
||||
await studyset(bob, 'Latin', frontLanguage: 'la', withPath: true),
|
||||
'latin',
|
||||
);
|
||||
|
||||
expect(
|
||||
titles(await list(ExploreQuery(frontLanguage: 'ja'))),
|
||||
['Japanese'],
|
||||
);
|
||||
expect(
|
||||
titles(await list(ExploreQuery(frontLanguage: 'JA-jp'))),
|
||||
['Japanese'],
|
||||
);
|
||||
expect(await list(ExploreQuery(frontLanguage: 'j')), isEmpty);
|
||||
expect(await list(ExploreQuery(frontLanguage: "ja' OR 1=1")), isEmpty);
|
||||
expect(titles(await list(ExploreQuery(withPaths: true))), ['Latin']);
|
||||
expect(await list(ExploreQuery(offset: 2)), isEmpty);
|
||||
});
|
||||
|
||||
test('gives out the latest release: its JSON files, and each file its '
|
||||
'content names', () async {
|
||||
final japanese = await studyset(alice, 'Japanese');
|
||||
final published = await publish(alice, japanese, 'japanese');
|
||||
|
||||
final release = await endpoints.explore.release(
|
||||
anonymous,
|
||||
published.publicId,
|
||||
);
|
||||
|
||||
expect(release.version, 1);
|
||||
final paths = {for (final item in release.items) item.path};
|
||||
expect(paths, contains('studyset.json'));
|
||||
expect(paths, contains('files.json'));
|
||||
expect(
|
||||
paths.where((path) => path.startsWith('cards/')),
|
||||
hasLength(1),
|
||||
);
|
||||
final root = release.items.firstWhere(
|
||||
(item) => item.path == 'studyset.json',
|
||||
);
|
||||
expect(
|
||||
(jsonDecode(root.json) as Map)['payload'],
|
||||
containsPair('title', 'Japanese'),
|
||||
);
|
||||
final hash = release.files.single;
|
||||
expect(
|
||||
utf8.decode(
|
||||
Uint8List.sublistView(
|
||||
await endpoints.explore.file(
|
||||
anonymous,
|
||||
published.publicId,
|
||||
1,
|
||||
hash,
|
||||
),
|
||||
),
|
||||
),
|
||||
'the image of Japanese',
|
||||
);
|
||||
for (final unknown in ['0' * 64, '../studyset.json', 'files.json']) {
|
||||
await expectLater(
|
||||
endpoints.explore.file(anonymous, published.publicId, 1, unknown),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
reason: unknown,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('gives out an older release, to take a newer one from', () async {
|
||||
final japanese = await studyset(alice, 'Japanese');
|
||||
await publish(alice, japanese, 'japanese');
|
||||
await addCard(alice, japanese);
|
||||
final published = await publish(alice, japanese, 'japanese');
|
||||
|
||||
Future<int> cardsIn(int? version) async =>
|
||||
(await endpoints.explore.release(
|
||||
anonymous,
|
||||
published.publicId,
|
||||
version: version,
|
||||
)).items.where((item) => item.path.startsWith('cards/')).length;
|
||||
expect(await cardsIn(1), 1);
|
||||
expect(await cardsIn(2), 2);
|
||||
expect(await cardsIn(null), 2);
|
||||
for (final version in [0, 3]) {
|
||||
await expectLater(
|
||||
endpoints.explore.release(
|
||||
anonymous,
|
||||
published.publicId,
|
||||
version: version,
|
||||
),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
reason: '$version',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('gives out a release in parts: its JSON files in the order of '
|
||||
'their paths from where asked, how many it holds in all, the '
|
||||
'files their content names, and the version they are of', () async {
|
||||
final japanese = await studyset(alice, 'Japanese');
|
||||
await publish(alice, japanese, 'japanese');
|
||||
await addCard(alice, japanese);
|
||||
final published = await publish(alice, japanese, 'japanese');
|
||||
final whole = await endpoints.explore.release(
|
||||
anonymous,
|
||||
published.publicId,
|
||||
);
|
||||
|
||||
final parts = <ReleasePart>[];
|
||||
var offset = 0;
|
||||
do {
|
||||
parts.add(
|
||||
await endpoints.explore.releasePart(
|
||||
anonymous,
|
||||
published.publicId,
|
||||
offset: offset,
|
||||
count: 2,
|
||||
),
|
||||
);
|
||||
offset += parts.last.items.length;
|
||||
} while (offset < parts.last.total);
|
||||
|
||||
expect(parts.length, greaterThan(1));
|
||||
expect(parts.every((part) => part.items.length <= 2), isTrue);
|
||||
expect(
|
||||
[
|
||||
for (final part in parts)
|
||||
for (final item in part.items) (item.path, item.json),
|
||||
],
|
||||
[for (final item in whole.items) (item.path, item.json)],
|
||||
);
|
||||
expect({for (final part in parts) part.total}, {whole.items.length});
|
||||
expect({for (final part in parts) part.version}, {2});
|
||||
expect(
|
||||
{for (final part in parts) part.files.join()},
|
||||
{
|
||||
whole.files.join(),
|
||||
},
|
||||
);
|
||||
|
||||
final older = await endpoints.explore.releasePart(
|
||||
anonymous,
|
||||
published.publicId,
|
||||
version: 1,
|
||||
offset: 0,
|
||||
count: 500,
|
||||
);
|
||||
expect(older.version, 1);
|
||||
expect(older.total, whole.items.length - 1);
|
||||
expect(older.items, hasLength(older.total));
|
||||
expect(
|
||||
(await endpoints.explore.releasePart(
|
||||
anonymous,
|
||||
published.publicId,
|
||||
offset: whole.items.length,
|
||||
count: 500,
|
||||
)).items,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('gives out no part of a release that does not exist, nor one '
|
||||
'from before the first file or of no files', () async {
|
||||
final published = await publish(
|
||||
alice,
|
||||
await studyset(alice, 'Japanese'),
|
||||
'japanese',
|
||||
);
|
||||
|
||||
for (final (version, offset, count) in [
|
||||
(0, 0, 2),
|
||||
(2, 0, 2),
|
||||
(null, -1, 2),
|
||||
(null, 0, 0),
|
||||
]) {
|
||||
await expectLater(
|
||||
endpoints.explore.releasePart(
|
||||
anonymous,
|
||||
published.publicId,
|
||||
version: version,
|
||||
offset: offset,
|
||||
count: count,
|
||||
),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
reason: '$version, $offset, $count',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('tells its author which of their studysets each of theirs was '
|
||||
'published from, and nobody else', () async {
|
||||
final japanese = await studyset(alice, 'Japanese');
|
||||
final published = await publish(alice, japanese, 'japanese');
|
||||
|
||||
UuidValue? yoursIn(List<ExploreEntry> entries) => entries
|
||||
.firstWhere((entry) => entry.publicId == published.publicId)
|
||||
.yourStudysetId;
|
||||
expect(
|
||||
yoursIn(
|
||||
await endpoints.explore.list(signedIn(alice), ExploreQuery()),
|
||||
),
|
||||
japanese,
|
||||
);
|
||||
expect(
|
||||
yoursIn(
|
||||
await endpoints.explore.entries(signedIn(alice), [
|
||||
published.publicId,
|
||||
]),
|
||||
),
|
||||
japanese,
|
||||
);
|
||||
expect(
|
||||
yoursIn(await endpoints.explore.list(signedIn(bob), ExploreQuery())),
|
||||
isNull,
|
||||
);
|
||||
expect(yoursIn(await list()), isNull);
|
||||
expect(
|
||||
yoursIn(
|
||||
await endpoints.explore.entries(signedIn(bob), [
|
||||
published.publicId,
|
||||
]),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('says which of the studysets asked for are out, and their '
|
||||
'latest version', () async {
|
||||
final published = await publish(
|
||||
alice,
|
||||
await studyset(alice, 'Japanese'),
|
||||
'japanese',
|
||||
);
|
||||
|
||||
final entries = await endpoints.explore.entries(anonymous, [
|
||||
published.publicId,
|
||||
const Uuid().v4obj(),
|
||||
]);
|
||||
|
||||
expect([for (final entry in entries) entry.version], [1]);
|
||||
});
|
||||
|
||||
test('a withdrawn studyset is neither listed nor given out, until '
|
||||
'published again', () async {
|
||||
final japanese = await studyset(alice, 'Japanese');
|
||||
final published = await publish(alice, japanese, 'japanese');
|
||||
|
||||
final withdrawn = await endpoints.publish.withdraw(
|
||||
signedIn(alice),
|
||||
japanese,
|
||||
);
|
||||
|
||||
expect(withdrawn.withdrawn, isTrue);
|
||||
expect(await list(), isEmpty);
|
||||
expect(
|
||||
await endpoints.explore.entries(anonymous, [published.publicId]),
|
||||
isEmpty,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.explore.release(anonymous, published.publicId),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.explore.releasePart(
|
||||
anonymous,
|
||||
published.publicId,
|
||||
offset: 0,
|
||||
count: 500,
|
||||
),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
// Nothing changed, yet publishing brings it back as it was.
|
||||
final again = await publish(alice, japanese, 'japanese');
|
||||
expect(again.withdrawn, isFalse);
|
||||
expect(again.releases.single.version, 1);
|
||||
expect(titles(await list()), ['Japanese']);
|
||||
});
|
||||
|
||||
test('only its author withdraws a studyset', () async {
|
||||
final japanese = await studyset(alice, 'Japanese');
|
||||
await publish(alice, japanese, 'japanese');
|
||||
|
||||
await expectLater(
|
||||
endpoints.publish.withdraw(signedIn(bob), japanese),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.publish.withdraw(anonymous, japanese),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
expect(titles(await list()), ['Japanese']);
|
||||
});
|
||||
|
||||
test('nobody signed in sees Explore', () async {
|
||||
final published = await publish(
|
||||
alice,
|
||||
await studyset(alice, 'Japanese'),
|
||||
'japanese',
|
||||
);
|
||||
final nobody = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
final unauthenticated = throwsA(
|
||||
isA<ServerpodUnauthenticatedException>(),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.explore.list(nobody, ExploreQuery()),
|
||||
unauthenticated,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.explore.release(nobody, published.publicId),
|
||||
unauthenticated,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.explore.releasePart(
|
||||
nobody,
|
||||
published.publicId,
|
||||
offset: 0,
|
||||
count: 500,
|
||||
),
|
||||
unauthenticated,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.explore.entries(nobody, [published.publicId]),
|
||||
unauthenticated,
|
||||
);
|
||||
});
|
||||
},
|
||||
databaseInterceptor: syncDatabaseInterceptor,
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
);
|
||||
}
|
||||
|
||||
/// [json] as a synced payload.
|
||||
ByteData _json(Map<String, Object?> json) =>
|
||||
ByteData.sublistView(utf8.encode(jsonEncode(json)));
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/explore/learner_count.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.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';
|
||||
|
||||
void main() {
|
||||
test('learners are shown rounded down, never fewer than 5', () {
|
||||
expect(
|
||||
[
|
||||
for (final n in [
|
||||
0,
|
||||
4,
|
||||
5,
|
||||
9,
|
||||
10,
|
||||
19,
|
||||
20,
|
||||
49,
|
||||
50,
|
||||
99,
|
||||
100,
|
||||
250,
|
||||
999,
|
||||
1000,
|
||||
])
|
||||
learnersShown(n),
|
||||
],
|
||||
[null, null, 5, 5, 10, 10, 20, 20, 50, 50, 100, 200, 500, 1000],
|
||||
);
|
||||
});
|
||||
|
||||
withServerpod(
|
||||
'Counting learners',
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
final now = DateTime.utc(2026, 10, 9, 12);
|
||||
final people = <UuidValue>[];
|
||||
late PublishedStudyset japanese;
|
||||
|
||||
setUp(() async {
|
||||
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
|
||||
useLearnBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
japanese = await PublishedStudyset.db.insertRow(
|
||||
session,
|
||||
PublishedStudyset(
|
||||
publicId: const Uuid().v4obj(),
|
||||
studysetId: const Uuid().v7obj(),
|
||||
authUserId: const Uuid().v7obj(),
|
||||
slug: 'japanese',
|
||||
createdAt: now,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final person in people) {
|
||||
await deletePerson(session, person);
|
||||
}
|
||||
people.clear();
|
||||
await PublishedStudyset.db.deleteRow(session, japanese);
|
||||
});
|
||||
|
||||
ByteData json(Map<String, Object?> json) =>
|
||||
ByteData.sublistView(utf8.encode(jsonEncode(json)));
|
||||
|
||||
/// A person (with an identity unless [anonymous]), counted if
|
||||
/// [counted], who studied a copy of Japanese on [daysAgo] (a review
|
||||
/// in [mode] each), and since put it in Recently deleted if
|
||||
/// [deleted].
|
||||
Future<void> learner({
|
||||
bool counted = true,
|
||||
bool anonymous = false,
|
||||
List<int> daysAgo = const [1, 2, 3],
|
||||
String mode = 'review',
|
||||
bool deleted = false,
|
||||
}) async {
|
||||
final person = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: anonymous ? const {} : {identified},
|
||||
)).id;
|
||||
people.add(person);
|
||||
await asPerson(session, person, (transaction) async {
|
||||
await PersonSettings.db.insertRow(
|
||||
session,
|
||||
PersonSettings(
|
||||
payload: json({
|
||||
'schemaVersion': 8,
|
||||
'studyTags': <String, Object?>{},
|
||||
'countLearning': ?(counted ? true : null),
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
final studyset = (await Studyset.db.insertRow(
|
||||
session,
|
||||
Studyset(
|
||||
deletedAt: deleted ? now : null,
|
||||
payload: json({
|
||||
'schemaVersion': 5,
|
||||
'title': 'Japanese',
|
||||
'published': {
|
||||
'publication': japanese.publicId.uuid,
|
||||
'item': 'root',
|
||||
'version': 1,
|
||||
},
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
final card = (await Card.db.insertRow(
|
||||
session,
|
||||
Card(studysetId: studyset, position: 'a0'),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
for (final days in daysAgo) {
|
||||
await ReviewLog.db.insertRow(
|
||||
session,
|
||||
ReviewLog(
|
||||
cardId: card,
|
||||
rating: 3,
|
||||
reviewedAt: now.subtract(Duration(days: days)),
|
||||
durationMs: 1000,
|
||||
mode: mode,
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<int> counted() async {
|
||||
await countLearnersOf(session, now: now);
|
||||
return (await PublishedStudyset.db.findById(
|
||||
session,
|
||||
japanese.id!,
|
||||
))!.learners;
|
||||
}
|
||||
|
||||
test('counts the people with an identity who chose to be, studying a '
|
||||
'copy on 3 days of the last 30', () async {
|
||||
await learner();
|
||||
await learner(daysAgo: [1, 1, 1, 2, 29]);
|
||||
|
||||
expect(await counted(), 2);
|
||||
});
|
||||
|
||||
test('leaves out who chose not to be counted, anonymous people, too '
|
||||
'little or too long ago, and a copy deleted', () async {
|
||||
await learner(counted: false);
|
||||
await learner(anonymous: true);
|
||||
await learner(daysAgo: [1, 2]);
|
||||
await learner(daysAgo: [31, 32, 40]);
|
||||
await learner(mode: 'reset');
|
||||
await learner(deleted: true);
|
||||
|
||||
expect(await counted(), 0);
|
||||
});
|
||||
|
||||
test('a count goes down when learning stops', () async {
|
||||
await learner();
|
||||
expect(await counted(), 1);
|
||||
|
||||
await deletePerson(session, people.removeLast());
|
||||
|
||||
expect(await counted(), 0);
|
||||
});
|
||||
},
|
||||
databaseInterceptor: syncDatabaseInterceptor,
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,400 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.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';
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Reports and suggestions',
|
||||
(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)));
|
||||
|
||||
/// Writes through [write] as [person].
|
||||
Future<T> as<T>(
|
||||
UuidValue person,
|
||||
Future<T> Function(Transaction transaction) write,
|
||||
) => asPerson(session, person, write);
|
||||
|
||||
/// Alice's Japanese, published under [license]; returns its public id.
|
||||
Future<UuidValue> japanese({String license = 'CC-BY-SA-4.0'}) async {
|
||||
final studyset = await as(alice, (transaction) async {
|
||||
final studyset = (await Studyset.db.insertRow(
|
||||
session,
|
||||
Studyset(
|
||||
payload: json({
|
||||
'schemaVersion': 5,
|
||||
'title': 'Japanese',
|
||||
'license': license,
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
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: alice,
|
||||
payload: json({'schemaVersion': 9, 'type': 'frontBack'}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
return studyset;
|
||||
});
|
||||
return (await endpoints.publish.publish(
|
||||
signedIn(alice),
|
||||
studyset,
|
||||
'japanese',
|
||||
'',
|
||||
)).publicId;
|
||||
}
|
||||
|
||||
/// A thread of [kind] by [writer] on [publication]; its id.
|
||||
Future<UuidValue> thread(
|
||||
UuidValue writer,
|
||||
UuidValue publication, {
|
||||
String kind = 'report',
|
||||
Map<String, Object?> payload = const {'body': 'A typo.'},
|
||||
}) => as(
|
||||
writer,
|
||||
(transaction) async => (await FeedbackThread.db.insertRow(
|
||||
session,
|
||||
FeedbackThread(
|
||||
publicationId: publication,
|
||||
kind: kind,
|
||||
payload: json({'schemaVersion': 1, ...payload}),
|
||||
),
|
||||
transaction: transaction,
|
||||
)).id!,
|
||||
);
|
||||
|
||||
Future<void> reply(UuidValue by, UuidValue threadId, String body) => as(
|
||||
by,
|
||||
(transaction) => FeedbackMessage.db.insertRow(
|
||||
session,
|
||||
FeedbackMessage(
|
||||
threadId: threadId,
|
||||
payload: json({'schemaVersion': 1, 'body': body}),
|
||||
),
|
||||
transaction: transaction,
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> decide(UuidValue by, UuidValue threadId, String decision) =>
|
||||
as(
|
||||
by,
|
||||
(transaction) => FeedbackDecision.db.insertRow(
|
||||
session,
|
||||
FeedbackDecision(threadId: threadId, decision: decision),
|
||||
transaction: transaction,
|
||||
),
|
||||
);
|
||||
|
||||
Future<List<FeedbackView>> readBy(UuidValue person, UuidValue on) =>
|
||||
endpoints.feedback.threadsOf(signedIn(person), on);
|
||||
|
||||
test("a report reaches its author's inbox, new to them until read, and "
|
||||
'everyone learning it reads it with its writer', () async {
|
||||
final publication = await japanese();
|
||||
|
||||
final id = await thread(bob, publication);
|
||||
|
||||
final inbox = await endpoints.inbox.inbox(signedIn(alice));
|
||||
expect(inbox.single.threadId, id);
|
||||
expect(inbox.single.forMe, isTrue);
|
||||
expect(inbox.single.unseen, isTrue);
|
||||
expect(inbox.single.writer.handle, 'bob');
|
||||
expect(inbox.single.studysetTitle, 'Japanese');
|
||||
expect(
|
||||
(jsonDecode(inbox.single.payload) as Map<String, Object?>)['body'],
|
||||
'A typo.',
|
||||
);
|
||||
expect(await endpoints.inbox.unseen(signedIn(alice)), 1);
|
||||
await endpoints.inbox.markSeen(signedIn(alice), id);
|
||||
expect(await endpoints.inbox.unseen(signedIn(alice)), 0);
|
||||
// Public: Carol reads it too, and so does an anonymous person.
|
||||
expect((await readBy(carol, publication)).single.mine, isFalse);
|
||||
final anonymous = await person(null, anonymous: true);
|
||||
expect(
|
||||
await endpoints.feedback.threadsOf(
|
||||
signedIn(anonymous, identity: false),
|
||||
publication,
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
test('replies count from the writer and the author alone; a reply of '
|
||||
"the author's is new to the writer", () async {
|
||||
final publication = await japanese();
|
||||
final id = await thread(bob, publication);
|
||||
|
||||
await reply(alice, id, 'Fixed in v2.');
|
||||
await reply(bob, id, 'Thanks!');
|
||||
await reply(carol, id, 'Me too.');
|
||||
|
||||
final view = (await readBy(carol, publication)).single;
|
||||
expect([for (final m in view.messages) m.by.handle], ['alice', 'bob']);
|
||||
expect(view.messages.first.fromAuthor, isTrue);
|
||||
final mine = (await endpoints.inbox.inbox(signedIn(bob))).single;
|
||||
expect((mine.mine, mine.unseen), (true, true));
|
||||
});
|
||||
|
||||
test('decisions count from the author alone: closed stays readable, '
|
||||
'hidden shows to the writer and the author', () async {
|
||||
final publication = await japanese();
|
||||
final id = await thread(bob, publication);
|
||||
|
||||
await decide(bob, id, 'closed');
|
||||
expect((await readBy(carol, publication)).single.status, 'open');
|
||||
await decide(alice, id, 'closed');
|
||||
expect((await readBy(carol, publication)).single.status, 'closed');
|
||||
await decide(alice, id, 'hidden');
|
||||
|
||||
expect(await readBy(carol, publication), isEmpty);
|
||||
expect((await readBy(bob, publication)).single.hidden, isTrue);
|
||||
expect(await readBy(alice, publication), hasLength(1));
|
||||
await decide(alice, id, 'shown');
|
||||
expect(await readBy(carol, publication), hasLength(1));
|
||||
});
|
||||
|
||||
test('only people with an identity and a public profile write, and '
|
||||
'no suggestion under "no derivatives"', () async {
|
||||
final publication = await japanese(license: 'CC-BY-ND-4.0');
|
||||
final anonymous = await person('anon', anonymous: true);
|
||||
final unnamed = await person(null);
|
||||
|
||||
await thread(anonymous, publication);
|
||||
await thread(unnamed, publication);
|
||||
await thread(bob, publication, kind: 'suggestion');
|
||||
await thread(bob, publication);
|
||||
|
||||
final threads = await readBy(carol, publication);
|
||||
expect([for (final t in threads) t.kind], ['report']);
|
||||
});
|
||||
|
||||
test(
|
||||
'a blocked person writes for themselves alone, until unblocked',
|
||||
() async {
|
||||
final publication = await japanese();
|
||||
await thread(bob, publication);
|
||||
|
||||
await endpoints.inbox.block(signedIn(alice), 'BOB');
|
||||
|
||||
expect(await endpoints.inbox.blockedPeople(signedIn(alice)), ['bob']);
|
||||
expect(await readBy(carol, publication), isEmpty);
|
||||
expect(await endpoints.inbox.inbox(signedIn(alice)), isEmpty);
|
||||
expect(await readBy(bob, publication), hasLength(1));
|
||||
await endpoints.inbox.unblock(signedIn(alice), 'bob');
|
||||
expect(await readBy(carol, publication), hasLength(1));
|
||||
await expectLater(
|
||||
endpoints.inbox.block(signedIn(alice), 'nobody'),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("a suggestion's files go to the author alone", () async {
|
||||
final publication = await japanese();
|
||||
final image = utf8.encode('an image');
|
||||
final hash = sha256.convert(image).toString();
|
||||
await session.storage.storeFile(
|
||||
storageId: assetStorage,
|
||||
path: assetPath(bob, hash),
|
||||
byteData: ByteData.sublistView(Uint8List.fromList(image)),
|
||||
);
|
||||
final id = await thread(
|
||||
bob,
|
||||
publication,
|
||||
kind: 'suggestion',
|
||||
payload: {
|
||||
'files': [
|
||||
{'sha256': hash, 'mimeType': 'image/png'},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
Uint8List.sublistView(
|
||||
await endpoints.inbox.file(signedIn(alice), id, hash),
|
||||
),
|
||||
image,
|
||||
);
|
||||
for (final (who, sha) in [
|
||||
(carol, hash),
|
||||
(alice, '0' * 64),
|
||||
(alice, '../$hash'),
|
||||
]) {
|
||||
await expectLater(
|
||||
endpoints.inbox.file(signedIn(who), id, sha),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
reason: '$who $sha',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the next release credits the writers of the suggestions accepted '
|
||||
'since the last', () async {
|
||||
final publication = await japanese();
|
||||
final accepted = await thread(bob, publication, kind: 'suggestion');
|
||||
final declined = await thread(carol, publication, kind: 'suggestion');
|
||||
await decide(alice, accepted, 'accepted');
|
||||
await decide(alice, declined, 'declined');
|
||||
// Something to release.
|
||||
final studysetId = (await PublishedStudyset.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.publicId.equals(publication),
|
||||
))!.studysetId;
|
||||
await as(
|
||||
alice,
|
||||
(transaction) => Card.db.insertRow(
|
||||
session,
|
||||
Card(studysetId: studysetId, position: 'a1'),
|
||||
transaction: transaction,
|
||||
),
|
||||
);
|
||||
await as(alice, (transaction) async {
|
||||
final card = (await Card.db.find(
|
||||
session,
|
||||
where: (t) => t.position.equals('a1'),
|
||||
transaction: transaction,
|
||||
)).single;
|
||||
await CardRevision.db.insertRow(
|
||||
session,
|
||||
CardRevision(
|
||||
cardId: card.id!,
|
||||
parents: [],
|
||||
authorId: alice,
|
||||
payload: json({'schemaVersion': 9, 'type': 'frontBack'}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
|
||||
await endpoints.publish.publish(
|
||||
signedIn(alice),
|
||||
studysetId,
|
||||
'japanese',
|
||||
'',
|
||||
);
|
||||
|
||||
final log = await Process.run('git', [
|
||||
'--git-dir',
|
||||
repositoryOf(publication).directory.path,
|
||||
'log',
|
||||
'-1',
|
||||
'--format=%B',
|
||||
'v2',
|
||||
]);
|
||||
expect('${log.stdout}', contains('Co-authored-by: bob <bob@'));
|
||||
expect('${log.stdout}', isNot(contains('carol')));
|
||||
});
|
||||
|
||||
test('the inbox needs an identity; reading needs signing in', () async {
|
||||
final publication = await japanese();
|
||||
final anonymous = signedIn(alice, identity: false);
|
||||
|
||||
await expectLater(
|
||||
endpoints.inbox.inbox(anonymous),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.inbox.block(anonymous, 'bob'),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.feedback.threadsOf(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
publication,
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('deleting a person deletes whom they blocked and what they '
|
||||
'read', () async {
|
||||
final publication = await japanese();
|
||||
final id = await thread(bob, publication);
|
||||
await endpoints.inbox.block(signedIn(alice), 'carol');
|
||||
await endpoints.inbox.markSeen(signedIn(alice), id);
|
||||
|
||||
await deletePerson(session, alice);
|
||||
people.remove(alice);
|
||||
|
||||
expect(await FeedbackBlock.db.count(session), 0);
|
||||
expect(await FeedbackSeen.db.count(session), 0);
|
||||
});
|
||||
},
|
||||
databaseInterceptor: syncDatabaseInterceptor,
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:halkyon_learn_server/src/auth/learn_base.dart';
|
||||
import 'package:halkyon_learn_server/src/instance/app_compatibility.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/auth_fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod('What the instance offers', (sessionBuilder, endpoints) {
|
||||
setUp(() => useLearnBase(sessionBuilder));
|
||||
|
||||
test('names this repository as the source when the operator set '
|
||||
'nothing', () async {
|
||||
// The tests run without SOURCE_URL.
|
||||
final info = await endpoints.instance.info(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(info.sourceCodeUrl, upstreamSourceCodeUrl);
|
||||
});
|
||||
|
||||
test('names the oldest app build it still works with, to anyone, so an '
|
||||
'older app can say it needs an update', () async {
|
||||
final info = await endpoints.instance.info(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(info.minimumAppBuild, minimumAppBuild);
|
||||
});
|
||||
|
||||
test('suggests no Jellyfin server where the operator set none and the '
|
||||
'instance has no domain of its own (localhost)', () async {
|
||||
final info = await endpoints.instance.info(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(info.jellyfinUrl, isNull);
|
||||
});
|
||||
|
||||
test('says nothing of where its data and backups are where the '
|
||||
'operator said nothing', () async {
|
||||
// The tests run without DATA_LOCATION and BACKUP_LOCATION.
|
||||
final info = await endpoints.instance.info(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(info.dataLocation, isNull);
|
||||
expect(info.backupLocation, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
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:serverpod/protocol.dart' show CloudStorageEntry;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/auth_fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Public profiles',
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
|
||||
setUp(() {
|
||||
useLearnBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
});
|
||||
tearDown(
|
||||
() => PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0),
|
||||
);
|
||||
|
||||
/// A person with an identity, signed in.
|
||||
TestSessionBuilder identifiedPerson() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{identified},
|
||||
),
|
||||
);
|
||||
|
||||
final alice = identifiedPerson();
|
||||
final bob = identifiedPerson();
|
||||
|
||||
Future<Profile> save(
|
||||
TestSessionBuilder person,
|
||||
String handle, {
|
||||
String displayName = '',
|
||||
String bio = '',
|
||||
List<String> links = const [],
|
||||
}) => endpoints.profile.save(person, handle, displayName, bio, links);
|
||||
|
||||
Matcher refused(ProfileRefusal reason) => throwsA(
|
||||
isA<ProfileRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
reason,
|
||||
),
|
||||
);
|
||||
|
||||
test('a person with an identity makes a profile, found by its handle '
|
||||
'whatever its case, at the account domain', () async {
|
||||
final saved = await save(
|
||||
alice,
|
||||
'Raisondetredev',
|
||||
displayName: ' Tizian ',
|
||||
bio: 'Learning Japanese.',
|
||||
links: [' https://halkyon.app ', ''],
|
||||
);
|
||||
|
||||
expect(saved.handle, 'Raisondetredev');
|
||||
expect(saved.address, startsWith('Raisondetredev@'));
|
||||
expect(saved.displayName, 'Tizian');
|
||||
expect(saved.links, ['https://halkyon.app']);
|
||||
expect(saved.handleFixed, isFalse);
|
||||
// Generated classes compare by identity: their JSON by value.
|
||||
expect((await endpoints.profile.mine(alice))!.toJson(), saved.toJson());
|
||||
expect(
|
||||
(await endpoints.profiles.byHandle(bob, 'raisondetredev')).toJson(),
|
||||
saved.toJson(),
|
||||
);
|
||||
});
|
||||
|
||||
test('an anonymous person has no profile to make or read', () async {
|
||||
final anonymous = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
save(anonymous, 'nobody'),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.profile.mine(anonymous),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.profile.checkHandle(anonymous, 'nobody'),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
expect(await PublicProfile.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('a handle is checked as saving would check it: free, or your '
|
||||
'own, whatever its case; else why not', () async {
|
||||
await save(alice, 'alice');
|
||||
|
||||
Future<ProfileRefusal?> check(String handle) =>
|
||||
endpoints.profile.checkHandle(bob, handle);
|
||||
expect(await check('bob'), isNull);
|
||||
expect(await check('ALICE'), ProfileRefusal.handleTaken);
|
||||
expect(await check('a-b'), ProfileRefusal.handleInvalid);
|
||||
expect(await check('Admin'), ProfileRefusal.handleReserved);
|
||||
expect(
|
||||
await endpoints.profile.checkHandle(alice, 'Alice'),
|
||||
isNull,
|
||||
);
|
||||
// Checking makes nothing.
|
||||
expect(await endpoints.profile.mine(bob), isNull);
|
||||
});
|
||||
|
||||
test('nobody signed in reads profiles', () async {
|
||||
await save(alice, 'alice');
|
||||
|
||||
await expectLater(
|
||||
endpoints.profiles.byHandle(sessionBuilder, 'alice'),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a handle someone else has is refused, whatever its case; their '
|
||||
'profile stays theirs', () async {
|
||||
await save(alice, 'alice', displayName: 'Alice');
|
||||
|
||||
await expectLater(
|
||||
save(bob, 'ALICE'),
|
||||
refused(ProfileRefusal.handleTaken),
|
||||
);
|
||||
expect((await endpoints.profile.mine(alice))!.displayName, 'Alice');
|
||||
expect(await endpoints.profile.mine(bob), isNull);
|
||||
});
|
||||
|
||||
test('handles are 1 to 30 letters, digits or underscores, not one '
|
||||
'kept for the instance', () async {
|
||||
for (final handle in ['', 'with space', 'é', 'a-b', 'a' * 31]) {
|
||||
await expectLater(
|
||||
save(alice, handle),
|
||||
refused(ProfileRefusal.handleInvalid),
|
||||
reason: handle,
|
||||
);
|
||||
}
|
||||
await expectLater(
|
||||
save(alice, 'Admin'),
|
||||
refused(ProfileRefusal.handleReserved),
|
||||
);
|
||||
expect((await save(alice, 'a_1' * 10)).handle, 'a_1' * 10);
|
||||
});
|
||||
|
||||
test('the name, bio and links stay within their bounds', () async {
|
||||
await expectLater(
|
||||
save(alice, 'alice', displayName: 'x' * 31),
|
||||
refused(ProfileRefusal.displayNameTooLong),
|
||||
);
|
||||
await expectLater(
|
||||
save(alice, 'alice', bio: 'x' * 501),
|
||||
refused(ProfileRefusal.bioTooLong),
|
||||
);
|
||||
await expectLater(
|
||||
save(alice, 'alice', links: List.filled(5, 'https://a.example')),
|
||||
refused(ProfileRefusal.tooManyLinks),
|
||||
);
|
||||
for (final link in ['halkyon.app', 'ftp://a.example', 'https://']) {
|
||||
await expectLater(
|
||||
save(alice, 'alice', links: [link]),
|
||||
refused(ProfileRefusal.linkInvalid),
|
||||
reason: link,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the handle changes until the first publish; then it is fixed, '
|
||||
'and the profile stays, though the rest still changes', () async {
|
||||
await save(alice, 'alice');
|
||||
expect((await save(alice, 'alice_jp')).handle, 'alice_jp');
|
||||
|
||||
final row = (await PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.handleKey.equals('alice_jp'),
|
||||
))!;
|
||||
await PublicProfile.db.updateRow(
|
||||
session,
|
||||
row.copyWith(firstPublishedAt: clock.now().toUtc()),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
save(alice, 'alice'),
|
||||
refused(ProfileRefusal.published),
|
||||
);
|
||||
expect(
|
||||
await endpoints.profile.checkHandle(alice, 'alice'),
|
||||
ProfileRefusal.published,
|
||||
);
|
||||
expect(
|
||||
await endpoints.profile.checkHandle(alice, 'ALICE_jp'),
|
||||
isNull,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.profile.remove(alice),
|
||||
refused(ProfileRefusal.published),
|
||||
);
|
||||
final renamed = await save(alice, 'Alice_JP', displayName: 'Alice');
|
||||
expect((renamed.handle, renamed.handleFixed), ('Alice_JP', true));
|
||||
});
|
||||
|
||||
test('removing a profile frees its handle', () async {
|
||||
await save(alice, 'alice');
|
||||
|
||||
await endpoints.profile.remove(alice);
|
||||
|
||||
expect(await endpoints.profile.mine(alice), isNull);
|
||||
expect((await save(bob, 'alice')).handle, 'alice');
|
||||
});
|
||||
|
||||
test("a handle nobody has isn't found", () async {
|
||||
await expectLater(
|
||||
endpoints.profiles.byHandle(alice, 'nobody'),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('deleting a person deletes their profile', () async {
|
||||
useLearnBase(sessionBuilder);
|
||||
final carol = (await AuthServices.instance.authUsers.create(session))
|
||||
.id;
|
||||
await endpoints.profile.save(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
carol.uuid,
|
||||
{identified},
|
||||
),
|
||||
),
|
||||
'carol',
|
||||
'',
|
||||
'',
|
||||
[],
|
||||
);
|
||||
|
||||
await deletePerson(session, carol);
|
||||
|
||||
expect(await PublicProfile.db.count(session), 0);
|
||||
});
|
||||
|
||||
group('An avatar', () {
|
||||
/// A PNG of one pixel, [shade] of grey.
|
||||
ByteData png(int shade) {
|
||||
final bytes = base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
);
|
||||
// A byte past the signature, so each shade is another file.
|
||||
bytes[bytes.length - 1] = shade;
|
||||
return ByteData.sublistView(bytes);
|
||||
}
|
||||
|
||||
Future<int> stored() => CloudStorageEntry.db.count(
|
||||
session,
|
||||
where: (t) => t.storageId.equals('public'),
|
||||
);
|
||||
|
||||
tearDown(
|
||||
() => CloudStorageEntry.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.storageId.equals('public'),
|
||||
),
|
||||
);
|
||||
|
||||
test('is shown from a public URL that names the picture, not the '
|
||||
'person', () async {
|
||||
await save(alice, 'alice');
|
||||
|
||||
final profile = await endpoints.profile.setAvatar(alice, png(1));
|
||||
|
||||
final url = profile.avatarUrl!;
|
||||
expect(url, contains('avatars'));
|
||||
final row = (await PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.handleKey.equals('alice'),
|
||||
))!;
|
||||
expect(url, isNot(contains(row.authUserId.toString())));
|
||||
expect(await stored(), 1);
|
||||
});
|
||||
|
||||
test('is a PNG of at most 1 MB, nothing else', () async {
|
||||
await save(alice, 'alice');
|
||||
|
||||
await expectLater(
|
||||
endpoints.profile.setAvatar(
|
||||
alice,
|
||||
ByteData.sublistView(Uint8List.fromList(utf8.encode('GIF89a…'))),
|
||||
),
|
||||
refused(ProfileRefusal.avatarInvalid),
|
||||
);
|
||||
final huge = Uint8List(1024 * 1024 + 1)
|
||||
..setRange(0, 8, png(1).buffer.asUint8List(0, 8));
|
||||
await expectLater(
|
||||
endpoints.profile.setAvatar(alice, ByteData.sublistView(huge)),
|
||||
refused(ProfileRefusal.avatarInvalid),
|
||||
);
|
||||
expect(await stored(), 0);
|
||||
});
|
||||
|
||||
test('needs a profile to show on', () async {
|
||||
await expectLater(
|
||||
endpoints.profile.setAvatar(alice, png(1)),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a new one replaces the old, whose file goes; one another '
|
||||
'profile shows stays', () async {
|
||||
await save(alice, 'alice');
|
||||
await save(bob, 'bob');
|
||||
await endpoints.profile.setAvatar(alice, png(1));
|
||||
await endpoints.profile.setAvatar(bob, png(1));
|
||||
|
||||
await endpoints.profile.setAvatar(alice, png(2));
|
||||
expect(await stored(), 2);
|
||||
await endpoints.profile.setAvatar(bob, png(3));
|
||||
expect(await stored(), 2);
|
||||
|
||||
final removed = await endpoints.profile.removeAvatar(alice);
|
||||
expect(removed.avatarUrl, isNull);
|
||||
expect(await stored(), 1);
|
||||
});
|
||||
|
||||
test('goes with its person, and with the profile', () async {
|
||||
useLearnBase(sessionBuilder);
|
||||
final carol = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
)).id;
|
||||
final asCarol = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
carol.uuid,
|
||||
{identified},
|
||||
),
|
||||
);
|
||||
await save(asCarol, 'carol');
|
||||
await endpoints.profile.setAvatar(asCarol, png(4));
|
||||
await save(alice, 'alice');
|
||||
await endpoints.profile.setAvatar(alice, png(5));
|
||||
|
||||
await deletePerson(session, carol);
|
||||
expect(await stored(), 1);
|
||||
await endpoints.profile.remove(alice);
|
||||
expect(await stored(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('the instance says which domain handles are on', () async {
|
||||
expect(
|
||||
(await endpoints.instance.info(sessionBuilder)).accountDomain,
|
||||
isNotEmpty,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/link_verification.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/profile_page.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
/// Pages by their address, in place of the web; a link without one can't
|
||||
/// be had.
|
||||
final class _Web implements LinkFetcher {
|
||||
final pages = <String, String>{};
|
||||
|
||||
@override
|
||||
Future<FetchedPage?> fetch(Uri link) async => switch (pages['$link']) {
|
||||
final html? => (url: link, html: html),
|
||||
null => null,
|
||||
};
|
||||
}
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Verified profile links',
|
||||
// Checks are counted by Serverpod's rate limiter, in transactions of
|
||||
// their own, which the test's rollback can't wrap.
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late _Web web;
|
||||
|
||||
TestSessionBuilder identifiedPerson() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{identified},
|
||||
),
|
||||
);
|
||||
final alice = identifiedPerson();
|
||||
final bob = identifiedPerson();
|
||||
|
||||
setUp(() {
|
||||
session = sessionBuilder.build();
|
||||
web = _Web();
|
||||
LinkChecks.of(session.serverpod).fetcher = web;
|
||||
});
|
||||
tearDown(() async {
|
||||
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await RateLimitedRequestAttempt.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
});
|
||||
|
||||
/// A page linking back to [handle]'s profile page with `rel="me"`.
|
||||
String linkingBack(String handle) =>
|
||||
'<a rel="me" href="${profilePageUrl(session.serverpod, handle)}">'
|
||||
'My profile</a>';
|
||||
|
||||
Future<Profile> save(
|
||||
TestSessionBuilder person,
|
||||
String handle,
|
||||
List<String> links,
|
||||
) => endpoints.profile.save(person, handle, 'Alice', '', links);
|
||||
|
||||
Matcher refused(ProfileRefusal reason) => throwsA(
|
||||
isA<ProfileRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
reason,
|
||||
),
|
||||
);
|
||||
|
||||
test('checking is refused when not signed in, and to an anonymous '
|
||||
'person, who has no profile', () async {
|
||||
await expectLater(
|
||||
endpoints.profile.checkLinks(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.profile.checkLinks(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
),
|
||||
),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('checking marks the links whose pages link back to the profile '
|
||||
"page, which others see too, beside the page's address", () async {
|
||||
await save(alice, 'Alice', [
|
||||
'https://alice.example/',
|
||||
'https://social.example/@alice',
|
||||
'https://elsewhere.example/',
|
||||
]);
|
||||
web.pages['https://alice.example/'] = linkingBack('alice');
|
||||
web.pages['https://social.example/@alice'] =
|
||||
'<a href="${profilePageUrl(session.serverpod, 'Alice')}">me</a>';
|
||||
|
||||
final checked = await endpoints.profile.checkLinks(alice);
|
||||
|
||||
expect(checked.verifiedLinks, ['https://alice.example/']);
|
||||
expect(
|
||||
checked.pageUrl,
|
||||
'${profilePageUrl(session.serverpod, 'Alice')}',
|
||||
);
|
||||
expect(checked.pageUrl, endsWith('/@Alice'));
|
||||
expect(
|
||||
(await endpoints.profiles.byHandle(bob, 'alice')).verifiedLinks,
|
||||
['https://alice.example/'],
|
||||
);
|
||||
});
|
||||
|
||||
test('saving keeps the checks of the links left as they were, and drops '
|
||||
'those of links changed or removed; a new handle drops them all, as '
|
||||
'the page moved', () async {
|
||||
await save(alice, 'alice', [
|
||||
'https://alice.example/',
|
||||
'https://blog.example/',
|
||||
]);
|
||||
web.pages['https://alice.example/'] = linkingBack('alice');
|
||||
web.pages['https://blog.example/'] = linkingBack('alice');
|
||||
await endpoints.profile.checkLinks(alice);
|
||||
|
||||
final saved = await save(alice, 'alice', [
|
||||
'https://alice.example/',
|
||||
'https://blog.example/new',
|
||||
]);
|
||||
expect(saved.verifiedLinks, ['https://alice.example/']);
|
||||
|
||||
final renamed = await save(alice, 'alice2', [
|
||||
'https://alice.example/',
|
||||
]);
|
||||
expect(renamed.verifiedLinks, isEmpty);
|
||||
});
|
||||
|
||||
test('an eleventh check within the hour is refused', () async {
|
||||
await save(alice, 'alice', ['https://alice.example/']);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await endpoints.profile.checkLinks(alice);
|
||||
}
|
||||
|
||||
await expectLater(
|
||||
endpoints.profile.checkLinks(alice),
|
||||
refused(ProfileRefusal.tooManyLinkChecks),
|
||||
);
|
||||
});
|
||||
|
||||
group('the profile page', () {
|
||||
test("shows the profile in the visitor's language, its links marked "
|
||||
'rel="me", what anybody wrote escaped', () async {
|
||||
await endpoints.profile.save(
|
||||
alice,
|
||||
'alice',
|
||||
'<script>Alice</script>',
|
||||
'Learns Japanese & German.\nLikes tea.',
|
||||
['https://alice.example/', 'https://blog.example/?a=1&b=2'],
|
||||
);
|
||||
web.pages['https://alice.example/'] = linkingBack('alice');
|
||||
await endpoints.profile.checkLinks(alice);
|
||||
|
||||
final page = await profilePage(
|
||||
session,
|
||||
'@ALICE',
|
||||
host: profilePageUrl(session.serverpod, 'alice').host,
|
||||
acceptLanguage: 'de-CH,de;q=0.9,en;q=0.5',
|
||||
);
|
||||
|
||||
expect(page.status, 200);
|
||||
expect(page.html, contains('<html lang="de">'));
|
||||
expect(page.html, contains('<script>Alice</script>'));
|
||||
expect(page.html, isNot(contains('<script>')));
|
||||
expect(page.html, contains('Learns Japanese & German.<br>'));
|
||||
expect(
|
||||
page.html,
|
||||
contains('<link rel="me" href="https://alice.example/">'),
|
||||
);
|
||||
expect(
|
||||
page.html,
|
||||
contains(
|
||||
'<a rel="me noopener" href="https://blog.example/?a=1&b=2">',
|
||||
),
|
||||
);
|
||||
expect(page.html, contains('geprüft'));
|
||||
expect(page.html, contains('alice@'));
|
||||
});
|
||||
|
||||
test(
|
||||
'is not found for a handle nobody has, or anything else',
|
||||
() async {
|
||||
for (final segment in ['@nobody', 'alice', '@', '@a/b']) {
|
||||
final page = await profilePage(
|
||||
session,
|
||||
segment,
|
||||
host: profilePageUrl(session.serverpod, 'x').host,
|
||||
);
|
||||
expect(page.status, 404, reason: segment);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'asked for on another host, sends the visitor to its own address',
|
||||
() async {
|
||||
await save(alice, 'alice', const []);
|
||||
|
||||
final page = await profilePage(
|
||||
session,
|
||||
'@alice',
|
||||
host: 'elsewhere.example',
|
||||
);
|
||||
|
||||
expect(page.status, 308);
|
||||
expect(
|
||||
page.location,
|
||||
'${profilePageUrl(session.serverpod, 'alice')}',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/learn_base.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
/// Sets the server up as it runs (lib/server.dart): halkyon_base as Learn
|
||||
/// configures it (ADR 0014), so deleting a person deletes Learn's data of
|
||||
/// theirs too, and authentication. On the pod [sessionBuilder]'s sessions
|
||||
/// run on, not on `Serverpod.instance`: that is the pod made last, and a test
|
||||
/// file with several `withServerpod` groups has a pod each.
|
||||
void useLearnBase(TestSessionBuilder sessionBuilder) {
|
||||
final pod = sessionBuilder.build().serverpod;
|
||||
HalkyonBase.configure(pod, learnBase());
|
||||
AuthServices.set(
|
||||
tokenManagerBuilders: tokenManagerBuilders,
|
||||
identityProviderBuilders: identityProviderBuilders(pod),
|
||||
);
|
||||
}
|
||||
|
||||
/// Signs [person] in on a new device and returns the new session's id.
|
||||
Future<UuidValue> signInNewDevice(
|
||||
Session session,
|
||||
UuidValue person, {
|
||||
Set<Scope> scopes = const {},
|
||||
}) async {
|
||||
Future<Set<UuidValue>> sessionIds() async => {
|
||||
for (final serverSideSession in await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
))
|
||||
serverSideSession.id!,
|
||||
};
|
||||
|
||||
final before = await sessionIds();
|
||||
await AuthServices.instance.tokenManager.createToken(
|
||||
session,
|
||||
authUserId: person,
|
||||
method: 'test',
|
||||
scopes: scopes,
|
||||
);
|
||||
return (await sessionIds()).difference(before).single;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
|
||||
/// The sync engine's database, as the running server has it, for
|
||||
/// `withServerpod(databaseInterceptor: …)`: 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 tests using it turn rollback off and
|
||||
/// delete what they made.
|
||||
Database syncDatabaseInterceptor(Session session, Database inner) {
|
||||
final database = offlineSyncDatabaseInterceptor(session, inner);
|
||||
if (database is OfflineSyncDatabase) {
|
||||
_syncDatabases[session] = database;
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
/// The sync engine's database of each session, as the test pod's own
|
||||
/// database wraps it.
|
||||
final _syncDatabases = Expando<OfflineSyncDatabase>();
|
||||
|
||||
/// Writes through [write] as [person] in their personal space, as their
|
||||
/// device would, on [session]'s sync engine database.
|
||||
Future<T> asPerson<T>(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
Future<T> Function(Transaction transaction) write,
|
||||
) => _syncDatabases[session]!.transactionForUser(person, write);
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue