chat-context.test.js 15 KB

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