chat-context.test.jsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. // Disable prop type checks for test harnesses
  2. /* eslint-disable react/prop-types */
  3. import { renderHook, act, waitFor } from '@testing-library/react'
  4. import { expect } from 'chai'
  5. import sinon from 'sinon'
  6. import fetchMock from 'fetch-mock'
  7. import {
  8. useChatContext,
  9. chatClientIdGenerator,
  10. } from '@/features/chat/context/chat-context'
  11. import { stubMathJax, tearDownMathJaxStubs } from '../components/stubs'
  12. import { SocketIOMock } from '@/ide/connection/SocketIoShim'
  13. import { EditorProviders } from '../../../helpers/editor-providers'
  14. describe('ChatContext', function () {
  15. const user = {
  16. id: 'fake_user',
  17. first_name: 'fake_user_first_name',
  18. email: 'fake@example.com',
  19. }
  20. const uuidValue = '00000000-0000-0000-0000-000000000000'
  21. beforeEach(function () {
  22. fetchMock.removeRoutes().clearHistory()
  23. stubMathJax()
  24. window.metaAttributesCache.set('ol-user', user)
  25. window.metaAttributesCache.set('ol-chatEnabled', true)
  26. window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
  27. this.stub = sinon.stub(chatClientIdGenerator, 'generate').returns(uuidValue)
  28. })
  29. afterEach(function () {
  30. tearDownMathJaxStubs()
  31. this.stub.restore()
  32. })
  33. describe('socket connection', function () {
  34. beforeEach(function () {
  35. // Mock GET messages to return no messages
  36. fetchMock.get('express:/project/:projectId/messages', [], {
  37. name: 'fetchMessages',
  38. })
  39. // Mock POST new message to return 200
  40. fetchMock.post('express:/project/:projectId/messages', 200)
  41. })
  42. afterEach(function () {
  43. fetchMock.removeRoutes().clearHistory()
  44. })
  45. it('subscribes when mounted', function () {
  46. const socket = new SocketIOMock()
  47. renderChatContextHook({ socket })
  48. expect(socket.countEventListeners('new-chat-message')).to.equal(1)
  49. })
  50. it('unsubscribes when unmounted', function () {
  51. const socket = new SocketIOMock()
  52. const { unmount } = renderChatContextHook({ socket })
  53. unmount()
  54. expect(socket.countEventListeners('new-chat-message')).to.equal(0)
  55. })
  56. it('adds received messages to the list', async function () {
  57. // Mock socket: we only need to emit events, not mock actual connections
  58. const socket = new SocketIOMock()
  59. const { result } = renderChatContextHook({
  60. socket,
  61. })
  62. // Wait until initial messages have loaded
  63. result.current.loadInitialMessages()
  64. await waitFor(
  65. () => expect(result.current.initialMessagesLoaded).to.be.true
  66. )
  67. // No messages shown at first
  68. expect(result.current.messages).to.deep.equal([])
  69. // Mock message being received from another user
  70. socket.emitToClient('new-chat-message', {
  71. id: 'msg_1',
  72. content: 'new message',
  73. timestamp: Date.now(),
  74. user: {
  75. id: 'another_fake_user',
  76. first_name: 'another_fake_user_first_name',
  77. email: 'another_fake@example.com',
  78. },
  79. })
  80. await waitFor(() => {
  81. const message = result.current.messages[0]
  82. expect(message.id).to.equal('msg_1')
  83. expect(message.contents).to.deep.equal(['new message'])
  84. })
  85. })
  86. it('deduplicate messages from preloading', async function () {
  87. // Mock socket: we only need to emit events, not mock actual connections
  88. const socket = new SocketIOMock()
  89. const { result } = renderChatContextHook({
  90. socket,
  91. })
  92. fetchMock.modifyRoute('fetchMessages', {
  93. response: () => [
  94. {
  95. id: 'msg_1',
  96. content: 'new message',
  97. timestamp: Date.now(),
  98. user: {
  99. id: 'another_fake_user',
  100. first_name: 'another_fake_user_first_name',
  101. email: 'another_fake@example.com',
  102. },
  103. },
  104. ],
  105. })
  106. // Mock message being received from another user
  107. socket.emitToClient('new-chat-message', {
  108. id: 'msg_1',
  109. content: 'new message',
  110. timestamp: Date.now(),
  111. user: {
  112. id: 'another_fake_user',
  113. first_name: 'another_fake_user_first_name',
  114. email: 'another_fake@example.com',
  115. },
  116. })
  117. // Check if received the message ID
  118. await waitFor(() => expect(result.current.messages).to.have.length(1))
  119. // Wait until initial messages have loaded
  120. result.current.loadInitialMessages()
  121. await waitFor(
  122. () => expect(result.current.initialMessagesLoaded).to.be.true
  123. )
  124. // Check if there are no message duplication
  125. expect(result.current.messages).to.have.length(1)
  126. const message = result.current.messages[0]
  127. expect(message.id).to.equal('msg_1')
  128. expect(message.contents).to.deep.equal(['new message'])
  129. })
  130. it('deduplicate messages from websocket', async function () {
  131. // Mock socket: we only need to emit events, not mock actual connections
  132. const socket = new SocketIOMock()
  133. const { result } = renderChatContextHook({
  134. socket,
  135. })
  136. fetchMock.modifyRoute('fetchMessages', {
  137. response: [
  138. {
  139. id: 'msg_1',
  140. content: 'new message',
  141. timestamp: Date.now(),
  142. user: {
  143. id: 'another_fake_user',
  144. first_name: 'another_fake_user_first_name',
  145. email: 'another_fake@example.com',
  146. },
  147. },
  148. ],
  149. })
  150. // Wait until initial messages have loaded
  151. result.current.loadInitialMessages()
  152. // Check if received the message ID
  153. await waitFor(() => expect(result.current.messages).to.have.length(1))
  154. // Mock message being received from another user
  155. socket.emitToClient('new-chat-message', {
  156. id: 'msg_1',
  157. content: 'new message',
  158. timestamp: Date.now(),
  159. user: {
  160. id: 'another_fake_user',
  161. first_name: 'another_fake_user_first_name',
  162. email: 'another_fake@example.com',
  163. },
  164. })
  165. // Check if there are no message duplication
  166. expect(result.current.messages).to.have.length(1)
  167. const message = result.current.messages[0]
  168. expect(message.id).to.equal('msg_1')
  169. expect(message.contents).to.deep.equal(['new message'])
  170. })
  171. it("doesn't add received messages from the current user if a message was just sent", async function () {
  172. const socket = new SocketIOMock()
  173. const { result } = renderChatContextHook({
  174. socket,
  175. })
  176. // Wait until initial messages have loaded
  177. result.current.loadInitialMessages()
  178. await waitFor(
  179. () => expect(result.current.initialMessagesLoaded).to.be.true
  180. )
  181. // Send a message from the current user
  182. const sentMsg = 'sent message'
  183. result.current.sendMessage(sentMsg)
  184. act(() => {
  185. // Receive a message from the current user
  186. socket.emitToClient('new-chat-message', {
  187. id: 'msg_1',
  188. content: 'received message',
  189. timestamp: Date.now(),
  190. user,
  191. clientId: uuidValue,
  192. })
  193. })
  194. await waitFor(() => expect(result.current.messages).to.have.length(1))
  195. const [message] = result.current.messages
  196. expect(message.contents).to.deep.equal([sentMsg])
  197. })
  198. it('adds the new message from the current user if another message was received after sending', async function () {
  199. const socket = new SocketIOMock()
  200. const { result } = renderChatContextHook({
  201. socket,
  202. })
  203. // Wait until initial messages have loaded
  204. result.current.loadInitialMessages()
  205. await waitFor(
  206. () => expect(result.current.initialMessagesLoaded).to.be.true
  207. )
  208. // Send a message from the current user
  209. const sentMsg = 'sent message from current user'
  210. act(() => {
  211. result.current.sendMessage(sentMsg)
  212. })
  213. const [sentMessageFromCurrentUser] = result.current.messages
  214. expect(sentMessageFromCurrentUser.contents).to.deep.equal([sentMsg])
  215. const otherMsg = 'new message from other user'
  216. act(() => {
  217. // Receive a message from another user.
  218. socket.emitToClient('new-chat-message', {
  219. id: 'msg_1',
  220. content: otherMsg,
  221. timestamp: Date.now(),
  222. user: {
  223. id: 'another_fake_user',
  224. first_name: 'another_fake_user_first_name',
  225. email: 'another_fake@example.com',
  226. },
  227. clientId: '123',
  228. })
  229. })
  230. const [, messageFromOtherUser] = result.current.messages
  231. expect(messageFromOtherUser.contents).to.deep.equal([otherMsg])
  232. act(() => {
  233. // Receive a message from the current user
  234. socket.emitToClient('new-chat-message', {
  235. id: 'msg_2',
  236. content: 'received message from current user',
  237. timestamp: Date.now(),
  238. user,
  239. clientId: uuidValue,
  240. })
  241. })
  242. // Since the current user didn't just send a message, it is now shown
  243. expect(result.current.messages).to.deep.equal([
  244. sentMessageFromCurrentUser,
  245. messageFromOtherUser,
  246. ])
  247. })
  248. })
  249. describe('loadInitialMessages', function () {
  250. beforeEach(function () {
  251. fetchMock.get('express:/project/:projectId/messages', [
  252. {
  253. id: 'msg_1',
  254. content: 'a message',
  255. user,
  256. timestamp: Date.now(),
  257. },
  258. ])
  259. })
  260. it('adds messages to the list', async function () {
  261. const { result } = renderChatContextHook({})
  262. result.current.loadInitialMessages()
  263. await waitFor(() =>
  264. expect(result.current.messages[0].contents).to.deep.equal(['a message'])
  265. )
  266. })
  267. it("won't load messages a second time", async function () {
  268. const { result } = renderChatContextHook({})
  269. result.current.loadInitialMessages()
  270. await waitFor(() =>
  271. expect(result.current.initialMessagesLoaded).to.equal(true)
  272. )
  273. // Calling a second time won't do anything
  274. result.current.loadInitialMessages()
  275. await waitFor(
  276. () => expect(result.current.initialMessagesLoaded).to.be.true
  277. )
  278. expect(
  279. fetchMock.callHistory.calls('express:/project/:projectId/messages')
  280. ).to.have.lengthOf(1)
  281. })
  282. it('provides an error on failure', async function () {
  283. fetchMock.removeRoutes().clearHistory()
  284. fetchMock.get('express:/project/:projectId/messages', 500)
  285. const { result } = renderChatContextHook({})
  286. result.current.loadInitialMessages()
  287. await waitFor(() => expect(result.current.error).to.exist)
  288. expect(result.current.status).to.equal('error')
  289. })
  290. })
  291. describe('loadMoreMessages', function () {
  292. it('adds messages to the list', async function () {
  293. // Mock a GET request for an initial message
  294. fetchMock.getOnce('express:/project/:projectId/messages', [
  295. {
  296. id: 'msg_1',
  297. content: 'first message',
  298. user,
  299. timestamp: new Date('2021-03-04T10:00:00').getTime(),
  300. },
  301. ])
  302. const { result } = renderChatContextHook({})
  303. result.current.loadMoreMessages()
  304. await waitFor(() =>
  305. expect(result.current.messages[0].contents).to.deep.equal([
  306. 'first message',
  307. ])
  308. )
  309. // The before query param is not set
  310. expect(getLastFetchMockQueryParam('before')).to.be.null
  311. })
  312. it('adds more messages if called a second time', async function () {
  313. // Mock 2 GET requests, with different content
  314. fetchMock
  315. .getOnce(
  316. 'express:/project/:projectId/messages',
  317. // Resolve a full "page" of messages (50)
  318. createMessages(50, user, new Date('2021-03-04T10:00:00').getTime())
  319. )
  320. .getOnce('express:/project/:projectId/messages', [
  321. {
  322. id: 'msg_51',
  323. content: 'message from second page',
  324. user,
  325. timestamp: new Date('2021-03-04T11:00:00').getTime(),
  326. },
  327. ])
  328. const { result } = renderChatContextHook({})
  329. result.current.loadMoreMessages()
  330. await waitFor(() =>
  331. expect(result.current.messages[0].contents).to.have.length(50)
  332. )
  333. // Call a second time
  334. result.current.loadMoreMessages()
  335. // The second request is added to the list
  336. // Since both messages from the same user, they are collapsed into the
  337. // same "message"
  338. await waitFor(() =>
  339. expect(result.current.messages[0].contents).to.include(
  340. 'message from second page'
  341. )
  342. )
  343. // The before query param for the second request matches the timestamp
  344. // of the first message
  345. const beforeParam = parseInt(getLastFetchMockQueryParam('before'), 10)
  346. expect(beforeParam).to.equal(new Date('2021-03-04T10:00:00').getTime())
  347. })
  348. it("won't load more messages if there are no more messages", async function () {
  349. // Mock a GET request for 49 messages. This is less than the full page
  350. // size (50 messages), meaning that there are no further messages to be
  351. // loaded
  352. fetchMock.getOnce(
  353. 'express:/project/:projectId/messages',
  354. createMessages(49, user)
  355. )
  356. const { result } = renderChatContextHook({})
  357. result.current.loadMoreMessages()
  358. await waitFor(() =>
  359. expect(result.current.messages[0].contents).to.have.length(49)
  360. )
  361. result.current.loadMoreMessages()
  362. await waitFor(() => expect(result.current.atEnd).to.be.true)
  363. expect(
  364. fetchMock.callHistory.calls('express:/project/:projectId/messages')
  365. ).to.have.lengthOf(1)
  366. })
  367. it('handles socket messages while loading', async function () {
  368. // Mock GET messages so that we can control when the promise is resolved
  369. let resolveLoadingMessages
  370. fetchMock.get(
  371. 'express:/project/:projectId/messages',
  372. new Promise(resolve => {
  373. resolveLoadingMessages = resolve
  374. })
  375. )
  376. const socket = new SocketIOMock()
  377. const { result } = renderChatContextHook({
  378. socket,
  379. })
  380. // Start loading messages
  381. result.current.loadMoreMessages()
  382. // Mock message being received from the socket while the request is in
  383. // flight
  384. socket.emitToClient('new-chat-message', {
  385. id: 'socket_msg',
  386. content: 'socket message',
  387. timestamp: Date.now(),
  388. user: {
  389. id: 'another_fake_user',
  390. first_name: 'another_fake_user_first_name',
  391. email: 'another_fake@example.com',
  392. },
  393. })
  394. // Resolve messages being loaded
  395. resolveLoadingMessages([
  396. {
  397. id: 'fetched_msg',
  398. content: 'loaded message',
  399. user,
  400. timestamp: Date.now(),
  401. },
  402. ])
  403. await waitFor(() => {
  404. // Although the loaded message was resolved last, it appears first (since
  405. // requested messages must have come first)
  406. const messageContents = result.current.messages.map(
  407. ({ contents }) => contents[0]
  408. )
  409. expect(messageContents).to.deep.equal([
  410. 'loaded message',
  411. 'socket message',
  412. ])
  413. })
  414. })
  415. it('provides an error on failures', async function () {
  416. fetchMock.removeRoutes().clearHistory()
  417. fetchMock.get('express:/project/:projectId/messages', 500)
  418. const { result } = renderChatContextHook({})
  419. result.current.loadMoreMessages()
  420. await waitFor(() => expect(result.current.error).to.exist)
  421. expect(result.current.status).to.equal('error')
  422. })
  423. })
  424. describe('sendMessage', function () {
  425. beforeEach(function () {
  426. // Mock GET messages to return no messages and POST new message to be
  427. // successful
  428. fetchMock
  429. .get('express:/project/:projectId/messages', [])
  430. .postOnce('express:/project/:projectId/messages', 200)
  431. })
  432. it('optimistically adds the message to the list', async function () {
  433. const { result } = renderChatContextHook({})
  434. result.current.sendMessage('sent message')
  435. await waitFor(() =>
  436. expect(result.current.messages[0].contents).to.deep.equal([
  437. 'sent message',
  438. ])
  439. )
  440. })
  441. it('POSTs the message to the backend', function () {
  442. const { result } = renderChatContextHook({})
  443. result.current.sendMessage('sent message')
  444. const {
  445. options: { body },
  446. } = fetchMock.callHistory
  447. .calls('express:/project/:projectId/messages', { method: 'POST' })
  448. .at(-1)
  449. expect(JSON.parse(body)).to.deep.include({ content: 'sent message' })
  450. })
  451. it("doesn't send if the content is empty", function () {
  452. const { result } = renderChatContextHook({})
  453. result.current.sendMessage('')
  454. expect(result.current.messages).to.be.empty
  455. expect(
  456. fetchMock.callHistory.called('express:/project/:projectId/messages', {
  457. method: 'post',
  458. })
  459. ).to.be.false
  460. })
  461. it('provides an error on failure', async function () {
  462. fetchMock.removeRoutes().clearHistory()
  463. fetchMock
  464. .get('express:/project/:projectId/messages', [])
  465. .postOnce('express:/project/:projectId/messages', 500)
  466. const { result } = renderChatContextHook({})
  467. result.current.sendMessage('sent message')
  468. await waitFor(() => expect(result.current.error).to.exist)
  469. expect(result.current.status).to.equal('error')
  470. })
  471. })
  472. describe('unread messages', function () {
  473. beforeEach(function () {
  474. // Mock GET messages to return no messages
  475. fetchMock.get('express:/project/:projectId/messages', [])
  476. })
  477. it('increments unreadMessageCount when a new message is received', function () {
  478. const socket = new SocketIOMock()
  479. const { result } = renderChatContextHook({ socket })
  480. // Receive a new message from the socket
  481. act(() => {
  482. socket.emitToClient('new-chat-message', {
  483. id: 'msg_1',
  484. content: 'new message',
  485. timestamp: Date.now(),
  486. user,
  487. })
  488. })
  489. expect(result.current.unreadMessageCount).to.equal(1)
  490. })
  491. it('resets unreadMessageCount when markMessagesAsRead is called', function () {
  492. const socket = new SocketIOMock()
  493. const { result } = renderChatContextHook({ socket })
  494. // Receive a new message from the socket, incrementing unreadMessageCount
  495. // by 1
  496. socket.emitToClient('new-chat-message', {
  497. id: 'msg_1',
  498. content: 'new message',
  499. timestamp: Date.now(),
  500. user,
  501. })
  502. result.current.markMessagesAsRead()
  503. expect(result.current.unreadMessageCount).to.equal(0)
  504. })
  505. })
  506. })
  507. function renderChatContextHook(props) {
  508. return renderHook(() => useChatContext(), {
  509. // Wrap with ChatContext.Provider (and the other editor context providers)
  510. // eslint-disable-next-line react/display-name
  511. wrapper: ({ children }) => (
  512. <EditorProviders {...props}>{children}</EditorProviders>
  513. ),
  514. })
  515. }
  516. function createMessages(number, user, timestamp = Date.now()) {
  517. return Array.from({ length: number }, (_m, idx) => ({
  518. id: `msg_${idx + 1}`,
  519. content: `message ${idx + 1}`,
  520. user,
  521. timestamp,
  522. }))
  523. }
  524. /*
  525. * Get query param by key from the last fetchMock response
  526. */
  527. function getLastFetchMockQueryParam(key) {
  528. const { url } = fetchMock.callHistory.calls().at(-1)
  529. const { searchParams } = new URL(url, 'https://www.overleaf.com')
  530. return searchParams.get(key)
  531. }