From 6d71362b589439fe3b4f234f4ff98871f8d98a20 Mon Sep 17 00:00:00 2001 From: Aye Min Aung <2681318+ayeminag@users.noreply.github.com> Date: Wed, 3 Apr 2024 19:48:53 +0700 Subject: [PATCH] Conversation.sendMessage API, e2e tests and fabric-http demo update (#999) * Conversation.sendMessage API, unit tests and fabric-http deomo update * rename conversation_id to addressId in public api * refactored so that conversation obj returned from getConversations() has sendMessage() method updated e2e and tests * added getMessage method to conversation objects returned from getConversations and updated e2e tests * added changesets * removed unused log and reverted package-lock.json changes * some refactoring * some refactoring --------- Co-authored-by: Aye Min Aung --- .changeset/unlucky-icons-burn.md | 9 ++ .../tests/callfabric/conversationRoom.spec.ts | 116 ++++++++++++++++++ internal/e2e-js/utils.ts | 25 +++- .../playground-js/src/fabric-http/index.html | 4 + .../playground-js/src/fabric-http/index.js | 31 ++++- packages/core/src/types/callfabric.ts | 26 ++++ packages/js/src/fabric/Conversation.test.ts | 40 ++++-- packages/js/src/fabric/Conversation.ts | 46 +++---- packages/js/src/fabric/ConversationAPI.ts | 44 +++++++ packages/js/src/fabric/SignalWire.ts | 2 + .../src/fabric/workers/conversationWorker.ts | 1 - packages/js/src/utils/paginatedResult.ts | 4 +- 12 files changed, 307 insertions(+), 41 deletions(-) create mode 100644 .changeset/unlucky-icons-burn.md create mode 100644 internal/e2e-js/tests/callfabric/conversationRoom.spec.ts create mode 100644 packages/js/src/fabric/ConversationAPI.ts diff --git a/.changeset/unlucky-icons-burn.md b/.changeset/unlucky-icons-burn.md new file mode 100644 index 000000000..13327bf6a --- /dev/null +++ b/.changeset/unlucky-icons-burn.md @@ -0,0 +1,9 @@ +--- +'@signalwire/core': minor +'@signalwire/js': minor +--- + +- `client.conversations.sendMessage()` +- `conversation.sendMessage()` API for conversation object returned from `getConversations()` API +- `conversation.getMessages()` API for conversation object returned from `getConversations()` +- added e2e tests for conversation (room) diff --git a/internal/e2e-js/tests/callfabric/conversationRoom.spec.ts b/internal/e2e-js/tests/callfabric/conversationRoom.spec.ts new file mode 100644 index 000000000..e11c4b9e2 --- /dev/null +++ b/internal/e2e-js/tests/callfabric/conversationRoom.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '../../fixtures' +import { + SERVER_URL, + createTestSATToken, + createVideoRoom, + createCFClient +} from '../../utils' +import { uuid } from '@signalwire/core' + +test.describe('Conversation Room', () => { + test('send message in a room conversation', async ({ createCustomVanillaPage }) => { + const page = await createCustomVanillaPage({ name: '[page]' }) + const page2 = await createCustomVanillaPage({ + name: '[page2]' + }) + await page.goto(SERVER_URL) + await page2.goto(SERVER_URL) + + const sat1 = await createTestSATToken() + await createCFClient(page, sat1) + const sat2 = await createTestSATToken() + await createCFClient(page2, sat2) + + const roomName = `e2e-js-convo-room_${uuid()}` + await createVideoRoom(roomName) + + const firstMsgEvent = await page.evaluate(({ roomName }) => { + return new Promise(async (resolve) => { + // @ts-expect-error + const client = window._client + const addresses = await client.addresses.getAddresses({ displayName: roomName }) + const roomAddress = addresses.data[0] + const addressId = roomAddress.id + client.conversation.subscribe(resolve) + client.conversation.sendMessage({ + text: '1st message from 1st subscriber', + addressId, + }) + }) + }, { roomName }) + + // @ts-expect-error + expect(firstMsgEvent.type).toBe('message') + + // @ts-expect-error + const addressId = firstMsgEvent.address_id + + const secondMsgEventPromiseFromPage1 = page.evaluate(() => { + return new Promise(resolve => { + // @ts-expect-error + const client = window._client + client.conversation.subscribe(resolve) + }) + }) + + const firstMsgEventFromPage2 = await page2.evaluate(({ addressId }) => { + return new Promise(async (resolve) => { + // @ts-expect-error + const client = window._client + await client.connect() + client.conversation.subscribe(resolve) + const result = await client.conversation.getConversations() + const convo = result.data.filter(c => c.id == addressId)[0] + convo.sendMessage({ + text: '1st message from 2nd subscriber', + }) + }) + }, { addressId }) + + const secondMsgEventFromPage1 = await secondMsgEventPromiseFromPage1 + expect(secondMsgEventFromPage1).not.toBeUndefined() + + // @ts-expect-error + expect(secondMsgEventFromPage1.type).toBe('message') + + expect(firstMsgEventFromPage2).not.toBeUndefined() + + // @ts-expect-error + expect(firstMsgEventFromPage2.type).toBe('message') + + const messages = await page.evaluate(async ({ addressId }) => { + // @ts-expect-error + const client = window._client + const result = await client.conversation.getConversations() + const convo = result.data.filter(c => c.id == addressId)[0] + return await convo.getMessages({}) + }, { addressId }) + + expect(messages).not.toBeUndefined() + + expect(messages.data.length).toEqual(2) + expect(messages.data[0]).toMatchObject({ + "conversation_id": addressId, + "details": {}, + "id": expect.anything(), + "kind": null, + "subtype": "chat", + "text": "1st message from 2nd subscriber", + "ts": expect.anything(), + "type": "message", + "user_id": expect.anything() + }) + + expect(messages.data[1]).toMatchObject({ + "conversation_id": addressId, + "details": {}, + "id": expect.anything(), + "kind": null, + "subtype": "chat", + "text": "1st message from 1st subscriber", + "ts": expect.anything(), + "type": "message", + "user_id": expect.anything() + }) + }) +}) \ No newline at end of file diff --git a/internal/e2e-js/utils.ts b/internal/e2e-js/utils.ts index 92d40fc4a..1395842c3 100644 --- a/internal/e2e-js/utils.ts +++ b/internal/e2e-js/utils.ts @@ -180,8 +180,8 @@ export const createTestRoomSessionWithJWT = async ( ) } -export const createCFClient = async (page: Page) => { - const sat = await createTestSATToken() +export const createCFClient = async (page: Page, sat?: string) => { + if (!sat) sat = await createTestSATToken() if (!sat) { console.error('Invalid SAT. Exiting..') process.exit(4) @@ -200,7 +200,6 @@ export const createCFClient = async (page: Page) => { // @ts-expect-error window._client = client - return client }, { @@ -280,10 +279,30 @@ export const createTestSATToken = async () => { }), } ) + console.log(response.body.read().toString()) const data = await response.json() return data.token } + +export const createVideoRoom= async (name?: string) => { + const response = await fetch( + `https://${process.env.API_HOST}/api/fabric/resources/video_rooms`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${BASIC_TOKEN}`, + }, + body: JSON.stringify({ + name: name ? name : `e2e-js-test-room_${uuid()}`, + }), + } + ) + const data = await response.json() + return data +} + interface CreateTestCRTOptions { ttl: number member_id: string diff --git a/internal/playground-js/src/fabric-http/index.html b/internal/playground-js/src/fabric-http/index.html index ff77a7eae..816ad2624 100644 --- a/internal/playground-js/src/fabric-http/index.html +++ b/internal/playground-js/src/fabric-http/index.html @@ -236,6 +236,10 @@

+
+ + +
diff --git a/internal/playground-js/src/fabric-http/index.js b/internal/playground-js/src/fabric-http/index.js index d7f8bf2e4..7b2987bca 100644 --- a/internal/playground-js/src/fabric-http/index.js +++ b/internal/playground-js/src/fabric-http/index.js @@ -2,6 +2,8 @@ import { SignalWire } from '@signalwire/js' const searchInput = document.getElementById('searchInput') const searchType = document.getElementById('searchType') +const conversationMessageInput = document.getElementById('new-conversation-message') +const sendMessageBtn = document.getElementById('send-message') let client = null @@ -156,9 +158,16 @@ const createAddressListItem = (address) => { const button = document.createElement('button') button.className = 'btn btn-sm btn-success' - button.addEventListener('click', () => dialAddress(channelValue)) - const icon = document.createElement('i') + if (channelName != 'messaging') { + button.addEventListener('click', () => dialAddress(channelValue)) + } else { + + button.addEventListener('click', () => { + subscribeToNewMessages() + openMessageModal(address) + }) + } if (channelName === 'messaging') { icon.className = 'bi bi-chat' } else if (channelName === 'video') { @@ -189,6 +198,7 @@ function updateAddressUI() { addresses .map(createAddressListItem) .forEach((item) => addressUl.appendChild(item)) + subscribeToNewMessages(); } async function fetchAddresses() { @@ -212,7 +222,6 @@ async function fetchAddresses() { window.dialAddress = async (address) => { const destinationInput = document.getElementById('destination') destinationInput.value = address - connect() } window.fetchNextAddresses = async () => { @@ -246,6 +255,17 @@ searchInput.addEventListener('input', () => { searchType.addEventListener('change', fetchAddresses) +sendMessageBtn.addEventListener('click', async () => { + if (!client) return + const address = window.__currentAddress + const text = conversationMessageInput.value + await client.conversation.sendMessage({ + addressId: address.id, + text, + }) + conversationMessageInput.value = '' +}) + /** ======= Address utilities end ======= */ /** ======= History utilities start ======= */ @@ -387,8 +407,9 @@ function createMessageListItem(msg) { listItem.innerHTML = `
-
${msg.type ?? 'unknown'}
+
${msg.text}
+ ${msg.type} ${msg.subtype ?? 'unknown'} ${msg.kind ?? 'unknown'}
@@ -426,9 +447,11 @@ function clearMessageModal() { if (avatarImage) { avatarImage.src = newImageUrl } + window.__currentAddress = undefined } async function openMessageModal(data) { + window.__currentAddress = data const modal = new bootstrap.Modal(msgModalDiv) modal.show() diff --git a/packages/core/src/types/callfabric.ts b/packages/core/src/types/callfabric.ts index 30cfbdd81..47737e989 100644 --- a/packages/core/src/types/callfabric.ts +++ b/packages/core/src/types/callfabric.ts @@ -8,6 +8,17 @@ export interface PaginatedResponse { } } +export interface PaginatedResult { + data: Array | [] + self() : Promise | undefined> + nextPage() : Promise | undefined> + prevPage() : Promise | undefined> + firstPage() : Promise | undefined> + hasNext : boolean + hasPrev : boolean +} + + /** * Addresses */ @@ -36,6 +47,12 @@ export interface FetchAddressResponse extends PaginatedResponse
{} /** * Conversations */ +export interface SendConversationMessageOptions { + text: string + addressId: string + metadata?: Record + details?: Record +} export interface GetConversationsOptions { pageSize?: number } @@ -46,6 +63,15 @@ export interface Conversation { last_message_at: number metadata: Record name: string + sendMessage(options: { text: string }): Promise + getMessages(options: { pageSize?: number }): Promise> +} + +export interface SendConversationMessageResponse { + table: { + conversation_id: string + text: string + } } export interface FetchConversationsResponse diff --git a/packages/js/src/fabric/Conversation.test.ts b/packages/js/src/fabric/Conversation.test.ts index 6cc077a0b..cc6711c09 100644 --- a/packages/js/src/fabric/Conversation.test.ts +++ b/packages/js/src/fabric/Conversation.test.ts @@ -1,6 +1,7 @@ import { Conversation } from './Conversation' import { HTTPClient } from './HTTPClient' import { WSClient } from './WSClient' +import { uuid } from '@signalwire/core' // Mock HTTPClient jest.mock('./HTTPClient', () => { @@ -47,17 +48,20 @@ describe('Conversation', () => { describe('getConversations', () => { it('should fetch conversations', async () => { + const conversations = [{ id: uuid() }, { id: uuid() }] ;(httpClient.fetch as jest.Mock).mockResolvedValue({ - body: { data: ['conversation1', 'conversation2'], links: {} }, + body: { data: conversations, links: {} }, }) const result = await conversation.getConversations() - expect(result.data).toEqual(['conversation1', 'conversation2']) + expect(result.data).toEqual(conversations) expect(result.hasNext).toBe(false) expect(result.hasPrev).toBe(false) expect(httpClient.fetch).toHaveBeenCalledWith( expect.stringContaining('/conversations') ) + expect(result.data[0].sendMessage).not.toBeUndefined() + expect(typeof result.data[0].sendMessage).toBe('function') }) it('should handle errors with getConversations', async () => { @@ -151,22 +155,35 @@ describe('Conversation', () => { }) }) - describe('createConversationMessage', () => { + describe('sendMessage', () => { it('should create a conversation message', async () => { - const expectedResponse = { success: true, messageId: '12345' } + const addressId = uuid() + const text = 'test message' + const expectedResponse = { + table: { + text, + conversation_id: addressId, + } + } ;(httpClient.fetch as jest.Mock).mockResolvedValue({ body: expectedResponse, }) // TODO: Test with payload - const result = await conversation.createConversationMessage() + const result = await conversation.sendMessage({ + addressId, + text + }) expect(result).toEqual(expectedResponse) expect(httpClient.fetch).toHaveBeenCalledWith( - '/api/fabric/conversations/messages', + '/api/fabric/messages', { method: 'POST', - body: {}, + body: { + conversation_id: addressId, + text, + }, } ) }) @@ -177,11 +194,14 @@ describe('Conversation', () => { ) try { - await conversation.createConversationMessage() - fail('Expected getConversationMessages to throw an error.') + await conversation.sendMessage({ + text: 'text message', + addressId: uuid(), + }) + fail('Expected sendMessage to throw error.') } catch (error) { expect(error).toBeInstanceOf(Error) - expect(error.message).toBe('Error creating a conversation messages!') + expect(error.message).toBe('Error sending message to conversation!') } }) }) diff --git a/packages/js/src/fabric/Conversation.ts b/packages/js/src/fabric/Conversation.ts index 9ee09455f..27799a2fb 100644 --- a/packages/js/src/fabric/Conversation.ts +++ b/packages/js/src/fabric/Conversation.ts @@ -2,17 +2,19 @@ import { HTTPClient } from './HTTPClient' import { WSClient } from './WSClient' import { ConversationEventParams, - Conversation as ConversationType, FetchConversationsResponse, GetMessagesOptions, GetConversationsOptions, GetConversationMessagesOptions, FetchConversationMessagesResponse, ConversationMessage, + SendConversationMessageOptions, + SendConversationMessageResponse, } from '@signalwire/core' import { conversationWorker } from './workers' import { buildPaginatedResult } from '../utils/paginatedResult' import { makeQueryParamsUrls } from '../utils/makeQueryParamsUrl' +import { ConversationAPI } from './ConversationAPI' type Callback = (event: ConversationEventParams) => unknown @@ -39,6 +41,25 @@ export class Conversation { }) } + public async sendMessage(options: SendConversationMessageOptions) { + try { + const { + addressId, text + } = options + const path = '/api/fabric/messages' + const { body } = await this.httpClient.fetch(path, { + method: 'POST', + body: { + conversation_id: addressId, + text, + } + }) + return body + } catch (error) { + throw new Error("Error sending message to conversation!", error) + } + } + public async getConversations(options?: GetConversationsOptions) { try { const { pageSize } = options || {} @@ -52,8 +73,9 @@ export class Conversation { const { body } = await this.httpClient.fetch( makeQueryParamsUrls(path, queryParams) ) - - return buildPaginatedResult(body, this.httpClient.fetch) + const self = this + body.data = body.data.map((conversation) => new ConversationAPI(self, conversation)) + return buildPaginatedResult(body, this.httpClient.fetch) } catch (error) { throw new Error('Error fetching the conversation history!', error) } @@ -109,24 +131,6 @@ export class Conversation { } } - public async createConversationMessage() { - try { - const path = '/api/fabric/conversations/messages' - - // TODO: Complete the payload - const payload = {} - - const { body } = await this.httpClient.fetch(path, { - method: 'POST', - body: payload, - }) - - return body - } catch (error) { - throw new Error('Error creating a conversation messages!', error) - } - } - public async subscribe(callback: Callback) { // Connect the websocket client first this.wsClient.connect() diff --git a/packages/js/src/fabric/ConversationAPI.ts b/packages/js/src/fabric/ConversationAPI.ts new file mode 100644 index 000000000..c9d2ce462 --- /dev/null +++ b/packages/js/src/fabric/ConversationAPI.ts @@ -0,0 +1,44 @@ +import { Conversation } from './Conversation' +import { Conversation as ConversationType } from '@signalwire/core' + +export interface ConversationAPISendMessageOptions { + text: string +} + +export interface ConversationAPIGetMessagesOptions { + pageSize?: number +} +export class ConversationAPI { + + get id() { + return this.data.id + } + + get created_at() { + return this.data.created_at + } + + get last_message_at() { + return this.data.last_message_at + } + + get metadata() { + return this.data.metadata + } + + get name() { + return this.data.name + } + + constructor(private conversation: Conversation, private data: ConversationType) {} + + sendMessage(options: ConversationAPISendMessageOptions) { + return this.conversation.sendMessage({ + addressId: this.id, + text: options.text, + }) + } + getMessages(options: ConversationAPIGetMessagesOptions | undefined) { + return this.conversation.getConversationMessages({ addressId: this.id, ...options }) + } +} \ No newline at end of file diff --git a/packages/js/src/fabric/SignalWire.ts b/packages/js/src/fabric/SignalWire.ts index 38917445b..284a8acc2 100644 --- a/packages/js/src/fabric/SignalWire.ts +++ b/packages/js/src/fabric/SignalWire.ts @@ -24,6 +24,7 @@ export interface SignalWireContract { getMessages: Conversation['getMessages'] getConversationMessages: Conversation['getConversationMessages'] subscribe: Conversation['subscribe'] + sendMessage: Conversation['sendMessage'] } } @@ -58,6 +59,7 @@ export const SignalWire = ( getConversationMessages: conversation.getConversationMessages.bind(conversation), subscribe: conversation.subscribe.bind(conversation), + sendMessage: conversation.sendMessage.bind(conversation), }, // @ts-expect-error __httpClient: httpClient, diff --git a/packages/js/src/fabric/workers/conversationWorker.ts b/packages/js/src/fabric/workers/conversationWorker.ts index 4a802c825..874ce534c 100644 --- a/packages/js/src/fabric/workers/conversationWorker.ts +++ b/packages/js/src/fabric/workers/conversationWorker.ts @@ -34,7 +34,6 @@ export const conversationWorker: SDKWorker = function* ( swEventChannel, isConversationEvent ) - conversation.handleEvent(action.payload) } diff --git a/packages/js/src/utils/paginatedResult.ts b/packages/js/src/utils/paginatedResult.ts index 9deb2e4fb..c2cae3726 100644 --- a/packages/js/src/utils/paginatedResult.ts +++ b/packages/js/src/utils/paginatedResult.ts @@ -1,10 +1,10 @@ -import { PaginatedResponse } from '@signalwire/core' +import { PaginatedResponse, PaginatedResult } from '@signalwire/core' import { CreateHttpClient } from '../fabric/createHttpClient' export function buildPaginatedResult( body: PaginatedResponse, client: CreateHttpClient -) { +): PaginatedResult { const anotherPage = async (url: string) => { const { body } = await client>(url) return buildPaginatedResult(body, client)