ticket-detail-view.spec.ts 33 KB

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