chat-context.test.jsx 19 KB

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