chat-context.test.jsx 19 KB

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