diff --git a/packages/realtime_client/test/channel_test.dart b/packages/realtime_client/test/channel_test.dart index 3cb03441..9932a19a 100644 --- a/packages/realtime_client/test/channel_test.dart +++ b/packages/realtime_client/test/channel_test.dart @@ -67,17 +67,12 @@ void main() { channel = socket.channel('topic'); }); - test('sets state to joining', () { - channel.subscribe(); - - expect(channel.isJoining, true); - }); - - test('sets joinedOnce to true', () { + test('sets state and joinedOnce when subscribing', () { expect(channel.joinedOnce, isFalse); channel.subscribe(); + expect(channel.isJoining, true); expect(channel.joinedOnce, isTrue); }); @@ -99,52 +94,30 @@ void main() { }); }); - group('onError', () { + group('state transitions', () { setUp(() { socket = RealtimeClient('/socket'); channel = socket.channel('topic'); channel.subscribe(); }); - test("sets state to 'errored'", () { + test('error and close events change state correctly', () { + // Test error state expect(channel.isErrored, isFalse); - channel.trigger('phx_error'); - expect(channel.isErrored, isTrue); - }); - }); - group('onClose', () { - setUp(() { + // Reset and test close state socket = RealtimeClient('/socket'); channel = socket.channel('topic'); channel.subscribe(); - }); - test("sets state to 'closed'", () { expect(channel.isClosed, isFalse); - channel.trigger('phx_close'); - expect(channel.isClosed, isTrue); }); }); - group('onMessage', () { - setUp(() { - socket = RealtimeClient('/socket'); - - channel = socket.channel('topic'); - }); - - test('returns payload by default', () { - final payload = channel.onMessage('event', {'one': 'two'}); - - expect(payload, {'one': 'two'}); - }); - }); - group('on', () { late RealtimeChannel channel; @@ -352,7 +325,7 @@ void main() { RealtimeChannel('topic', socket, params: RealtimeChannelConfig()); }); - test('description', () async { + test('presence callbacks work correctly', () async { bool syncCalled = false, joinCalled = false, leaveCalled = false; channel.onPresenceSync((payload) { syncCalled = true; @@ -386,4 +359,99 @@ void main() { expect(leaveCalled, isTrue); }); }); + + group('postgres changes', () { + setUp(() { + socket = RealtimeClient('', timeout: const Duration(milliseconds: 1234)); + channel = + RealtimeChannel('topic', socket, params: RealtimeChannelConfig()); + }); + + test('onPostgresChanges registers postgres change listener', () { + var called = false; + PostgresChangePayload? receivedPayload; + + channel.onPostgresChanges( + event: PostgresChangeEvent.insert, + schema: 'public', + table: 'users', + callback: (payload) { + called = true; + receivedPayload = payload; + }, + ); + + // Simulate postgres change event + final payload = { + 'event': 'INSERT', + 'schema': 'public', + 'table': 'users', + 'eventType': 'INSERT', + 'new': {'id': 1, 'name': 'John'}, + 'old': {}, + 'commit_timestamp': '2023-01-01T00:00:00Z', + 'errors': null, + }; + + channel.trigger('postgres_changes', payload); + + expect(called, isTrue); + expect(receivedPayload?.eventType, PostgresChangeEvent.insert); + expect(receivedPayload?.newRecord['name'], 'John'); + }); + }); + + group('broadcast', () { + setUp(() { + socket = RealtimeClient('', timeout: const Duration(milliseconds: 1234)); + channel = + RealtimeChannel('topic', socket, params: RealtimeChannelConfig()); + }); + + test('onBroadcast registers broadcast listener', () { + var called = false; + Map? receivedPayload; + + channel.onBroadcast( + event: 'chat_message', + callback: (payload) { + called = true; + receivedPayload = payload; + }, + ); + + // Simulate broadcast event + channel.trigger('broadcast', { + 'event': 'chat_message', + 'payload': {'text': 'Hello world'}, + }); + + expect(called, isTrue); + expect(receivedPayload?['payload']['text'], 'Hello world'); + }); + }); + + group('helper methods', () { + setUp(() { + socket = RealtimeClient('', timeout: const Duration(milliseconds: 1234)); + channel = + RealtimeChannel('topic', socket, params: RealtimeChannelConfig()); + }); + + test('utility methods work correctly', () { + // replyEventName + expect(channel.replyEventName('ref123'), 'chan_reply_ref123'); + expect(channel.replyEventName(null), 'chan_reply_null'); + + // isMember + expect(channel.isMember('topic'), isTrue); + expect(channel.isMember('other:topic'), isFalse); + expect(channel.isMember('*'), isFalse); + + // state getters + expect(channel.isClosed, isTrue); + expect(channel.isErrored, isFalse); + expect(channel.isJoined, isFalse); + }); + }); } diff --git a/packages/realtime_client/test/constants_test.dart b/packages/realtime_client/test/constants_test.dart new file mode 100644 index 00000000..6a5bef16 --- /dev/null +++ b/packages/realtime_client/test/constants_test.dart @@ -0,0 +1,47 @@ +import 'package:realtime_client/src/constants.dart'; +import 'package:test/test.dart'; + +void main() { + group('Constants', () { + test('has correct values', () { + expect(Constants.vsn, '1.0.0'); + expect(Constants.defaultTimeout.inMilliseconds, 10000); + expect(Constants.defaultHeartbeatIntervalMs, 25000); + expect(Constants.wsCloseNormal, 1000); + expect(Constants.defaultHeaders, isA>()); + expect(Constants.defaultHeaders.containsKey('X-Client-Info'), isTrue); + }); + }); + + group('ChannelEventsExtended', () { + test('conversion methods work correctly', () { + // fromType with names + expect(ChannelEventsExtended.fromType('close'), ChannelEvents.close); + expect(ChannelEventsExtended.fromType('error'), ChannelEvents.error); + + // fromType with eventNames + expect(ChannelEventsExtended.fromType('phx_close'), ChannelEvents.close); + expect(ChannelEventsExtended.fromType('access_token'), + ChannelEvents.accessToken); + expect(ChannelEventsExtended.fromType('postgres_changes'), + ChannelEvents.postgresChanges); + + // eventName returns + expect(ChannelEvents.close.eventName(), 'phx_close'); + expect(ChannelEvents.accessToken.eventName(), 'access_token'); + expect(ChannelEvents.postgresChanges.eventName(), 'postgres_changes'); + + // Invalid type throws + expect( + () => ChannelEventsExtended.fromType('invalid_type'), + throwsA(isA().having( + (s) => s, 'error', contains('No type invalid_type exists')))); + }); + }); + + group('Transports', () { + test('has correct websocket value', () { + expect(Transports.websocket, 'websocket'); + }); + }); +} diff --git a/packages/realtime_client/test/presence_test.dart b/packages/realtime_client/test/presence_test.dart new file mode 100644 index 00000000..17c03ea5 --- /dev/null +++ b/packages/realtime_client/test/presence_test.dart @@ -0,0 +1,954 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:realtime_client/realtime_client.dart'; +import 'package:realtime_client/src/types.dart'; +import 'package:test/test.dart'; + +class MockRealtimeChannel extends Mock implements RealtimeChannel {} + +class FakeChannelFilter extends Fake implements ChannelFilter {} + +void main() { + setUpAll(() { + registerFallbackValue(FakeChannelFilter()); + }); + + group('Presence', () { + test('fromJson creates Presence with correct values', () { + final map = { + 'presence_ref': 'ref123', + 'user_id': 1, + 'status': 'online', + }; + + final presence = Presence.fromJson(map); + + expect(presence.presenceRef, 'ref123'); + expect(presence.payload['user_id'], 1); + expect(presence.payload['status'], 'online'); + expect(presence.payload.containsKey('presence_ref'), isFalse); + }); + + test('deepClone creates a copy of Presence', () { + final map = { + 'presence_ref': 'ref123', + 'user_id': 1, + 'status': 'online', + }; + final presence = Presence.fromJson(map); + + final cloned = presence.deepClone(); + + expect(cloned.presenceRef, presence.presenceRef); + expect(cloned.payload['user_id'], presence.payload['user_id']); + expect(cloned.payload['status'], presence.payload['status']); + }); + + test('toString returns correct representation', () { + final map = { + 'presence_ref': 'ref123', + 'user_id': 1, + }; + final presence = Presence.fromJson(map); + + expect(presence.toString(), + 'Presence(presenceRef: ref123, payload: {user_id: 1})'); + }); + }); + + group('RealtimePresence', () { + late MockRealtimeChannel mockChannel; + late RealtimePresence presence; + + setUp(() { + mockChannel = MockRealtimeChannel(); + when(() => mockChannel.joinRef).thenReturn('join_ref_1'); + + // Setup stubs for onEvents + when(() => mockChannel.onEvents(any(), any(), any())) + .thenReturn(mockChannel); + when(() => mockChannel.trigger(any(), any())).thenReturn(null); + }); + + group('initialization', () { + test('initializes with default events', () { + presence = RealtimePresence(mockChannel); + + expect(presence.state, isEmpty); + expect(presence.pendingDiffs, isEmpty); + expect(presence.joinRef, isNull); + expect(presence.caller.containsKey('onJoin'), isTrue); + expect(presence.caller.containsKey('onLeave'), isTrue); + expect(presence.caller.containsKey('onSync'), isTrue); + + // Verify it registers for default events + verify(() => mockChannel.onEvents('presence_state', any(), any())) + .called(1); + verify(() => mockChannel.onEvents('presence_diff', any(), any())) + .called(1); + }); + + test('initializes with custom events', () { + final opts = PresenceOpts( + events: PresenceEvents(state: 'custom_state', diff: 'custom_diff'), + ); + presence = RealtimePresence(mockChannel, opts); + + // Verify it registers for custom events + verify(() => mockChannel.onEvents('custom_state', any(), any())) + .called(1); + verify(() => mockChannel.onEvents('custom_diff', any(), any())) + .called(1); + }); + + test('sets up onJoin callback to trigger presence events', () { + presence = RealtimePresence(mockChannel); + + // The initialization already sets up the join trigger + // Verify that trigger gets called when the internal onJoin is invoked + presence.caller['onJoin']!('key1', [], ['new1']); + + verify(() => mockChannel.trigger('presence', { + 'event': 'join', + 'key': 'key1', + 'currentPresences': [], + 'newPresences': ['new1'], + })).called(1); + }); + + test('sets up onLeave callback to trigger presence events', () { + presence = RealtimePresence(mockChannel); + + // The initialization already sets up the leave trigger + presence.caller['onLeave']!('key1', [], ['left1']); + + verify(() => mockChannel.trigger('presence', { + 'event': 'leave', + 'key': 'key1', + 'currentPresences': [], + 'leftPresences': ['left1'], + })).called(1); + }); + + test('sets up onSync callback to trigger presence events', () { + presence = RealtimePresence(mockChannel); + + // The initialization already sets up the sync trigger + presence.caller['onSync']!(); + + verify(() => mockChannel.trigger('presence', {'event': 'sync'})) + .called(1); + }); + }); + + group('instance methods', () { + setUp(() { + presence = RealtimePresence(mockChannel); + }); + + test('inPendingSyncState works correctly', () { + when(() => mockChannel.joinRef).thenReturn('channel_ref'); + + // Null joinRef + presence.joinRef = null; + expect(presence.inPendingSyncState(), isTrue); + + // Different refs + presence.joinRef = 'old_ref'; + expect(presence.inPendingSyncState(), isTrue); + + // Matching refs + presence.joinRef = 'channel_ref'; + expect(presence.inPendingSyncState(), isFalse); + }); + + test('list returns formatted presence list', () { + presence.state = { + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'id': 1}), + Presence.fromJson({'presence_ref': 'ref2', 'id': 2}), + ], + 'user2': [ + Presence.fromJson({'presence_ref': 'ref3', 'id': 3}), + ], + }; + + final list = presence.list(); + expect(list.length, 2); + expect(list[0], presence.state['user1']); + expect(list[1], presence.state['user2']); + }); + + test('list with chooser transforms presence data', () { + presence.state = { + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'id': 1}), + ], + 'user2': [ + Presence.fromJson({'presence_ref': 'ref2', 'id': 2}), + ], + }; + + final list = presence.list((key, presences) => key); + expect(list, ['user1', 'user2']); + }); + }); + + group('static methods', () { + group('syncState', () { + test('handles empty states', () { + final currentState = >{}; + final newState = {}; + + final result = RealtimePresence.syncState(currentState, newState); + + expect(result, isEmpty); + }); + + test('adds new presences', () { + final currentState = >{}; + final newState = { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }; + + var joinCalled = false; + final result = RealtimePresence.syncState( + currentState, + newState, + (key, current, newP) { + joinCalled = true; + expect(key, 'user1'); + expect(current, isEmpty); + expect((newP as List).length, 1); + }, + ); + + expect(joinCalled, isTrue); + expect(result['user1']!.length, 1); + expect(result['user1']![0].presenceRef, 'ref1'); + expect(result['user1']![0].payload['user_id'], 1); + }); + + test('removes presences not in new state', () { + final currentState = { + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), + ], + }; + final newState = {}; + + var leaveCalled = false; + final result = RealtimePresence.syncState( + currentState, + newState, + null, + (key, current, left) { + leaveCalled = true; + expect(key, 'user1'); + expect((left as List).length, 1); + }, + ); + + expect(leaveCalled, isTrue); + expect(result, isEmpty); + }); + + test('handles both joins and leaves in same sync', () { + final currentState = { + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), + Presence.fromJson({'presence_ref': 'ref2', 'user_id': 2}), + ], + }; + final newState = { + 'user1': { + 'metas': [ + {'phx_ref': 'ref2', 'user_id': 2}, // ref1 removed + {'phx_ref': 'ref3', 'user_id': 3}, // ref3 added + ], + }, + }; + + var joinCalled = false; + var leaveCalled = false; + + final result = RealtimePresence.syncState( + currentState, + newState, + (key, current, newP) { + joinCalled = true; + expect((newP as List).length, 1); + expect(newP[0].presenceRef, 'ref3'); + }, + (key, current, left) { + leaveCalled = true; + expect((left as List).length, 1); + expect(left[0].presenceRef, 'ref1'); + }, + ); + + expect(joinCalled, isTrue); + expect(leaveCalled, isTrue); + expect(result['user1']!.length, 2); + }); + + test('preserves existing presences that continue to exist', () { + final currentState = { + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), + Presence.fromJson({'presence_ref': 'ref2', 'user_id': 2}), + ], + }; + final newState = { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, // stays + {'phx_ref': 'ref2', 'user_id': 2}, // stays + {'phx_ref': 'ref3', 'user_id': 3}, // new + ], + }, + }; + + var joinCalled = false; + var leaveCalled = false; + + final result = RealtimePresence.syncState( + currentState, + newState, + (key, current, newP) { + joinCalled = true; + expect((newP as List).length, 1); + expect(newP[0].presenceRef, 'ref3'); + }, + (key, current, left) { + leaveCalled = true; + }, + ); + + expect(joinCalled, isTrue); + expect(leaveCalled, isFalse); // No one left + expect(result['user1']!.length, 3); + expect(result['user1']!.map((p) => p.presenceRef), + containsAll(['ref1', 'ref2', 'ref3'])); + }); + + test('handles multiple users joining and leaving', () { + final currentState = { + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), + ], + 'user2': [ + Presence.fromJson({'presence_ref': 'ref2', 'user_id': 2}), + ], + }; + final newState = { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, // stays + ], + }, + 'user3': { + 'metas': [ + {'phx_ref': 'ref3', 'user_id': 3}, // new user + ], + }, + }; + + var joinCount = 0; + var leaveCount = 0; + + final result = RealtimePresence.syncState( + currentState, + newState, + (key, current, newP) { + joinCount++; + if (key == 'user3') { + expect((newP as List)[0].presenceRef, 'ref3'); + } + }, + (key, current, left) { + leaveCount++; + if (key == 'user2') { + expect((left as List)[0].presenceRef, 'ref2'); + } + }, + ); + + expect(joinCount, 1); // user3 joined + expect(leaveCount, 1); // user2 left + expect(result.containsKey('user1'), isTrue); + expect(result.containsKey('user2'), isFalse); + expect(result.containsKey('user3'), isTrue); + }); + + test('handles state transformation with phx_ref_prev', () { + final currentState = >{}; + final newState = { + 'user1': { + 'metas': [ + { + 'phx_ref': 'new_ref', + 'phx_ref_prev': 'old_ref', + 'user_id': 1, + 'status': 'online' + }, + ], + }, + }; + + final result = RealtimePresence.syncState(currentState, newState); + + expect(result['user1']!.length, 1); + expect(result['user1']![0].presenceRef, 'new_ref'); + expect(result['user1']![0].payload['user_id'], 1); + expect(result['user1']![0].payload['status'], 'online'); + expect(result['user1']![0].payload.containsKey('phx_ref'), isFalse); + expect( + result['user1']![0].payload.containsKey('phx_ref_prev'), isFalse); + }); + }); + + group('syncDiff', () { + test('handles joins', () { + final state = >{}; + final diff = { + 'joins': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }, + 'leaves': {}, + }; + + var joinCalled = false; + final result = RealtimePresence.syncDiff( + state, + diff, + (key, current, newP) { + joinCalled = true; + expect(key, 'user1'); + }, + ); + + expect(joinCalled, isTrue); + expect(result['user1']!.length, 1); + }); + + test('handles leaves', () { + final state = >{ + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), + Presence.fromJson({'presence_ref': 'ref2', 'user_id': 2}), + ], + }; + final diff = { + 'joins': {}, + 'leaves': { + 'user1': { + 'metas': [ + { + 'phx_ref': 'ref1', + 'phx_ref_prev': 'ref0', + 'user_id': 1 + }, + ], + }, + }, + }; + + var leaveCalled = false; + final result = RealtimePresence.syncDiff( + state, + diff, + null, + (key, current, left) { + leaveCalled = true; + expect(key, 'user1'); + expect((left as List).length, 1); + }, + ); + + expect(leaveCalled, isTrue); + expect(result['user1']!.length, 1); + expect(result['user1']![0].presenceRef, 'ref2'); + }); + + test('removes key when all presences leave', () { + final state = >{ + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), + ], + }; + final diff = { + 'joins': {}, + 'leaves': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }, + }; + + final result = RealtimePresence.syncDiff(state, diff); + + expect(result.containsKey('user1'), isFalse); + }); + + test('merges new presences with existing ones', () { + final state = >{ + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), + ], + }; + final diff = { + 'joins': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref2', 'user_id': 2}, + ], + }, + }, + 'leaves': {}, + }; + + final result = RealtimePresence.syncDiff(state, diff); + + expect(result['user1']!.length, 2); + expect(result['user1']![0].presenceRef, 'ref1'); + expect(result['user1']![1].presenceRef, 'ref2'); + }); + + test('handles null callbacks gracefully', () { + final state = >{}; + final diff = { + 'joins': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }, + 'leaves': {}, + }; + + // Should not throw + final result = RealtimePresence.syncDiff(state, diff); + + expect(result['user1']!.length, 1); + }); + + test('handles joins with preservation and deduplication', () { + // Test simple preservation + var state = >{ + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}) + ], + }; + var diff = { + 'joins': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref2', 'user_id': 2} + ], + }, + }, + 'leaves': {}, + }; + + var result = RealtimePresence.syncDiff(state, diff); + expect(result['user1']!.length, 2); + expect(result['user1']![0].presenceRef, 'ref1'); // Original preserved + expect(result['user1']![1].presenceRef, 'ref2'); // New added + + // Test deduplication with new presences + state = >{ + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), + Presence.fromJson({'presence_ref': 'ref2', 'user_id': 2}), + ], + }; + diff = { + 'joins': { + 'user1': { + 'metas': [ + { + 'phx_ref': 'ref1', + 'user_id': 1 + }, // Duplicate + {'phx_ref': 'ref3', 'user_id': 3}, // New + ], + }, + }, + 'leaves': {}, + }; + + result = RealtimePresence.syncDiff(state, diff); + expect(result['user1']!.length, 3); + final refs = result['user1']!.map((p) => p.presenceRef).toList(); + expect(refs, containsAll(['ref1', 'ref2', 'ref3'])); + }); + + test('handles leaves when current presences is null', () { + final state = >{}; + final diff = { + 'joins': {}, + 'leaves': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }, + }; + + final result = RealtimePresence.syncDiff(state, diff); + + // Should not crash and should not add anything + expect(result.containsKey('user1'), isFalse); + }); + + test('calls onJoin callback with correct parameters', () { + final state = >{ + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), + ], + }; + final diff = { + 'joins': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref2', 'user_id': 2}, + ], + }, + }, + 'leaves': {}, + }; + + String? callbackKey; + List? callbackCurrentPresences; + List? callbackNewPresences; + + RealtimePresence.syncDiff( + state, + diff, + (key, current, newP) { + callbackKey = key; + callbackCurrentPresences = current as List; + callbackNewPresences = newP as List; + }, + ); + + expect(callbackKey, 'user1'); + expect(callbackCurrentPresences!.length, 1); + expect(callbackCurrentPresences![0].presenceRef, 'ref1'); + expect(callbackNewPresences!.length, 1); + expect(callbackNewPresences![0].presenceRef, 'ref2'); + }); + + test('calls onLeave callback with correct parameters', () { + final state = >{ + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), + Presence.fromJson({'presence_ref': 'ref2', 'user_id': 2}), + ], + }; + final diff = { + 'joins': {}, + 'leaves': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }, + }; + + String? callbackKey; + List? callbackCurrentPresences; + List? callbackLeftPresences; + + RealtimePresence.syncDiff( + state, + diff, + null, + (key, current, left) { + callbackKey = key; + callbackCurrentPresences = current as List; + callbackLeftPresences = left as List; + }, + ); + + expect(callbackKey, 'user1'); + expect(callbackCurrentPresences!.length, 1); + expect(callbackCurrentPresences![0].presenceRef, 'ref2'); + expect(callbackLeftPresences!.length, 1); + expect(callbackLeftPresences![0].presenceRef, 'ref1'); + }); + }); + }); + + group('event handling', () { + test('handles presence_state event', () { + final stateCallback = []; + final diffCallback = []; + + when(() => mockChannel.onEvents('presence_state', any(), any())) + .thenAnswer((invocation) { + stateCallback.add(invocation.positionalArguments[2] as Function); + return mockChannel; + }); + + when(() => mockChannel.onEvents('presence_diff', any(), any())) + .thenAnswer((invocation) { + diffCallback.add(invocation.positionalArguments[2] as Function); + return mockChannel; + }); + + presence = RealtimePresence(mockChannel); + + // Simulate state event + final newState = { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }; + + stateCallback[0](newState); + + expect(presence.state['user1']!.length, 1); + expect(presence.joinRef, 'join_ref_1'); + }); + + test('queues diffs when in pending sync state', () { + final stateCallback = []; + final diffCallback = []; + + when(() => mockChannel.onEvents('presence_state', any(), any())) + .thenAnswer((invocation) { + stateCallback.add(invocation.positionalArguments[2] as Function); + return mockChannel; + }); + + when(() => mockChannel.onEvents('presence_diff', any(), any())) + .thenAnswer((invocation) { + diffCallback.add(invocation.positionalArguments[2] as Function); + return mockChannel; + }); + + presence = RealtimePresence(mockChannel); + presence.joinRef = null; // Simulate pending state + + // Simulate diff event + final diff = { + 'joins': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }, + 'leaves': {}, + }; + + diffCallback[0](diff); + + expect(presence.pendingDiffs.length, 1); + expect(presence.pendingDiffs[0], diff); + expect(presence.state, isEmpty); + }); + + test('applies pending diffs after state sync', () { + final stateCallback = []; + final diffCallback = []; + + when(() => mockChannel.onEvents('presence_state', any(), any())) + .thenAnswer((invocation) { + stateCallback.add(invocation.positionalArguments[2] as Function); + return mockChannel; + }); + + when(() => mockChannel.onEvents('presence_diff', any(), any())) + .thenAnswer((invocation) { + diffCallback.add(invocation.positionalArguments[2] as Function); + return mockChannel; + }); + + presence = RealtimePresence(mockChannel); + + // Add pending diff + presence.pendingDiffs = [ + { + 'joins': { + 'user2': { + 'metas': [ + {'phx_ref': 'ref2', 'user_id': 2}, + ], + }, + }, + 'leaves': {}, + }, + ]; + + // Simulate state event + final newState = { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }; + + stateCallback[0](newState); + + expect(presence.state['user1']!.length, 1); + expect(presence.state['user2']!.length, 1); + expect(presence.pendingDiffs, isEmpty); + }); + + test('processes diff immediately when not in pending state', () { + final stateCallback = []; + final diffCallback = []; + + when(() => mockChannel.onEvents('presence_state', any(), any())) + .thenAnswer((invocation) { + stateCallback.add(invocation.positionalArguments[2] as Function); + return mockChannel; + }); + + when(() => mockChannel.onEvents('presence_diff', any(), any())) + .thenAnswer((invocation) { + diffCallback.add(invocation.positionalArguments[2] as Function); + return mockChannel; + }); + + presence = RealtimePresence(mockChannel); + presence.joinRef = 'join_ref_1'; // Not in pending state + + // Simulate diff event + final diff = { + 'joins': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }, + 'leaves': {}, + }; + + diffCallback[0](diff); + + expect(presence.state['user1']!.length, 1); + expect(presence.pendingDiffs, isEmpty); + }); + + test('triggers onSync callback after state event', () { + final stateCallback = []; + var syncTriggered = false; + + when(() => mockChannel.onEvents('presence_state', any(), any())) + .thenAnswer((invocation) { + stateCallback.add(invocation.positionalArguments[2] as Function); + return mockChannel; + }); + + when(() => mockChannel.onEvents('presence_diff', any(), any())) + .thenReturn(mockChannel); + + presence = RealtimePresence(mockChannel); + presence.onSync(() { + syncTriggered = true; + }); + + // Simulate state event + final newState = { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }; + + stateCallback[0](newState); + + expect(syncTriggered, isTrue); + }); + + test('triggers onJoin and onLeave callbacks during state sync', () { + final stateCallback = []; + var joinTriggered = false; + var leaveTriggered = false; + dynamic joinData; + dynamic leaveData; + + when(() => mockChannel.onEvents('presence_state', any(), any())) + .thenAnswer((invocation) { + stateCallback.add(invocation.positionalArguments[2] as Function); + return mockChannel; + }); + + when(() => mockChannel.onEvents('presence_diff', any(), any())) + .thenReturn(mockChannel); + + presence = RealtimePresence(mockChannel); + + // Add existing state + presence.state = { + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}) + ], + }; + + presence.onJoin((key, current, newP) { + joinTriggered = true; + joinData = {'key': key, 'current': current, 'new': newP}; + }); + + presence.onLeave((key, current, left) { + leaveTriggered = true; + leaveData = {'key': key, 'current': current, 'left': left}; + }); + + // Simulate state event with user2 joining and user1 leaving + final newState = { + 'user2': { + 'metas': [ + {'phx_ref': 'ref2', 'user_id': 2}, + ], + }, + }; + + stateCallback[0](newState); + + expect(joinTriggered, isTrue); + expect(leaveTriggered, isTrue); + expect(joinData['key'], 'user2'); + expect(leaveData['key'], 'user1'); + }); + }); + }); + + group('PresenceEvents', () { + test('creates with state and diff', () { + final events = PresenceEvents(state: 'custom_state', diff: 'custom_diff'); + expect(events.state, 'custom_state'); + expect(events.diff, 'custom_diff'); + }); + }); + + group('PresenceOpts', () { + test('creates with events', () { + final events = PresenceEvents(state: 'state', diff: 'diff'); + final opts = PresenceOpts(events: events); + expect(opts.events, events); + }); + }); +} diff --git a/packages/realtime_client/test/types_test.dart b/packages/realtime_client/test/types_test.dart new file mode 100644 index 00000000..93270534 --- /dev/null +++ b/packages/realtime_client/test/types_test.dart @@ -0,0 +1,280 @@ +import 'package:realtime_client/realtime_client.dart'; +import 'package:realtime_client/src/types.dart'; +import 'package:test/test.dart'; + +void main() { + group('Binding', () { + test('copyWith returns new instance with updated values', () { + callback(payload, [ref]) {} + final binding = Binding('type1', {'key': 'value'}, callback, 'id1'); + + final copied = binding.copyWith( + type: 'type2', + filter: {'newKey': 'newValue'}, + ); + + expect(copied.type, 'type2'); + expect(copied.filter, {'newKey': 'newValue'}); + expect(copied.callback, callback); // Same callback + expect(copied.id, 'id1'); // Same id + }); + + test('copyWith keeps original values when not specified', () { + callback(payload, [ref]) {} + final binding = Binding('type1', {'key': 'value'}, callback, 'id1'); + + final copied = binding.copyWith(); + + expect(copied.type, 'type1'); + expect(copied.filter, {'key': 'value'}); + expect(copied.callback, callback); + expect(copied.id, 'id1'); + }); + }); + + group('PostgresChangeEvent', () { + test('toRealtimeEvent returns correct string representation', () { + expect(PostgresChangeEvent.all.toRealtimeEvent(), '*'); + expect(PostgresChangeEvent.insert.toRealtimeEvent(), 'INSERT'); + expect(PostgresChangeEvent.update.toRealtimeEvent(), 'UPDATE'); + expect(PostgresChangeEvent.delete.toRealtimeEvent(), 'DELETE'); + }); + + test('fromString throws for invalid event', () { + expect( + () => PostgresChangeEventMethods.fromString('INVALID'), + throwsA(isA().having((e) => e.message, 'message', + contains('Only "INSERT", "UPDATE", or "DELETE"')))); + }); + }); + + group('PresenceEvent', () { + test('fromString returns correct enum value', () { + expect(PresenceEventExtended.fromString('sync'), PresenceEvent.sync); + expect(PresenceEventExtended.fromString('join'), PresenceEvent.join); + expect(PresenceEventExtended.fromString('leave'), PresenceEvent.leave); + }); + + test('fromString throws for invalid event', () { + expect( + () => PresenceEventExtended.fromString('invalid'), + throwsA(isA().having((e) => e.message, 'message', + contains('Only "sync", "join", or "leave"')))); + }); + }); + + group('RealtimeListenTypes', () { + test('toType returns correct string representation', () { + expect(RealtimeListenTypes.postgresChanges.toType(), 'postgres_changes'); + expect(RealtimeListenTypes.broadcast.toType(), 'broadcast'); + expect(RealtimeListenTypes.presence.toType(), 'presence'); + expect(RealtimeListenTypes.system.toType(), 'system'); + }); + }); + + group('PostgresChangePayload', () { + test('toString returns correct representation', () { + final payload = PostgresChangePayload( + schema: 'public', + table: 'users', + commitTimestamp: DateTime(2023, 1, 1), + eventType: PostgresChangeEvent.insert, + newRecord: {'id': 1, 'name': 'John'}, + oldRecord: {}, + errors: null, + ); + + expect(payload.toString(), contains('schema: public')); + expect(payload.toString(), contains('table: users')); + expect(payload.toString(), + contains('eventType: PostgresChangeEvent.insert')); + }); + + test('equality operator works correctly', () { + final timestamp = DateTime(2023, 1, 1); + final payload1 = PostgresChangePayload( + schema: 'public', + table: 'users', + commitTimestamp: timestamp, + eventType: PostgresChangeEvent.insert, + newRecord: {'id': 1, 'name': 'John'}, + oldRecord: {}, + errors: null, + ); + + final payload2 = PostgresChangePayload( + schema: 'public', + table: 'users', + commitTimestamp: timestamp, + eventType: PostgresChangeEvent.insert, + newRecord: {'id': 1, 'name': 'John'}, + oldRecord: {}, + errors: null, + ); + + final payload3 = PostgresChangePayload( + schema: 'private', + table: 'users', + commitTimestamp: timestamp, + eventType: PostgresChangeEvent.insert, + newRecord: {'id': 1, 'name': 'John'}, + oldRecord: {}, + errors: null, + ); + + expect(payload1, equals(payload2)); + expect(payload1, isNot(equals(payload3))); + }); + }); + + group('PostgresChangeFilter', () { + test('toString formats correctly for all filter types', () { + // Standard filters + expect( + PostgresChangeFilter( + type: PostgresChangeFilterType.eq, column: 'id', value: 5) + .toString(), + 'id=eq.5'); + expect( + PostgresChangeFilter( + type: PostgresChangeFilterType.neq, + column: 'status', + value: 'deleted') + .toString(), + 'status=neq.deleted'); + + // Comparison filters + expect( + PostgresChangeFilter( + type: PostgresChangeFilterType.lt, column: 'age', value: 18) + .toString(), + 'age=lt.18'); + expect( + PostgresChangeFilter( + type: PostgresChangeFilterType.gte, column: 'count', value: 0) + .toString(), + 'count=gte.0'); + + // List filters + expect( + PostgresChangeFilter( + type: PostgresChangeFilterType.inFilter, + column: 'status', + value: ['active', 'pending']).toString(), + 'status=in.("active","pending")'); + expect( + PostgresChangeFilter( + type: PostgresChangeFilterType.inFilter, + column: 'id', + value: [1, 2, 3]).toString(), + 'id=in.("1","2","3")'); + }); + }); + + group('RealtimePresencePayload', () { + test('toString returns correct representation', () { + final payload = RealtimePresenceSyncPayload( + event: PresenceEvent.sync, + ); + + expect( + payload.toString(), 'PresenceSyncPayload(event: PresenceEvent.sync)'); + }); + + test('fromJson creates correct instance', () { + final json = {'event': 'sync'}; + final payload = RealtimePresenceSyncPayload.fromJson(json); + + expect(payload.event, PresenceEvent.sync); + }); + }); + + group('RealtimePresenceJoinPayload', () { + test('toString returns correct representation', () { + final presence1 = Presence.fromJson({'presence_ref': 'ref1', 'id': 1}); + final presence2 = Presence.fromJson({'presence_ref': 'ref2', 'id': 2}); + + final payload = RealtimePresenceJoinPayload( + event: PresenceEvent.join, + key: 'user123', + newPresences: [presence1], + currentPresences: [presence1, presence2], + ); + + expect(payload.toString(), contains('key: user123')); + expect(payload.toString(), contains('newPresences:')); + expect(payload.toString(), contains('currentPresences:')); + }); + + test('fromJson creates correct instance', () { + final presence1 = Presence.fromJson({'presence_ref': 'ref1', 'id': 1}); + final presence2 = Presence.fromJson({'presence_ref': 'ref2', 'id': 2}); + + final json = { + 'event': 'join', + 'key': 'user123', + 'newPresences': [presence1], + 'currentPresences': [presence1, presence2], + }; + + final payload = RealtimePresenceJoinPayload.fromJson(json); + + expect(payload.event, PresenceEvent.join); + expect(payload.key, 'user123'); + expect(payload.newPresences.length, 1); + expect(payload.currentPresences.length, 2); + }); + }); + + group('RealtimePresenceLeavePayload', () { + test('toString returns correct representation', () { + final presence1 = Presence.fromJson({'presence_ref': 'ref1', 'id': 1}); + final presence2 = Presence.fromJson({'presence_ref': 'ref2', 'id': 2}); + + final payload = RealtimePresenceLeavePayload( + event: PresenceEvent.leave, + key: 'user123', + leftPresences: [presence1], + currentPresences: [presence2], + ); + + expect(payload.toString(), contains('key: user123')); + expect(payload.toString(), contains('leftPresences:')); + expect(payload.toString(), contains('currentPresences:')); + }); + + test('fromJson creates correct instance', () { + final presence1 = Presence.fromJson({'presence_ref': 'ref1', 'id': 1}); + final presence2 = Presence.fromJson({'presence_ref': 'ref2', 'id': 2}); + + final json = { + 'event': 'leave', + 'key': 'user123', + 'leftPresences': [presence1], + 'currentPresences': [presence2], + }; + + final payload = RealtimePresenceLeavePayload.fromJson(json); + + expect(payload.event, PresenceEvent.leave); + expect(payload.key, 'user123'); + expect(payload.leftPresences.length, 1); + expect(payload.currentPresences.length, 1); + }); + }); + + group('SinglePresenceState', () { + test('toString returns correct representation', () { + final presence1 = Presence.fromJson({'presence_ref': 'ref1', 'id': 1}); + final presence2 = Presence.fromJson({'presence_ref': 'ref2', 'id': 2}); + + final state = SinglePresenceState( + key: 'user123', + presences: [presence1, presence2], + ); + + expect(state.toString(), contains('key: user123')); + expect(state.toString(), contains('presences:')); + }); + }); +}