chat-context.test.tsx 20 KB

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