chat-context.test.js 18 KB

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