ticket-detail-view.spec.ts 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186
  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 { TicketArticlesDocument } from '#shared/entities/ticket/graphql/queries/ticket/articles.api.ts'
  17. import { TicketArticleUpdatesDocument } from '#shared/entities/ticket/graphql/subscriptions/ticketArticlesUpdates.api.ts'
  18. import { TicketUpdatesDocument } from '#shared/entities/ticket/graphql/subscriptions/ticketUpdates.api.ts'
  19. import { TicketState } from '#shared/entities/ticket/types.ts'
  20. import { TicketArticleRetrySecurityProcessDocument } from '#shared/entities/ticket-article/graphql/mutations/ticketArticleRetrySecurityProcess.api.ts'
  21. import {
  22. EnumChannelArea,
  23. EnumSecurityStateType,
  24. type TicketArticleRetrySecurityProcessMutation,
  25. } from '#shared/graphql/types.ts'
  26. import { convertToGraphQLId } from '#shared/graphql/utils.ts'
  27. import { TicketWithMentionLimitDocument } from '#mobile/entities/ticket/graphql/queries/ticketWithMentionLimit.api.ts'
  28. import { clearTicketArticlesLoadedState } from '../composable/useTicketArticlesVariables.ts'
  29. import { TicketLiveUserDeleteDocument } from '../graphql/mutations/live-user/delete.api.ts'
  30. import { TicketLiveUserUpsertDocument } from '../graphql/mutations/live-user/ticketLiveUserUpsert.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.firstArticles!.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.firstArticles!.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(
  215. TicketWithMentionLimitDocument,
  216. ).willFailWithNotFoundError('The ticket 9866 could not be found')
  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.firstArticles!.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.firstArticles!.edges[0].node
  378. article.preferences = {
  379. remote_content_removed: true,
  380. }
  381. article.attachmentsWithoutInline = [
  382. {
  383. id: convertToGraphQLId('Store', 1),
  384. internalId: 1,
  385. name: 'message',
  386. preferences: {
  387. 'original-format': true,
  388. },
  389. },
  390. ]
  391. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  392. articles: articlesQuery,
  393. })
  394. const view = await visitView('/tickets/1')
  395. await waitUntilTicketLoaded()
  396. const blockedContent = view.getByRole('button', { name: 'Blocked Content' })
  397. await view.events.click(blockedContent)
  398. await view.events.click(view.getByText('Original Formatting'))
  399. expect(view.queryByTestId('popupWindow')).not.toBeInTheDocument()
  400. })
  401. })
  402. describe('ticket viewers inside a ticket', () => {
  403. it('displays information with newer last interaction (and without own entry)', async () => {
  404. const { waitUntilTicketLoaded, mockTicketLiveUsersSubscription } =
  405. mockTicketDetailViewGql()
  406. mockUserCurrent({
  407. lastname: 'Doe',
  408. firstname: 'John',
  409. fullname: 'John Doe',
  410. id: convertToGraphQLId('User', 4),
  411. })
  412. mockPermissions(['ticket.agent'])
  413. const view = await visitView('/tickets/1')
  414. await waitUntilTicketLoaded()
  415. await mockTicketLiveUsersSubscription.next({
  416. data: {
  417. ticketLiveUserUpdates: {
  418. liveUsers: [
  419. {
  420. user: {
  421. id: 'gid://zammad/User/4',
  422. firstname: 'Agent 1',
  423. lastname: 'Test',
  424. fullname: 'Agent 1 Test',
  425. __typename: 'User',
  426. },
  427. apps: [
  428. {
  429. name: 'mobile',
  430. editing: false,
  431. lastInteraction: '2022-02-01T10:55:26Z',
  432. __typename: 'TicketLiveUserApp',
  433. },
  434. ],
  435. __typename: 'TicketLiveUser',
  436. },
  437. {
  438. user: {
  439. id: 'gid://zammad/User/160',
  440. firstname: 'John',
  441. lastname: 'Doe',
  442. fullname: 'John Doe',
  443. __typename: 'User',
  444. },
  445. apps: [
  446. {
  447. name: 'desktop',
  448. editing: false,
  449. lastInteraction: '2022-01-31T10:30:24Z',
  450. __typename: 'TicketLiveUserApp',
  451. },
  452. {
  453. name: 'mobile',
  454. editing: false,
  455. lastInteraction: '2022-01-31T16:45:53Z',
  456. __typename: 'TicketLiveUserApp',
  457. },
  458. ],
  459. __typename: 'TicketLiveUser',
  460. },
  461. {
  462. user: {
  463. id: 'gid://zammad/User/165',
  464. firstname: 'Rose',
  465. lastname: 'Nylund',
  466. fullname: 'Rose Nylund',
  467. __typename: 'User',
  468. },
  469. apps: [
  470. {
  471. name: 'mobile',
  472. editing: false,
  473. lastInteraction: '2022-01-31T16:45:53Z',
  474. __typename: 'TicketLiveUserApp',
  475. },
  476. ],
  477. __typename: 'TicketLiveUser',
  478. },
  479. ],
  480. __typename: 'TicketLiveUserUpdatesPayload',
  481. },
  482. },
  483. })
  484. const counter = view.getByLabelText(/Ticket has 2 viewers/)
  485. expect(counter, 'has a counter').toBeInTheDocument()
  486. expect(counter).toHaveTextContent('+1')
  487. await view.events.click(
  488. view.getByRole('button', { name: 'Show ticket viewers' }),
  489. )
  490. await waitUntil(() =>
  491. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  492. )
  493. expect(view.getByText('Opened in tabs')).toBeInTheDocument()
  494. expect(
  495. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  496. ).toHaveTextContent('John Doe')
  497. expect(view.queryByIconName('desktop')).not.toBeInTheDocument()
  498. await mockTicketLiveUsersSubscription.next({
  499. data: {
  500. ticketLiveUserUpdates: {
  501. liveUsers: [
  502. {
  503. user: {
  504. id: 'gid://zammad/User/160',
  505. firstname: 'John',
  506. lastname: 'Doe',
  507. fullname: 'John Doe',
  508. __typename: 'User',
  509. },
  510. apps: [
  511. {
  512. name: 'desktop',
  513. editing: false,
  514. lastInteraction: '2022-01-31T18:30:24Z',
  515. __typename: 'TicketLiveUserApp',
  516. },
  517. {
  518. name: 'mobile',
  519. editing: false,
  520. lastInteraction: '2022-01-31T16:45:53Z',
  521. __typename: 'TicketLiveUserApp',
  522. },
  523. ],
  524. __typename: 'TicketLiveUser',
  525. },
  526. ],
  527. __typename: 'TicketLiveUserUpdatesPayload',
  528. },
  529. },
  530. })
  531. expect(view.queryByIconName('desktop')).toBeInTheDocument()
  532. })
  533. it('editing has always the highest priority', async () => {
  534. const { waitUntilTicketLoaded, mockTicketLiveUsersSubscription } =
  535. mockTicketDetailViewGql()
  536. mockUserCurrent({
  537. lastname: 'Doe',
  538. firstname: 'John',
  539. fullname: 'John Doe',
  540. id: convertToGraphQLId('User', 4),
  541. })
  542. mockPermissions(['ticket.agent'])
  543. const view = await visitView('/tickets/1')
  544. await waitUntilTicketLoaded()
  545. await mockTicketLiveUsersSubscription.next({
  546. data: {
  547. ticketLiveUserUpdates: {
  548. liveUsers: [
  549. {
  550. user: {
  551. id: 'gid://zammad/User/160',
  552. firstname: 'John',
  553. lastname: 'Doe',
  554. fullname: 'John Doe',
  555. __typename: 'User',
  556. },
  557. apps: [
  558. {
  559. name: 'desktop',
  560. editing: true,
  561. lastInteraction: '2022-01-31T10:30:24Z',
  562. __typename: 'TicketLiveUserApp',
  563. },
  564. {
  565. name: 'mobile',
  566. editing: false,
  567. lastInteraction: '2022-01-31T16:45:53Z',
  568. __typename: 'TicketLiveUserApp',
  569. },
  570. ],
  571. __typename: 'TicketLiveUser',
  572. },
  573. ],
  574. __typename: 'TicketLiveUserUpdatesPayload',
  575. },
  576. },
  577. })
  578. await view.events.click(
  579. view.getByRole('button', { name: 'Show ticket viewers' }),
  580. )
  581. await waitUntil(() =>
  582. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  583. )
  584. expect(
  585. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  586. ).toHaveTextContent('John Doe')
  587. expect(view.queryByIconName('desktop-edit')).toBeInTheDocument()
  588. })
  589. it('show current user avatar when editing on other device', async () => {
  590. const { waitUntilTicketLoaded, mockTicketLiveUsersSubscription } =
  591. mockTicketDetailViewGql()
  592. mockUserCurrent({
  593. lastname: 'Doe',
  594. firstname: 'John',
  595. fullname: 'John Doe',
  596. id: convertToGraphQLId('User', 4),
  597. })
  598. mockPermissions(['ticket.agent'])
  599. const view = await visitView('/tickets/1')
  600. await waitUntilTicketLoaded()
  601. await mockTicketLiveUsersSubscription.next({
  602. data: {
  603. ticketLiveUserUpdates: {
  604. liveUsers: [
  605. {
  606. user: {
  607. id: 'gid://zammad/User/4',
  608. firstname: 'Agent 1',
  609. lastname: 'Test',
  610. fullname: 'Agent 1 Test',
  611. __typename: 'User',
  612. },
  613. apps: [
  614. {
  615. name: 'mobile',
  616. editing: false,
  617. lastInteraction: '2022-02-01T10:55:26Z',
  618. __typename: 'TicketLiveUserApp',
  619. },
  620. {
  621. name: 'desktop',
  622. editing: true,
  623. lastInteraction: '2022-02-01T09:55:26Z',
  624. __typename: 'TicketLiveUserApp',
  625. },
  626. ],
  627. __typename: 'TicketLiveUser',
  628. },
  629. ],
  630. __typename: 'TicketLiveUserUpdatesPayload',
  631. },
  632. },
  633. })
  634. await view.events.click(
  635. view.getByRole('button', { name: 'Show ticket viewers' }),
  636. )
  637. await waitUntil(() =>
  638. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  639. )
  640. expect(
  641. view.queryByRole('dialog', { name: 'Ticket viewers' }),
  642. ).toHaveTextContent('Agent 1 Test')
  643. expect(view.queryByIconName('desktop-edit')).toBeInTheDocument()
  644. })
  645. it('customer should only add live user entry but not subscribe', async () => {
  646. mockUserCurrent({
  647. lastname: 'Braun',
  648. firstname: 'Nicole',
  649. fullname: 'Nicole Braun',
  650. id: convertToGraphQLId('User', 3),
  651. })
  652. const {
  653. waitUntilTicketLoaded,
  654. mockTicketLiveUserUpsert,
  655. mockTicketLiveUsersSubscription,
  656. } = mockTicketDetailViewGql({ ticketView: 'customer' })
  657. const view = await visitView('/tickets/1')
  658. await waitUntilTicketLoaded()
  659. await waitUntil(() => mockTicketLiveUserUpsert.calls.resolve === 1)
  660. await mockTicketLiveUsersSubscription.next({
  661. data: {
  662. ticketLiveUserUpdates: {
  663. liveUsers: [
  664. {
  665. user: {
  666. id: 'gid://zammad/User/160',
  667. firstname: 'John',
  668. lastname: 'Doe',
  669. fullname: 'John Doe',
  670. __typename: 'User',
  671. },
  672. apps: [
  673. {
  674. name: 'desktop',
  675. editing: false,
  676. lastInteraction: '2022-01-31T18:30:24Z',
  677. __typename: 'TicketLiveUserApp',
  678. },
  679. {
  680. name: 'mobile',
  681. editing: false,
  682. lastInteraction: '2022-01-31T16:45:53Z',
  683. __typename: 'TicketLiveUserApp',
  684. },
  685. ],
  686. __typename: 'TicketLiveUser',
  687. },
  688. ],
  689. __typename: 'TicketLiveUserUpdatesPayload',
  690. },
  691. },
  692. })
  693. expect(
  694. view.queryByRole('button', { name: 'Show ticket viewers' }),
  695. ).not.toBeInTheDocument()
  696. })
  697. })
  698. describe('ticket add/edit reply article', () => {
  699. beforeEach(() => {
  700. vi.useRealTimers()
  701. })
  702. it('save button is not shown when select field is opened', async () => {
  703. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  704. mockFrontendObjectAttributes: true,
  705. })
  706. const view = await visitView('/tickets/1')
  707. await waitUntilTicketLoaded()
  708. await view.events.click(view.getByRole('button', { name: 'Add reply' }))
  709. await waitUntil(() => view.queryByRole('dialog', { name: 'Add reply' }))
  710. await view.events.type(view.getByLabelText('Text'), 'Testing')
  711. await expect(
  712. view.findByRole('button', { name: 'Save' }),
  713. ).resolves.toBeInTheDocument()
  714. await view.events.click(view.getByRole('combobox', { name: 'Visibility' }))
  715. expect(view.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
  716. await view.events.click(view.getByRole('option', { name: 'Public' }))
  717. await expect(
  718. view.findByRole('button', { name: 'Save' }),
  719. ).resolves.toBeInTheDocument()
  720. })
  721. it('save button is not shown when non-reply dialog field is opened', async () => {
  722. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  723. mockFrontendObjectAttributes: true,
  724. })
  725. const view = await visitView('/tickets/1')
  726. await waitUntilTicketLoaded()
  727. await view.events.click(view.getByRole('button', { name: 'Add reply' }))
  728. await waitUntil(() => view.queryByRole('dialog', { name: 'Add reply' }))
  729. await view.events.type(view.getByLabelText('Text'), 'Testing')
  730. await expect(
  731. view.findByRole('button', { name: 'Save' }),
  732. ).resolves.toBeInTheDocument()
  733. await view.events.click(view.getByRole('button', { name: 'Done' }))
  734. await view.events.click(
  735. view.getByRole('button', { name: 'Show ticket actions' }),
  736. )
  737. await waitUntil(() =>
  738. view.queryByRole('dialog', { name: 'Ticket actions' }),
  739. )
  740. expect(
  741. view.queryByText('You have unsaved changes.'),
  742. ).not.toBeInTheDocument()
  743. await view.events.click(view.getByText('Change customer'))
  744. await waitUntil(() =>
  745. view.queryByRole('dialog', { name: 'Change customer' }),
  746. )
  747. expect(
  748. view.queryByText('You have unsaved changes.'),
  749. ).not.toBeInTheDocument()
  750. })
  751. it('add reply (first time) should hold the form state after save button with an invalid state is used', async () => {
  752. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  753. mockFrontendObjectAttributes: true,
  754. })
  755. const view = await visitView('/tickets/1')
  756. await waitUntilTicketLoaded()
  757. const form = getNode('form-ticket-edit')
  758. await form?.settled
  759. form?.find('title', 'name')?.input('')
  760. await expect(
  761. view.findByLabelText('Validation failed'),
  762. ).resolves.toBeInTheDocument()
  763. await view.events.click(view.getByRole('button', { name: 'Add reply' }))
  764. await waitUntil(() => view.queryByRole('dialog', { name: 'Add reply' }))
  765. await getNode('form-ticket-edit')?.settled
  766. await view.events.type(view.getByLabelText('Text'), 'Testing')
  767. // Wait for form updater.
  768. await getNode('form-ticket-edit')?.settled
  769. await view.events.click(view.getByRole('button', { name: 'Save' }))
  770. expect(view.getByText('This field is required.')).toBeInTheDocument()
  771. expect(form?.find('body', 'name')?.value).toBe('Testing')
  772. })
  773. })
  774. it('correctly redirects from ticket hash-based routes', async () => {
  775. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  776. ticketView: 'agent',
  777. })
  778. await visitView('/#ticket/zoom/1')
  779. await waitUntilTicketLoaded()
  780. const router = getTestRouter()
  781. const route = router.currentRoute.value
  782. expect(route.name).toBe('TicketDetailArticlesView')
  783. expect(route.params).toEqual({ internalId: '1' })
  784. })
  785. it('correctly redirects from ticket hash-based routes with other ids', async () => {
  786. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  787. ticketView: 'agent',
  788. })
  789. await visitView('/#ticket/zoom/1/20')
  790. await waitUntilTicketLoaded()
  791. const router = getTestRouter()
  792. const route = router.currentRoute.value
  793. expect(route.name).toBe('TicketDetailArticlesView')
  794. expect(route.params).toEqual({ internalId: '1' })
  795. })
  796. it("scrolls to the bottom the first time, but doesn't trigger rescroll on subsequent updates", async () => {
  797. const newArticlesQuery = mockArticleQuery(
  798. {
  799. internalId: 1,
  800. bodyWithUrls: '<p>Existing article> all can see this haha</p>',
  801. },
  802. [
  803. {
  804. internalId: 2,
  805. bodyWithUrls:
  806. '<p>Existing article switched to internal> all can see this haha</p>',
  807. },
  808. {
  809. internalId: 3,
  810. bodyWithUrls: '<p>Existing article> all can see this haha</p>',
  811. },
  812. ],
  813. )
  814. const newArticlesQueryAfterUpdate = mockArticleQuery(
  815. {
  816. internalId: 1,
  817. bodyWithUrls: '<p>Existing article> all can see this haha</p>',
  818. },
  819. [
  820. {
  821. internalId: 3,
  822. bodyWithUrls: '<p>Existing article> all can see this haha</p>',
  823. },
  824. ],
  825. )
  826. const { waitUntilTicketLoaded, mockTicketArticleSubscription } =
  827. mockTicketDetailViewGql({
  828. ticketView: 'agent',
  829. articles: [newArticlesQuery, newArticlesQueryAfterUpdate],
  830. })
  831. vi.spyOn(window, 'scrollTo').mockReturnValue()
  832. await visitView('/tickets/1')
  833. await waitUntilTicketLoaded()
  834. const router = getTestRouter()
  835. router.restoreMethods()
  836. expect(Element.prototype.scrollIntoView).toHaveBeenCalledTimes(1)
  837. await mockTicketArticleSubscription.next(
  838. nullableMock({
  839. data: {
  840. ticketArticleUpdates: {
  841. addArticle: {
  842. __typename: 'TicketArticle',
  843. id: convertToGraphQLId('TicketArticle', 100),
  844. createdAt: new Date(2022, 0, 31, 0, 0, 0, 0).toISOString(),
  845. },
  846. },
  847. },
  848. }),
  849. )
  850. expect(Element.prototype.scrollIntoView).toHaveBeenCalledTimes(1)
  851. })
  852. describe('with ticket on a whatsapp channel', () => {
  853. it('shows reply link in the article context when the service window is open', async () => {
  854. const testDate = new Date()
  855. const articles = defaultArticles()
  856. articles.firstArticles!.edges[0].node.type!.name = 'whatsapp message'
  857. const ticket = defaultTicket(
  858. {},
  859. {
  860. whatsapp: {
  861. timestamp_incoming: testDate.getTime(),
  862. },
  863. },
  864. )
  865. ticket.ticket.initialChannel = EnumChannelArea.WhatsAppBusiness
  866. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  867. ticket,
  868. articles,
  869. })
  870. const view = await visitView('/tickets/1', {
  871. global: {
  872. stubs: {
  873. transition: false,
  874. },
  875. },
  876. })
  877. await waitUntilTicketLoaded()
  878. vi.useRealTimers()
  879. const contextTriggers = view.getAllByRole('button', {
  880. name: 'Article actions',
  881. })
  882. expect(contextTriggers).toHaveLength(3)
  883. await view.events.click(contextTriggers[0])
  884. expect(view.getByText('Reply')).toBeInTheDocument()
  885. })
  886. it('hides reply link in the article context when the service window is closed', async () => {
  887. const testDate = new Date()
  888. const articles = defaultArticles()
  889. articles.firstArticles!.edges[0].node.type!.name = 'whatsapp message'
  890. const ticket = defaultTicket(
  891. {},
  892. {
  893. whatsapp: {
  894. timestamp_incoming:
  895. testDate.setHours(testDate.getHours() - 25).valueOf() / 1000,
  896. },
  897. },
  898. )
  899. ticket.ticket.initialChannel = EnumChannelArea.WhatsAppBusiness
  900. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  901. ticket,
  902. articles,
  903. })
  904. const view = await visitView('/tickets/1', {
  905. global: {
  906. stubs: {
  907. transition: false,
  908. },
  909. },
  910. })
  911. await waitUntilTicketLoaded()
  912. vi.useRealTimers()
  913. const contextTriggers = view.getAllByRole('button', {
  914. name: 'Article actions',
  915. })
  916. expect(contextTriggers).toHaveLength(3)
  917. await view.events.click(contextTriggers[0])
  918. expect(view.queryByText('Reply')).not.toBeInTheDocument()
  919. })
  920. it('hides reply link in the article context when the ticket is closed', async () => {
  921. const testDate = new Date()
  922. const articles = defaultArticles()
  923. articles.firstArticles!.edges[0].node.type!.name = 'whatsapp message'
  924. const ticket = defaultTicket(
  925. {},
  926. {
  927. whatsapp: {
  928. timestamp_incoming: testDate.getTime(),
  929. },
  930. },
  931. {
  932. name: 'closed',
  933. stateType: {
  934. id: convertToGraphQLId('TicketStateType', 5),
  935. name: TicketState.Closed,
  936. },
  937. },
  938. )
  939. ticket.ticket.initialChannel = EnumChannelArea.WhatsAppBusiness
  940. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  941. ticket,
  942. articles,
  943. })
  944. const view = await visitView('/tickets/1', {
  945. global: {
  946. stubs: {
  947. transition: false,
  948. },
  949. },
  950. })
  951. await waitUntilTicketLoaded()
  952. vi.useRealTimers()
  953. const contextTriggers = view.getAllByRole('button', {
  954. name: 'Article actions',
  955. })
  956. expect(contextTriggers).toHaveLength(3)
  957. await view.events.click(contextTriggers[0])
  958. expect(view.queryByText('Reply')).not.toBeInTheDocument()
  959. })
  960. })