From 356b8f7bd643bc8953a16a058bb09a177c0789ca Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 15 Dec 2023 13:40:59 +0000 Subject: [PATCH] feat: add conversation caching and some tests --- __mocks__/ably/promises/index.ts | 78 ++++++++++++++++++ src/Conversations.ts | 11 ++- src/Messages.test.ts | 131 +++++++++++++++++++++++++++++++ src/Messages.ts | 1 + 4 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 __mocks__/ably/promises/index.ts create mode 100644 src/Messages.test.ts diff --git a/__mocks__/ably/promises/index.ts b/__mocks__/ably/promises/index.ts new file mode 100644 index 00000000..a18358df --- /dev/null +++ b/__mocks__/ably/promises/index.ts @@ -0,0 +1,78 @@ +import { Types } from 'ably/promises'; + +const MOCK_CLIENT_ID = 'MOCK_CLIENT_ID'; + +const mockPromisify = (expectedReturnValue): Promise => new Promise((resolve) => resolve(expectedReturnValue)); +const methodReturningVoidPromise = () => mockPromisify((() => {})()); + +function createMockPresence() { + return { + get: () => mockPromisify([]), + update: () => mockPromisify(undefined), + enter: methodReturningVoidPromise, + leave: methodReturningVoidPromise, + subscriptions: { + once: (_: unknown, fn: Function) => { + fn(); + }, + }, + subscribe: () => {}, + unsubscribe: () => {}, + }; +} + +function createMockEmitter() { + return { + any: [], + events: {}, + anyOnce: [], + eventsOnce: {}, + }; +} + +function createMockChannel() { + return { + presence: createMockPresence(), + subscribe: () => {}, + unsubscribe: () => {}, + on: () => {}, + off: () => {}, + publish: () => {}, + subscriptions: createMockEmitter(), + }; +} + +class MockRealtime { + public channels: { + get: () => ReturnType; + }; + public auth: { + clientId: string; + }; + public connection: { + id?: string; + state: string; + }; + + public time() {} + + constructor() { + this.channels = { + get: (() => { + const mockChannel = createMockChannel(); + return () => mockChannel; + })(), + }; + this.auth = { + clientId: MOCK_CLIENT_ID, + }; + this.connection = { + id: '1', + state: 'connected', + }; + + this['options'] = {}; + } +} + +export { MockRealtime as Realtime }; diff --git a/src/Conversations.ts b/src/Conversations.ts index 6fdddeeb..958f4346 100644 --- a/src/Conversations.ts +++ b/src/Conversations.ts @@ -6,12 +6,19 @@ export class Conversations { private readonly realtime: Realtime; private readonly chatApi: ChatApi; + private conversations: Record = {}; + constructor(realtime: Realtime) { this.realtime = realtime; this.chatApi = new ChatApi((realtime as any).options.clientId); } - get(conversationId: string) { - return new Conversation(conversationId, this.realtime, this.chatApi); + get(conversationId: string): Conversation { + if (this.conversations[conversationId]) return this.conversations[conversationId]; + + const conversation = new Conversation(conversationId, this.realtime, this.chatApi); + this.conversations[conversationId] = conversation; + + return conversation; } } diff --git a/src/Messages.test.ts b/src/Messages.test.ts new file mode 100644 index 00000000..233b33fc --- /dev/null +++ b/src/Messages.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, vi, it, expect } from 'vitest'; +import { Realtime, Types } from 'ably/promises'; +import { ChatApi } from './ChatApi.js'; +import { Conversation } from './Conversation.js'; + +interface TestContext { + realtime: Realtime; + chatApi: ChatApi; + emulateBackendPublish: Types.messageCallback>; +} + +vi.mock('ably/promises'); + +describe('Messages', () => { + beforeEach((context) => { + context.realtime = new Realtime({ clientId: 'clientId', key: 'key' }); + context.chatApi = new ChatApi('clientId'); + + const channel = context.realtime.channels.get('conversationId'); + vi.spyOn(channel, 'subscribe').mockImplementation( + // @ts-ignore + async (name: string, listener: Types.messageCallback) => { + context.emulateBackendPublish = listener; + }, + ); + }); + + describe('sending message', () => { + it('should return message if chat backend request come before realtime', async (context) => { + const { chatApi, realtime } = context; + vi.spyOn(chatApi, 'sendMessage').mockResolvedValue({ id: 'messageId' }); + + const conversation = new Conversation('conversationId', realtime, chatApi); + const messagePromise = conversation.messages.send('text'); + + context.emulateBackendPublish({ + clientId: 'clientId', + data: { + id: 'messageId', + content: 'text', + client_id: 'clientId', + }, + }); + + const message = await messagePromise; + + expect(message).toContain({ + id: 'messageId', + content: 'text', + client_id: 'clientId', + }); + }); + + it('should return message if chat backend request come after realtime', async (context) => { + const { chatApi, realtime } = context; + + vi.spyOn(chatApi, 'sendMessage').mockImplementation(async (conversationId, text) => { + context.emulateBackendPublish({ + clientId: 'clientId', + data: { + id: 'messageId', + content: text, + client_id: 'clientId', + }, + }); + return { id: 'messageId' }; + }); + + const conversation = new Conversation('conversationId', realtime, chatApi); + const message = await conversation.messages.send('text'); + + expect(message).toContain({ + id: 'messageId', + content: 'text', + client_id: 'clientId', + }); + }); + }); + + describe('editing message', () => { + it('should return message if chat backend request come before realtime', async (context) => { + const { chatApi, realtime } = context; + vi.spyOn(chatApi, 'editMessage').mockResolvedValue({ id: 'messageId' }); + + const conversation = new Conversation('conversationId', realtime, chatApi); + const messagePromise = conversation.messages.edit('messageId', 'new_text'); + + context.emulateBackendPublish({ + clientId: 'clientId', + data: { + id: 'messageId', + content: 'new_text', + client_id: 'clientId', + }, + }); + + const message = await messagePromise; + + expect(message).toContain({ + id: 'messageId', + content: 'new_text', + client_id: 'clientId', + }); + }); + + it('should return message if chat backend request come after realtime', async (context) => { + const { chatApi, realtime } = context; + + vi.spyOn(chatApi, 'editMessage').mockImplementation(async (conversationId, messageId, text) => { + context.emulateBackendPublish({ + clientId: 'clientId', + data: { + id: messageId, + content: text, + client_id: 'clientId', + }, + }); + return { id: 'messageId' }; + }); + + const conversation = new Conversation('conversationId', realtime, chatApi); + const message = await conversation.messages.edit('messageId', 'new_text'); + + expect(message).toContain({ + id: 'messageId', + content: 'new_text', + client_id: 'clientId', + }); + }); + }); +}); diff --git a/src/Messages.ts b/src/Messages.ts index 35e04e6f..47a450e9 100644 --- a/src/Messages.ts +++ b/src/Messages.ts @@ -28,6 +28,7 @@ export class Messages { private readonly conversationId: string; private readonly channel: RealtimeChannelPromise; private readonly chatApi: ChatApi; + private messageToChannelListener = new WeakMap(); constructor(conversationId: string, channel: RealtimeChannelPromise, chatApi: ChatApi) {