diff --git a/src/Members.test.ts b/src/Members.test.ts new file mode 100644 index 00000000..08203d23 --- /dev/null +++ b/src/Members.test.ts @@ -0,0 +1,181 @@ +import { it, describe, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Types, Realtime } from 'ably/promises'; + +import Space from './Space.js'; + +import { createPresenceEvent, createSpaceMember, createProfileUpdate } from './utilities/test/fakes.js'; + +interface SpaceTestContext { + client: Types.RealtimePromise; + space: Space; + presence: Types.RealtimePresencePromise; + presenceMap: Map; +} + +vi.mock('ably/promises'); +vi.mock('nanoid'); + +describe('Members', () => { + beforeEach((context) => { + const client = new Realtime({}); + const space = new Space('test', client); + const presence = space.channel.presence; + const presenceMap = new Map(); + + vi.spyOn(presence, 'get').mockImplementation(async () => { + return Array.from(presenceMap.values()); + }); + + context.client = client; + context.space = space; + context.presence = presence; + context.presenceMap = presenceMap; + }); + + describe('subscribe', () => { + it('calls enter and update on enter presence events', async ({ space, presenceMap }) => { + const updateSpy = vi.fn(); + const enterSpy = vi.fn(); + space.members.subscribe('update', updateSpy); + space.members.subscribe('enter', enterSpy); + + await createPresenceEvent(space, presenceMap, 'enter'); + + const member1 = createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }); + expect(updateSpy).toHaveBeenNthCalledWith(1, member1); + expect(enterSpy).toHaveBeenNthCalledWith(1, member1); + + await createPresenceEvent(space, presenceMap, 'enter', { + clientId: '2', + connectionId: '2', + data: createProfileUpdate({ current: { name: 'Betty' } }), + }); + + const member2 = createSpaceMember({ + clientId: '2', + connectionId: '2', + lastEvent: { name: 'enter', timestamp: 1 }, + profileData: { name: 'Betty' }, + }); + + expect(updateSpy).toHaveBeenNthCalledWith(2, member2); + expect(enterSpy).toHaveBeenNthCalledWith(2, member2); + }); + + it('calls updateProfile and update on update presence events', async ({ space, presenceMap }) => { + const updateSpy = vi.fn(); + const updateProfileSpy = vi.fn(); + space.members.subscribe('update', updateSpy); + space.members.subscribe('updateProfile', updateProfileSpy); + + await createPresenceEvent(space, presenceMap, 'enter'); + expect(updateSpy).toHaveBeenNthCalledWith(1, createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } })); + + await createPresenceEvent(space, presenceMap, 'update', { + data: createProfileUpdate({ current: { name: 'Betty' } }), + }); + + const memberUpdate = createSpaceMember({ profileData: { name: 'Betty' } }); + expect(updateSpy).toHaveBeenNthCalledWith(2, memberUpdate); + expect(updateProfileSpy).toHaveBeenNthCalledWith(1, memberUpdate); + }); + + it('updates the connected status of clients who have left', async ({ space, presenceMap }) => { + const updateSpy = vi.fn(); + const leaveSpy = vi.fn(); + space.members.subscribe('update', updateSpy); + space.members.subscribe('leave', leaveSpy); + + await createPresenceEvent(space, presenceMap, 'enter'); + expect(updateSpy).toHaveBeenNthCalledWith(1, createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } })); + + await createPresenceEvent(space, presenceMap, 'leave'); + const memberUpdate = createSpaceMember({ isConnected: false, lastEvent: { name: 'leave', timestamp: 1 } }); + expect(updateSpy).toHaveBeenNthCalledWith(2, memberUpdate); + expect(leaveSpy).toHaveBeenNthCalledWith(1, memberUpdate); + }); + + describe('leavers', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('removes a member who has left after the offlineTimeout', async ({ space, presenceMap }) => { + const leaveSpy = vi.fn(); + const removeSpy = vi.fn(); + space.members.subscribe('leave', leaveSpy); + space.members.subscribe('remove', removeSpy); + + await createPresenceEvent(space, presenceMap, 'enter'); + await createPresenceEvent(space, presenceMap, 'leave'); + + const memberUpdate = createSpaceMember({ isConnected: false, lastEvent: { name: 'leave', timestamp: 1 } }); + expect(leaveSpy).toHaveBeenNthCalledWith(1, memberUpdate); + + await vi.advanceTimersByTimeAsync(130_000); + + expect(removeSpy).toHaveBeenNthCalledWith(1, memberUpdate); + }); + + it('does not remove a member that has rejoined', async ({ space, presenceMap }) => { + const callbackSpy = vi.fn(); + space.members.subscribe('update', callbackSpy); + + await createPresenceEvent(space, presenceMap, 'enter'); + expect(callbackSpy).toHaveBeenNthCalledWith( + 1, + createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }), + ); + await createPresenceEvent(space, presenceMap, 'enter', { clientId: '2', connectionId: '2' }); + expect(callbackSpy).toHaveBeenNthCalledWith( + 2, + createSpaceMember({ clientId: '2', connectionId: '2', lastEvent: { name: 'enter', timestamp: 1 } }), + ); + + await createPresenceEvent(space, presenceMap, 'leave'); + expect(callbackSpy).toHaveBeenNthCalledWith( + 3, + createSpaceMember({ lastEvent: { name: 'leave', timestamp: 1 }, isConnected: false }), + ); + + await vi.advanceTimersByTimeAsync(60_000); + await createPresenceEvent(space, presenceMap, 'enter'); + + expect(callbackSpy).toHaveBeenNthCalledWith( + 4, + createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }), + ); + + await vi.advanceTimersByTimeAsync(130_000); // 2:10 passed, default timeout is 2 min + expect(callbackSpy).toHaveBeenCalledTimes(4); + }); + + it('unsubscribes when unsubscribe is called', async ({ space, presenceMap }) => { + const spy = vi.fn(); + space.members.subscribe('update', spy); + await createPresenceEvent(space, presenceMap, 'enter', { clientId: '2' }); + space.members.unsubscribe('update', spy); + await createPresenceEvent(space, presenceMap, 'enter', { clientId: '2' }); + + expect(spy).toHaveBeenCalledOnce(); + }); + + it('unsubscribes when unsubscribe is called with no arguments', async ({ + space, + presenceMap, + }) => { + const spy = vi.fn(); + space.members.subscribe('update', spy); + await createPresenceEvent(space, presenceMap, 'enter', { clientId: '2' }); + space.members.unsubscribe(); + await createPresenceEvent(space, presenceMap, 'enter', { clientId: '2' }); + + expect(spy).toHaveBeenCalledOnce(); + }); + }); + }); +}); diff --git a/src/Members.ts b/src/Members.ts index d0d56796..2edae1f3 100644 --- a/src/Members.ts +++ b/src/Members.ts @@ -14,6 +14,7 @@ type MemberEventsMap = { leave: SpaceMember; enter: SpaceMember; update: SpaceMember; + updateProfile: SpaceMember; remove: SpaceMember; }; @@ -34,18 +35,21 @@ class Members extends EventEmitter { if (action === 'leave') { this.leavers.addLeaver(member, () => this.onMemberOffline(member)); this.emit('leave', member); + this.emit('update', member); } else if (isLeaver) { this.leavers.removeLeaver(connectionId); } if (action === 'enter') { this.emit('enter', member); + this.emit('update', member); } // Emit profileData updates only if they are different then the last held update. // A locationUpdate is handled in Locations. if (message.data.profileUpdate.id && this.lastMemberUpdate[connectionId] !== message.data.profileUpdate.id) { this.lastMemberUpdate[message.connectionId] = message.data.profileUpdate.id; + this.emit('updateProfile', member); this.emit('update', member); } } @@ -122,6 +126,7 @@ class Members extends EventEmitter { this.leavers.removeLeaver(member.connectionId); this.emit('remove', member); + this.emit('update', member); if (member.location) { this.space.locations.emit('update', { diff --git a/src/Space.test.ts b/src/Space.test.ts index 67db9d9a..946d4468 100644 --- a/src/Space.test.ts +++ b/src/Space.test.ts @@ -10,6 +10,7 @@ import { createPresenceMessage, createSpaceMember, createProfileUpdate, + createLocationUpdate, } from './utilities/test/fakes.js'; interface SpaceTestContext { @@ -192,7 +193,7 @@ describe('Space', () => { expect(spy).toHaveBeenCalledTimes(1); }); - it('adds new members', async ({ space, presenceMap }) => { + it('is called when members enter', async ({ space, presenceMap }) => { const callbackSpy = vi.fn(); space.subscribe('update', callbackSpy); await createPresenceEvent(space, presenceMap, 'enter'); @@ -208,49 +209,53 @@ describe('Space', () => { data: createProfileUpdate({ current: { name: 'Betty' } }), }); + const member2 = createSpaceMember({ + clientId: '2', + connectionId: '2', + lastEvent: { name: 'enter', timestamp: 1 }, + profileData: { name: 'Betty' }, + }); + expect(callbackSpy).toHaveBeenNthCalledWith(2, { - members: [ - member1, - createSpaceMember({ - clientId: '2', - connectionId: '2', - lastEvent: { name: 'enter', timestamp: 1 }, - profileData: { name: 'Betty' }, - }), - ], + members: [member1, member2], }); }); - it('updates the data of members', async ({ space, presenceMap }) => { + it('is called when members leave', async ({ space, presenceMap }) => { const callbackSpy = vi.fn(); space.subscribe('update', callbackSpy); await createPresenceEvent(space, presenceMap, 'enter'); - expect(callbackSpy).toHaveBeenNthCalledWith(1, { - members: [createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } })], - }); + let member = createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }); - await createPresenceEvent(space, presenceMap, 'update', { - data: createProfileUpdate({ current: { name: 'Betty' } }), + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [member], }); + await createPresenceEvent(space, presenceMap, 'leave'); + member = createSpaceMember({ isConnected: false, lastEvent: { name: 'leave', timestamp: 1 } }); expect(callbackSpy).toHaveBeenNthCalledWith(2, { - members: [createSpaceMember({ profileData: { name: 'Betty' } })], + members: [member], }); }); - it('updates the connected status of clients who have left', async ({ space, presenceMap }) => { + it('is called when members location changes', async ({ space, presenceMap }) => { const callbackSpy = vi.fn(); space.subscribe('update', callbackSpy); await createPresenceEvent(space, presenceMap, 'enter'); + let member = createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }); expect(callbackSpy).toHaveBeenNthCalledWith(1, { - members: [createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } })], + members: [member], }); - await createPresenceEvent(space, presenceMap, 'leave'); + await createPresenceEvent(space, presenceMap, 'update', { + data: createLocationUpdate({ current: 'newLocation' }), + }); + + member = createSpaceMember({ lastEvent: { name: 'update', timestamp: 1 }, location: 'newLocation' }); expect(callbackSpy).toHaveBeenNthCalledWith(2, { - members: [createSpaceMember({ isConnected: false, lastEvent: { name: 'leave', timestamp: 1 } })], + members: [member], }); }); @@ -263,93 +268,6 @@ describe('Space', () => { members: [createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } })], }); }); - - describe('leavers', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('removes a member who has left after the offlineTimeout', async ({ space, presenceMap }) => { - const callbackSpy = vi.fn(); - space.subscribe('update', callbackSpy); - - await createPresenceEvent(space, presenceMap, 'enter'); - expect(callbackSpy).toHaveBeenNthCalledWith(1, { - members: [createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } })], - }); - - await createPresenceEvent(space, presenceMap, 'leave'); - expect(callbackSpy).toHaveBeenNthCalledWith(2, { - members: [createSpaceMember({ isConnected: false, lastEvent: { name: 'leave', timestamp: 1 } })], - }); - - await vi.advanceTimersByTimeAsync(130_000); - - expect(callbackSpy).toHaveBeenNthCalledWith(3, { members: [] }); - expect(callbackSpy).toHaveBeenCalledTimes(3); - }); - - it('does not remove a member that has rejoined', async ({ space, presenceMap }) => { - const callbackSpy = vi.fn(); - space.subscribe('update', callbackSpy); - - await createPresenceEvent(space, presenceMap, 'enter'); - await createPresenceEvent(space, presenceMap, 'enter', { clientId: '2', connectionId: '2' }); - expect(callbackSpy).toHaveBeenNthCalledWith(2, { - members: [ - createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }), - createSpaceMember({ clientId: '2', connectionId: '2', lastEvent: { name: 'enter', timestamp: 1 } }), - ], - }); - - await createPresenceEvent(space, presenceMap, 'leave'); - expect(callbackSpy).toHaveBeenNthCalledWith(3, { - members: [ - createSpaceMember({ clientId: '2', connectionId: '2', lastEvent: { name: 'enter', timestamp: 1 } }), - createSpaceMember({ lastEvent: { name: 'leave', timestamp: 1 }, isConnected: false }), - ], - }); - - await vi.advanceTimersByTimeAsync(60_000); - await createPresenceEvent(space, presenceMap, 'enter'); - expect(callbackSpy).toHaveBeenNthCalledWith(4, { - members: [ - createSpaceMember({ clientId: '2', connectionId: '2', lastEvent: { name: 'enter', timestamp: 1 } }), - createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }), - ], - }); - - await vi.advanceTimersByTimeAsync(130_000); // 2:10 passed, default timeout is 2 min - expect(callbackSpy).toHaveBeenCalledTimes(4); - }); - - it('unsubscribes when unsubscribe is called', async ({ space, presenceMap }) => { - const spy = vi.fn(); - space.subscribe('update', spy); - await createPresenceEvent(space, presenceMap, 'enter', { clientId: '2' }); - space.unsubscribe('update', spy); - await createPresenceEvent(space, presenceMap, 'enter', { clientId: '2' }); - - expect(spy).toHaveBeenCalledOnce(); - }); - - it('unsubscribes when unsubscribe is called with no arguments', async ({ - space, - presenceMap, - }) => { - const spy = vi.fn(); - space.subscribe('update', spy); - await createPresenceEvent(space, presenceMap, 'enter', { clientId: '2' }); - space.unsubscribe(); - await createPresenceEvent(space, presenceMap, 'enter', { clientId: '2' }); - - expect(spy).toHaveBeenCalledOnce(); - }); - }); }); describe('locations', () => {