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:
Tizian 「ティツィアーン」 2026-10-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 0 additions and 0 deletions

View file

@ -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)),
);

View file

@ -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},
],
},
},
],
},
},
};

View file

@ -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;

View file

@ -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]);
},
);
});
}

View file

@ -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>()),
);
});
},
);
}

View file

@ -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');
});
}

View file

@ -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,
);
}

View file

@ -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)));

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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);
});
});
}

View file

@ -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,
);
});
},
);
}

View file

@ -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('&lt;script&gt;Alice&lt;/script&gt;'));
expect(page.html, isNot(contains('<script>')));
expect(page.html, contains('Learns Japanese &amp; 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&amp;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

View file

@ -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;
}

View file

@ -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);