chat-context.test.jsx 19 KB

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