ticket-detail-view.spec.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185
  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. name: TicketState.Closed,
  935. },
  936. },
  937. )
  938. ticket.ticket.initialChannel = EnumChannelArea.WhatsAppBusiness
  939. const { waitUntilTicketLoaded } = mockTicketDetailViewGql({
  940. ticket,
  941. articles,
  942. })
  943. const view = await visitView('/tickets/1', {
  944. global: {
  945. stubs: {
  946. transition: false,
  947. },
  948. },
  949. })
  950. await waitUntilTicketLoaded()
  951. vi.useRealTimers()
  952. const contextTriggers = view.getAllByRole('button', {
  953. name: 'Article actions',
  954. })
  955. expect(contextTriggers).toHaveLength(3)
  956. await view.events.click(contextTriggers[0])
  957. expect(view.queryByText('Reply')).not.toBeInTheDocument()
  958. })
  959. })