UNPKG

pubnub

Version:

Publish & Subscribe Real-time Messaging with PubNub

518 lines (402 loc) 19.1 kB
import nock from 'nock'; import { expect } from 'chai'; import PubNub from '../../src/node/index'; import utils from '../utils'; describe('EventEngine', () => { // @ts-expect-error Access event engine core for test purpose. let engine: PubNub['presenceEventEngine']['engine']; let pubnub: PubNub; let receivedEvents: { type: string; event: { type: string } }[] = []; let stateChanges: { type: string; toState: { label: string } }[] = []; before(() => { nock.disableNetConnect(); }); after(() => { nock.enableNetConnect(); }); let unsub: () => void; beforeEach(() => { nock.cleanAll(); receivedEvents = []; stateChanges = []; pubnub = new PubNub({ subscribeKey: 'demo', publishKey: 'demo', uuid: 'test-js', enableEventEngine: true, heartbeatInterval: 1, // logVerbosity: true, }); // @ts-expect-error Access event engine core for test purpose. engine = pubnub.presenceEventEngine._engine; unsub = engine.subscribe((_change: Record<string, unknown>) => { // FOR DEBUG // console.dir(change); }); }); afterEach(() => { unsub(); // Properly destroy PubNub instance to prevent open handles pubnub.destroy(true); }); function forEvent(eventLabel: string, timeout?: number) { return new Promise<void>((resolve, reject) => { let timeoutId: NodeJS.Timeout | null = null; for (const change of receivedEvents) { if (change.type === 'eventReceived' && change.event.type === eventLabel) { resolve(); return; } } const unsubscribe = engine.subscribe((change: { type: string; event: { type: string } }) => { if (change.type === 'eventReceived' && change.event.type === eventLabel) { if (timeoutId) clearTimeout(timeoutId); unsubscribe(); resolve(); } }); if (timeout) { timeoutId = setTimeout(() => { unsubscribe(); reject(new Error(`Timeout occured while waiting for event ${eventLabel}`)); }, timeout); } }); } function forState(stateLabel: string, timeout?: number) { return new Promise<void>((resolve, reject) => { let timeoutId: NodeJS.Timeout | null = null; for (const change of stateChanges) { if (change.type === 'transitionDone' && change.toState.label === stateLabel) { resolve(); return; } } const unsubscribe = engine.subscribe((change: { type: string; toState: { label: string } }) => { if (change.type === 'transitionDone' && change.toState.label === stateLabel) { if (timeoutId) clearTimeout(timeoutId); unsubscribe(); resolve(); } }); if (timeout) { timeoutId = setTimeout(() => { unsubscribe(); reject(new Error(`Timeout occured while waiting for state ${stateLabel}`)); }, timeout); } }); } function forInvocation(invocationLabel: string, timeout?: number) { return new Promise<void>((resolve, reject) => { let timeoutId: NodeJS.Timeout | null = null; const unsubscribe = engine.subscribe((change: { type: string; invocation: { type: string } }) => { if (change.type === 'invocationDispatched' && change.invocation.type === invocationLabel) { if (timeoutId) clearTimeout(timeoutId); unsubscribe(); resolve(); } }); if (timeout) { timeoutId = setTimeout(() => { unsubscribe(); reject(new Error(`Timeout occured while waiting for invocation ${invocationLabel}`)); }, timeout); } }); } it('presence event_engine should work correctly', async () => { utils.createPresenceMockScopes({ subKey: 'demo', presenceType: 'leave', requests: [{ channels: ['test'] }], }); utils .createNock() .get('/v2/presence/sub-key/demo/channel/test/heartbeat') .query(true) .reply(200, '{"message":"OK", "service":"Presence"}', { 'content-type': 'text/javascript' }); engine.subscribe( (change: { type: string; event: { type: string } } | { type: string; toState: { label: string } }) => 'toState' in change ? stateChanges.push(change) : receivedEvents.push(change), ); // @ts-expect-error Intentional access to the private interface. pubnub.join({ channels: ['test'] }); await forEvent('JOINED', 1000); await forState('HEARTBEATING', 1000); await forEvent('HEARTBEAT_SUCCESS', 1000); await forState('HEARTBEAT_COOLDOWN', 1000); // @ts-expect-error Intentional access to the private interface. pubnub.leaveAll(); await forEvent('LEFT_ALL', 2000); }); it('should properly manage channel tracking during subscribe/unsubscribe sequence to prevent stale heartbeat requests', async () => { // This test verifies the fix for the issue where unsubscribing from channels // doesn't remove them from heartbeat requests when using Event Engine // Scenario: subscribe(a) -> subscribe(b) -> unsubscribe(b) -> subscribe(c) // Expected: presence engine should only track [a,c], not [a,b,c] // Mock heartbeat requests (we'll verify state, not HTTP requests) utils.createNock() .get('/v2/presence/sub-key/demo/channel/a/heartbeat') .query(true) .reply(200, '{"message":"OK", "service":"Presence"}', { 'content-type': 'text/javascript' }) .persist(); utils.createNock() .get('/v2/presence/sub-key/demo/channel/a,b/heartbeat') .query(true) .reply(200, '{"message":"OK", "service":"Presence"}', { 'content-type': 'text/javascript' }) .persist(); utils.createNock() .get('/v2/presence/sub-key/demo/channel/a,c/heartbeat') .query(true) .reply(200, '{"message":"OK", "service":"Presence"}', { 'content-type': 'text/javascript' }) .persist(); // Mock leave request for channel 'b' utils.createPresenceMockScopes({ subKey: 'demo', presenceType: 'leave', requests: [{ channels: ['b'] }], }); engine.subscribe( (change: { type: string; event: { type: string } } | { type: string; toState: { label: string } }) => 'toState' in change ? stateChanges.push(change) : receivedEvents.push(change), ); // @ts-expect-error Access internal state for verification const presenceEngine = pubnub.presenceEventEngine; expect(presenceEngine).to.not.be.undefined; // Step 1: Subscribe to channel 'a' // @ts-expect-error Intentional access to the private interface. pubnub.join({ channels: ['a'] }); await forEvent('JOINED', 1000); // Verify presence engine internal state after joining 'a' expect(presenceEngine!.channels).to.deep.equal(['a']); // Step 2: Subscribe to channel 'b' (now should have [a, b]) // @ts-expect-error Intentional access to the private interface. pubnub.join({ channels: ['b'] }); await forEvent('JOINED', 1000); // Verify presence engine internal state after joining 'b' expect(presenceEngine!.channels).to.deep.equal(['a', 'b']); // Step 3: Unsubscribe from channel 'b' // @ts-expect-error Intentional access to the private interface. pubnub.leave({ channels: ['b'] }); await forEvent('LEFT', 1000); // CRITICAL VERIFICATION: After unsubscribing from 'b', // the presence engine should only track channel 'a' // This test verifies the fix is working expect(presenceEngine?.channels).to.deep.equal(['a']); // Step 4: Subscribe to channel 'c' // @ts-expect-error Intentional access to the private interface. pubnub.join({ channels: ['c'] }); await forEvent('JOINED', 1000); // FINAL VERIFICATION: Presence engine should only track channels [a, c] // This verifies the fix prevents stale channel 'b' from being tracked expect(presenceEngine?.channels).to.deep.equal(['a', 'c']); // Verify that channel 'b' is NOT in the tracked channels expect(presenceEngine?.channels).to.not.include('b'); }); it('should handle channel group unsubscribe correctly in presence engine', async () => { // Test the same fix but for channel groups // Scenario: subscribe(group1) -> subscribe(group2) -> unsubscribe(group2) -> subscribe(group3) // Expected: presence engine should only track [group1, group3], not [group1, group2, group3] // Mock heartbeat requests for channel groups (we'll verify state, not HTTP requests) utils.createNock() .get('/v2/presence/sub-key/demo/channel/,/heartbeat') .query(query => query['channel-group'] === 'group1') .reply(200, '{"message":"OK", "service":"Presence"}', { 'content-type': 'text/javascript' }) .persist(); utils.createNock() .get('/v2/presence/sub-key/demo/channel/,/heartbeat') .query(query => query['channel-group'] === 'group1,group2') .reply(200, '{"message":"OK", "service":"Presence"}', { 'content-type': 'text/javascript' }) .persist(); utils.createNock() .get('/v2/presence/sub-key/demo/channel/,/heartbeat') .query(query => query['channel-group'] === 'group1,group3') .reply(200, '{"message":"OK", "service":"Presence"}', { 'content-type': 'text/javascript' }) .persist(); // Mock leave request for group2 utils.createPresenceMockScopes({ subKey: 'demo', presenceType: 'leave', requests: [{ groups: ['group2'] }], }); engine.subscribe( (change: { type: string; event: { type: string } } | { type: string; toState: { label: string } }) => 'toState' in change ? stateChanges.push(change) : receivedEvents.push(change), ); // @ts-expect-error Access internal state for verification const presenceEngine = pubnub.presenceEventEngine; // Step 1: Subscribe to group1 // @ts-expect-error Intentional access to the private interface. pubnub.join({ groups: ['group1'] }); await forEvent('JOINED', 1000); // Verify presence engine internal state after joining group1 expect(presenceEngine?.groups).to.deep.equal(['group1']); // Step 2: Subscribe to group2 // @ts-expect-error Intentional access to the private interface. pubnub.join({ groups: ['group2'] }); await forEvent('JOINED', 1000); // Verify presence engine internal state after joining group2 expect(presenceEngine?.groups).to.deep.equal(['group1', 'group2']); // Step 3: Unsubscribe from group2 // @ts-expect-error Intentional access to the private interface. pubnub.leave({ groups: ['group2'] }); await forEvent('LEFT', 1000); // CRITICAL VERIFICATION: After unsubscribing from group2, // the presence engine should only track group1 // This test verifies the fix is working for channel groups expect(presenceEngine?.groups).to.deep.equal(['group1']); // Step 4: Subscribe to group3 // @ts-expect-error Intentional access to the private interface. pubnub.join({ groups: ['group3'] }); await forEvent('JOINED', 1000); // FINAL VERIFICATION: Presence engine should only track groups [group1, group3] // This verifies the fix prevents stale group 'group2' from being tracked expect(presenceEngine?.groups).to.deep.equal(['group1', 'group3']); // Verify that group2 is NOT in the tracked groups expect(presenceEngine?.groups).to.not.include('group2'); }); it('should properly reset all channel tracking when calling leaveAll', async () => { // This test verifies that leaveAll properly resets internal channel and group tracking // Scenario: subscribe(a,b) -> subscribe(groups: [g1,g2]) -> leaveAll -> verify empty tracking // Mock heartbeat requests utils.createNock() .get('/v2/presence/sub-key/demo/channel/a,b/heartbeat') .query(true) .reply(200, '{"message":"OK", "service":"Presence"}', { 'content-type': 'text/javascript' }) .persist(); utils.createNock() .get('/v2/presence/sub-key/demo/channel/a,b/heartbeat') .query(query => query['channel-group'] === 'group1,group2') .reply(200, '{"message":"OK", "service":"Presence"}', { 'content-type': 'text/javascript' }) .persist(); // Mock leaveAll request utils.createPresenceMockScopes({ subKey: 'demo', presenceType: 'leave', requests: [{ channels: [], groups: [] }], // leaveAll sends empty arrays }); engine.subscribe( (change: { type: string; event: { type: string } } | { type: string; toState: { label: string } }) => 'toState' in change ? stateChanges.push(change) : receivedEvents.push(change), ); // @ts-expect-error Access internal state for verification const presenceEngine = pubnub.presenceEventEngine; // Step 1: Subscribe to channels 'a' and 'b' // @ts-expect-error Intentional access to the private interface. pubnub.join({ channels: ['a', 'b'] }); await forEvent('JOINED', 1000); // Verify presence engine has both channels expect(presenceEngine?.channels).to.deep.equal(['a', 'b']); // Step 2: Subscribe to channel groups 'group1' and 'group2' // @ts-expect-error Intentional access to the private interface. pubnub.join({ groups: ['group1', 'group2'] }); await forEvent('JOINED', 1000); // Verify presence engine has both channels and groups expect(presenceEngine?.channels).to.deep.equal(['a', 'b']); expect(presenceEngine?.groups).to.deep.equal(['group1', 'group2']); // Step 3: Call leaveAll // @ts-expect-error Intentional access to the private interface. pubnub.leaveAll(); await forEvent('LEFT_ALL', 1000); // CRITICAL VERIFICATION: After leaveAll, both channels and groups should be empty // This test verifies the leaveAll fix is working expect(presenceEngine?.channels).to.deep.equal([]); expect(presenceEngine?.groups).to.deep.equal([]); // Double-check that no channels or groups remain tracked expect(presenceEngine?.channels).to.have.lengthOf(0); expect(presenceEngine?.groups).to.have.lengthOf(0); }); it('should properly clear presence state for all channels and groups when calling leaveAll', async () => { // This test verifies that leaveAll clears presence state for all tracked channels/groups // and that subsequent operations start with clean state // Mock heartbeat requests utils.createNock() .get('/v2/presence/sub-key/demo/channel/test1,test2/heartbeat') .query(true) .reply(200, '{"message":"OK", "service":"Presence"}', { 'content-type': 'text/javascript' }) .persist(); // Mock leaveAll request utils.createPresenceMockScopes({ subKey: 'demo', presenceType: 'leave', requests: [{ channels: [], groups: [] }], }); engine.subscribe( (change: { type: string; event: { type: string } } | { type: string; toState: { label: string } }) => 'toState' in change ? stateChanges.push(change) : receivedEvents.push(change), ); // @ts-expect-error Access internal state for verification const presenceEngine = pubnub.presenceEventEngine; // Step 1: Join channels with presence state // @ts-expect-error Intentional access to the private interface. pubnub.join({ channels: ['test1', 'test2'] }); await forEvent('JOINED', 1000); // Simulate presence state being set (this would normally happen through heartbeat responses) if (presenceEngine?.dependencies.presenceState) { presenceEngine.dependencies.presenceState['test1'] = { mood: 'happy' }; presenceEngine.dependencies.presenceState['test2'] = { mood: 'excited' }; } // Verify initial state expect(presenceEngine?.channels).to.deep.equal(['test1', 'test2']); expect(presenceEngine?.dependencies.presenceState?.['test1']).to.deep.equal({ mood: 'happy' }); expect(presenceEngine?.dependencies.presenceState?.['test2']).to.deep.equal({ mood: 'excited' }); // Step 2: Call leaveAll // @ts-expect-error Intentional access to the private interface. pubnub.leaveAll(); await forEvent('LEFT_ALL', 1000); // CRITICAL VERIFICATION: After leaveAll, presence state should be cleared expect(presenceEngine?.channels).to.deep.equal([]); expect(presenceEngine?.dependencies?.presenceState?.['test1']).to.be.undefined; expect(presenceEngine?.dependencies.presenceState?.['test2']).to.be.undefined; }); it('should handle leaveAll correctly when called with offline flag', async () => { // This test verifies that leaveAll(true) properly handles offline scenarios // and still resets internal tracking even when offline // Mock heartbeat requests utils.createNock() .get('/v2/presence/sub-key/demo/channel/offline-test/heartbeat') .query(true) .reply(200, '{"message":"OK", "service":"Presence"}', { 'content-type': 'text/javascript' }) .persist(); engine.subscribe( (change: { type: string; event: { type: string } } | { type: string; toState: { label: string } }) => 'toState' in change ? stateChanges.push(change) : receivedEvents.push(change), ); // @ts-expect-error Access internal state for verification const presenceEngine = pubnub.presenceEventEngine; // Step 1: Join a channel // @ts-expect-error Intentional access to the private interface. pubnub.join({ channels: ['offline-test'] }); await forEvent('JOINED', 1000); // Verify initial state expect(presenceEngine?.channels).to.deep.equal(['offline-test']); // Step 2: Call leaveAll with offline=true (simulating network disconnection) // @ts-expect-error Intentional access to the private interface. pubnub.leaveAll(true); await forEvent('LEFT_ALL', 1000); // CRITICAL VERIFICATION: Even when offline, leaveAll should reset tracking expect(presenceEngine?.channels).to.deep.equal([]); expect(presenceEngine?.groups).to.deep.equal([]); }); it('should handle leaveAll when no channels or groups are tracked', async () => { // This test verifies that leaveAll behaves correctly when called with empty tracking // (edge case that should not cause errors) engine.subscribe( (change: { type: string; event: { type: string } } | { type: string; toState: { label: string } }) => 'toState' in change ? stateChanges.push(change) : receivedEvents.push(change), ); // @ts-expect-error Access internal state for verification const presenceEngine = pubnub.presenceEventEngine; // Verify initial empty state expect(presenceEngine?.channels).to.deep.equal([]); expect(presenceEngine?.groups).to.deep.equal([]); // Call leaveAll on empty state (should not cause errors) // @ts-expect-error Intentional access to the private interface. pubnub.leaveAll(); await forEvent('LEFT_ALL', 1000); // Verify state remains empty (no side effects) expect(presenceEngine?.channels).to.deep.equal([]); expect(presenceEngine?.groups).to.deep.equal([]); }); });