From f5a33d19feeb7bca8ef36553fc4c2e1c27f7281f Mon Sep 17 00:00:00 2001 From: dshukertjr Date: Thu, 12 Jun 2025 14:46:58 +0900 Subject: [PATCH 1/4] chore: Improve test coverage for realtime_client --- .../realtime_client/test/channel_test.dart | 102 +- .../realtime_client/test/constants_test.dart | 112 ++ .../realtime_client/test/presence_test.dart | 1011 +++++++++++++++++ packages/realtime_client/test/types_test.dart | 349 ++++++ 4 files changed, 1573 insertions(+), 1 deletion(-) create mode 100644 packages/realtime_client/test/constants_test.dart create mode 100644 packages/realtime_client/test/presence_test.dart create mode 100644 packages/realtime_client/test/types_test.dart diff --git a/packages/realtime_client/test/channel_test.dart b/packages/realtime_client/test/channel_test.dart index 3cb03441..4c0a696e 100644 --- a/packages/realtime_client/test/channel_test.dart +++ b/packages/realtime_client/test/channel_test.dart @@ -352,7 +352,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 +386,104 @@ 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 = { + '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', { + 'type': 'INSERT', + ...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('replyEventName generates correct event name', () { + expect(channel.replyEventName('ref123'), 'chan_reply_ref123'); + expect(channel.replyEventName(null), ''); + }); + + test('isMember checks topic membership correctly', () { + expect(channel.isMember('topic'), isTrue); + expect(channel.isMember('other:topic'), isFalse); + expect(channel.isMember('*'), isTrue); + }); + + test('state getters return correct values', () { + expect(channel.isClosed, isTrue); + expect(channel.isErrored, isFalse); + expect(channel.isJoined, isFalse); + expect(channel.isJoining, isFalse); + expect(channel.isLeaving, 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..4d482915 --- /dev/null +++ b/packages/realtime_client/test/constants_test.dart @@ -0,0 +1,112 @@ +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('RealtimeConstants', () { + test('is type alias for Constants', () { + expect(RealtimeConstants.vsn, Constants.vsn); + }); + }); + + group('SocketStates', () { + test('enum values exist', () { + expect(SocketStates.connecting, isNotNull); + expect(SocketStates.open, isNotNull); + expect(SocketStates.disconnecting, isNotNull); + expect(SocketStates.closed, isNotNull); + expect(SocketStates.disconnected, isNotNull); + }); + }); + + group('ChannelStates', () { + test('enum values exist', () { + expect(ChannelStates.closed, isNotNull); + expect(ChannelStates.errored, isNotNull); + expect(ChannelStates.joined, isNotNull); + expect(ChannelStates.joining, isNotNull); + expect(ChannelStates.leaving, isNotNull); + }); + }); + + group('ChannelEvents', () { + test('enum values exist', () { + expect(ChannelEvents.close, isNotNull); + expect(ChannelEvents.error, isNotNull); + expect(ChannelEvents.join, isNotNull); + expect(ChannelEvents.reply, isNotNull); + expect(ChannelEvents.leave, isNotNull); + expect(ChannelEvents.heartbeat, isNotNull); + expect(ChannelEvents.accessToken, isNotNull); + expect(ChannelEvents.broadcast, isNotNull); + expect(ChannelEvents.presence, isNotNull); + expect(ChannelEvents.postgresChanges, isNotNull); + }); + }); + + group('ChannelEventsExtended', () { + test('fromType returns correct enum from name', () { + expect(ChannelEventsExtended.fromType('close'), ChannelEvents.close); + expect(ChannelEventsExtended.fromType('error'), ChannelEvents.error); + expect(ChannelEventsExtended.fromType('join'), ChannelEvents.join); + }); + + test('fromType returns correct enum from eventName', () { + expect(ChannelEventsExtended.fromType('phx_close'), ChannelEvents.close); + expect(ChannelEventsExtended.fromType('phx_error'), ChannelEvents.error); + expect(ChannelEventsExtended.fromType('access_token'), + ChannelEvents.accessToken); + expect(ChannelEventsExtended.fromType('postgres_changes'), + ChannelEvents.postgresChanges); + expect( + ChannelEventsExtended.fromType('broadcast'), ChannelEvents.broadcast); + expect( + ChannelEventsExtended.fromType('presence'), ChannelEvents.presence); + }); + + test('fromType throws for invalid type', () { + expect( + () => ChannelEventsExtended.fromType('invalid_type'), + throwsA(isA().having( + (s) => s, 'error', contains('No type invalid_type exists')))); + }); + + test('eventName returns correct string', () { + expect(ChannelEvents.close.eventName(), 'phx_close'); + expect(ChannelEvents.error.eventName(), 'phx_error'); + expect(ChannelEvents.join.eventName(), 'phx_join'); + expect(ChannelEvents.reply.eventName(), 'phx_reply'); + expect(ChannelEvents.leave.eventName(), 'phx_leave'); + expect(ChannelEvents.heartbeat.eventName(), 'phx_heartbeat'); + expect(ChannelEvents.accessToken.eventName(), 'access_token'); + expect(ChannelEvents.postgresChanges.eventName(), 'postgres_changes'); + expect(ChannelEvents.broadcast.eventName(), 'broadcast'); + expect(ChannelEvents.presence.eventName(), 'presence'); + }); + }); + + group('Transports', () { + test('has correct websocket value', () { + expect(Transports.websocket, 'websocket'); + }); + }); + + group('RealtimeLogLevel', () { + test('enum values exist', () { + expect(RealtimeLogLevel.info, isNotNull); + expect(RealtimeLogLevel.debug, isNotNull); + expect(RealtimeLogLevel.warn, isNotNull); + expect(RealtimeLogLevel.error, isNotNull); + }); + }); +} diff --git a/packages/realtime_client/test/presence_test.dart b/packages/realtime_client/test/presence_test.dart new file mode 100644 index 00000000..7621aee4 --- /dev/null +++ b/packages/realtime_client/test/presence_test.dart @@ -0,0 +1,1011 @@ +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('onJoin sets the callback', () { + var called = false; + callback(String? key, dynamic current, dynamic newP) { + called = true; + } + + presence.onJoin(callback); + presence.caller['onJoin']!('key', [], []); + + expect(called, isTrue); + }); + + test('onLeave sets the callback', () { + var called = false; + callback(String? key, dynamic current, dynamic left) { + called = true; + } + + presence.onLeave(callback); + presence.caller['onLeave']!('key', [], []); + + expect(called, isTrue); + }); + + test('onSync sets the callback', () { + var called = false; + callback() { + called = true; + } + + presence.onSync(callback); + presence.caller['onSync']!(); + + expect(called, isTrue); + }); + + test('inPendingSyncState returns true when joinRef is null', () { + presence.joinRef = null; + when(() => mockChannel.joinRef).thenReturn('channel_ref'); + + expect(presence.inPendingSyncState(), isTrue); + }); + + test('inPendingSyncState returns true when joinRef differs from channel', + () { + presence.joinRef = 'old_ref'; + when(() => mockChannel.joinRef).thenReturn('new_ref'); + + expect(presence.inPendingSyncState(), isTrue); + }); + + test('inPendingSyncState returns false when refs match', () { + presence.joinRef = 'same_ref'; + when(() => mockChannel.joinRef).thenReturn('same_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('preserves existing presences when new ones join', () { + 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'); // Original first + expect(result['user1']![1].presenceRef, 'ref2'); // New second + }); + + test('correctly removes duplicate presence refs during joins', () { + final state = { + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), + Presence.fromJson({'presence_ref': 'ref2', 'user_id': 2}), + ], + }; + final diff = { + 'joins': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, // Duplicate + {'phx_ref': 'ref3', 'user_id': 3}, // New + ], + }, + }, + 'leaves': {}, + }; + + final result = RealtimePresence.syncDiff(state, diff); + + expect(result['user1']!.length, 3); + // Should have ref2 (preserved), ref1 (new), ref3 (new) + 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'); + }); + + test('clones presences during join to avoid reference issues', () { + final state = >{}; + final diff = { + 'joins': { + 'user1': { + 'metas': [ + {'phx_ref': 'ref1', 'user_id': 1}, + ], + }, + }, + 'leaves': {}, + }; + + final result = RealtimePresence.syncDiff(state, diff); + + expect(result['user1']!.length, 1); + expect(result['user1']![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..6c00778e --- /dev/null +++ b/packages/realtime_client/test/types_test.dart @@ -0,0 +1,349 @@ +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('ChannelResponse', () { + test('enum values exist', () { + expect(ChannelResponse.ok, isNotNull); + expect(ChannelResponse.timedOut, isNotNull); + expect(ChannelResponse.error, isNotNull); + }); + }); + + 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))); + }); + + test('hashCode is consistent', () { + final timestamp = DateTime(2023, 1, 1); + final payload1 = PostgresChangePayload( + schema: 'public', + table: 'users', + commitTimestamp: timestamp, + eventType: PostgresChangeEvent.insert, + newRecord: {'id': 1}, + oldRecord: {}, + errors: null, + ); + + final payload2 = PostgresChangePayload( + schema: 'public', + table: 'users', + commitTimestamp: timestamp, + eventType: PostgresChangeEvent.insert, + newRecord: {'id': 1}, + oldRecord: {}, + errors: null, + ); + + expect(payload1.hashCode, equals(payload2.hashCode)); + }); + }); + + group('PostgresChangeFilter', () { + test('toString for standard filters', () { + final filter = PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'id', + value: 5, + ); + + expect(filter.toString(), 'id=eq.5'); + }); + + test('toString for neq filter', () { + final filter = PostgresChangeFilter( + type: PostgresChangeFilterType.neq, + column: 'status', + value: 'deleted', + ); + + expect(filter.toString(), 'status=neq.deleted'); + }); + + test('toString for comparison filters', () { + expect( + PostgresChangeFilter( + type: PostgresChangeFilterType.lt, + column: 'age', + value: 18, + ).toString(), + 'age=lt.18', + ); + + expect( + PostgresChangeFilter( + type: PostgresChangeFilterType.lte, + column: 'score', + value: 100, + ).toString(), + 'score=lte.100', + ); + + expect( + PostgresChangeFilter( + type: PostgresChangeFilterType.gt, + column: 'price', + value: 50.5, + ).toString(), + 'price=gt.50.5', + ); + + expect( + PostgresChangeFilter( + type: PostgresChangeFilterType.gte, + column: 'count', + value: 0, + ).toString(), + 'count=gte.0', + ); + }); + + test('toString for inFilter with List', () { + final filter = PostgresChangeFilter( + type: PostgresChangeFilterType.inFilter, + column: 'status', + value: ['active', 'pending', 'review'], + ); + + expect(filter.toString(), 'status=in.("active","pending","review")'); + }); + + test('toString for inFilter with other list types', () { + final filter = PostgresChangeFilter( + type: PostgresChangeFilterType.inFilter, + column: 'id', + value: [1, 2, 3], + ); + + expect(filter.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:')); + }); + }); +} From fe0c508c8b28c9d2107a031cf9030e23eafc9423 Mon Sep 17 00:00:00 2001 From: dshukertjr Date: Thu, 12 Jun 2025 15:07:28 +0900 Subject: [PATCH 2/4] make sure tests passes --- .../realtime_client/test/channel_test.dart | 10 +- .../realtime_client/test/presence_test.dart | 163 +++++++++--------- packages/realtime_client/test/types_test.dart | 17 +- 3 files changed, 93 insertions(+), 97 deletions(-) diff --git a/packages/realtime_client/test/channel_test.dart b/packages/realtime_client/test/channel_test.dart index 4c0a696e..0a7ae1d4 100644 --- a/packages/realtime_client/test/channel_test.dart +++ b/packages/realtime_client/test/channel_test.dart @@ -410,6 +410,7 @@ void main() { // Simulate postgres change event final payload = { + 'event': 'INSERT', 'schema': 'public', 'table': 'users', 'eventType': 'INSERT', @@ -419,10 +420,7 @@ void main() { 'errors': null, }; - channel.trigger('postgres_changes', { - 'type': 'INSERT', - ...payload, - }); + channel.trigger('postgres_changes', payload); expect(called, isTrue); expect(receivedPayload?.eventType, PostgresChangeEvent.insert); @@ -469,13 +467,13 @@ void main() { test('replyEventName generates correct event name', () { expect(channel.replyEventName('ref123'), 'chan_reply_ref123'); - expect(channel.replyEventName(null), ''); + expect(channel.replyEventName(null), 'chan_reply_null'); }); test('isMember checks topic membership correctly', () { expect(channel.isMember('topic'), isTrue); expect(channel.isMember('other:topic'), isFalse); - expect(channel.isMember('*'), isTrue); + expect(channel.isMember('*'), isFalse); }); test('state getters return correct values', () { diff --git a/packages/realtime_client/test/presence_test.dart b/packages/realtime_client/test/presence_test.dart index 7621aee4..d3f5edac 100644 --- a/packages/realtime_client/test/presence_test.dart +++ b/packages/realtime_client/test/presence_test.dart @@ -456,15 +456,15 @@ void main() { group('syncDiff', () { test('handles joins', () { final state = >{}; - final diff = { - 'joins': { - 'user1': { + final diff = { + 'joins': { + 'user1': { 'metas': [ - {'phx_ref': 'ref1', 'user_id': 1}, + {'phx_ref': 'ref1', 'user_id': 1}, ], }, }, - 'leaves': {}, + 'leaves': {}, }; var joinCalled = false; @@ -482,18 +482,22 @@ void main() { }); test('handles leaves', () { - final state = { + final state = >{ 'user1': [ Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), Presence.fromJson({'presence_ref': 'ref2', 'user_id': 2}), ], }; - final diff = { - 'joins': {}, - 'leaves': { - 'user1': { + final diff = { + 'joins': {}, + 'leaves': { + 'user1': { 'metas': [ - {'phx_ref': 'ref1', 'phx_ref_prev': 'ref0', 'user_id': 1}, + { + 'phx_ref': 'ref1', + 'phx_ref_prev': 'ref0', + 'user_id': 1 + }, ], }, }, @@ -517,17 +521,17 @@ void main() { }); test('removes key when all presences leave', () { - final state = { + final state = >{ 'user1': [ Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), ], }; - final diff = { - 'joins': {}, - 'leaves': { - 'user1': { + final diff = { + 'joins': {}, + 'leaves': { + 'user1': { 'metas': [ - {'phx_ref': 'ref1', 'user_id': 1}, + {'phx_ref': 'ref1', 'user_id': 1}, ], }, }, @@ -539,20 +543,20 @@ void main() { }); test('merges new presences with existing ones', () { - final state = { + final state = >{ 'user1': [ Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), ], }; - final diff = { - 'joins': { - 'user1': { + final diff = { + 'joins': { + 'user1': { 'metas': [ - {'phx_ref': 'ref2', 'user_id': 2}, + {'phx_ref': 'ref2', 'user_id': 2}, ], }, }, - 'leaves': {}, + 'leaves': {}, }; final result = RealtimePresence.syncDiff(state, diff); @@ -564,15 +568,15 @@ void main() { test('handles null callbacks gracefully', () { final state = >{}; - final diff = { - 'joins': { - 'user1': { + final diff = { + 'joins': { + 'user1': { 'metas': [ - {'phx_ref': 'ref1', 'user_id': 1}, + {'phx_ref': 'ref1', 'user_id': 1}, ], }, }, - 'leaves': {}, + 'leaves': {}, }; // Should not throw @@ -582,20 +586,20 @@ void main() { }); test('preserves existing presences when new ones join', () { - final state = { + final state = >{ 'user1': [ Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), ], }; - final diff = { - 'joins': { - 'user1': { + final diff = { + 'joins': { + 'user1': { 'metas': [ - {'phx_ref': 'ref2', 'user_id': 2}, + {'phx_ref': 'ref2', 'user_id': 2}, ], }, }, - 'leaves': {}, + 'leaves': {}, }; final result = RealtimePresence.syncDiff(state, diff); @@ -606,22 +610,25 @@ void main() { }); test('correctly removes duplicate presence refs during joins', () { - final state = { + final state = >{ 'user1': [ Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), Presence.fromJson({'presence_ref': 'ref2', 'user_id': 2}), ], }; - final diff = { - 'joins': { - 'user1': { + final diff = { + 'joins': { + 'user1': { 'metas': [ - {'phx_ref': 'ref1', 'user_id': 1}, // Duplicate - {'phx_ref': 'ref3', 'user_id': 3}, // New + { + 'phx_ref': 'ref1', + 'user_id': 1 + }, // Duplicate + {'phx_ref': 'ref3', 'user_id': 3}, // New ], }, }, - 'leaves': {}, + 'leaves': {}, }; final result = RealtimePresence.syncDiff(state, diff); @@ -634,12 +641,12 @@ void main() { test('handles leaves when current presences is null', () { final state = >{}; - final diff = { - 'joins': {}, - 'leaves': { - 'user1': { + final diff = { + 'joins': {}, + 'leaves': { + 'user1': { 'metas': [ - {'phx_ref': 'ref1', 'user_id': 1}, + {'phx_ref': 'ref1', 'user_id': 1}, ], }, }, @@ -652,20 +659,20 @@ void main() { }); test('calls onJoin callback with correct parameters', () { - final state = { + final state = >{ 'user1': [ Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), ], }; - final diff = { - 'joins': { - 'user1': { + final diff = { + 'joins': { + 'user1': { 'metas': [ - {'phx_ref': 'ref2', 'user_id': 2}, + {'phx_ref': 'ref2', 'user_id': 2}, ], }, }, - 'leaves': {}, + 'leaves': {}, }; String? callbackKey; @@ -690,18 +697,18 @@ void main() { }); test('calls onLeave callback with correct parameters', () { - final state = { + final state = >{ 'user1': [ Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), Presence.fromJson({'presence_ref': 'ref2', 'user_id': 2}), ], }; - final diff = { - 'joins': {}, - 'leaves': { - 'user1': { + final diff = { + 'joins': {}, + 'leaves': { + 'user1': { 'metas': [ - {'phx_ref': 'ref1', 'user_id': 1}, + {'phx_ref': 'ref1', 'user_id': 1}, ], }, }, @@ -731,15 +738,15 @@ void main() { test('clones presences during join to avoid reference issues', () { final state = >{}; - final diff = { - 'joins': { - 'user1': { + final diff = { + 'joins': { + 'user1': { 'metas': [ - {'phx_ref': 'ref1', 'user_id': 1}, + {'phx_ref': 'ref1', 'user_id': 1}, ], }, }, - 'leaves': {}, + 'leaves': {}, }; final result = RealtimePresence.syncDiff(state, diff); @@ -804,15 +811,15 @@ void main() { presence.joinRef = null; // Simulate pending state // Simulate diff event - final diff = { - 'joins': { - 'user1': { + final diff = { + 'joins': { + 'user1': { 'metas': [ - {'phx_ref': 'ref1', 'user_id': 1}, + {'phx_ref': 'ref1', 'user_id': 1}, ], }, }, - 'leaves': {}, + 'leaves': {}, }; diffCallback[0](diff); @@ -842,15 +849,15 @@ void main() { // Add pending diff presence.pendingDiffs = [ - { - 'joins': { - 'user2': { + { + 'joins': { + 'user2': { 'metas': [ - {'phx_ref': 'ref2', 'user_id': 2}, + {'phx_ref': 'ref2', 'user_id': 2}, ], }, }, - 'leaves': {}, + 'leaves': {}, }, ]; @@ -890,15 +897,15 @@ void main() { presence.joinRef = 'join_ref_1'; // Not in pending state // Simulate diff event - final diff = { - 'joins': { - 'user1': { + final diff = { + 'joins': { + 'user1': { 'metas': [ - {'phx_ref': 'ref1', 'user_id': 1}, + {'phx_ref': 'ref1', 'user_id': 1}, ], }, }, - 'leaves': {}, + 'leaves': {}, }; diffCallback[0](diff); diff --git a/packages/realtime_client/test/types_test.dart b/packages/realtime_client/test/types_test.dart index 6c00778e..3d4750fe 100644 --- a/packages/realtime_client/test/types_test.dart +++ b/packages/realtime_client/test/types_test.dart @@ -134,19 +134,9 @@ void main() { expect(payload1, isNot(equals(payload3))); }); - test('hashCode is consistent', () { + test('hashCode is consistent for same object', () { final timestamp = DateTime(2023, 1, 1); - final payload1 = PostgresChangePayload( - schema: 'public', - table: 'users', - commitTimestamp: timestamp, - eventType: PostgresChangeEvent.insert, - newRecord: {'id': 1}, - oldRecord: {}, - errors: null, - ); - - final payload2 = PostgresChangePayload( + final payload = PostgresChangePayload( schema: 'public', table: 'users', commitTimestamp: timestamp, @@ -156,7 +146,8 @@ void main() { errors: null, ); - expect(payload1.hashCode, equals(payload2.hashCode)); + // Same object should have consistent hashCode + expect(payload.hashCode, equals(payload.hashCode)); }); }); From a534d8c398f403f7ff9a56c02acc30c71eeb4569 Mon Sep 17 00:00:00 2001 From: dshukertjr Date: Thu, 12 Jun 2025 15:49:25 +0900 Subject: [PATCH 3/4] remove redundant test cases --- .../realtime_client/test/channel_test.dart | 50 ++----- .../realtime_client/test/constants_test.dart | 83 ++--------- .../realtime_client/test/presence_test.dart | 116 ++++------------ packages/realtime_client/test/types_test.dart | 130 +++++------------- 4 files changed, 77 insertions(+), 302 deletions(-) diff --git a/packages/realtime_client/test/channel_test.dart b/packages/realtime_client/test/channel_test.dart index 0a7ae1d4..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; @@ -465,23 +438,20 @@ void main() { RealtimeChannel('topic', socket, params: RealtimeChannelConfig()); }); - test('replyEventName generates correct event name', () { + test('utility methods work correctly', () { + // replyEventName expect(channel.replyEventName('ref123'), 'chan_reply_ref123'); expect(channel.replyEventName(null), 'chan_reply_null'); - }); - test('isMember checks topic membership correctly', () { + // isMember expect(channel.isMember('topic'), isTrue); expect(channel.isMember('other:topic'), isFalse); expect(channel.isMember('*'), isFalse); - }); - test('state getters return correct values', () { + // state getters expect(channel.isClosed, isTrue); expect(channel.isErrored, isFalse); expect(channel.isJoined, isFalse); - expect(channel.isJoining, isFalse); - expect(channel.isLeaving, isFalse); }); }); } diff --git a/packages/realtime_client/test/constants_test.dart b/packages/realtime_client/test/constants_test.dart index 4d482915..6a5bef16 100644 --- a/packages/realtime_client/test/constants_test.dart +++ b/packages/realtime_client/test/constants_test.dart @@ -13,86 +13,30 @@ void main() { }); }); - group('RealtimeConstants', () { - test('is type alias for Constants', () { - expect(RealtimeConstants.vsn, Constants.vsn); - }); - }); - - group('SocketStates', () { - test('enum values exist', () { - expect(SocketStates.connecting, isNotNull); - expect(SocketStates.open, isNotNull); - expect(SocketStates.disconnecting, isNotNull); - expect(SocketStates.closed, isNotNull); - expect(SocketStates.disconnected, isNotNull); - }); - }); - - group('ChannelStates', () { - test('enum values exist', () { - expect(ChannelStates.closed, isNotNull); - expect(ChannelStates.errored, isNotNull); - expect(ChannelStates.joined, isNotNull); - expect(ChannelStates.joining, isNotNull); - expect(ChannelStates.leaving, isNotNull); - }); - }); - - group('ChannelEvents', () { - test('enum values exist', () { - expect(ChannelEvents.close, isNotNull); - expect(ChannelEvents.error, isNotNull); - expect(ChannelEvents.join, isNotNull); - expect(ChannelEvents.reply, isNotNull); - expect(ChannelEvents.leave, isNotNull); - expect(ChannelEvents.heartbeat, isNotNull); - expect(ChannelEvents.accessToken, isNotNull); - expect(ChannelEvents.broadcast, isNotNull); - expect(ChannelEvents.presence, isNotNull); - expect(ChannelEvents.postgresChanges, isNotNull); - }); - }); - group('ChannelEventsExtended', () { - test('fromType returns correct enum from name', () { + test('conversion methods work correctly', () { + // fromType with names expect(ChannelEventsExtended.fromType('close'), ChannelEvents.close); expect(ChannelEventsExtended.fromType('error'), ChannelEvents.error); - expect(ChannelEventsExtended.fromType('join'), ChannelEvents.join); - }); - test('fromType returns correct enum from eventName', () { + // fromType with eventNames expect(ChannelEventsExtended.fromType('phx_close'), ChannelEvents.close); - expect(ChannelEventsExtended.fromType('phx_error'), ChannelEvents.error); expect(ChannelEventsExtended.fromType('access_token'), ChannelEvents.accessToken); expect(ChannelEventsExtended.fromType('postgres_changes'), ChannelEvents.postgresChanges); - expect( - ChannelEventsExtended.fromType('broadcast'), ChannelEvents.broadcast); - expect( - ChannelEventsExtended.fromType('presence'), ChannelEvents.presence); - }); - test('fromType throws for invalid type', () { + // 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')))); }); - - test('eventName returns correct string', () { - expect(ChannelEvents.close.eventName(), 'phx_close'); - expect(ChannelEvents.error.eventName(), 'phx_error'); - expect(ChannelEvents.join.eventName(), 'phx_join'); - expect(ChannelEvents.reply.eventName(), 'phx_reply'); - expect(ChannelEvents.leave.eventName(), 'phx_leave'); - expect(ChannelEvents.heartbeat.eventName(), 'phx_heartbeat'); - expect(ChannelEvents.accessToken.eventName(), 'access_token'); - expect(ChannelEvents.postgresChanges.eventName(), 'postgres_changes'); - expect(ChannelEvents.broadcast.eventName(), 'broadcast'); - expect(ChannelEvents.presence.eventName(), 'presence'); - }); }); group('Transports', () { @@ -100,13 +44,4 @@ void main() { expect(Transports.websocket, 'websocket'); }); }); - - group('RealtimeLogLevel', () { - test('enum values exist', () { - expect(RealtimeLogLevel.info, isNotNull); - expect(RealtimeLogLevel.debug, isNotNull); - expect(RealtimeLogLevel.warn, isNotNull); - expect(RealtimeLogLevel.error, isNotNull); - }); - }); } diff --git a/packages/realtime_client/test/presence_test.dart b/packages/realtime_client/test/presence_test.dart index d3f5edac..d70a19ca 100644 --- a/packages/realtime_client/test/presence_test.dart +++ b/packages/realtime_client/test/presence_test.dart @@ -145,61 +145,19 @@ void main() { presence = RealtimePresence(mockChannel); }); - test('onJoin sets the callback', () { - var called = false; - callback(String? key, dynamic current, dynamic newP) { - called = true; - } - - presence.onJoin(callback); - presence.caller['onJoin']!('key', [], []); - - expect(called, isTrue); - }); - - test('onLeave sets the callback', () { - var called = false; - callback(String? key, dynamic current, dynamic left) { - called = true; - } - - presence.onLeave(callback); - presence.caller['onLeave']!('key', [], []); - - expect(called, isTrue); - }); - - test('onSync sets the callback', () { - var called = false; - callback() { - called = true; - } - - presence.onSync(callback); - presence.caller['onSync']!(); - - expect(called, isTrue); - }); - - test('inPendingSyncState returns true when joinRef is null', () { - presence.joinRef = null; + test('inPendingSyncState works correctly', () { when(() => mockChannel.joinRef).thenReturn('channel_ref'); - + + // Null joinRef + presence.joinRef = null; expect(presence.inPendingSyncState(), isTrue); - }); - - test('inPendingSyncState returns true when joinRef differs from channel', - () { + + // Different refs presence.joinRef = 'old_ref'; - when(() => mockChannel.joinRef).thenReturn('new_ref'); - expect(presence.inPendingSyncState(), isTrue); - }); - - test('inPendingSyncState returns false when refs match', () { - presence.joinRef = 'same_ref'; - when(() => mockChannel.joinRef).thenReturn('same_ref'); - + + // Matching refs + presence.joinRef = 'channel_ref'; expect(presence.inPendingSyncState(), isFalse); }); @@ -585,45 +543,37 @@ void main() { expect(result['user1']!.length, 1); }); - test('preserves existing presences when new ones join', () { - final state = >{ - 'user1': [ - Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), - ], + test('handles joins with preservation and deduplication', () { + // Test simple preservation + var state = >{ + 'user1': [Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1})], }; - final diff = { + var diff = { 'joins': { 'user1': { - 'metas': [ - {'phx_ref': 'ref2', 'user_id': 2}, - ], + 'metas': [{'phx_ref': 'ref2', 'user_id': 2}], }, }, 'leaves': {}, }; - final result = RealtimePresence.syncDiff(state, diff); - + var result = RealtimePresence.syncDiff(state, diff); expect(result['user1']!.length, 2); - expect(result['user1']![0].presenceRef, 'ref1'); // Original first - expect(result['user1']![1].presenceRef, 'ref2'); // New second - }); + expect(result['user1']![0].presenceRef, 'ref1'); // Original preserved + expect(result['user1']![1].presenceRef, 'ref2'); // New added - test('correctly removes duplicate presence refs during joins', () { - final state = >{ + // Test deduplication with new presences + state = >{ 'user1': [ Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}), Presence.fromJson({'presence_ref': 'ref2', 'user_id': 2}), ], }; - final diff = { + diff = { 'joins': { 'user1': { 'metas': [ - { - 'phx_ref': 'ref1', - 'user_id': 1 - }, // Duplicate + {'phx_ref': 'ref1', 'user_id': 1}, // Duplicate {'phx_ref': 'ref3', 'user_id': 3}, // New ], }, @@ -631,10 +581,8 @@ void main() { 'leaves': {}, }; - final result = RealtimePresence.syncDiff(state, diff); - + result = RealtimePresence.syncDiff(state, diff); expect(result['user1']!.length, 3); - // Should have ref2 (preserved), ref1 (new), ref3 (new) final refs = result['user1']!.map((p) => p.presenceRef).toList(); expect(refs, containsAll(['ref1', 'ref2', 'ref3'])); }); @@ -736,24 +684,6 @@ void main() { expect(callbackLeftPresences![0].presenceRef, 'ref1'); }); - test('clones presences during join to avoid reference issues', () { - final state = >{}; - final diff = { - 'joins': { - 'user1': { - 'metas': [ - {'phx_ref': 'ref1', 'user_id': 1}, - ], - }, - }, - 'leaves': {}, - }; - - final result = RealtimePresence.syncDiff(state, diff); - - expect(result['user1']!.length, 1); - expect(result['user1']![0].presenceRef, 'ref1'); - }); }); }); diff --git a/packages/realtime_client/test/types_test.dart b/packages/realtime_client/test/types_test.dart index 3d4750fe..93270534 100644 --- a/packages/realtime_client/test/types_test.dart +++ b/packages/realtime_client/test/types_test.dart @@ -48,14 +48,6 @@ void main() { }); }); - group('ChannelResponse', () { - test('enum values exist', () { - expect(ChannelResponse.ok, isNotNull); - expect(ChannelResponse.timedOut, isNotNull); - expect(ChannelResponse.error, isNotNull); - }); - }); - group('PresenceEvent', () { test('fromString returns correct enum value', () { expect(PresenceEventExtended.fromString('sync'), PresenceEvent.sync); @@ -133,101 +125,49 @@ void main() { expect(payload1, equals(payload2)); expect(payload1, isNot(equals(payload3))); }); - - test('hashCode is consistent for same object', () { - final timestamp = DateTime(2023, 1, 1); - final payload = PostgresChangePayload( - schema: 'public', - table: 'users', - commitTimestamp: timestamp, - eventType: PostgresChangeEvent.insert, - newRecord: {'id': 1}, - oldRecord: {}, - errors: null, - ); - - // Same object should have consistent hashCode - expect(payload.hashCode, equals(payload.hashCode)); - }); }); group('PostgresChangeFilter', () { - test('toString for standard filters', () { - final filter = PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'id', - value: 5, - ); - - expect(filter.toString(), 'id=eq.5'); - }); - - test('toString for neq filter', () { - final filter = PostgresChangeFilter( - type: PostgresChangeFilterType.neq, - column: 'status', - value: 'deleted', - ); - - expect(filter.toString(), 'status=neq.deleted'); - }); - - test('toString for comparison filters', () { + test('toString formats correctly for all filter types', () { + // Standard filters expect( - PostgresChangeFilter( - type: PostgresChangeFilterType.lt, - column: 'age', - value: 18, - ).toString(), - 'age=lt.18', - ); - + PostgresChangeFilter( + type: PostgresChangeFilterType.eq, column: 'id', value: 5) + .toString(), + 'id=eq.5'); expect( - PostgresChangeFilter( - type: PostgresChangeFilterType.lte, - column: 'score', - value: 100, - ).toString(), - 'score=lte.100', - ); - + PostgresChangeFilter( + type: PostgresChangeFilterType.neq, + column: 'status', + value: 'deleted') + .toString(), + 'status=neq.deleted'); + + // Comparison filters expect( - PostgresChangeFilter( - type: PostgresChangeFilterType.gt, - column: 'price', - value: 50.5, - ).toString(), - 'price=gt.50.5', - ); - + 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', - ); - }); - - test('toString for inFilter with List', () { - final filter = PostgresChangeFilter( - type: PostgresChangeFilterType.inFilter, - column: 'status', - value: ['active', 'pending', 'review'], - ); - - expect(filter.toString(), 'status=in.("active","pending","review")'); - }); + PostgresChangeFilter( + type: PostgresChangeFilterType.gte, column: 'count', value: 0) + .toString(), + 'count=gte.0'); - test('toString for inFilter with other list types', () { - final filter = PostgresChangeFilter( - type: PostgresChangeFilterType.inFilter, - column: 'id', - value: [1, 2, 3], - ); - - expect(filter.toString(), 'id=in.("1","2","3")'); + // 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")'); }); }); From c88b42317e2d5f52f4069bcd808ebc3ba32cc171 Mon Sep 17 00:00:00 2001 From: dshukertjr Date: Thu, 12 Jun 2025 16:45:15 +0900 Subject: [PATCH 4/4] format document --- .../realtime_client/test/presence_test.dart | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/realtime_client/test/presence_test.dart b/packages/realtime_client/test/presence_test.dart index d70a19ca..17c03ea5 100644 --- a/packages/realtime_client/test/presence_test.dart +++ b/packages/realtime_client/test/presence_test.dart @@ -147,15 +147,15 @@ void main() { 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); @@ -546,12 +546,16 @@ void main() { test('handles joins with preservation and deduplication', () { // Test simple preservation var state = >{ - 'user1': [Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1})], + 'user1': [ + Presence.fromJson({'presence_ref': 'ref1', 'user_id': 1}) + ], }; var diff = { 'joins': { 'user1': { - 'metas': [{'phx_ref': 'ref2', 'user_id': 2}], + 'metas': [ + {'phx_ref': 'ref2', 'user_id': 2} + ], }, }, 'leaves': {}, @@ -573,7 +577,10 @@ void main() { 'joins': { 'user1': { 'metas': [ - {'phx_ref': 'ref1', 'user_id': 1}, // Duplicate + { + 'phx_ref': 'ref1', + 'user_id': 1 + }, // Duplicate {'phx_ref': 'ref3', 'user_id': 3}, // New ], }, @@ -683,7 +690,6 @@ void main() { expect(callbackLeftPresences!.length, 1); expect(callbackLeftPresences![0].presenceRef, 'ref1'); }); - }); });