test(app): studysets synced against a running server
The first tests that need serverpod start running (ADR 0001, rule 9: the client's and the server's sync engines can't share one process), tagged requires-server: tool/check.sh skips them, flutter test -t requires-server runs them. Every device is its own client and local database with the app's real gateway and repository, and syncs only when a test says so. They state: two devices of one person end up with the same studysets; a deleted studyset leaves the other devices, and its payload is blank on the server, checked with SQL against the development database because the engine keeps deleted rows; a studyset written into another person's space never reaches them; claiming another person's space shows none of their studysets, since the server sends only the spaces the signed-in person has access to. As written here, the forgery test also expects the server to refuse and log the write, and the SQL checks connect over TCP; the first run against the server corrected both (see the commit "the sync tests pass against the running server"). They live in the app package: the server's Docker build resolves its dependencies with only the server package present, so a dev dependency on the client package would break it. They never initialize a Flutter test binding, which would replace real HTTP with a mock. Without a server they fail at sign-in with "connection refused". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5d6680dc5b
commit
ba55d6f725
5 changed files with 272 additions and 1 deletions
|
|
@ -96,6 +96,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
|
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
|
||||||
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
|
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
|
||||||
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
|
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
|
||||||
|
- **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 logged refusal) is checked with SQL against the development database. 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 the signed-in app** pump `SessionGate` with `testDependencies(...)` and `pumpAndSettle()` until `PersonScope` has opened the person's data; the fake gateways work in memory (`FakeStudysetGateway`, with `failReads` for the error path).
|
- **Widget tests of the signed-in app** pump `SessionGate` with `testDependencies(...)` and `pumpAndSettle()` until `PersonScope` has opened the person's data; the fake gateways work in memory (`FakeStudysetGateway`, with `failReads` for the error path).
|
||||||
|
|
||||||
## Rules the code relies on
|
## Rules the code relies on
|
||||||
|
|
|
||||||
5
halkyon_learn_flutter/dart_test.yaml
Normal file
5
halkyon_learn_flutter/dart_test.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
tags:
|
||||||
|
# Tests against a running server (`serverpod start`), such as the sync tests in
|
||||||
|
# test/sync/ (ADR 0001, rule 9). tool/check.sh skips them; run them with
|
||||||
|
# `flutter test -t requires-server`.
|
||||||
|
requires-server: {}
|
||||||
|
|
@ -51,6 +51,10 @@ dev_dependencies:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
# The sync tests (test/sync/) sign in like the app and read the server's database.
|
||||||
|
postgres: ^3.5.16
|
||||||
|
serverpod_auth_core_client: 4.0.3
|
||||||
|
yaml: ^3.1.4
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
|
|
||||||
259
halkyon_learn_flutter/test/sync/studyset_sync_test.dart
Normal file
259
halkyon_learn_flutter/test/sync/studyset_sync_test.dart
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
// Studysets synced over the network, against a separately started server (ADR 0001,
|
||||||
|
// rule 9: the client's and the server's sync engines can't share one process).
|
||||||
|
// Every device is its own client and local database with the app's real gateway
|
||||||
|
// and repository on top, and syncs only when a test says so.
|
||||||
|
//
|
||||||
|
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
|
||||||
|
// this package with `flutter test -t requires-server`. Each run signs in two new
|
||||||
|
// anonymous people (at most 30 per IP and hour).
|
||||||
|
@Tags(['requires-server'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||||
|
import 'package:postgres/postgres.dart' as pg;
|
||||||
|
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
|
||||||
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
import 'package:yaml/yaml.dart';
|
||||||
|
|
||||||
|
const _serverUrl = 'http://localhost:8080/';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AuthSuccess alice;
|
||||||
|
late AuthSuccess bob;
|
||||||
|
late Directory folder;
|
||||||
|
final devices = <_Device>[];
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
alice = await _signInAnonymously();
|
||||||
|
bob = await _signInAnonymously();
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
folder = await Directory.systemTemp.createTemp('studyset_sync_');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
for (final device in devices) {
|
||||||
|
await device.close();
|
||||||
|
}
|
||||||
|
devices.clear();
|
||||||
|
await folder.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A device of [person]. Its local database claims [space] as its personal
|
||||||
|
/// space: the person's own, unless a test forges another.
|
||||||
|
Future<_Device> device(
|
||||||
|
AuthSuccess person,
|
||||||
|
String name, {
|
||||||
|
protocol.UuidValue? space,
|
||||||
|
}) async {
|
||||||
|
final device = await _Device.open(
|
||||||
|
person,
|
||||||
|
File('${folder.path}/$name.sqlite'),
|
||||||
|
space: space ?? person.authUserId,
|
||||||
|
);
|
||||||
|
devices.add(device);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('two devices of one person end up with the same studysets', () async {
|
||||||
|
final phone = await device(alice, 'phone');
|
||||||
|
final laptop = await device(alice, 'laptop');
|
||||||
|
|
||||||
|
final id = await phone.studysets.create(title: 'Japanese');
|
||||||
|
await phone.sync();
|
||||||
|
await laptop.sync();
|
||||||
|
final onLaptop = await laptop.find(id);
|
||||||
|
expect(onLaptop?.title, 'Japanese');
|
||||||
|
|
||||||
|
await laptop.studysets.update(onLaptop!.copyWith(title: '日本語'));
|
||||||
|
await laptop.sync();
|
||||||
|
await phone.sync();
|
||||||
|
expect((await phone.find(id))?.title, '日本語');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deleted studyset leaves the other devices and is blank on the '
|
||||||
|
'server', () async {
|
||||||
|
final phone = await device(alice, 'phone');
|
||||||
|
final laptop = await device(alice, 'laptop');
|
||||||
|
final id = await phone.studysets.create(title: 'Private notes');
|
||||||
|
await phone.sync();
|
||||||
|
await laptop.sync();
|
||||||
|
|
||||||
|
// The repository blanks the payload, then deletes; one sync carries both.
|
||||||
|
await phone.studysets.delete(id);
|
||||||
|
await phone.sync();
|
||||||
|
await laptop.sync();
|
||||||
|
|
||||||
|
expect(await laptop.find(id), isNull);
|
||||||
|
// The engine keeps the deleted row until its space is deleted, so its
|
||||||
|
// content has to be gone (ADR 0002, rule 2).
|
||||||
|
// SELECT "payload" FROM "studyset" WHERE "id" = $1
|
||||||
|
final rows = await _queryServer(
|
||||||
|
'SELECT "payload" FROM "studyset" WHERE "id" = @id:uuid',
|
||||||
|
{'id': id},
|
||||||
|
);
|
||||||
|
expect(rows.single.single, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writing into another person's space is refused and logged", () async {
|
||||||
|
// Bob has synced before, so his personal space exists on the server.
|
||||||
|
final bobsPhone = await device(bob, 'bobs-phone');
|
||||||
|
await bobsPhone.sync();
|
||||||
|
// Alice's device, signed in as her, but its database claims Bob's space.
|
||||||
|
final forged = await device(alice, 'forged', space: bob.authUserId);
|
||||||
|
final intruder = await forged.studysets.create(title: 'Intruder');
|
||||||
|
|
||||||
|
Exception? refusal;
|
||||||
|
try {
|
||||||
|
await forged.sync();
|
||||||
|
} on Exception catch (error) {
|
||||||
|
refusal = error;
|
||||||
|
}
|
||||||
|
await bobsPhone.sync();
|
||||||
|
|
||||||
|
expect(await bobsPhone.find(intruder), isNull);
|
||||||
|
// SELECT "type" FROM "offline_sync_integrity_violations"
|
||||||
|
// WHERE "uuidRowId" = $1
|
||||||
|
final violations = await _queryServer(
|
||||||
|
'SELECT "type" FROM "offline_sync_integrity_violations" '
|
||||||
|
'WHERE "uuidRowId" = @id:uuid',
|
||||||
|
{'id': intruder},
|
||||||
|
);
|
||||||
|
expect(violations.map((row) => row.single), ['unauthorizedWrite']);
|
||||||
|
expect(refusal, isNotNull, reason: 'the whole sync is refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"claiming another person's space shows none of their studysets",
|
||||||
|
() async {
|
||||||
|
final bobsPhone = await device(bob, 'bobs-phone');
|
||||||
|
final bobs = await bobsPhone.studysets.create(title: "Bob's");
|
||||||
|
await bobsPhone.sync();
|
||||||
|
|
||||||
|
// Alice's device claims Bob's space but writes nothing, so nothing is
|
||||||
|
// refused: the server sends only the spaces Alice has access to.
|
||||||
|
final forged = await device(alice, 'forged', space: bob.authUserId);
|
||||||
|
await forged.sync();
|
||||||
|
|
||||||
|
expect(await forged.find(bobs), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new anonymous person, as the app signs one in on first launch.
|
||||||
|
Future<AuthSuccess> _signInAnonymously() async {
|
||||||
|
final client = protocol.Client(_serverUrl);
|
||||||
|
try {
|
||||||
|
return await client.anonymousIdp.login();
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs [sql] on the running development server's database, as its operator
|
||||||
|
/// could: the connection from `config/development.yaml`, the password from
|
||||||
|
/// `config/passwords.yaml`.
|
||||||
|
Future<pg.Result> _queryServer(
|
||||||
|
String sql,
|
||||||
|
Map<String, Object?> parameters,
|
||||||
|
) async {
|
||||||
|
const config = '../halkyon_learn_server/config';
|
||||||
|
final development =
|
||||||
|
loadYaml(File('$config/development.yaml').readAsStringSync()) as YamlMap;
|
||||||
|
final database = development['database'] as YamlMap;
|
||||||
|
final passwords =
|
||||||
|
loadYaml(File('$config/passwords.yaml').readAsStringSync()) as YamlMap;
|
||||||
|
final connection = await pg.Connection.open(
|
||||||
|
pg.Endpoint(
|
||||||
|
host: database['host'] as String,
|
||||||
|
port: database['port'] as int,
|
||||||
|
database: database['name'] as String,
|
||||||
|
username: database['user'] as String,
|
||||||
|
password: (passwords['development'] as YamlMap)['database'] as String,
|
||||||
|
),
|
||||||
|
settings: const pg.ConnectionSettings(sslMode: pg.SslMode.disable),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return await connection.execute(pg.Sql.named(sql), parameters: parameters);
|
||||||
|
} finally {
|
||||||
|
await connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device: its own client and session, its own local database, and the
|
||||||
|
/// app's repository over it. It syncs only when told to.
|
||||||
|
final class _Device(
|
||||||
|
final protocol.Client _client,
|
||||||
|
final OfflineSyncDatabaseSession _db,
|
||||||
|
final SyncService _syncRequests,
|
||||||
|
|
||||||
|
/// The app's repository on this device's database.
|
||||||
|
final StudysetRepository studysets,
|
||||||
|
) {
|
||||||
|
/// Opens a device signed in as [person], whose local database claims
|
||||||
|
/// [space] as its personal space.
|
||||||
|
static Future<_Device> open(
|
||||||
|
AuthSuccess person,
|
||||||
|
File database, {
|
||||||
|
required protocol.UuidValue space,
|
||||||
|
}) async {
|
||||||
|
final session = ClientAuthSessionManager(storage: _MemoryAuthStorage());
|
||||||
|
final client = protocol.Client(_serverUrl)..authSessionManager = session;
|
||||||
|
await session.updateSignedInUser(person);
|
||||||
|
final db = await client.createSyncSession(
|
||||||
|
database.path,
|
||||||
|
persistentUserId: space,
|
||||||
|
);
|
||||||
|
// The gateway asks for a sync after every write; these tests sync
|
||||||
|
// explicitly instead, so the requests go nowhere.
|
||||||
|
final syncRequests = SyncService(({required onMerge}) async {});
|
||||||
|
return _Device(
|
||||||
|
client,
|
||||||
|
db,
|
||||||
|
syncRequests,
|
||||||
|
StudysetRepository(SyncStudysetGateway(db, syncRequests)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends this device's changes and receives everyone else's.
|
||||||
|
Future<void> sync() => _client.offlineSync.syncOnce(_db);
|
||||||
|
|
||||||
|
/// The studyset with [id] on this device, or null.
|
||||||
|
Future<Studyset?> find(String id) async {
|
||||||
|
for (final studyset in await studysets.watchAll().first) {
|
||||||
|
if (studyset.id == id) {
|
||||||
|
return studyset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes everything the device opened.
|
||||||
|
Future<void> close() async {
|
||||||
|
await studysets.dispose();
|
||||||
|
await _syncRequests.dispose();
|
||||||
|
await _db.close();
|
||||||
|
_client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps a device's session in memory, where the app uses secure storage.
|
||||||
|
final class _MemoryAuthStorage() implements ClientAuthSuccessStorage {
|
||||||
|
AuthSuccess? _session;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthSuccess?> get() async => _session;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> set(AuthSuccess? data) async {
|
||||||
|
_session = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,8 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo '▸ App tests'
|
echo '▸ App tests'
|
||||||
(cd halkyon_learn_flutter && flutter test)
|
# The sync tests need `serverpod start` running; run them by hand with
|
||||||
|
# `flutter test -t requires-server` in the app package.
|
||||||
|
(cd halkyon_learn_flutter && flutter test -x requires-server)
|
||||||
|
|
||||||
echo '✓ All checks passed.'
|
echo '✓ All checks passed.'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue