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
2 changed files with 0 additions and 0 deletions

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