// Disable prop type checks for test harnesses /* eslint-disable react/prop-types */ import React from 'react' import { renderHook, act } from '@testing-library/react-hooks/dom' import { expect } from 'chai' import fetchMock from 'fetch-mock' import EventEmitter from 'events' import { useChatContext } from '../../../../../frontend/js/features/chat/context/chat-context' import { ChatProviders, cleanUpContext, } from '../../../helpers/render-with-context' import { stubMathJax, tearDownMathJaxStubs } from '../components/stubs' describe('ChatContext', function () { const user = { id: 'fake_user', first_name: 'fake_user_first_name', email: 'fake@example.com', } beforeEach(function () { fetchMock.reset() cleanUpContext() stubMathJax() }) afterEach(function () { tearDownMathJaxStubs() }) describe('socket connection', function () { beforeEach(function () { // Mock GET messages to return no messages fetchMock.get('express:/project/:projectId/messages', []) // Mock POST new message to return 200 fetchMock.post('express:/project/:projectId/messages', 200) }) it('subscribes when mounted', function () { const socket = new EventEmitter() renderChatContextHook({ user, socket }) // Assert that there is 1 listener expect(socket.rawListeners('new-chat-message').length).to.equal(1) }) it('unsubscribes when unmounted', function () { const socket = new EventEmitter() const { unmount } = renderChatContextHook({ user, socket }) unmount() // Assert that there is 0 listeners expect(socket.rawListeners('new-chat-message').length).to.equal(0) }) it('adds received messages to the list', async function () { // Mock socket: we only need to emit events, not mock actual connections const socket = new EventEmitter() const { result, waitForNextUpdate } = renderChatContextHook({ user, socket, }) // Wait until initial messages have loaded result.current.loadInitialMessages() await waitForNextUpdate() // No messages shown at first expect(result.current.messages).to.deep.equal([]) // Mock message being received from another user socket.emit('new-chat-message', { id: 'msg_1', content: 'new message', timestamp: Date.now(), user: { id: 'another_fake_user', first_name: 'another_fake_user_first_name', email: 'another_fake@example.com', }, }) const message = result.current.messages[0] expect(message.id).to.equal('msg_1') expect(message.contents).to.deep.equal(['new message']) }) it("doesn't add received messages from the current user if a message was just sent", async function () { const socket = new EventEmitter() const { result, waitForNextUpdate } = renderChatContextHook({ user, socket, }) // Wait until initial messages have loaded result.current.loadInitialMessages() await waitForNextUpdate() // Send a message from the current user result.current.sendMessage('sent message') // Receive a message from the current user socket.emit('new-chat-message', { id: 'msg_1', content: 'received message', timestamp: Date.now(), user, }) // Expect that the sent message is shown, but the new message is not const messageContents = result.current.messages.map( ({ contents }) => contents[0] ) expect(messageContents).to.include('sent message') expect(messageContents).to.not.include('received message') }) it('adds the new message from the current user if another message was received after sending', async function () { const socket = new EventEmitter() const { result, waitForNextUpdate } = renderChatContextHook({ user, socket, }) // Wait until initial messages have loaded result.current.loadInitialMessages() await waitForNextUpdate() // Send a message from the current user result.current.sendMessage('sent message from current user') const [sentMessageFromCurrentUser] = result.current.messages expect(sentMessageFromCurrentUser.contents).to.deep.equal([ 'sent message from current user', ]) act(() => { // Receive a message from another user. socket.emit('new-chat-message', { id: 'msg_1', content: 'new message from other user', timestamp: Date.now(), user: { id: 'another_fake_user', first_name: 'another_fake_user_first_name', email: 'another_fake@example.com', }, }) }) const [, messageFromOtherUser] = result.current.messages expect(messageFromOtherUser.contents).to.deep.equal([ 'new message from other user', ]) // Receive a message from the current user socket.emit('new-chat-message', { id: 'msg_2', content: 'received message from current user', timestamp: Date.now(), user, }) // Since the current user didn't just send a message, it is now shown const [, , receivedMessageFromCurrentUser] = result.current.messages expect(receivedMessageFromCurrentUser.contents).to.deep.equal([ 'received message from current user', ]) }) }) describe('loadInitialMessages', function () { beforeEach(function () { fetchMock.get('express:/project/:projectId/messages', [ { id: 'msg_1', content: 'a message', user, timestamp: Date.now(), }, ]) }) it('adds messages to the list', async function () { const { result, waitForNextUpdate } = renderChatContextHook({ user }) result.current.loadInitialMessages() await waitForNextUpdate() expect(result.current.messages[0].contents).to.deep.equal(['a message']) }) it("won't load messages a second time", async function () { const { result, waitForNextUpdate } = renderChatContextHook({ user }) result.current.loadInitialMessages() await waitForNextUpdate() expect(result.current.initialMessagesLoaded).to.equal(true) // Calling a second time won't do anything result.current.loadInitialMessages() expect(fetchMock.calls()).to.have.lengthOf(1) }) }) describe('loadMoreMessages', function () { it('adds messages to the list', async function () { // Mock a GET request for an initial message fetchMock.getOnce('express:/project/:projectId/messages', [ { id: 'msg_1', content: 'first message', user, timestamp: new Date('2021-03-04T10:00:00').getTime(), }, ]) const { result, waitForNextUpdate } = renderChatContextHook({ user }) result.current.loadMoreMessages() await waitForNextUpdate() expect(result.current.messages[0].contents).to.deep.equal([ 'first message', ]) // The before query param is not set expect(getLastFetchMockQueryParam('before')).to.be.null }) it('adds more messages if called a second time', async function () { // Mock 2 GET requests, with different content fetchMock .getOnce( 'express:/project/:projectId/messages', // Resolve a full "page" of messages (50) createMessages(50, user, new Date('2021-03-04T10:00:00').getTime()) ) .getOnce( 'express:/project/:projectId/messages', [ { id: 'msg_51', content: 'message from second page', user, timestamp: new Date('2021-03-04T11:00:00').getTime(), }, ], { overwriteRoutes: false } ) const { result, waitForNextUpdate } = renderChatContextHook({ user }) result.current.loadMoreMessages() await waitForNextUpdate() // Call a second time result.current.loadMoreMessages() await waitForNextUpdate() // The second request is added to the list // Since both messages from the same user, they are collapsed into the // same "message" expect(result.current.messages[0].contents).to.include( 'message from second page' ) // The before query param for the second request matches the timestamp // of the first message const beforeParam = parseInt(getLastFetchMockQueryParam('before'), 10) expect(beforeParam).to.equal(new Date('2021-03-04T10:00:00').getTime()) }) it("won't load more messages if there are no more messages", async function () { // Mock a GET request for 49 messages. This is less the the full page size // (50 messages), meaning that there are no further messages to be loaded fetchMock.getOnce( 'express:/project/:projectId/messages', createMessages(49, user) ) const { result, waitForNextUpdate } = renderChatContextHook({ user }) result.current.loadMoreMessages() await waitForNextUpdate() expect(result.current.messages[0].contents).to.have.length(49) result.current.loadMoreMessages() expect(result.current.atEnd).to.be.true expect(fetchMock.calls()).to.have.lengthOf(1) }) it('handles socket messages while loading', async function () { // Mock GET messages so that we can control when the promise is resolved let resolveLoadingMessages fetchMock.get( 'express:/project/:projectId/messages', new Promise(resolve => { resolveLoadingMessages = resolve }) ) const socket = new EventEmitter() const { result, waitForNextUpdate } = renderChatContextHook({ user, socket, }) // Start loading messages result.current.loadMoreMessages() // Mock message being received from the socket while the request is in // flight socket.emit('new-chat-message', { id: 'socket_msg', content: 'socket message', timestamp: Date.now(), user: { id: 'another_fake_user', first_name: 'another_fake_user_first_name', email: 'another_fake@example.com', }, }) // Resolve messages being loaded resolveLoadingMessages([ { id: 'fetched_msg', content: 'loaded message', user, timestamp: Date.now(), }, ]) await waitForNextUpdate() // Although the loaded message was resolved last, it appears first (since // requested messages must have come first) const messageContents = result.current.messages.map( ({ contents }) => contents[0] ) expect(messageContents).to.deep.equal([ 'loaded message', 'socket message', ]) }) }) describe('sendMessage', function () { beforeEach(function () { // Mock GET messages to return no messages and POST new message to be // successful fetchMock .get('express:/project/:projectId/messages', []) .postOnce('express:/project/:projectId/messages', 200) }) it('optimistically adds the message to the list', function () { const { result } = renderChatContextHook({ user }) result.current.sendMessage('sent message') expect(result.current.messages[0].contents).to.deep.equal([ 'sent message', ]) }) it('POSTs the message to the backend', function () { const { result } = renderChatContextHook({ user }) result.current.sendMessage('sent message') const [, { body }] = fetchMock.lastCall( 'express:/project/:projectId/messages', 'POST' ) expect(JSON.parse(body)).to.deep.equal({ content: 'sent message' }) }) it("doesn't send if the content is empty", function () { const { result } = renderChatContextHook({ user }) result.current.sendMessage('') expect(result.current.messages).to.be.empty expect( fetchMock.called('express:/project/:projectId/messages', { method: 'post', }) ).to.be.false }) }) describe('unread messages', function () { beforeEach(function () { // Mock GET messages to return no messages fetchMock.get('express:/project/:projectId/messages', []) }) it('increments unreadMessageCount when a new message is received', function () { const socket = new EventEmitter() const { result } = renderChatContextHook({ user, socket }) // Receive a new message from the socket socket.emit('new-chat-message', { id: 'msg_1', content: 'new message', timestamp: Date.now(), user, }) expect(result.current.unreadMessageCount).to.equal(1) }) it('resets unreadMessageCount when markMessagesAsRead is called', function () { const socket = new EventEmitter() const { result } = renderChatContextHook({ user, socket }) // Receive a new message from the socket, incrementing unreadMessageCount // by 1 socket.emit('new-chat-message', { id: 'msg_1', content: 'new message', timestamp: Date.now(), user, }) result.current.markMessagesAsRead() expect(result.current.unreadMessageCount).to.equal(0) }) }) }) function renderChatContextHook(props) { return renderHook(() => useChatContext(), { // Wrap with ChatContext.Provider (and the other editor context providers) wrapper: ({ children }) => ( {children} ), }) } function createMessages(number, user, timestamp = Date.now()) { return Array.from({ length: number }, (_m, idx) => ({ id: `msg_${idx + 1}`, content: `message ${idx + 1}`, user, timestamp, })) } /* * Get query param by key from the last fetchMock response */ function getLastFetchMockQueryParam(key) { const { url } = fetchMock.lastResponse() const { searchParams } = new URL(url, 'https://www.overleaf.com') return searchParams.get(key) }