chat-context.test.jsx 19 KB

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