chat-context.test.jsx 19 KB

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