| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469 |
- // 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)
- // eslint-disable-next-line react/display-name
- wrapper: ({ children }) => (
- <ChatProviders {...props}>{children}</ChatProviders>
- ),
- })
- }
- 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)
- }
|