chat-context.test.js 15 KB

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