first commit
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
fireEvent,
|
||||
screen,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
import ChatPane from '../../../../../frontend/js/features/chat/components/chat-pane'
|
||||
import {
|
||||
cleanUpContext,
|
||||
renderWithEditorContext,
|
||||
} from '../../../helpers/render-with-context'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
|
||||
describe('<ChatPane />', function () {
|
||||
const user = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', user)
|
||||
window.metaAttributesCache.set('ol-chatEnabled', true)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
const testMessages = [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'a message',
|
||||
user,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
{
|
||||
id: 'msg_2',
|
||||
content: 'another message',
|
||||
user,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
cleanUpContext()
|
||||
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
it('renders multiple messages', async function () {
|
||||
fetchMock.get(/messages/, testMessages)
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
await screen.findByText('a message')
|
||||
await screen.findByText('another message')
|
||||
})
|
||||
|
||||
it('provides error message with reload button on FetchError', async function () {
|
||||
fetchMock.get(/messages/, 500)
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
// should have hit a FetchError and will prompt user to reconnect
|
||||
await screen.findByText('Try again')
|
||||
|
||||
// bring chat back up
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock.get(/messages/, [])
|
||||
|
||||
const reconnectButton = screen.getByRole('button', {
|
||||
name: 'Try again',
|
||||
})
|
||||
expect(reconnectButton).to.exist
|
||||
|
||||
// should now reconnect with placeholder message
|
||||
fireEvent.click(reconnectButton)
|
||||
await screen.findByText('Send your first message to your collaborators')
|
||||
})
|
||||
|
||||
it('a loading spinner is rendered while the messages are loading, then disappears', async function () {
|
||||
fetchMock.get(/messages/, [], { delay: 1000 })
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
// not displayed initially
|
||||
expect(screen.queryByText('Loading…')).to.not.exist
|
||||
|
||||
// eventually displayed
|
||||
await screen.findByText('Loading…')
|
||||
|
||||
// eventually removed when the fetch call returns
|
||||
await waitForElementToBeRemoved(() => screen.getByText('Loading…'))
|
||||
})
|
||||
|
||||
describe('"send your first message" placeholder', function () {
|
||||
it('is rendered when there are no messages ', async function () {
|
||||
fetchMock.get(/messages/, [])
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
await screen.findByText('Send your first message to your collaborators')
|
||||
})
|
||||
|
||||
it('is not rendered when messages are displayed', function () {
|
||||
fetchMock.get(/messages/, testMessages)
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
expect(
|
||||
screen.queryByText('Send your first message to your collaborators')
|
||||
).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,57 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import MessageInput from '../../../../../frontend/js/features/chat/components/message-input'
|
||||
|
||||
describe('<MessageInput />', function () {
|
||||
let resetUnreadMessages, sendMessage
|
||||
|
||||
beforeEach(function () {
|
||||
resetUnreadMessages = sinon.stub()
|
||||
sendMessage = sinon.stub()
|
||||
})
|
||||
|
||||
it('renders successfully', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByLabelText('Send a message to your collaborators')
|
||||
})
|
||||
|
||||
it('sends a message after typing and hitting enter', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'hello world' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(sendMessage).to.be.calledOnce
|
||||
expect(sendMessage).to.be.calledWith('hello world')
|
||||
})
|
||||
|
||||
it('resets the number of unread messages after clicking on the input', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
'Send a message to your collaborators…'
|
||||
)
|
||||
|
||||
fireEvent.click(input)
|
||||
expect(resetUnreadMessages).to.be.calledOnce
|
||||
})
|
||||
})
|
@@ -0,0 +1,119 @@
|
||||
import sinon from 'sinon'
|
||||
import { expect } from 'chai'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import MessageList from '../../../../../frontend/js/features/chat/components/message-list'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
import { UserProvider } from '@/shared/context/user-context'
|
||||
|
||||
describe('<MessageList />', function () {
|
||||
const currentUser = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
|
||||
function createMessages() {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
contents: ['another message'],
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
before(function () {
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
after(function () {
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
let olUser
|
||||
beforeEach(function () {
|
||||
olUser = window.metaAttributesCache.get('ol-user')
|
||||
window.metaAttributesCache.set('ol-user', currentUser)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', olUser)
|
||||
})
|
||||
|
||||
it('renders multiple messages', function () {
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
screen.getByText('a message')
|
||||
screen.getByText('another message')
|
||||
})
|
||||
|
||||
it('renders a single timestamp for all messages within 5 minutes', function () {
|
||||
const msgs = createMessages()
|
||||
msgs[0].timestamp = new Date(2019, 6, 3, 4, 23).getTime()
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 27).getTime()
|
||||
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={msgs}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
screen.getByText('4:23 am Wed, 3rd Jul 19')
|
||||
expect(screen.queryByText('4:27 am Wed, 3rd Jul 19')).to.not.exist
|
||||
})
|
||||
|
||||
it('renders a timestamp for each messages separated for more than 5 minutes', function () {
|
||||
const msgs = createMessages()
|
||||
msgs[0].timestamp = new Date(2019, 6, 3, 4, 23).getTime()
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 31).getTime()
|
||||
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={msgs}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
screen.getByText('4:23 am Wed, 3rd Jul 19')
|
||||
screen.getByText('4:31 am Wed, 3rd Jul 19')
|
||||
})
|
||||
|
||||
it('resets the number of unread messages after clicking on the input', function () {
|
||||
const resetUnreadMessages = sinon.stub()
|
||||
render(
|
||||
<UserProvider>
|
||||
<MessageList
|
||||
userId={currentUser.id}
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('list'))
|
||||
expect(resetUnreadMessages).to.be.calledOnce
|
||||
})
|
||||
})
|
@@ -0,0 +1,105 @@
|
||||
import { expect } from 'chai'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import Message from '../../../../../frontend/js/features/chat/components/message'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
|
||||
describe('<Message />', function () {
|
||||
const currentUser = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', currentUser)
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
it('renders a basic message', function () {
|
||||
const message = {
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByText('a message')
|
||||
})
|
||||
|
||||
it('renders a message with multiple contents', function () {
|
||||
const message = {
|
||||
contents: ['a message', 'another message'],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByText('a message')
|
||||
screen.getByText('another message')
|
||||
})
|
||||
|
||||
it('renders HTML links within messages', function () {
|
||||
const message = {
|
||||
contents: [
|
||||
'a message with a <a href="https://overleaf.com">link to Overleaf</a>',
|
||||
],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByRole('link', { name: 'https://overleaf.com' })
|
||||
})
|
||||
|
||||
describe('when the message is from the user themselves', function () {
|
||||
const message = {
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
}
|
||||
|
||||
it('does not render the user name nor the email', function () {
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
expect(screen.queryByText(currentUser.first_name)).to.not.exist
|
||||
expect(screen.queryByText(currentUser.email)).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the message is from other user', function () {
|
||||
const otherUser = {
|
||||
id: 'other_user',
|
||||
first_name: 'other_user_first_name',
|
||||
}
|
||||
|
||||
const message = {
|
||||
contents: ['a message'],
|
||||
user: otherUser,
|
||||
}
|
||||
|
||||
it('should render the other user name', function () {
|
||||
render(<Message message={message} />)
|
||||
|
||||
screen.getByText(otherUser.first_name)
|
||||
})
|
||||
|
||||
it('should render the other user email when their name is not available', function () {
|
||||
const msg = {
|
||||
contents: message.contents,
|
||||
user: {
|
||||
id: otherUser.id,
|
||||
email: 'other@example.com',
|
||||
},
|
||||
}
|
||||
|
||||
render(<Message message={msg} />)
|
||||
|
||||
expect(screen.queryByText(otherUser.first_name)).to.not.exist
|
||||
screen.getByText(msg.user.email)
|
||||
})
|
||||
})
|
||||
})
|
14
services/web/test/frontend/features/chat/components/stubs.js
Normal file
14
services/web/test/frontend/features/chat/components/stubs.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import sinon from 'sinon'
|
||||
|
||||
export function stubMathJax() {
|
||||
window.MathJax = {
|
||||
Hub: {
|
||||
Queue: sinon.stub(),
|
||||
config: { tex2jax: { inlineMath: [['$', '$']] } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function tearDownMathJaxStubs() {
|
||||
delete window.MathJax
|
||||
}
|
@@ -0,0 +1,614 @@
|
||||
// Disable prop type checks for test harnesses
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { renderHook, act } from '@testing-library/react-hooks/dom'
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import {
|
||||
useChatContext,
|
||||
chatClientIdGenerator,
|
||||
} from '@/features/chat/context/chat-context'
|
||||
import { cleanUpContext } from '../../../helpers/render-with-context'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from '../components/stubs'
|
||||
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
|
||||
describe('ChatContext', function () {
|
||||
const user = {
|
||||
id: 'fake_user',
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
const uuidValue = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
beforeEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
cleanUpContext()
|
||||
|
||||
stubMathJax()
|
||||
|
||||
window.metaAttributesCache.set('ol-user', user)
|
||||
window.metaAttributesCache.set('ol-chatEnabled', true)
|
||||
|
||||
this.stub = sinon.stub(chatClientIdGenerator, 'generate').returns(uuidValue)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tearDownMathJaxStubs()
|
||||
|
||||
this.stub.restore()
|
||||
})
|
||||
|
||||
describe('socket connection', function () {
|
||||
beforeEach(function () {
|
||||
// Mock GET messages to return no messages
|
||||
// FIXME?
|
||||
// fetchMock.get('express:/project/:projectId/messages', [])
|
||||
|
||||
// Mock POST new message to return 200
|
||||
fetchMock.post('express:/project/:projectId/messages', 200)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('subscribes when mounted', function () {
|
||||
const socket = new SocketIOMock()
|
||||
renderChatContextHook({ socket })
|
||||
expect(socket.countEventListeners('new-chat-message')).to.equal(1)
|
||||
})
|
||||
|
||||
it('unsubscribes when unmounted', function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { unmount } = renderChatContextHook({ socket })
|
||||
|
||||
unmount()
|
||||
|
||||
expect(socket.countEventListeners('new-chat-message')).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 SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
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.emitToClient('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('deduplicate messages from preloading', async function () {
|
||||
// Mock socket: we only need to emit events, not mock actual connections
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
fetchMock.get('express:/project/:projectId/messages', [
|
||||
{
|
||||
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',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// Mock message being received from another user
|
||||
socket.emitToClient('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',
|
||||
},
|
||||
})
|
||||
|
||||
// Check if received the message ID
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Check if there are no message duplication
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
const message = result.current.messages[0]
|
||||
expect(message.id).to.equal('msg_1')
|
||||
expect(message.contents).to.deep.equal(['new message'])
|
||||
})
|
||||
|
||||
it('deduplicate messages from websocket', async function () {
|
||||
// Mock socket: we only need to emit events, not mock actual connections
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
fetchMock.get('express:/project/:projectId/messages', [
|
||||
{
|
||||
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',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Check if received the message ID
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
// Mock message being received from another user
|
||||
socket.emitToClient('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',
|
||||
},
|
||||
})
|
||||
|
||||
// Check if there are no message duplication
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
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 SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Send a message from the current user
|
||||
const sentMsg = 'sent message'
|
||||
result.current.sendMessage(sentMsg)
|
||||
|
||||
act(() => {
|
||||
// Receive a message from the current user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'received message',
|
||||
timestamp: Date.now(),
|
||||
user,
|
||||
clientId: uuidValue,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.messages).to.have.length(1)
|
||||
|
||||
const [message] = result.current.messages
|
||||
|
||||
expect(message.contents).to.deep.equal([sentMsg])
|
||||
})
|
||||
|
||||
it('adds the new message from the current user if another message was received after sending', async function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
// Send a message from the current user
|
||||
const sentMsg = 'sent message from current user'
|
||||
result.current.sendMessage(sentMsg)
|
||||
|
||||
const [sentMessageFromCurrentUser] = result.current.messages
|
||||
expect(sentMessageFromCurrentUser.contents).to.deep.equal([sentMsg])
|
||||
|
||||
const otherMsg = 'new message from other user'
|
||||
|
||||
act(() => {
|
||||
// Receive a message from another user.
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: otherMsg,
|
||||
timestamp: Date.now(),
|
||||
user: {
|
||||
id: 'another_fake_user',
|
||||
first_name: 'another_fake_user_first_name',
|
||||
email: 'another_fake@example.com',
|
||||
},
|
||||
clientId: '123',
|
||||
})
|
||||
})
|
||||
|
||||
const [, messageFromOtherUser] = result.current.messages
|
||||
expect(messageFromOtherUser.contents).to.deep.equal([otherMsg])
|
||||
|
||||
act(() => {
|
||||
// Receive a message from the current user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_2',
|
||||
content: 'received message from current user',
|
||||
timestamp: Date.now(),
|
||||
user,
|
||||
clientId: uuidValue,
|
||||
})
|
||||
})
|
||||
|
||||
// Since the current user didn't just send a message, it is now shown
|
||||
expect(result.current.messages).to.deep.equal([
|
||||
sentMessageFromCurrentUser,
|
||||
messageFromOtherUser,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
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({})
|
||||
|
||||
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({})
|
||||
|
||||
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.callHistory.calls('express:/project/:projectId/messages')
|
||||
).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it('provides an error on failure', async function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock.get('express:/project/:projectId/messages', 500)
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadInitialMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.error).to.exist
|
||||
expect(result.current.status).to.equal('error')
|
||||
})
|
||||
})
|
||||
|
||||
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({})
|
||||
|
||||
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(),
|
||||
},
|
||||
])
|
||||
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
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({})
|
||||
|
||||
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.callHistory.calls('express:/project/:projectId/messages')
|
||||
).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 SocketIOMock()
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({
|
||||
socket,
|
||||
})
|
||||
|
||||
// Start loading messages
|
||||
result.current.loadMoreMessages()
|
||||
|
||||
// Mock message being received from the socket while the request is in
|
||||
// flight
|
||||
socket.emitToClient('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',
|
||||
])
|
||||
})
|
||||
|
||||
it('provides an error on failures', async function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock.get('express:/project/:projectId/messages', 500)
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.error).to.exist
|
||||
expect(result.current.status).to.equal('error')
|
||||
})
|
||||
})
|
||||
|
||||
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({})
|
||||
|
||||
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({})
|
||||
|
||||
result.current.sendMessage('sent message')
|
||||
|
||||
const {
|
||||
options: { body },
|
||||
} = fetchMock.callHistory
|
||||
.calls('express:/project/:projectId/messages', { method: 'POST' })
|
||||
.at(-1)
|
||||
expect(JSON.parse(body)).to.deep.include({ content: 'sent message' })
|
||||
})
|
||||
|
||||
it("doesn't send if the content is empty", function () {
|
||||
const { result } = renderChatContextHook({})
|
||||
|
||||
result.current.sendMessage('')
|
||||
|
||||
expect(result.current.messages).to.be.empty
|
||||
expect(
|
||||
fetchMock.callHistory.called('express:/project/:projectId/messages', {
|
||||
method: 'post',
|
||||
})
|
||||
).to.be.false
|
||||
})
|
||||
|
||||
it('provides an error on failure', async function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
fetchMock
|
||||
.get('express:/project/:projectId/messages', [])
|
||||
.postOnce('express:/project/:projectId/messages', 500)
|
||||
const { result, waitForNextUpdate } = renderChatContextHook({})
|
||||
|
||||
result.current.sendMessage('sent message')
|
||||
await waitForNextUpdate()
|
||||
|
||||
expect(result.current.error).to.exist
|
||||
expect(result.current.status).to.equal('error')
|
||||
})
|
||||
})
|
||||
|
||||
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 SocketIOMock()
|
||||
const { result } = renderChatContextHook({ socket })
|
||||
|
||||
// Receive a new message from the socket
|
||||
socket.emitToClient('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 SocketIOMock()
|
||||
const { result } = renderChatContextHook({ socket })
|
||||
|
||||
// Receive a new message from the socket, incrementing unreadMessageCount
|
||||
// by 1
|
||||
socket.emitToClient('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 }) => (
|
||||
<EditorProviders {...props}>{children}</EditorProviders>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
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.callHistory.calls().at(-1)
|
||||
const { searchParams } = new URL(url, 'https://www.overleaf.com')
|
||||
return searchParams.get(key)
|
||||
}
|
@@ -0,0 +1,264 @@
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
appendMessage,
|
||||
prependMessages,
|
||||
} from '../../../../../frontend/js/features/chat/utils/message-list-appender'
|
||||
|
||||
const testUser = {
|
||||
id: '123abc',
|
||||
}
|
||||
|
||||
const otherUser = {
|
||||
id: '234other',
|
||||
}
|
||||
|
||||
function createTestMessageList() {
|
||||
return [
|
||||
{
|
||||
id: 'msg_1',
|
||||
contents: ['hello', 'world'],
|
||||
timestamp: new Date().getTime(),
|
||||
user: otherUser,
|
||||
},
|
||||
{
|
||||
id: 'msg_2',
|
||||
contents: ['foo'],
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
describe('prependMessages()', function () {
|
||||
function createTestMessages() {
|
||||
const message1 = {
|
||||
id: 'prepended_message',
|
||||
content: 'hello',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
}
|
||||
const message2 = { ...message1, id: 'prepended_message_2' }
|
||||
return [message1, message2]
|
||||
}
|
||||
|
||||
it('to an empty list', function () {
|
||||
const messages = createTestMessages()
|
||||
const uniqueMessageIds = []
|
||||
|
||||
expect(
|
||||
prependMessages([], messages, uniqueMessageIds).messages
|
||||
).to.deep.equal([
|
||||
{
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from the same user', function () {
|
||||
let list, messages, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
messages[0].user = testUser // makes all the messages have the same author
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('when the prepended messages are close in time, contents should be merged into the same message', function () {
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content],
|
||||
})
|
||||
})
|
||||
|
||||
it('when the prepended messages are separated in time, each message is prepended', function () {
|
||||
messages[0].timestamp = messages[1].timestamp - 6 * 60 * 1000 // 6 minutes before the next message
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content],
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
contents: [messages[1].content],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from different users', function () {
|
||||
let list, messages, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('should prepend separate messages to the list', function () {
|
||||
messages[0].user = otherUser
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content],
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
contents: [messages[1].content],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should merge the prepended messages into the first existing one when user is same user and are close in time', function () {
|
||||
const list = createTestMessageList()
|
||||
const messages = createTestMessages()
|
||||
messages[0].user = messages[1].user = list[0].user
|
||||
const uniqueMessageIds = []
|
||||
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content, ...list[0].contents],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendMessage()', function () {
|
||||
function createTestMessage() {
|
||||
return {
|
||||
id: 'appended_message',
|
||||
content: 'hi!',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
}
|
||||
}
|
||||
|
||||
it('to an empty list', function () {
|
||||
const testMessage = createTestMessage()
|
||||
const uniqueMessageIds = []
|
||||
|
||||
expect(
|
||||
appendMessage([], testMessage, uniqueMessageIds).messages
|
||||
).to.deep.equal([
|
||||
{
|
||||
id: 'appended_message',
|
||||
timestamp: testMessage.timestamp,
|
||||
user: testMessage.user,
|
||||
contents: [testMessage.content],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('messages appended shortly after the last message on the list', function () {
|
||||
let list, message, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 1000 // 6 seconds after the last message in the list
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
describe('when the author is the same as the last message', function () {
|
||||
it('should append the content to the last message', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(list.length)
|
||||
expect(result[1].contents).to.deep.equal(
|
||||
list[1].contents.concat(message.content)
|
||||
)
|
||||
})
|
||||
|
||||
it('should update the last message timestamp', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result[1].timestamp).to.equal(message.timestamp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the author is different than the last message', function () {
|
||||
beforeEach(function () {
|
||||
message.user = otherUser
|
||||
})
|
||||
|
||||
it('should append the new message to the list', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('messages appended later after the last message on the list', function () {
|
||||
let list, message, uniqueMessageIds
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 60 * 1000 // 6 minutes after the last message in the list
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('when the author is the same as the last message, should be appended as new message', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
|
||||
it('when the author is the different than the last message, should be appended as new message', function () {
|
||||
message.user = otherUser
|
||||
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
Reference in New Issue
Block a user