feat(train): Halkyon Train's shell, Todo's copied and renamed (ADR 0030, D12)
apps/train with halkyon_train_server, _client and _flutter, as Todo's: halkyon_base configured by trainBase(), its own register alone and a member of the instance's where the operator names one, the app on halkyon_app's gates with Train's frame, account sheet and About, texts in British English and German that speak of workouts, app.halkyon.train on Android, iOS, Linux, macOS and Windows. Where it differs from Todo: development ports of its own (8180 to 8190), so it runs beside Learn's server; one initial migration with the schema Todo's migrations end at (the same tables, compared line by line), not Todo's seventeen; and Todo's icon as a placeholder until the icon tool of ADR 0031 draws Train's bike. The workspace, the licences, the READMEs and AGENTS.md name it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
2fd39352fc
commit
7b5fbee913
3 changed files with 22363 additions and 7 deletions
|
|
@ -0,0 +1,17 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_train_server/src/train_base.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 Train
|
||||
/// configures it (ADR 0014), and authentication. On the pod [sessionBuilder]'s
|
||||
/// sessions run on, not on `Serverpod.instance`: that is the pod made last.
|
||||
void useTrainBase(TestSessionBuilder sessionBuilder) {
|
||||
final pod = sessionBuilder.build().serverpod;
|
||||
HalkyonBase.configure(pod, trainBase());
|
||||
AuthServices.set(
|
||||
tokenManagerBuilders: tokenManagerBuilders,
|
||||
identityProviderBuilders: identityProviderBuilders(pod),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
// ignore_for_file: no_leading_underscores_for_local_identifiers
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:io' as _idi;
|
||||
import 'package:halkyon_train_server/src/generated/future_calls.dart'
|
||||
as _i59xasdy;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_test/serverpod_test.dart' as _ist;
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/generated/endpoints.dart';
|
||||
export 'package:serverpod_test/serverpod_test_public_exports.dart';
|
||||
|
||||
/// Creates a new test group that takes a callback that can be used to write tests.
|
||||
/// The callback has two parameters: `sessionBuilder` and `endpoints`.
|
||||
/// `sessionBuilder` is used to build a `Session` object that represents the server state during an endpoint call and is used to set up scenarios.
|
||||
/// `endpoints` contains all your Serverpod endpoints and lets you call them:
|
||||
/// ```dart
|
||||
/// withServerpod('Given Example endpoint', (sessionBuilder, endpoints) {
|
||||
/// test('when calling `hello` then should return greeting', () async {
|
||||
/// final greeting = await endpoints.example.hello(sessionBuilder, 'Michael');
|
||||
/// expect(greeting, 'Hello Michael');
|
||||
/// });
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// **Configuration options**
|
||||
///
|
||||
/// [applyMigrations] Whether pending migrations should be applied when starting Serverpod. Defaults to `true`
|
||||
///
|
||||
/// [enableSessionLogging] Whether session logging should be enabled. Defaults to `false`
|
||||
///
|
||||
/// [ephemeralDatabase] Whether this group gets its own empty database, created when the group starts and dropped when it finishes. Defaults to `true`.
|
||||
/// Set this to `false` to use the database configured for [runMode], including a previously seeded database. [configOverride] can still replace that database. The configured database is not created or dropped, so groups that share it cannot run in parallel. [rollbackDatabase] still controls whether writes inside the group are rolled back.
|
||||
///
|
||||
/// [rollbackDatabase] Options for when to rollback the database during the test lifecycle.
|
||||
/// By default `withServerpod` does all database operations inside a transaction that is rolled back after each `test` case.
|
||||
/// Just like the following enum describes, the behavior of the automatic rollbacks can be configured:
|
||||
/// ```dart
|
||||
/// /// Options for when to rollback the database during the test lifecycle.
|
||||
/// enum RollbackDatabase {
|
||||
/// /// After each test. This is the default.
|
||||
/// afterEach,
|
||||
///
|
||||
/// /// After all tests.
|
||||
/// afterAll,
|
||||
///
|
||||
/// /// Disable rolling back the database.
|
||||
/// disabled,
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// [runMode] The run mode that Serverpod should be running in. Defaults to `test`.
|
||||
///
|
||||
/// [serverpodLoggingMode] The logging mode used when creating Serverpod. Defaults to `ServerpodLoggingMode.normal`
|
||||
///
|
||||
/// [serverpodStartTimeout] The timeout to use when starting Serverpod, which connects to the database among other things. Defaults to `Duration(seconds: 120)`.
|
||||
///
|
||||
/// [testServerOutputMode] Options for controlling test server output during test execution. Defaults to `TestServerOutputMode.normal`.
|
||||
/// ```dart
|
||||
/// /// Options for controlling test server output during test execution.
|
||||
/// enum TestServerOutputMode {
|
||||
/// /// Default mode - only stderr is printed (stdout suppressed).
|
||||
/// /// This hides normal startup/shutdown logs while preserving error messages.
|
||||
/// normal,
|
||||
///
|
||||
/// /// All logging - both stdout and stderr are printed.
|
||||
/// /// Useful for debugging when you need to see all server output.
|
||||
/// verbose,
|
||||
///
|
||||
/// /// No logging - both stdout and stderr are suppressed.
|
||||
/// /// Completely silent mode, useful when you don't want any server output.
|
||||
/// silent,
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// [configOverride] A function to override the server configuration. This function is called with
|
||||
/// the default server configuration after it is loaded from the config/ directory
|
||||
/// and before it is used to start the server. Use this to override particular
|
||||
/// settings in the server configuration.
|
||||
///
|
||||
/// [databaseInterceptor] Optional interceptor that replaces the default database for each session.
|
||||
/// See [Serverpod.databaseInterceptor] for more information.
|
||||
///
|
||||
/// [testGroupTagsOverride] By default Serverpod test tools tags the `withServerpod` test group with `"integration"`.
|
||||
/// This is to provide a simple way to only run unit or integration tests.
|
||||
/// This property allows this tag to be overridden to something else. Defaults to `['integration']`.
|
||||
///
|
||||
/// [experimentalFeatures] Optionally specify experimental features. See [Serverpod] for more information.
|
||||
///
|
||||
/// [serverDirectory] The server package directory `config/<runMode>.yaml`, `config/passwords.yaml`,
|
||||
/// and `migrations/<module>/...` are resolved against. Defaults to
|
||||
/// [Directory.current] at the time the test boots. Pass this when the test
|
||||
/// isolate's cwd is not the server package root (e.g. running tests from a
|
||||
/// workspace parent directory) so config and migrations are still loaded
|
||||
/// from the right place.
|
||||
@_ist.isTestGroup
|
||||
void withServerpod(
|
||||
String testGroupName,
|
||||
_ist.TestClosure<TestEndpoints> testClosure, {
|
||||
bool? applyMigrations,
|
||||
_is.ServerpodConfig Function(_is.ServerpodConfig)? configOverride,
|
||||
_is.DatabaseInterceptor? databaseInterceptor,
|
||||
bool? enableSessionLogging,
|
||||
bool? ephemeralDatabase,
|
||||
_is.ExperimentalFeatures? experimentalFeatures,
|
||||
_ist.RollbackDatabase? rollbackDatabase,
|
||||
String? runMode,
|
||||
_is.RuntimeParametersListBuilder? runtimeParametersBuilder,
|
||||
_idi.Directory? serverDirectory,
|
||||
_is.ServerpodLoggingMode? serverpodLoggingMode,
|
||||
Duration? serverpodStartTimeout,
|
||||
List<String>? testGroupTagsOverride,
|
||||
_ist.TestServerOutputMode? testServerOutputMode,
|
||||
}) {
|
||||
_ist.buildWithServerpod<_InternalTestEndpoints>(
|
||||
testGroupName,
|
||||
_ist.TestServerpod(
|
||||
testEndpoints: _InternalTestEndpoints(),
|
||||
endpoints: Endpoints(),
|
||||
serializationManager: Protocol(),
|
||||
runMode: runMode,
|
||||
applyMigrations: applyMigrations,
|
||||
ephemeralDatabase: ephemeralDatabase,
|
||||
isDatabaseEnabled: true,
|
||||
serverpodLoggingMode: serverpodLoggingMode,
|
||||
testServerOutputMode: testServerOutputMode,
|
||||
serverDirectory: serverDirectory,
|
||||
experimentalFeatures: experimentalFeatures,
|
||||
configOverride: configOverride,
|
||||
runtimeParametersBuilder: runtimeParametersBuilder,
|
||||
databaseInterceptor: databaseInterceptor,
|
||||
),
|
||||
maybeRollbackDatabase: rollbackDatabase,
|
||||
maybeEnableSessionLogging: enableSessionLogging,
|
||||
maybeTestGroupTagsOverride: testGroupTagsOverride,
|
||||
maybeServerpodStartTimeout: serverpodStartTimeout,
|
||||
maybeTestServerOutputMode: testServerOutputMode,
|
||||
)(testClosure);
|
||||
}
|
||||
|
||||
class TestEndpoints {
|
||||
late final futureCalls = _FutureCalls();
|
||||
}
|
||||
|
||||
class _InternalTestEndpoints extends TestEndpoints
|
||||
implements _ist.InternalTestEndpoints {
|
||||
@override
|
||||
void initialize(
|
||||
_is.SerializationManager serializationManager,
|
||||
_is.EndpointDispatch endpoints,
|
||||
) {}
|
||||
}
|
||||
|
||||
class _FutureCalls {
|
||||
late final pruning = _PruningFutureCall();
|
||||
}
|
||||
|
||||
class _PruningFutureCall {
|
||||
Future<void> pruneAccounts(_ist.TestSessionBuilder sessionBuilder) async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild();
|
||||
try {
|
||||
await _i59xasdy.PruningPruneAccountsFutureCall().invoke(
|
||||
_localUniqueSession,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> forgetSignUpAttempts(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild();
|
||||
try {
|
||||
await _i59xasdy.PruningForgetSignUpAttemptsFutureCall().invoke(
|
||||
_localUniqueSession,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> tellMembersOfDeletions(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild();
|
||||
try {
|
||||
await _i59xasdy.PruningTellMembersOfDeletionsFutureCall().invoke(
|
||||
_localUniqueSession,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> forgetDeletedAccounts(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild();
|
||||
try {
|
||||
await _i59xasdy.PruningForgetDeletedAccountsFutureCall().invoke(
|
||||
_localUniqueSession,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_train_server/src/pruning.dart';
|
||||
import 'package:halkyon_train_server/src/train_base.dart';
|
||||
import 'package:serverpod/protocol.dart' show FutureCallEntry;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'support/train_fixtures.dart';
|
||||
import 'test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
"Train's server",
|
||||
// Signing in runs concurrent transactions (the rate limit and the new
|
||||
// account), which the test's database rollback can't wrap. So these
|
||||
// tests don't roll back; they delete what they create instead.
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Serverpod pod;
|
||||
|
||||
setUp(() async {
|
||||
useTrainBase(sessionBuilder);
|
||||
pod = sessionBuilder.build().serverpod;
|
||||
await _deleteAll(sessionBuilder.build());
|
||||
});
|
||||
tearDown(() => _deleteAll(sessionBuilder.build()));
|
||||
|
||||
/// Calls [method] of the module's endpoint [endpoint] as a request
|
||||
/// reaches it: through the server's own dispatch, under the name the
|
||||
/// app's client calls (`halkyon_base.<endpoint>`).
|
||||
Future<Object?> call(
|
||||
String endpoint,
|
||||
String method, [
|
||||
Map<String, Object?> parameters = const {},
|
||||
]) {
|
||||
final connector = pod.endpoints.getConnectorByName(
|
||||
'halkyon_base.$endpoint',
|
||||
);
|
||||
final methodConnector =
|
||||
connector!.methodConnectors[method]! as MethodConnector;
|
||||
return methodConnector.call(sessionBuilder.build(), parameters);
|
||||
}
|
||||
|
||||
test('its basics name the app it serves, `train`, so only Train takes it '
|
||||
'for an instance of its own', () async {
|
||||
final basics = (await call('instance', 'basics'))! as InstanceBasics;
|
||||
|
||||
expect(basics.app, 'train');
|
||||
expect(basics.minimumAppBuild, 1);
|
||||
expect(
|
||||
basics.sourceCodeUrl,
|
||||
'https://code.halkyon.app/project-halkyon/halkyon',
|
||||
);
|
||||
});
|
||||
|
||||
test('makes no account without a way in: the module signs nobody in '
|
||||
'anonymously (ADR 0020, D25)', () {
|
||||
expect(
|
||||
pod.endpoints.getConnectorByName('halkyon_base.anonymousIdp'),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
pod.endpoints.getConnectorByName('halkyon_base.instance'),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test("is a member of the instance's register the operator names, and "
|
||||
'holds the register otherwise (ADR 0016)', () {
|
||||
expect(
|
||||
trainBase(
|
||||
accounts: AccountsMode.fromEnvironment(const {
|
||||
'ACCOUNT_REGISTER_URL': 'http://learn:8082',
|
||||
}),
|
||||
).accounts,
|
||||
isA<MemberOfRegister>().having(
|
||||
(member) => member.register,
|
||||
'register',
|
||||
Uri.parse('http://learn:8082'),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
trainBase(accounts: AccountsMode.fromEnvironment(const {})).accounts,
|
||||
isA<HoldsRegister>(),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'the pruning is scheduled once, however often the server starts, '
|
||||
'and the one under its earlier name goes',
|
||||
() async {
|
||||
// INSERT INTO "serverpod_future_call" (…) VALUES (…)
|
||||
await FutureCallEntry.db.insertRow(
|
||||
sessionBuilder.build(),
|
||||
FutureCallEntry(
|
||||
name: 'PruningFutureCall.pruneAnonymousPeople',
|
||||
time: DateTime.utc(2026, 10, 25, 3, 17),
|
||||
serverId: 'default',
|
||||
identifier: 'prune-abandoned-anonymous-people',
|
||||
),
|
||||
);
|
||||
await schedulePruning(pod);
|
||||
await schedulePruning(pod);
|
||||
|
||||
final scheduled = await FutureCallEntry.db.find(
|
||||
sessionBuilder.build(),
|
||||
where: (t) => t.identifier.inSet(pruningIdentifiers.toSet()),
|
||||
);
|
||||
expect(
|
||||
scheduled.map((entry) => entry.identifier),
|
||||
unorderedEquals(pruningIdentifiers),
|
||||
);
|
||||
expect(
|
||||
await FutureCallEntry.db.count(
|
||||
sessionBuilder.build(),
|
||||
where: (t) =>
|
||||
t.identifier.equals('prune-abandoned-anonymous-people'),
|
||||
),
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes every rate-limit record, every person (their sessions go with
|
||||
/// them, `ON DELETE CASCADE`) and every future call.
|
||||
Future<void> _deleteAll(Session session) async {
|
||||
await RateLimitedRequestAttempt.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
||||
await FutureCallEntry.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue