chat-context.test.jsx 19 KB

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