chat-context.test.jsx 19 KB

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