halkyon/packages/halkyon_app/test/sync/app_compatibility_test.dart
Tizian 「ティツィアーン」 e23cc77f7c
halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps
What every Halkyon app's shell needs, taken from Halkyon Learn's and set
on halkyon_base's client: the instances a device keeps an account on,
the first start's tiles and where an instance keeps people's data, the
account switcher; signing in anonymously, with GitHub or an email code,
switching person and the start screen; the gates that build an app's
dependencies per instance and per person; and the sync service with its
status, Settings card and banner. Texts of its own in English and
German take the app's name; what only the app can say, it passes in.
Fakes for apps' tests are in testing.dart.

An instance's basics now name the app it serves (`app`, set by the
host's configuration), and an app accepts only an instance of its own.
SerialQueue and CoalescingRunner move into halkyon_core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 15:31:40 +02:00

120 lines
3.1 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
void main() {
group('AppCompatibility', () {
late int asked;
late Object? answer;
AppCompatibility compatibility(int? appBuild) => AppCompatibility(
appBuild: appBuild,
minimumAppBuild: () {
asked++;
return switch (answer) {
final Exception error => Future.error(error),
_ => Future.value(answer as int?),
};
},
);
setUp(() {
asked = 0;
answer = null;
});
test('lets any build sync when the server names no minimum', () async {
await compatibility(1).check();
});
test('lets a build at or above the minimum sync', () async {
answer = 3;
await compatibility(3).check();
await compatibility(4).check();
});
test('refuses a build below the minimum', () async {
answer = 3;
await expectLater(
compatibility(2).check(),
throwsA(isA<AppServerMismatchException>()),
);
});
test('asks the server once per launch', () async {
answer = 1;
final check = compatibility(1);
await check.check();
await check.check();
expect(asked, 1);
});
test('asks again after the question failed', () async {
answer = Exception('No connection');
final check = compatibility(1);
await expectLater(check.check(), throwsException);
answer = 1;
await check.check();
expect(asked, 2);
});
test('never refuses a build without a number, and never asks', () async {
answer = 99;
await compatibility(null).check();
expect(asked, 0);
});
});
group('compatibleSync', () {
test('checks before syncing', () async {
var synced = false;
final sync = compatibleSync(
({required onMerge}) {
synced = true;
return Future.value();
},
AppCompatibility(appBuild: 1, minimumAppBuild: () => Future.value(2)),
);
await expectLater(
sync(onMerge: () {}),
throwsA(isA<AppServerMismatchException>()),
);
expect(synced, isFalse);
});
test("tells when the synced tables don't match the server's", () async {
final sync = compatibleSync(
({required onMerge}) => Future.error(
const OfflineSyncTablesHashMismatchException(
received: 'server',
expected: 'app',
),
),
AppCompatibility(
appBuild: 1,
minimumAppBuild: Future<int?>.value,
),
);
await expectLater(
sync(onMerge: () {}),
throwsA(isA<AppServerMismatchException>()),
);
});
test('passes other failures on as they are', () async {
final failure = Exception('No connection');
final sync = compatibleSync(
({required onMerge}) => Future.error(failure),
AppCompatibility(
appBuild: 1,
minimumAppBuild: Future<int?>.value,
),
);
await expectLater(sync(onMerge: () {}), throwsA(same(failure)));
});
});
}