import 'dart:async'; import 'package:drift/drift.dart' show Value; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/main.dart' as app; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/wm_executor.dart'; import 'package:integration_test/integration_test.dart'; import 'package:openapi/api.dart'; import 'test_utils/fake_immich_server.dart'; void main() { final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // These tests do real I/O without pumping a widget tree, so disable the fake async clock binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; late Drift drift; late FakeImmichServer server; setUpAll(() async { await app.initApp(); (drift, _) = await Bootstrap.initDomain(); }); setUp(() async { await workerManagerPatch.init(dynamicSpawning: true); server = await FakeImmichServer.start(); await ApiService().resolveAndSetEndpoint(server.endpoint); await drift.delete(drift.userEntity).go(); await Store.delete(StoreKey.syncMigrationStatus); }); tearDown(() async { await workerManagerPatch.dispose(); await server.close(); await Store.delete(StoreKey.serverEndpoint); await Store.delete(StoreKey.syncMigrationStatus); }); void sendUser(SyncStream stream, String id, String name) { stream.send( type: SyncEntityType.userV1.value, data: SyncUserV1( id: id, name: name, email: '$id@test.com', hasProfileImage: false, deletedAt: null, profileChangedAt: DateTime.utc(2025), ).toJson(), ack: id, ); } Future dbReadable() async { try { await drift.customSelect('SELECT 1').get().timeout(const Duration(seconds: 5)); return true; } catch (_) { return false; } } Future userCount() async => (await drift.select(drift.userEntity).get()).length; // Starts a remote sync and resolves once its /sync/stream request is open. Future<(Future, SyncStream)> startSync() async { final sync = BackgroundSyncManager().syncRemote(); final stream = await server.streamOpened.timeout( const Duration(seconds: 30), onTimeout: () => fail('sync isolate never opened /sync/stream'), ); return (sync, stream); } testWidgets('a full sync ingests streamed events into the shared DB', (tester) async { expect(await userCount(), 0); final (sync, stream) = await startSync(); sendUser(stream, 'u1', 'Alice'); sendUser(stream, 'u2', 'Bob'); await stream.close(); final result = await sync.timeout( const Duration(seconds: 30), onTimeout: () => fail('sync did not complete after the stream ended'), ); expect(result, isTrue); expect(await userCount(), 2); expect(server.ackRequests, greaterThan(0)); }); testWidgets('disposing the pool during an in-flight sync drains promptly', (tester) async { final (sync, _) = await startSync(); final sw = Stopwatch()..start(); await workerManagerPatch.dispose().timeout( const Duration(seconds: 15), onTimeout: () => fail('dispose() hung — worker did not drain and exit'), ); expect(sw.elapsed, lessThan(const Duration(seconds: 10)), reason: 'abort-driven, not socket-timeout bound'); expect(await sync.timeout(const Duration(seconds: 5), onTimeout: () => false), isFalse); }); testWidgets('tearing down a worker blocked mid-write leaves the DB usable', (tester) async { final (sync, stream) = await startSync(); // Hold an exclusive write transaction so the worker's write is blocked. The lock is taken only // after the stream opens to avoid blocking the worker's own startup DB reads. final releaseTxn = Completer(); final txnHeld = Completer(); final txn = drift.transaction(() async { await drift.into(drift.userEntity).insert( UserEntityCompanion.insert( id: 'holder', name: 'holder', email: 'holder@test.com', hasProfileImage: const Value(false), profileChangedAt: Value(DateTime.utc(2025)), ), ); txnHeld.complete(); await releaseTxn.future; }); await txnHeld.future; sendUser(stream, 'u1', 'Alice'); await stream.close(); // dispose() can only finish once the worker unwinds, which is blocked on the // lock — so start it, release the lock, then await completion. final disposed = workerManagerPatch.dispose(); releaseTxn.complete(); await txn; await disposed.timeout( const Duration(seconds: 15), onTimeout: () => fail('dispose() hung after releasing the write lock'), ); await sync.timeout(const Duration(seconds: 5), onTimeout: () => false); expect(await dbReadable(), isTrue); final users = await drift.select(drift.userEntity).get(); expect(users.map((u) => u.id), contains('holder')); }); }