ticket-detail-view.spec.ts 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252
  1. // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. import { ApolloError } from '@apollo/client/errors'
  3. import { getNode } from '@formkit/core'
  4. import { getAllByTestId, getByLabelText, getByRole } from '@testing-library/vue'
  5. import { flushPromises } from '@vue/test-utils'
  6. import { getByIconName } from '#tests/support/components/iconQueries.ts'
  7. import { getTestRouter } from '#tests/support/components/renderComponent.ts'
  8. import { visitView } from '#tests/support/components/visitView.ts'
  9. import {
  10. mockGraphQLApi,
  11. mockGraphQLSubscription,
  12. } from '#tests/support/mock-graphql-api.ts'
  13. import { mockPermissions } from '#tests/support/mock-permissions.ts'
  14. import { mockUserCurrent } from '#tests/support/mock-userCurrent.ts'
  15. import { nullableMock, waitUntil } from '#tests/support/utils.ts'
  16. import { TicketUpdateDocument } from '#shared/entities/ticket/graphql/mutations/update.api.ts'
  17. import { TicketArticlesDocument } from '#shared/entities/ticket/graphql/queries/ticket/articles.api.ts'
  18. import { TicketArticleUpdatesDocument } from '#shared/entities/ticket/graphql/subscriptions/ticketArticlesUpdates.api.ts'
  19. import { TicketUpdatesDocument } from '#shared/entities/ticket/graphql/subscriptions/ticketUpdates.api.ts'
  20. import { TicketState } from '#shared/entities/ticket/types.ts'
  21. import { TicketArticleRetrySecurityProcessDocument } from '#shared/entities/ticket-article/graphql/mutations/ticketArticleRetrySecurityProcess.api.ts'
  22. import {
  23. EnumChannelArea,
  24. EnumSecurityStateType,
  25. type TicketArticleRetrySecurityProcessMutation,
  26. } from '#shared/graphql/types.ts'
  27. import { convertToGraphQLId } from '#shared/graphql/utils.ts'
  28. import { TicketWithMentionLimitDocument } from '#mobile/entities/ticket/graphql/queries/ticketWithMentionLimit.api.ts'
  29. import { clearTicketArticlesLoadedState } from '../composable/useTicketArticlesVariables.ts'
  30. import { TicketLiveUserDeleteDocument } from '../graphql/mutations/live-user/delete.api.ts'
  31. import { TicketLiveUserUpsertDocument } from '../graphql/mutations/live-user/ticketLiveUserUpsert.api.ts'
  32. import { mockArticleQuery } from './mocks/articles.ts'
  33. import {
  34. defaultArticles,
  35. defaultTicket,
  36. mockTicketDetailViewGql,
  37. } from './mocks/detail-view.ts'
  38. vi.hoisted(() => {
  39. const now = new Date(2022, 1, 1, 0, 0, 0, 0)
  40. vi.setSystemTime(now)
  41. })
  42. beforeEach(() => {
  43. mockPermissions(['ticket.agent'])
  44. clearTicketArticlesLoadedState()
  45. })
  46. test('statics inside ticket zoom view', async () => {
  47. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  48. mockFrontendObjectAttributes: true,
  49. })
  50. const view = await visitView('/tickets/1')
  51. expect(view.getByTestId('loader-list')).toBeInTheDocument()
  52. expect(view.getByTestId('loader-title')).toBeInTheDocument()
  53. expect(view.getByTestId('loader-header')).toBeInTheDocument()
  54. await waitUntilTicketLoaded()
  55. const form = getNode('form-ticket-edit')
  56. await form?.settled
  57. const header = view.getByTestId('header-content')
  58. expect(header).toHaveTextContent('#610001')
  59. expect(header).toHaveTextContent('created 3 days ago')
  60. const titleElement = view.getByTestId('title-content')
  61. expect(titleElement).toHaveTextContent('Test Ticket View')
  62. expect(titleElement).toHaveTextContent('escalation 2 days ago')
  63. expect(titleElement, 'has customer avatar').toHaveTextContent('JD')
  64. const articlesElement = view.getByRole('group', { name: 'Articles' })
  65. const times = getAllByTestId(articlesElement, 'date-time-absolute')
  66. expect(times).toHaveLength(2)
  67. expect(times[0]).toHaveTextContent('2022-01-29')
  68. expect(times[1]).toHaveTextContent('2022-01-30')
  69. const comments = view.getAllByRole('comment')
  70. // everything else for article is testes inside ArticleBubble
  71. expect(comments).toHaveLength(3)
  72. // customer article
  73. expect(comments[0]).toHaveClass('flex-row-reverse')
  74. expect(comments[0]).toHaveTextContent('John')
  75. expect(comments[0]).toHaveTextContent('Body of a test ticket')
  76. // agent public comment
  77. expect(comments[1]).not.toHaveClass('flex-row-reverse')
  78. expect(comments[1]).toHaveTextContent('Albert')
  79. expect(comments[1]).toHaveTextContent('energy equals power times time')
  80. // agent internal comment
  81. expect(comments[2]).not.toHaveClass('flex-row-reverse')
  82. expect(comments[2]).toHaveTextContent('Monkey')
  83. expect(comments[2]).toHaveTextContent('only agents can see this haha')
  84. expect(view.getByRole('button', { name: 'Add reply' })).toBeInTheDocument()
  85. expect(
  86. view.queryByText('not-visible-attachment.png'),
  87. 'filters original-format attachments',
  88. ).not.toBeInTheDocument()
  89. })
  90. describe('user avatars', () => {
  91. it('renders customer avatar, when user is inactive', async () => {
  92. const ticket = defaultTicket()
  93. const image = Buffer.from('max.png').toString('base64')
  94. const { customer } = ticket.ticket
  95. customer.active = false
  96. customer.image = image
  97. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  98. ticket,
  99. })
  100. const view = await visitView('/tickets/1')
  101. await waitUntilTicketLoaded()
  102. const titleBlock = view.getByTestId('ticket-title')
  103. const avatar = getByRole(titleBlock, 'img', {
  104. name: `Avatar (${customer.fullname})`,
  105. })
  106. expect(avatar).toBeAvatarElement({
  107. active: false,
  108. image,
  109. type: 'user',
  110. })
  111. })
  112. it('renders organization avatar when organization is present', async () => {
  113. const ticket = defaultTicket()
  114. const { organization } = ticket.ticket
  115. organization!.vip = false
  116. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  117. ticket,
  118. })
  119. const view = await visitView('/tickets/1')
  120. await waitUntilTicketLoaded()
  121. const titleBlock = view.getByTestId('ticket-title')
  122. const avatar = getByRole(titleBlock, 'img', {
  123. name: `Avatar (${organization!.name})`,
  124. })
  125. expect(avatar).toBeAvatarElement({
  126. active: true,
  127. vip: false,
  128. type: 'organization',
  129. })
  130. })
  131. it('renders organization avatar when organization is VIP', async () => {
  132. const ticket = defaultTicket()
  133. const image = Buffer.from('max.png').toString('base64')
  134. const { customer, organization } = ticket.ticket
  135. organization!.vip = true
  136. customer.image = image
  137. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  138. ticket,
  139. })
  140. const view = await visitView('/tickets/1')
  141. await waitUntilTicketLoaded()
  142. const titleBlock = view.getByTestId('ticket-title')
  143. const orgAvatar = getByRole(titleBlock, 'img', {
  144. name: `Avatar (${organization!.name})`,
  145. })
  146. const userAvatar = getByRole(titleBlock, 'img', {
  147. name: `Avatar (${customer.fullname})`,
  148. })
  149. expect(orgAvatar).toBeAvatarElement({
  150. active: true,
  151. vip: true,
  152. type: 'organization',
  153. })
  154. expect(userAvatar).toBeAvatarElement({
  155. active: true,
  156. vip: false,
  157. image,
  158. type: 'user',
  159. })
  160. })
  161. it('renders article user image when he is inactive', async () => {
  162. const articles = defaultArticles()
  163. const { author } = articles.firstArticles!.edges[0].node
  164. author.active = false
  165. author.image = 'avatar.png'
  166. author.firstname = 'Max'
  167. author.lastname = 'Mustermann'
  168. author.fullname = 'Max Mustermann'
  169. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  170. articles,
  171. })
  172. const view = await visitView('/tickets/1')
  173. await waitUntilTicketLoaded()
  174. expect(
  175. view.getByRole('img', { name: `Avatar (${author.fullname})` }),
  176. ).toBeAvatarElement({
  177. type: 'user',
  178. image: 'avatar.png',
  179. outOfOffice: false,
  180. outOfOfficeStartAt: null,
  181. outOfOfficeEndAt: null,
  182. vip: false,
  183. active: false,
  184. })
  185. })
  186. it('renders article user when he is out of office', async () => {
  187. const articles = defaultArticles()
  188. const { author } = articles.firstArticles!.edges[0].node
  189. author.outOfOffice = true
  190. author.outOfOfficeStartAt = '2021-12-01'
  191. author.outOfOfficeEndAt = '2022-02-01'
  192. author.active = true
  193. author.vip = true
  194. author.firstname = 'Max'
  195. author.lastname = 'Mustermann'
  196. author.fullname = 'Max Mustermann'
  197. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  198. articles,
  199. })
  200. const view = await visitView('/tickets/1')
  201. await waitUntilTicketLoaded()
  202. expect(
  203. view.getByRole('img', { name: `Avatar (${author.fullname}) (VIP)` }),
  204. ).toBeAvatarElement({
  205. type: 'user',
  206. outOfOffice: true,
  207. outOfOfficeStartAt: '2021-12-01',
  208. outOfOfficeEndAt: '2022-02-01',
  209. vip: true,
  210. active: true,
  211. })
  212. })
  213. })
  214. test("redirects to error page, if can't find ticket", async () => {
  215. const { calls } = mockGraphQLApi(
  216. TicketWithMentionLimitDocument,
  217. ).willFailWithNotFoundError('The ticket 9866 could not be found')
  218. mockGraphQLApi(TicketLiveUserDeleteDocument).willFailWithNotFoundError(
  219. 'The ticket 9866 could not be found',
  220. )
  221. mockGraphQLApi(TicketLiveUserUpsertDocument).willFailWithNotFoundError(
  222. 'The ticket 9866 could not be found',
  223. )
  224. mockGraphQLApi(TicketArticlesDocument).willFailWithNotFoundError(
  225. 'The ticket 9866 could not be found',
  226. )
  227. mockGraphQLSubscription(TicketUpdatesDocument).error(
  228. new ApolloError({ errorMessage: "Couldn't find Ticket with 'id'=9866" }),
  229. )
  230. mockGraphQLSubscription(TicketArticleUpdatesDocument).error(
  231. new ApolloError({ errorMessage: "Couldn't find Ticket with 'id'=9866" }),
  232. )
  233. await visitView('/tickets/9866')
  234. await waitUntil(() => calls.error > 0)
  235. await flushPromises()
  236. const router = getTestRouter()
  237. expect(router.replace).toHaveBeenCalledWith({
  238. name: 'Error',
  239. query: {
  240. redirect: '1',
  241. },
  242. })
  243. })
  244. test('show article context on click', async () => {
  245. const { waitUntilTicketLoaded } = mockTicketDetailViewGql()
  246. const view = await visitView('/tickets/1', {
  247. global: {
  248. stubs: {
  249. transition: false,
  250. },
  251. },
  252. })
  253. await waitUntilTicketLoaded()
  254. vi.useRealTimers()
  255. const contextTriggers = view.getAllByRole('button', {
  256. name: 'Article actions',
  257. })
  258. expect(contextTriggers).toHaveLength(3)
  259. await view.events.click(contextTriggers[0])
  260. expect(view.getByText('Set to internal')).toBeInTheDocument()
  261. })
  262. test('change content on subscription', async () => {
  263. const { waitUntilTicketLoaded, mockTicketSubscription, ticket } =
  264. mockTicketDetailViewGql()
  265. const view = await visitView('/tickets/1')
  266. await waitUntilTicketLoaded()
  267. expect(view.getByText(ticket.title)).toBeInTheDocument()
  268. await mockTicketSubscription.next({
  269. data: {
  270. ticketUpdates: {
  271. __typename: 'TicketUpdatesPayload',
  272. ticket: nullableMock({ ...ticket, title: 'Some New Title' }),
  273. ticketArticle: null,
  274. },
  275. },
  276. })
  277. expect(view.getByText('Some New Title')).toBeInTheDocument()
  278. })
  279. describe('calling API to retry encryption', () => {
  280. it('updates ticket description', async () => {
  281. const articlesQuery = defaultArticles()
  282. const article = articlesQuery.firstArticles!.edges[0].node
  283. article.securityState = {
  284. __typename: 'TicketArticleSecurityState',
  285. encryptionMessage: '',
  286. encryptionSuccess: false,
  287. signingMessage: 'The certificate for verification could not be found.',
  288. signingSuccess: false,
  289. }
  290. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  291. articles: articlesQuery,
  292. })
  293. const view = await visitView('/tickets/1')
  294. await waitUntilTicketLoaded()
  295. const securityError = view.getByRole('button', { name: 'Security Error' })
  296. await view.events.click(securityError)
  297. const retryResult = {
  298. __typename: 'TicketArticleSecurityState',
  299. encryptionMessage: '',
  300. encryptionSuccess: false,
  301. signingMessage:
  302. '/emailAddress=smime1@example.com/C=DE/ST=Berlin/L=Berlin/O=Example Security/OU=IT Department/CN=example.com',
  303. signingSuccess: true,
  304. type: EnumSecurityStateType.Smime,
  305. } as const
  306. const mutation = mockGraphQLApi(
  307. TicketArticleRetrySecurityProcessDocument,
  308. ).willResolve<TicketArticleRetrySecurityProcessMutation>({
  309. ticketArticleRetrySecurityProcess: {
  310. __typename: 'TicketArticleRetrySecurityProcessPayload',
  311. retryResult,
  312. article: {
  313. __typename: 'TicketArticle',
  314. id: article.id,
  315. securityState: { ...retryResult },
  316. },
  317. errors: null,
  318. },
  319. })
  320. await view.events.click(view.getByText('Try again'))
  321. expect(mutation.spies.resolve).toHaveBeenCalled()
  322. expect(view.queryByTestId('popupWindow')).not.toBeInTheDocument()
  323. const [articlesElement] = view.getAllByRole('comment')
  324. expect(getByLabelText(articlesElement, 'Signed')).toBeInTheDocument()
  325. expect(getByIconName(articlesElement, 'signed')).toBeInTheDocument()
  326. })
  327. it('updates non-description article', async () => {
  328. const articlesQuery = defaultArticles()
  329. const article = articlesQuery.articles.edges[0].node
  330. article.securityState = {
  331. __typename: 'TicketArticleSecurityState',
  332. encryptionMessage: '',
  333. encryptionSuccess: false,
  334. signingMessage: 'The certificate for verification could not be found.',
  335. signingSuccess: false,
  336. }
  337. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  338. articles: articlesQuery,
  339. })
  340. const view = await visitView('/tickets/1')
  341. await waitUntilTicketLoaded()
  342. const securityError = view.getByRole('button', { name: 'Security Error' })
  343. await view.events.click(securityError)
  344. const retryResult = {
  345. __typename: 'TicketArticleSecurityState',
  346. encryptionMessage: '',
  347. encryptionSuccess: false,
  348. signingMessage:
  349. '/emailAddress=smime1@example.com/C=DE/ST=Berlin/L=Berlin/O=Example Security/OU=IT Department/CN=example.com',
  350. signingSuccess: true,
  351. type: EnumSecurityStateType.Smime,
  352. } as const
  353. const mutation = mockGraphQLApi(
  354. TicketArticleRetrySecurityProcessDocument,
  355. ).willResolve<TicketArticleRetrySecurityProcessMutation>({
  356. ticketArticleRetrySecurityProcess: {
  357. __typename: 'TicketArticleRetrySecurityProcessPayload',
  358. retryResult,
  359. article: {
  360. __typename: 'TicketArticle',
  361. id: article.id,
  362. securityState: { ...retryResult },
  363. },
  364. errors: null,
  365. },
  366. })
  367. await view.events.click(view.getByText('Try again'))
  368. expect(mutation.spies.resolve).toHaveBeenCalled()
  369. expect(view.queryByTestId('popupWindow')).not.toBeInTheDocument()
  370. const [, firstCommentArticle] = view.getAllByRole('comment')
  371. expect(getByLabelText(firstCommentArticle, 'Signed')).toBeInTheDocument()
  372. expect(getByIconName(firstCommentArticle, 'signed')).toBeInTheDocument()
  373. })
  374. })
  375. describe('remote content removal', () => {
  376. it('shows blocked content badge', async () => {
  377. const articlesQuery = defaultArticles()
  378. const article = articlesQuery.firstArticles!.edges[0].node
  379. article.preferences = {
  380. remote_content_removed: true,
  381. }
  382. article.attachmentsWithoutInline = [
  383. {
  384. id: convertToGraphQLId('Store', 1),
  385. internalId: 1,
  386. name: 'message',
  387. preferences: {
  388. 'original-format': true,
  389. },
  390. },
  391. ]
  392. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  393. articles: articlesQuery,
  394. })
  395. const view = await visitView('/tickets/1')
  396. await waitUntilTicketLoaded()
  397. const blockedContent = view.getByRole('button', { name: 'Blocked Content' })
  398. await view.events.click(blockedContent)
  399. await view.events.click(view.getByText('Original Formatting'))
  400. expect(view.queryByTestId('popupWindow')).not.toBeInTheDocument()
  401. })
  402. })
  403. describe('ticket viewers inside a ticket', () => {
  404. it('displays information with newer last interaction (and without own entry)', async () => {
  405. const { waitUntilTicketLoaded, mockTicketLiveUsersSubscription } =
  406. mockTicketDetailViewGql()
  407. mockUserCurrent({
  408. lastname: 'Doe',
  409. firstname: 'John',
  410. fullname: 'John Doe',
  411. id: convertToGraphQLId('User', 4),
  412. })
  413. mockPermissions(['ticket.agent'])
  414. const view = await visitView('/tickets/1')
  415. await waitUntilTicketLoaded()
  416. await mockTicketLiveUsersSubscription.next({
  417. data: {
  418. ticketLiveUserUpdates: {
  419. liveUsers: [
  420. {
  421. user: {
  422. id: 'gid://zammad/User/4',
  423. firstname: 'Agent 1',
  424. lastname: 'Test',
  425. fullname: 'Agent 1 Test',
  426. __typename: 'User',
  427. },
  428. apps: [
  429. {
  430. name: 'mobile',
  431. editing: false,
  432. lastInteraction: '2022-02-01T10:55:26Z',
  433. __typename: 'TicketLiveUserApp',
  434. },
  435. ],
  436. __typename: 'TicketLiveUser',
  437. },
  438. {
  439. user: {
  440. id: 'gid://zammad/User/160',
  441. firstname: 'John',
  442. lastname: 'Doe',
  443. fullname: 'John Doe',
  444. __typename: 'User',
  445. },
  446. apps: [
  447. {
  448. name: 'desktop',
  449. editing: false,
  450. lastInteraction: '2022-01-31T10:30:24Z',
  451. __typename: 'TicketLiveUserApp',
  452. },
  453. {
  454. name: 'mobile',
  455. editing: false,
  456. lastInteraction: '2022-01-31T16:45:53Z',
  457. __typename: 'TicketLiveUserApp',
  458. },
  459. ],
  460. __typename: 'TicketLiveUser',
  461. },
  462. {
  463. user: {
  464. id: 'gid://zammad/User/165',
  465. firstname: 'Rose',
  466. lastname: 'Nylund',
  467. fullname: 'Rose Nylund',
  468. __typename: 'User',
  469. },
  470. apps: [
  471. {
  472. name: 'mobile',
  473. editing: false,
  474. lastInteraction: '2022-01-31T16:45:53Z',
  475. __typename: 'TicketLiveUserApp',
  476. },
  477. ],
  478. __typename: 'TicketLiveUser',
  479. },
  480. ],
  481. __typename: 'TicketLiveUserUpdatesPayload',
  482. },
  483. },
  484. })
  485. const counter = view.getByLabelText(/Ticket has 2 viewers/)
  486. expect(counter, 'has a counter').toBeInTheDocument()
  487. expect(counter).toHaveTextContent('+1')
  488. await view.events.click(
  489. view.getByRole('button', { name: 'Show ticket viewers' }),
  490. )
  491. await waitUntil(() =>
  492. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  493. )
  494. expect(view.getByText('Opened in tabs')).toBeInTheDocument()
  495. expect(
  496. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  497. ).toHaveTextContent('John Doe')
  498. expect(view.queryByIconName('desktop')).not.toBeInTheDocument()
  499. await mockTicketLiveUsersSubscription.next({
  500. data: {
  501. ticketLiveUserUpdates: {
  502. liveUsers: [
  503. {
  504. user: {
  505. id: 'gid://zammad/User/160',
  506. firstname: 'John',
  507. lastname: 'Doe',
  508. fullname: 'John Doe',
  509. __typename: 'User',
  510. },
  511. apps: [
  512. {
  513. name: 'desktop',
  514. editing: false,
  515. lastInteraction: '2022-01-31T18:30:24Z',
  516. __typename: 'TicketLiveUserApp',
  517. },
  518. {
  519. name: 'mobile',
  520. editing: false,
  521. lastInteraction: '2022-01-31T16:45:53Z',
  522. __typename: 'TicketLiveUserApp',
  523. },
  524. ],
  525. __typename: 'TicketLiveUser',
  526. },
  527. ],
  528. __typename: 'TicketLiveUserUpdatesPayload',
  529. },
  530. },
  531. })
  532. expect(view.queryByIconName('desktop')).toBeInTheDocument()
  533. })
  534. it('editing has always the highest priority', async () => {
  535. const { waitUntilTicketLoaded, mockTicketLiveUsersSubscription } =
  536. mockTicketDetailViewGql()
  537. mockUserCurrent({
  538. lastname: 'Doe',
  539. firstname: 'John',
  540. fullname: 'John Doe',
  541. id: convertToGraphQLId('User', 4),
  542. })
  543. mockPermissions(['ticket.agent'])
  544. const view = await visitView('/tickets/1')
  545. await waitUntilTicketLoaded()
  546. await mockTicketLiveUsersSubscription.next({
  547. data: {
  548. ticketLiveUserUpdates: {
  549. liveUsers: [
  550. {
  551. user: {
  552. id: 'gid://zammad/User/160',
  553. firstname: 'John',
  554. lastname: 'Doe',
  555. fullname: 'John Doe',
  556. __typename: 'User',
  557. },
  558. apps: [
  559. {
  560. name: 'desktop',
  561. editing: true,
  562. lastInteraction: '2022-01-31T10:30:24Z',
  563. __typename: 'TicketLiveUserApp',
  564. },
  565. {
  566. name: 'mobile',
  567. editing: false,
  568. lastInteraction: '2022-01-31T16:45:53Z',
  569. __typename: 'TicketLiveUserApp',
  570. },
  571. ],
  572. __typename: 'TicketLiveUser',
  573. },
  574. ],
  575. __typename: 'TicketLiveUserUpdatesPayload',
  576. },
  577. },
  578. })
  579. await view.events.click(
  580. view.getByRole('button', { name: 'Show ticket viewers' }),
  581. )
  582. await waitUntil(() =>
  583. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  584. )
  585. expect(
  586. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  587. ).toHaveTextContent('John Doe')
  588. expect(view.queryByIconName('desktop-edit')).toBeInTheDocument()
  589. })
  590. it('show current user avatar when editing on other device', async () => {
  591. const { waitUntilTicketLoaded, mockTicketLiveUsersSubscription } =
  592. mockTicketDetailViewGql()
  593. mockUserCurrent({
  594. lastname: 'Doe',
  595. firstname: 'John',
  596. fullname: 'John Doe',
  597. id: convertToGraphQLId('User', 4),
  598. })
  599. mockPermissions(['ticket.agent'])
  600. const view = await visitView('/tickets/1')
  601. await waitUntilTicketLoaded()
  602. await mockTicketLiveUsersSubscription.next({
  603. data: {
  604. ticketLiveUserUpdates: {
  605. liveUsers: [
  606. {
  607. user: {
  608. id: 'gid://zammad/User/4',
  609. firstname: 'Agent 1',
  610. lastname: 'Test',
  611. fullname: 'Agent 1 Test',
  612. __typename: 'User',
  613. },
  614. apps: [
  615. {
  616. name: 'mobile',
  617. editing: false,
  618. lastInteraction: '2022-02-01T10:55:26Z',
  619. __typename: 'TicketLiveUserApp',
  620. },
  621. {
  622. name: 'desktop',
  623. editing: true,
  624. lastInteraction: '2022-02-01T09:55:26Z',
  625. __typename: 'TicketLiveUserApp',
  626. },
  627. ],
  628. __typename: 'TicketLiveUser',
  629. },
  630. ],
  631. __typename: 'TicketLiveUserUpdatesPayload',
  632. },
  633. },
  634. })
  635. await view.events.click(
  636. view.getByRole('button', { name: 'Show ticket viewers' }),
  637. )
  638. await waitUntil(() =>
  639. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  640. )
  641. expect(
  642. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  643. ).toHaveTextContent('Agent 1 Test')
  644. expect(view.queryByIconName('desktop-edit')).toBeInTheDocument()
  645. })
  646. it('customer should only add live user entry but not subscribe', async () => {
  647. mockUserCurrent({
  648. lastname: 'Braun',
  649. firstname: 'Nicole',
  650. fullname: 'Nicole Braun',
  651. id: convertToGraphQLId('User', 3),
  652. })
  653. const {
  654. waitUntilTicketLoaded,
  655. mockTicketLiveUserUpsert,
  656. mockTicketLiveUsersSubscription,
  657. } = mockTicketDetailViewGql({ ticketView: 'customer' })
  658. const view = await visitView('/tickets/1')
  659. await waitUntilTicketLoaded()
  660. await waitUntil(() => mockTicketLiveUserUpsert.calls.resolve === 1)
  661. await mockTicketLiveUsersSubscription.next({
  662. data: {
  663. ticketLiveUserUpdates: {
  664. liveUsers: [
  665. {
  666. user: {
  667. id: 'gid://zammad/User/160',
  668. firstname: 'John',
  669. lastname: 'Doe',
  670. fullname: 'John Doe',
  671. __typename: 'User',
  672. },
  673. apps: [
  674. {
  675. name: 'desktop',
  676. editing: false,
  677. lastInteraction: '2022-01-31T18:30:24Z',
  678. __typename: 'TicketLiveUserApp',
  679. },
  680. {
  681. name: 'mobile',
  682. editing: false,
  683. lastInteraction: '2022-01-31T16:45:53Z',
  684. __typename: 'TicketLiveUserApp',
  685. },
  686. ],
  687. __typename: 'TicketLiveUser',
  688. },
  689. ],
  690. __typename: 'TicketLiveUserUpdatesPayload',
  691. },
  692. },
  693. })
  694. expect(
  695. view.queryByRole('button', { name: 'Show ticket viewers' }),
  696. ).not.toBeInTheDocument()
  697. })
  698. })
  699. describe('ticket add/edit reply article', () => {
  700. beforeEach(() => {
  701. vi.useRealTimers()
  702. })
  703. it('save button is not shown when select field is opened', async () => {
  704. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  705. mockFrontendObjectAttributes: true,
  706. })
  707. const view = await visitView('/tickets/1')
  708. await waitUntilTicketLoaded()
  709. await view.events.click(view.getByRole('button', { name: 'Add reply' }))
  710. await waitUntil(() => view.queryByRole('dialog', { name: 'Add reply' }))
  711. await view.events.type(view.getByLabelText('Text'), 'Testing')
  712. await expect(
  713. view.findByRole('button', { name: 'Save' }),
  714. ).resolves.toBeInTheDocument()
  715. await view.events.click(view.getByRole('combobox', { name: 'Visibility' }))
  716. expect(view.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
  717. await view.events.click(view.getByRole('option', { name: 'Public' }))
  718. await expect(
  719. view.findByRole('button', { name: 'Save' }),
  720. ).resolves.toBeInTheDocument()
  721. })
  722. it('save button is not shown when non-reply dialog field is opened', async () => {
  723. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  724. mockFrontendObjectAttributes: true,
  725. })
  726. const view = await visitView('/tickets/1')
  727. await waitUntilTicketLoaded()
  728. await view.events.click(view.getByRole('button', { name: 'Add reply' }))
  729. await waitUntil(() => view.queryByRole('dialog', { name: 'Add reply' }))
  730. await view.events.type(view.getByLabelText('Text'), 'Testing')
  731. await expect(
  732. view.findByRole('button', { name: 'Save' }),
  733. ).resolves.toBeInTheDocument()
  734. await view.events.click(view.getByRole('button', { name: 'Done' }))
  735. await view.events.click(
  736. view.getByRole('button', { name: 'Show ticket actions' }),
  737. )
  738. await waitUntil(() =>
  739. view.queryByRole('dialog', { name: 'Ticket actions' }),
  740. )
  741. expect(
  742. view.queryByText('You have unsaved changes.'),
  743. ).not.toBeInTheDocument()
  744. await view.events.click(view.getByText('Change customer'))
  745. await waitUntil(() =>
  746. view.queryByRole('dialog', { name: 'Change customer' }),
  747. )
  748. expect(
  749. view.queryByText('You have unsaved changes.'),
  750. ).not.toBeInTheDocument()
  751. })
  752. it('add reply (first time) should hold the form state after save button with an invalid state is used', async () => {
  753. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  754. mockFrontendObjectAttributes: true,
  755. })
  756. const view = await visitView('/tickets/1')
  757. await waitUntilTicketLoaded()
  758. const form = getNode('form-ticket-edit')
  759. await form?.settled
  760. form?.find('title', 'name')?.input('')
  761. await expect(
  762. view.findByLabelText('Validation failed'),
  763. ).resolves.toBeInTheDocument()
  764. await view.events.click(view.getByRole('button', { name: 'Add reply' }))
  765. await waitUntil(() => view.queryByRole('dialog', { name: 'Add reply' }))
  766. await getNode('form-ticket-edit')?.settled
  767. await view.events.type(view.getByLabelText('Text'), 'Testing')
  768. // Wait for form updater.
  769. await getNode('form-ticket-edit')?.settled
  770. await view.events.click(view.getByRole('button', { name: 'Save' }))
  771. expect(view.getByText('This field is required.')).toBeInTheDocument()
  772. expect(form?.find('body', 'name')?.value).toBe('Testing')
  773. })
  774. it('save one reply and cancel second reply (save button should not be visible)', async () => {
  775. const { waitUntilTicketLoaded, ticket } = mockTicketDetailViewGql({
  776. mockFrontendObjectAttributes: true,
  777. })
  778. const view = await visitView('/tickets/1')
  779. await waitUntilTicketLoaded()
  780. await view.events.click(view.getByRole('button', { name: 'Add reply' }))
  781. expect(
  782. await view.findByRole('dialog', { name: 'Add reply' }),
  783. ).toBeInTheDocument()
  784. await view.events.type(view.getByLabelText('Text'), 'Testing')
  785. expect(
  786. await view.findByRole('button', { name: 'Save' }),
  787. ).toBeInTheDocument()
  788. mockGraphQLApi(TicketUpdateDocument).willResolve({
  789. ticketUpdate: {
  790. ticket,
  791. errors: null,
  792. __typename: 'TicketUpdatePayload',
  793. },
  794. })
  795. await view.events.click(view.getByRole('button', { name: 'Save' }))
  796. expect(
  797. await view.findByRole('button', { name: 'Add reply' }),
  798. ).toBeInTheDocument()
  799. await view.events.click(view.getByRole('button', { name: 'Add reply' }))
  800. expect(
  801. await view.findByRole('dialog', { name: 'Add reply' }),
  802. ).toBeInTheDocument()
  803. await view.events.click(view.getByRole('button', { name: 'Cancel' }))
  804. expect(
  805. await view.findByRole('button', { name: 'Add reply' }),
  806. ).toBeInTheDocument()
  807. expect(view.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
  808. })
  809. })
  810. it('correctly redirects from ticket hash-based routes', async () => {
  811. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  812. ticketView: 'agent',
  813. })
  814. await visitView('/#ticket/zoom/1')
  815. await waitUntilTicketLoaded()
  816. const router = getTestRouter()
  817. const route = router.currentRoute.value
  818. expect(route.name).toBe('TicketDetailArticlesView')
  819. expect(route.params).toEqual({ internalId: '1' })
  820. })
  821. it('correctly redirects from ticket hash-based routes with other ids', async () => {
  822. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  823. ticketView: 'agent',
  824. articles: [
  825. defaultArticles(),
  826. {
  827. articles: {
  828. edges: [],
  829. pageInfo: {
  830. endCursor: null,
  831. startCursor: null,
  832. hasPreviousPage: false,
  833. __typename: 'PageInfo',
  834. },
  835. totalCount: 5,
  836. },
  837. },
  838. ],
  839. })
  840. await visitView('/#ticket/zoom/1/20')
  841. await waitUntilTicketLoaded()
  842. const router = getTestRouter()
  843. const route = router.currentRoute.value
  844. expect(route.name).toBe('TicketDetailArticlesView')
  845. expect(route.params).toEqual({ internalId: '1' })
  846. })
  847. it("scrolls to the bottom the first time, but doesn't trigger rescroll on subsequent updates", async () => {
  848. const newArticlesQuery = mockArticleQuery(
  849. {
  850. internalId: 1,
  851. bodyWithUrls: '<p>Existing article> all can see this haha</p>',
  852. },
  853. [
  854. {
  855. internalId: 2,
  856. bodyWithUrls:
  857. '<p>Existing article switched to internal> all can see this haha</p>',
  858. },
  859. {
  860. internalId: 3,
  861. bodyWithUrls: '<p>Existing article> all can see this haha</p>',
  862. },
  863. ],
  864. )
  865. const newArticlesQueryAfterUpdate = mockArticleQuery(
  866. {
  867. internalId: 1,
  868. bodyWithUrls: '<p>Existing article> all can see this haha</p>',
  869. },
  870. [
  871. {
  872. internalId: 3,
  873. bodyWithUrls: '<p>Existing article> all can see this haha</p>',
  874. },
  875. ],
  876. )
  877. const { waitUntilTicketLoaded, mockTicketArticleSubscription } =
  878. mockTicketDetailViewGql({
  879. ticketView: 'agent',
  880. articles: [newArticlesQuery, newArticlesQueryAfterUpdate],
  881. })
  882. vi.spyOn(window, 'scrollTo').mockReturnValue()
  883. await visitView('/tickets/1')
  884. await waitUntilTicketLoaded()
  885. const router = getTestRouter()
  886. router.restoreMethods()
  887. expect(Element.prototype.scrollIntoView).toHaveBeenCalledTimes(1)
  888. await mockTicketArticleSubscription.next(
  889. nullableMock({
  890. data: {
  891. ticketArticleUpdates: {
  892. addArticle: {
  893. __typename: 'TicketArticle',
  894. id: convertToGraphQLId('TicketArticle', 100),
  895. createdAt: new Date(2022, 0, 31, 0, 0, 0, 0).toISOString(),
  896. },
  897. },
  898. },
  899. }),
  900. )
  901. expect(Element.prototype.scrollIntoView).toHaveBeenCalledTimes(1)
  902. })
  903. describe('with ticket on a whatsapp channel', () => {
  904. it('shows reply link in the article context when the service window is open', async () => {
  905. const testDate = new Date()
  906. const articles = defaultArticles()
  907. articles.firstArticles!.edges[0].node.type!.name = 'whatsapp message'
  908. const ticket = defaultTicket(
  909. {},
  910. {
  911. whatsapp: {
  912. timestamp_incoming: testDate.getTime(),
  913. },
  914. },
  915. )
  916. ticket.ticket.initialChannel = EnumChannelArea.WhatsAppBusiness
  917. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  918. ticket,
  919. articles,
  920. })
  921. const view = await visitView('/tickets/1', {
  922. global: {
  923. stubs: {
  924. transition: false,
  925. },
  926. },
  927. })
  928. await waitUntilTicketLoaded()
  929. vi.useRealTimers()
  930. const contextTriggers = view.getAllByRole('button', {
  931. name: 'Article actions',
  932. })
  933. expect(contextTriggers).toHaveLength(3)
  934. await view.events.click(contextTriggers[0])
  935. expect(view.getByText('Reply')).toBeInTheDocument()
  936. })
  937. it('hides reply link in the article context when the service window is closed', async () => {
  938. const testDate = new Date()
  939. const articles = defaultArticles()
  940. articles.firstArticles!.edges[0].node.type!.name = 'whatsapp message'
  941. const ticket = defaultTicket(
  942. {},
  943. {
  944. whatsapp: {
  945. timestamp_incoming:
  946. testDate.setHours(testDate.getHours() - 25).valueOf() / 1000,
  947. },
  948. },
  949. )
  950. ticket.ticket.initialChannel = EnumChannelArea.WhatsAppBusiness
  951. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  952. ticket,
  953. articles,
  954. })
  955. const view = await visitView('/tickets/1', {
  956. global: {
  957. stubs: {
  958. transition: false,
  959. },
  960. },
  961. })
  962. await waitUntilTicketLoaded()
  963. vi.useRealTimers()
  964. const contextTriggers = view.getAllByRole('button', {
  965. name: 'Article actions',
  966. })
  967. expect(contextTriggers).toHaveLength(3)
  968. await view.events.click(contextTriggers[0])
  969. expect(view.queryByText('Reply')).not.toBeInTheDocument()
  970. })
  971. it('hides reply link in the article context when the ticket is closed', async () => {
  972. const testDate = new Date()
  973. const articles = defaultArticles()
  974. articles.firstArticles!.edges[0].node.type!.name = 'whatsapp message'
  975. const ticket = defaultTicket(
  976. {},
  977. {
  978. whatsapp: {
  979. timestamp_incoming: testDate.getTime(),
  980. },
  981. },
  982. {
  983. name: 'closed',
  984. stateType: {
  985. id: convertToGraphQLId('TicketStateType', 5),
  986. name: TicketState.Closed,
  987. },
  988. },
  989. )
  990. ticket.ticket.initialChannel = EnumChannelArea.WhatsAppBusiness
  991. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  992. ticket,
  993. articles,
  994. })
  995. const view = await visitView('/tickets/1', {
  996. global: {
  997. stubs: {
  998. transition: false,
  999. },
  1000. },
  1001. })
  1002. await waitUntilTicketLoaded()
  1003. vi.useRealTimers()
  1004. const contextTriggers = view.getAllByRole('button', {
  1005. name: 'Article actions',
  1006. })
  1007. expect(contextTriggers).toHaveLength(3)
  1008. await view.events.click(contextTriggers[0])
  1009. expect(view.queryByText('Reply')).not.toBeInTheDocument()
  1010. })
  1011. })