first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -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
})
})
})

View File

@@ -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
})
})

View File

@@ -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
})
})

View File

@@ -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)
})
})
})

View 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
}

View File

@@ -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)
}

View File

@@ -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],
})
})
})
})