chat-context.test.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743
  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.content).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.content).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.content).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 content = 'sent message'
  192. result.current.sendMessage(content)
  193. act(() => {
  194. // Receive a message from the current user
  195. socket.emitToClient('new-chat-message', {
  196. id: 'msg_1',
  197. content,
  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.content).to.deep.equal(content)
  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 content = 'sent message from current user'
  219. act(() => {
  220. result.current.sendMessage(content)
  221. })
  222. const [sentMessageFromCurrentUser] = result.current.messages
  223. expect(sentMessageFromCurrentUser.content).to.deep.equal(content)
  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.content).to.deep.equal(otherMsg)
  241. const receivedMessageTimestamp = Date.now()
  242. act(() => {
  243. // Receive a message from the current user
  244. socket.emitToClient('new-chat-message', {
  245. id: 'msg_2',
  246. content,
  247. timestamp: receivedMessageTimestamp,
  248. user,
  249. clientId: uuidValue,
  250. })
  251. })
  252. // Since this message has the same clientId, it should update the pending message
  253. const updatedSentMessage = {
  254. ...sentMessageFromCurrentUser,
  255. id: 'msg_2',
  256. content,
  257. pending: false,
  258. user,
  259. timestamp: receivedMessageTimestamp,
  260. }
  261. expect(result.current.messages).to.deep.equal([
  262. updatedSentMessage,
  263. messageFromOtherUser,
  264. ])
  265. })
  266. it('handles multiple pending messages correctly when confirmed out of order', async function () {
  267. const socket = new SocketIOMock()
  268. const { result } = renderChatContextHook({
  269. socket: socket as any as Socket,
  270. })
  271. // Wait until initial messages have loaded
  272. result.current.loadInitialMessages()
  273. await waitFor(
  274. () => expect(result.current.initialMessagesLoaded).to.be.true
  275. )
  276. // Send first message
  277. act(() => {
  278. result.current.sendMessage('first message')
  279. })
  280. // Send second message quickly
  281. act(() => {
  282. result.current.sendMessage('second message')
  283. })
  284. // At this point we should have 2 pending messages
  285. expect(result.current.messages).to.have.length(2)
  286. expect(result.current.messages[0].content).to.equal('first message')
  287. expect(result.current.messages[0].pending).to.be.true
  288. expect(result.current.messages[1].content).to.equal('second message')
  289. expect(result.current.messages[1].pending).to.be.true
  290. // Server confirms the second message first
  291. const secondMessageTimestamp = Date.now()
  292. act(() => {
  293. socket.emitToClient('new-chat-message', {
  294. id: 'server-id-2',
  295. content: 'second message',
  296. user,
  297. timestamp: secondMessageTimestamp,
  298. clientId: uuidValue,
  299. })
  300. })
  301. await waitFor(() => {
  302. expect(result.current.messages[0].content).to.equal('first message')
  303. expect(result.current.messages[0].pending).to.be.true // Still pending
  304. expect(result.current.messages[1].content).to.equal('second message')
  305. expect(result.current.messages[1].pending).to.be.false // Confirmed
  306. expect(result.current.messages[1].id).to.equal('server-id-2')
  307. })
  308. // Server confirms the first message
  309. const firstMessageTimestamp = Date.now()
  310. act(() => {
  311. socket.emitToClient('new-chat-message', {
  312. id: 'server-id-1',
  313. content: 'first message',
  314. user,
  315. timestamp: firstMessageTimestamp,
  316. clientId: uuidValue,
  317. })
  318. })
  319. await waitFor(() => {
  320. expect(result.current.messages[0].content).to.equal('first message')
  321. expect(result.current.messages[0].pending).to.be.false // Now confirmed
  322. expect(result.current.messages[0].id).to.equal('server-id-1')
  323. expect(result.current.messages[1].content).to.equal('second message')
  324. expect(result.current.messages[1].pending).to.be.false // Still confirmed
  325. expect(result.current.messages[1].id).to.equal('server-id-2')
  326. })
  327. })
  328. })
  329. describe('loadInitialMessages', function () {
  330. beforeEach(function () {
  331. fetchMock.get('express:/project/:projectId/messages', [
  332. {
  333. id: 'msg_1',
  334. content: 'a message',
  335. user,
  336. timestamp: Date.now(),
  337. },
  338. ])
  339. })
  340. it('adds messages to the list', async function () {
  341. const { result } = renderChatContextHook({})
  342. result.current.loadInitialMessages()
  343. await waitFor(() =>
  344. expect(result.current.messages[0].content).to.deep.equal('a message')
  345. )
  346. })
  347. it("won't load messages a second time", async function () {
  348. const { result } = renderChatContextHook({})
  349. result.current.loadInitialMessages()
  350. await waitFor(() =>
  351. expect(result.current.initialMessagesLoaded).to.equal(true)
  352. )
  353. // Calling a second time won't do anything
  354. result.current.loadInitialMessages()
  355. await waitFor(
  356. () => expect(result.current.initialMessagesLoaded).to.be.true
  357. )
  358. expect(
  359. fetchMock.callHistory.calls('express:/project/:projectId/messages')
  360. ).to.have.lengthOf(1)
  361. })
  362. it('provides an error on failure', async function () {
  363. fetchMock.removeRoutes().clearHistory()
  364. fetchMock.get('express:/project/:projectId/messages', 500)
  365. const { result } = renderChatContextHook({})
  366. result.current.loadInitialMessages()
  367. await waitFor(() => expect(result.current.error).to.exist)
  368. expect(result.current.status).to.equal('error')
  369. })
  370. })
  371. describe('loadMoreMessages', function () {
  372. it('adds messages to the list', async function () {
  373. // Mock a GET request for an initial message
  374. fetchMock.getOnce('express:/project/:projectId/messages', [
  375. {
  376. id: 'msg_1',
  377. content: 'first message',
  378. user,
  379. timestamp: new Date('2021-03-04T10:00:00').getTime(),
  380. },
  381. ])
  382. const { result } = renderChatContextHook({})
  383. result.current.loadMoreMessages()
  384. await waitFor(() =>
  385. expect(result.current.messages[0].content).to.deep.equal(
  386. 'first message'
  387. )
  388. )
  389. // The before query param is not set
  390. expect(getLastFetchMockQueryParam('before')).to.be.null
  391. })
  392. it('adds more messages if called a second time', async function () {
  393. // Mock 2 GET requests, with different content
  394. fetchMock
  395. .getOnce(
  396. 'express:/project/:projectId/messages',
  397. // Resolve a full "page" of messages (50)
  398. createMessages(50, user, new Date('2021-03-04T10:00:00').getTime())
  399. )
  400. .getOnce('express:/project/:projectId/messages', [
  401. {
  402. id: 'msg_51',
  403. content: 'message from second page',
  404. user,
  405. timestamp: new Date('2021-03-04T11:00:00').getTime(),
  406. },
  407. ])
  408. const { result } = renderChatContextHook({})
  409. result.current.loadMoreMessages()
  410. await waitFor(() => expect(result.current.messages).to.have.length(50))
  411. // Call a second time
  412. result.current.loadMoreMessages()
  413. // The second request is added to the list
  414. // Since both messages from the same user, they are collapsed into the
  415. // same "message"
  416. await waitFor(() =>
  417. expect(result.current.messages[0].content).to.include(
  418. 'message from second page'
  419. )
  420. )
  421. // The before query param for the second request matches the timestamp
  422. // of the first message
  423. const beforeParamRaw = getLastFetchMockQueryParam('before')
  424. expect(beforeParamRaw).to.not.be.null
  425. const beforeParam = parseInt(beforeParamRaw!, 10)
  426. expect(beforeParam).to.equal(new Date('2021-03-04T10:00:00').getTime())
  427. })
  428. it("won't load more messages if there are no more messages", async function () {
  429. // Mock a GET request for 49 messages. This is less than the full page
  430. // size (50 messages), meaning that there are no further messages to be
  431. // loaded
  432. fetchMock.getOnce(
  433. 'express:/project/:projectId/messages',
  434. createMessages(49, user)
  435. )
  436. const { result } = renderChatContextHook({})
  437. result.current.loadMoreMessages()
  438. await waitFor(() => expect(result.current.messages).to.have.length(49))
  439. result.current.loadMoreMessages()
  440. await waitFor(() => expect(result.current.atEnd).to.be.true)
  441. expect(
  442. fetchMock.callHistory.calls('express:/project/:projectId/messages')
  443. ).to.have.lengthOf(1)
  444. })
  445. it('handles socket messages while loading', async function () {
  446. // Mock GET messages so that we can control when the promise is resolved
  447. let resolveLoadingMessages: (messages: ServerMessageEntry[]) => void
  448. fetchMock.get(
  449. 'express:/project/:projectId/messages',
  450. new Promise(resolve => {
  451. resolveLoadingMessages = resolve
  452. })
  453. )
  454. const socket = new SocketIOMock()
  455. const { result } = renderChatContextHook({
  456. socket: socket as any as Socket,
  457. })
  458. // Start loading messages
  459. result.current.loadMoreMessages()
  460. // Mock message being received from the socket while the request is in
  461. // flight
  462. socket.emitToClient('new-chat-message', {
  463. id: 'socket_msg',
  464. content: 'socket message',
  465. timestamp: Date.now(),
  466. user: {
  467. id: 'another_fake_user',
  468. first_name: 'another_fake_user_first_name',
  469. email: 'another_fake@example.com',
  470. },
  471. })
  472. // Resolve messages being loaded
  473. resolveLoadingMessages!([
  474. {
  475. id: 'fetched_msg',
  476. content: 'loaded message',
  477. user,
  478. timestamp: Date.now(),
  479. },
  480. ])
  481. await waitFor(() => {
  482. // Although the loaded message was resolved last, it appears first (since
  483. // requested messages must have come first)
  484. const messageContents = result.current.messages.map(
  485. ({ content }) => content
  486. )
  487. expect(messageContents).to.deep.equal([
  488. 'loaded message',
  489. 'socket message',
  490. ])
  491. })
  492. })
  493. it('provides an error on failures', async function () {
  494. fetchMock.removeRoutes().clearHistory()
  495. fetchMock.get('express:/project/:projectId/messages', 500)
  496. const { result } = renderChatContextHook({})
  497. result.current.loadMoreMessages()
  498. await waitFor(() => expect(result.current.error).to.exist)
  499. expect(result.current.status).to.equal('error')
  500. })
  501. })
  502. describe('sendMessage', function () {
  503. beforeEach(function () {
  504. // Mock GET messages to return no messages and POST new message to be
  505. // successful
  506. fetchMock
  507. .get('express:/project/:projectId/messages', [])
  508. .postOnce('express:/project/:projectId/messages', 200)
  509. })
  510. it('optimistically adds the message to the list', async function () {
  511. const { result } = renderChatContextHook({})
  512. result.current.sendMessage('sent message')
  513. await waitFor(() =>
  514. expect(result.current.messages[0].content).to.deep.equal('sent message')
  515. )
  516. })
  517. it('POSTs the message to the backend', function () {
  518. const { result } = renderChatContextHook({})
  519. result.current.sendMessage('sent message')
  520. const calls = fetchMock.callHistory.calls(
  521. 'express:/project/:projectId/messages',
  522. { method: 'POST' }
  523. )
  524. expect(calls.length).to.be.greaterThanOrEqual(1)
  525. const {
  526. options: { body },
  527. } = calls.at(-1)!
  528. expect(body).to.not.be.undefined
  529. expect(JSON.parse(body!.toString())).to.deep.include({
  530. content: 'sent message',
  531. })
  532. })
  533. it("doesn't send if the content is empty", function () {
  534. const { result } = renderChatContextHook({})
  535. result.current.sendMessage('')
  536. expect(result.current.messages).to.be.empty
  537. expect(
  538. fetchMock.callHistory.called('express:/project/:projectId/messages', {
  539. method: 'post',
  540. })
  541. ).to.be.false
  542. })
  543. it('provides an error on failure', async function () {
  544. fetchMock.removeRoutes().clearHistory()
  545. fetchMock
  546. .get('express:/project/:projectId/messages', [])
  547. .postOnce('express:/project/:projectId/messages', 500)
  548. const { result } = renderChatContextHook({})
  549. result.current.sendMessage('sent message')
  550. await waitFor(() => expect(result.current.error).to.exist)
  551. expect(result.current.status).to.equal('error')
  552. })
  553. })
  554. describe('unread messages', function () {
  555. beforeEach(function () {
  556. // Mock GET messages to return no messages
  557. fetchMock.get('express:/project/:projectId/messages', [])
  558. })
  559. it('increments unreadMessageCount when a new message is received', function () {
  560. const socket = new SocketIOMock()
  561. const { result } = renderChatContextHook({
  562. socket: socket as any as Socket,
  563. })
  564. // Receive a new message from the socket
  565. act(() => {
  566. socket.emitToClient('new-chat-message', {
  567. id: 'msg_1',
  568. content: 'new message',
  569. timestamp: Date.now(),
  570. user,
  571. })
  572. })
  573. expect(result.current.unreadMessageCount).to.equal(1)
  574. })
  575. it('resets unreadMessageCount when markMessagesAsRead is called', function () {
  576. const socket = new SocketIOMock()
  577. const { result } = renderChatContextHook({
  578. socket: socket as any as Socket,
  579. })
  580. // Receive a new message from the socket, incrementing unreadMessageCount
  581. // by 1
  582. socket.emitToClient('new-chat-message', {
  583. id: 'msg_1',
  584. content: 'new message',
  585. timestamp: Date.now(),
  586. user,
  587. })
  588. result.current.markMessagesAsRead()
  589. expect(result.current.unreadMessageCount).to.equal(0)
  590. })
  591. })
  592. })
  593. function renderChatContextHook(props: EditorProvidersProps) {
  594. return renderHook(() => useChatContext(), {
  595. // Wrap with ChatContext.Provider (and the other editor context providers)
  596. // eslint-disable-next-line react/display-name
  597. wrapper: ({ children }) => (
  598. <EditorProviders {...props}>{children}</EditorProviders>
  599. ),
  600. })
  601. }
  602. function createMessages(
  603. number: number,
  604. user: User,
  605. timestamp = Date.now()
  606. ): ServerMessageEntry[] {
  607. return Array.from({ length: number }, (_m, idx) => ({
  608. id: `msg_${idx + 1}`,
  609. content: `message ${idx + 1}`,
  610. user,
  611. timestamp,
  612. }))
  613. }
  614. /*
  615. * Get query param by key from the last fetchMock response
  616. */
  617. function getLastFetchMockQueryParam(key: string) {
  618. const calls = fetchMock.callHistory.calls()
  619. if (calls.length === 0) {
  620. throw new Error('No fetchMock calls found')
  621. }
  622. const { url } = calls.at(-1)!
  623. const { searchParams } = new URL(url, 'https://www.overleaf.com')
  624. return searchParams.get(key)
  625. }