This repository has been archived on 2026-10-23. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
halkyon_shared/packages/halkyon_app/test/sync/sync_service_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

390 lines
10 KiB
Dart

import 'dart:async';
import 'package:clock/clock.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late int syncs;
late Exception? failWith;
late VoidCallback? merge;
// While set and not completed, a sync keeps running.
late Completer<void>? hold;
SyncService service({
PendingChangesStore? pendingChanges,
bool syncedBefore = false,
}) => SyncService(
({required onMerge}) async {
syncs++;
merge = onMerge;
await hold?.future;
if (failWith case final error?) {
throw error;
}
},
pendingChanges: pendingChanges,
syncedBefore: syncedBefore,
);
setUp(() {
syncs = 0;
failWith = null;
merge = null;
hold = null;
});
test('syncs once at start', () {
fakeAsync((async) {
final sync = service()..start();
async.flushMicrotasks();
expect(syncs, 1);
sync.dispose();
});
});
test('syncs once after a burst of local changes has settled', () {
fakeAsync((async) {
final sync = service()..start();
async.flushMicrotasks();
sync
..requestSync()
..requestSync();
async.elapse(const Duration(seconds: 1));
sync.requestSync();
async.elapse(const Duration(seconds: 1));
expect(syncs, 1, reason: 'still within the debounce');
async.elapse(const Duration(seconds: 1));
expect(syncs, 2);
sync.dispose();
});
});
test('syncs every five minutes without other triggers', () {
fakeAsync((async) {
final sync = service()..start();
async.elapse(const Duration(minutes: 10));
expect(syncs, 3);
sync.dispose();
});
});
test('keeps syncing after a failed sync', () {
fakeAsync((async) {
failWith = Exception('No connection');
final sync = service()..start();
async.flushMicrotasks();
failWith = null;
sync.requestSync();
async.elapse(const Duration(seconds: 2));
expect(syncs, 2);
sync.dispose();
});
});
test('reports changes from other devices', () {
fakeAsync((async) {
final sync = service();
var merges = 0;
sync.merges.listen((_) => merges++);
sync.start();
async.flushMicrotasks();
merge!();
async.flushMicrotasks();
expect(merges, 1);
sync.dispose();
});
});
test('a trigger during a sync runs one more sync afterwards', () {
fakeAsync((async) {
final running = hold = Completer<void>();
final sync = service()..start();
async.flushMicrotasks();
sync.requestSync();
async.elapse(const Duration(seconds: 2));
sync.requestSync();
async.elapse(const Duration(seconds: 2));
expect(syncs, 1, reason: 'the first sync is still running');
running.complete();
async.flushMicrotasks();
expect(syncs, 2);
sync.dispose();
});
});
test('lets a running sync finish before it stops', () {
fakeAsync((async) {
final running = hold = Completer<void>();
final sync = service()..start();
async.flushMicrotasks();
sync.requestSync();
async.elapse(const Duration(seconds: 2));
var stopped = false;
unawaited(sync.dispose().then((_) => stopped = true));
async.flushMicrotasks();
expect(stopped, isFalse, reason: 'the database is still in use');
running.complete();
async.flushMicrotasks();
expect(stopped, isTrue);
expect(syncs, 1, reason: 'no sync starts once it is stopping');
});
});
test('stops syncing when disposed', () {
fakeAsync((async) {
final sync = service()..start();
async.flushMicrotasks();
sync
..requestSync()
..dispose();
async.elapse(const Duration(minutes: 10));
expect(syncs, 1);
});
});
group('status', () {
final noon = DateTime.utc(2026, 10, 3, 12);
test('nothing has synced before the first sync', () {
expect(service().status, const SyncStatus());
expect(service().status.hasTrouble, isFalse);
});
test('says while a sync runs, and when it went through', () {
fakeAsync((async) {
withClock(async.getClock(noon), () {
final running = hold = Completer<void>();
final sync = service()..start();
async.flushMicrotasks();
expect(sync.status, const SyncStatus(syncing: true));
async.elapse(const Duration(seconds: 3));
running.complete();
async.flushMicrotasks();
expect(
sync.status,
SyncStatus(
lastSyncedAt: noon.add(const Duration(seconds: 3)),
syncedHere: true,
),
);
sync.dispose();
});
});
});
test('a failed sync is trouble until a sync goes through again', () {
fakeAsync((async) {
withClock(async.getClock(noon), () {
final sync = service()..start();
async.flushMicrotasks();
failWith = Exception('No connection');
async.elapse(const Duration(minutes: 5));
expect(
sync.status,
SyncStatus(
lastSyncedAt: noon,
failure: (
at: noon.add(const Duration(minutes: 5)),
appMismatch: false,
),
syncedHere: true,
),
);
expect(sync.status.hasTrouble, isTrue);
failWith = null;
async.elapse(const Duration(minutes: 5));
expect(
sync.status,
SyncStatus(
lastSyncedAt: noon.add(const Duration(minutes: 10)),
syncedHere: true,
),
);
sync.dispose();
});
});
});
test('still shows the failure while the next sync runs', () {
fakeAsync((async) {
withClock(async.getClock(noon), () {
failWith = Exception('No connection');
final sync = service()..start();
async.flushMicrotasks();
hold = Completer<void>();
sync.syncNow();
async.flushMicrotasks();
expect(sync.status.syncing, isTrue);
expect(sync.status.hasTrouble, isTrue);
hold!.complete();
sync.dispose();
});
});
});
test("tells when this app and the server don't match", () {
fakeAsync((async) {
withClock(async.getClock(noon), () {
failWith = const AppServerMismatchException();
final sync = service()..start();
async.flushMicrotasks();
expect(sync.status.appMismatch, isTrue);
expect(sync.status.failure, (at: noon, appMismatch: true));
sync.dispose();
});
});
});
test('reports every change', () {
fakeAsync((async) {
final sync = service();
final seen = <SyncStatus>[];
sync.statusChanges.listen(seen.add);
sync.start();
async.flushMicrotasks();
expect(seen.map((status) => status.syncing), [true, false]);
sync.dispose();
});
});
});
test('syncs at once when the person asks', () {
fakeAsync((async) {
final sync = service()..start();
async.flushMicrotasks();
sync.syncNow();
async.flushMicrotasks();
expect(syncs, 2, reason: 'no waiting for changes to settle');
sync.dispose();
});
});
group('changes not on the server yet', () {
test('are the saves since the last sync that went through', () {
fakeAsync((async) {
final sync = service()..start();
async.flushMicrotasks();
sync
..requestSync()
..requestSync();
expect(sync.status.pending, 2);
async.elapse(const Duration(seconds: 2));
expect(sync.status.pending, 0);
sync.dispose();
});
});
test('stay while syncs fail', () {
fakeAsync((async) {
failWith = Exception('No connection');
final sync = service()..start();
async.flushMicrotasks();
sync.requestSync();
async.elapse(const Duration(seconds: 2));
expect(sync.status.pending, 1);
expect(sync.status.hasTrouble, isTrue);
failWith = null;
sync.syncNow();
async.flushMicrotasks();
expect(sync.status.pending, 0);
sync.dispose();
});
});
test('saved while a sync runs, wait for the next', () {
fakeAsync((async) {
final running = hold = Completer<void>();
final sync = service()..start();
async.flushMicrotasks();
sync.requestSync();
running.complete();
async.flushMicrotasks();
expect(sync.status.pending, 1);
hold = null;
async.elapse(const Duration(seconds: 2));
expect(sync.status.pending, 0);
sync.dispose();
});
});
test('are kept while the app is closed, and taken by the first sync', () {
fakeAsync((async) {
failWith = Exception('No connection');
final kept = MemoryPendingChangesStore(3);
final sync = service(pendingChanges: kept)..start();
async.flushMicrotasks();
expect(sync.status.pending, 3);
sync.requestSync();
async.flushMicrotasks();
expect(kept.kept, 4);
failWith = null;
sync.syncNow();
async.flushMicrotasks();
expect(sync.status.pending, 0);
expect(kept.kept, 0);
sync.dispose();
});
});
});
test('says whether a sync ever went through on this device: as kept from '
'before, and from the first that goes through, kept for next '
'time', () {
fakeAsync((async) {
expect(service(syncedBefore: true).status.syncedHere, isTrue);
failWith = Exception('No connection');
final kept = MemoryPendingChangesStore(0, false);
final sync = service(pendingChanges: kept)..start();
async.flushMicrotasks();
expect(sync.status.syncedHere, isFalse);
expect(sync.status.hasTrouble, isTrue);
failWith = null;
sync.syncNow();
async.flushMicrotasks();
expect(sync.status.syncedHere, isTrue);
expect(kept.synced, isTrue);
sync.requestSync();
async.flushMicrotasks();
expect(sync.status.syncedHere, isTrue);
sync.dispose();
});
});
}