import 'package:flutter_test/flutter_test.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:tasq/providers/realtime_controller.dart'; import 'package:tasq/providers/stream_recovery.dart'; // --------------------------------------------------------------------------- // RealtimeController only accesses `_client.auth.onAuthStateChange` during // `_init()`, which wraps the subscription in a try-catch. We use a minimal // SupabaseClient pointed at localhost — the auth stream subscription will // either return an empty stream or throw (caught internally), so no network // activity occurs during tests. // --------------------------------------------------------------------------- void main() { group('RealtimeController', () { late RealtimeController controller; setUp(() { // SupabaseClient constructor does not connect eagerly; _init() catches // any exception thrown when accessing auth.onAuthStateChange. controller = RealtimeController( SupabaseClient('http://localhost', 'test-anon-key', authOptions: const AuthClientOptions(autoRefreshToken: false)), ); }); tearDown(() { controller.dispose(); }); // ── Initial state ─────────────────────────────────────────────────── test('starts with no recovering channels', () { expect(controller.recoveringChannels, isEmpty); expect(controller.isAnyStreamRecovering, isFalse); }); test('isChannelRecovering returns false for unknown channel', () { expect(controller.isChannelRecovering('tasks'), isFalse); }); // ── markChannelRecovering ─────────────────────────────────────────── test('markChannelRecovering adds channel to the set', () { controller.markChannelRecovering('tasks'); expect(controller.isChannelRecovering('tasks'), isTrue); expect(controller.isAnyStreamRecovering, isTrue); }); test('markChannelRecovering with multiple channels tracks all of them', () { controller.markChannelRecovering('tasks'); controller.markChannelRecovering('tickets'); expect(controller.isChannelRecovering('tasks'), isTrue); expect(controller.isChannelRecovering('tickets'), isTrue); expect(controller.recoveringChannels.length, 2); }); test('markChannelRecovering notifies listeners exactly once per new channel', () { int notifyCount = 0; controller.addListener(() => notifyCount++); controller.markChannelRecovering('tasks'); expect(notifyCount, 1); // Adding the same channel again must NOT trigger a second notification. controller.markChannelRecovering('tasks'); expect(notifyCount, 1); }); // ── markChannelRecovered ──────────────────────────────────────────── test('markChannelRecovered removes channel from the set', () { controller.markChannelRecovering('tasks'); controller.markChannelRecovered('tasks'); expect(controller.isChannelRecovering('tasks'), isFalse); expect(controller.isAnyStreamRecovering, isFalse); }); test('markChannelRecovered on unknown channel does not notify', () { int notifyCount = 0; controller.addListener(() => notifyCount++); // Recovering a channel that was never marked recovering is a no-op. controller.markChannelRecovered('nonexistent'); expect(notifyCount, 0); }); test('markChannelRecovered notifies listeners when channel is removed', () { controller.markChannelRecovering('tasks'); int notifyCount = 0; controller.addListener(() => notifyCount++); controller.markChannelRecovered('tasks'); expect(notifyCount, 1); }); test('recovering two channels and recovering one leaves the other active', () { controller.markChannelRecovering('tasks'); controller.markChannelRecovering('tickets'); controller.markChannelRecovered('tasks'); expect(controller.isChannelRecovering('tasks'), isFalse); expect(controller.isChannelRecovering('tickets'), isTrue); expect(controller.isAnyStreamRecovering, isTrue); }); // ── recoveringChannels unmodifiable view ──────────────────────────── test('recoveringChannels returns an unmodifiable set', () { controller.markChannelRecovering('tasks'); final view = controller.recoveringChannels; expect(() => (view as dynamic).add('other'), throwsUnsupportedError); }); // ── handleChannelStatus ───────────────────────────────────────────── test('handleChannelStatus with connected marks channel as recovered', () { controller.markChannelRecovering('tasks'); controller.handleChannelStatus('tasks', StreamConnectionStatus.connected); expect(controller.isChannelRecovering('tasks'), isFalse); }); test('handleChannelStatus with polling marks channel as recovered', () { controller.markChannelRecovering('tasks'); controller.handleChannelStatus('tasks', StreamConnectionStatus.polling); expect(controller.isChannelRecovering('tasks'), isFalse); }); test('handleChannelStatus with recovering marks channel as recovering', () { controller.handleChannelStatus( 'tasks', StreamConnectionStatus.recovering); expect(controller.isChannelRecovering('tasks'), isTrue); }); test('handleChannelStatus with stale marks channel as recovering', () { controller.handleChannelStatus('tasks', StreamConnectionStatus.stale); expect(controller.isChannelRecovering('tasks'), isTrue); }); test('handleChannelStatus with failed marks channel as recovering', () { controller.handleChannelStatus('tasks', StreamConnectionStatus.failed); expect(controller.isChannelRecovering('tasks'), isTrue); }); test('handleChannelStatus connected after recovering clears it', () { controller.handleChannelStatus( 'tasks', StreamConnectionStatus.recovering); expect(controller.isAnyStreamRecovering, isTrue); controller.handleChannelStatus('tasks', StreamConnectionStatus.connected); expect(controller.isAnyStreamRecovering, isFalse); }); // ── Legacy compat ─────────────────────────────────────────────────── test('markStreamRecovering maps to _global channel', () { controller.markStreamRecovering(); expect(controller.isChannelRecovering('_global'), isTrue); expect(controller.isAnyStreamRecovering, isTrue); }); test('markStreamRecovered clears _global channel', () { controller.markStreamRecovering(); controller.markStreamRecovered(); expect(controller.isChannelRecovering('_global'), isFalse); expect(controller.isAnyStreamRecovering, isFalse); }); // ── isAnyStreamRecovering aggregate ──────────────────────────────── test('isAnyStreamRecovering is false only when all channels are recovered', () { controller.markChannelRecovering('a'); controller.markChannelRecovering('b'); controller.markChannelRecovered('a'); expect(controller.isAnyStreamRecovering, isTrue); controller.markChannelRecovered('b'); expect(controller.isAnyStreamRecovering, isFalse); }); }); }