test(sync): test devices sync through their sync service

A test device synced by calling the engine itself, so its repositories
never heard of what a sync merged; reading the database each time, they
didn't need to. Since cards and reviews are kept until something changes
them, a device went on reading its cards from before the sync, and the
conflict test failed. Devices now sync through their SyncService, as the
app does, and fail when the sync failed. SYNC_TEST_SERVER_DIR names the
server folder whose database the SQL checks read, for a worktree testing
against another checkout's running server. All 30 sync tests pass.

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-17 14:57:33 +02:00
commit f1fee7e63c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 27 additions and 7 deletions

View file

@ -119,7 +119,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
- **A menu item acts in the frame after its menu closes** (`MenuAnchor`): pump twice after tapping one before expecting what it does.
- **Server tests of what writes synced rows** run on the sync engine's database, as the server does (the generated test tools build the pod without it): pass the interceptor to `withServerpod`, call `initializeOfflineSync` in `setUp`, and write as a person through `transactionForUser`. Its writes take transactions of their own, so these tests turn rollback off and delete what they made (`test/integration/asset/asset_test.dart`).
- **Sync tests run against a running server** (ADR 0001, rule 9: the client's and the server's sync engines can't share one process): `halkyon_learn_flutter/test/sync/`, tagged `requires-server`, skipped by `tool/check.sh`; run them with `flutter test -t requires-server` while `serverpod start` runs. Every device is a client with in-memory session storage and its own local database, with the app's real gateway and repository on top; tests sync explicitly. What only the operator could see (a blanked payload, a row that must not exist) is checked with SQL against the development database, through the embedded PostgreSQL's Unix socket where there is one. An honest device only syncs the spaces the server announces, so a forged write never leaves it; the server's own check against modified clients (`_assertCanMergeInboundSpace` in the sync engine) has no test yet. They live in the app package because the server's Docker build resolves the server's dependencies without the client package. They must not initialize a Flutter test binding (no `testWidgets`), which would replace real HTTP with a mock.
- **Sync tests run against a running server** (ADR 0001, rule 9: the client's and the server's sync engines can't share one process): `halkyon_learn_flutter/test/sync/`, tagged `requires-server`, skipped by `tool/check.sh`; run them with `flutter test -t requires-server` while `serverpod start` runs (from a worktree against another checkout's server, `SYNC_TEST_SERVER_DIR=<its halkyon_learn_server>` for the SQL checks). A test device syncs through its `SyncService`, as the app does, so what a sync merges reaches its repositories, which keep what they read until then. Every device is a client with in-memory session storage and its own local database, with the app's real gateway and repository on top; tests sync explicitly. What only the operator could see (a blanked payload, a row that must not exist) is checked with SQL against the development database, through the embedded PostgreSQL's Unix socket where there is one. An honest device only syncs the spaces the server announces, so a forged write never leaves it; the server's own check against modified clients (`_assertCanMergeInboundSpace` in the sync engine) has no test yet. They live in the app package because the server's Docker build resolves the server's dependencies without the client package. They must not initialize a Flutter test binding (no `testWidgets`), which would replace real HTTP with a mock.
- **Widget tests of screens start the whole app**: `pumpApp(tester, testDependencies(...), location: …)` (`test/support/pump_app.dart`) pumps `SessionGate` with the real `PersonApp`, opened at the screen's path, and settles once `PersonScope` has opened the person's data. From there a test moves as a person would: `openTab` (the tab's label alone may also be a screen's title), taps, `pageBack`. Data goes into the fake gateways before pumping; they work in memory (`FakeStudysetGateway`, with `failReads` for the error path), and a repository of the test's own on the same fake gateway writes what the app then reads. `pumpAndSettle` never ends while a spinner shows (`AppLoadingIndicator` animates without end): pump frames instead where one is expected. A widget pumped on its own that reads `AppLocalizations` needs `AppLocalizations.localizationsDelegates` in its `MaterialApp`.
## CI and deploys

View file

@ -68,7 +68,10 @@ Future<pg.Result> queryServer(
}
Future<pg.Connection> _openServerDatabase() async {
const server = '../halkyon_learn_server';
// A worktree's tests run against the server `serverpod start` runs, which
// may be another checkout's: SYNC_TEST_SERVER_DIR names its server folder.
final server =
Platform.environment['SYNC_TEST_SERVER_DIR'] ?? '../halkyon_learn_server';
const settings = pg.ConnectionSettings(sslMode: pg.SslMode.disable);
final development = loadYaml(
File('$server/config/development.yaml').readAsStringSync(),
@ -169,9 +172,17 @@ final class SyncDevice(
database.path,
persistentUserId: space,
);
// The gateways ask for a sync after every write; these tests sync
// explicitly instead, so the requests go nowhere.
final syncRequests = SyncService(({required onMerge}) async {});
// Syncs as the app's do, so what a sync merges reaches the repositories
// (which keep what they read until then). The gateways ask for a sync
// after every write; these tests sync explicitly instead ([sync]), so
// a request would wait a day.
final syncRequests = SyncService(
({required onMerge}) => client.offlineSync.syncOnce(
db,
onInboundMerge: (space) => onMerge(),
),
debounce: const Duration(days: 1),
);
final cards = CardRepository(
SyncCardGateway(db, syncRequests, person.authUserId.uuid),
);
@ -213,8 +224,17 @@ final class SyncDevice(
/// Explore).
protocol.Client get client => _client;
/// Sends this device's changes and receives everyone else's.
Future<void> sync() => _client.offlineSync.syncOnce(_db);
/// Sends this device's changes and receives everyone else's, through
/// its sync service, as the app syncs; throws when the sync failed.
Future<void> sync() async {
final done = _syncRequests.statusChanges.firstWhere(
(status) => !status.syncing,
);
_syncRequests.syncNow();
if ((await done).failure != null) {
throw StateError('The sync failed; the log says why.');
}
}
/// The studyset with [id] on this device, or null.
Future<Studyset?> studyset(String id) async {