document.spec.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import { expect, type Page, test } from '@playwright/test'
  2. import { attachment, attachmentBytes, clearMailbox, waitForMail } from '../../support/mail'
  3. import { type PdfContent, pdfContent } from '../../support/pdf'
  4. import {
  5. addQuoteLabor,
  6. addQuotePart,
  7. newQuote,
  8. quotePdfUrl,
  9. quoteShareLink,
  10. saveQuote,
  11. } from '../../support/quote'
  12. import { setTax } from '../../support/settings'
  13. /**
  14. * The quote a customer is actually handed.
  15. *
  16. * Turning a quote into a work order is covered elsewhere; this is the document
  17. * itself, which is a priced offer and has been untested. It is drawn by its
  18. * own renderer (`QuotePDF`), from its own layout, and reaches the customer
  19. * three ways: the workshop's download, a public link, and a copy attached to
  20. * an email. Then the customer answers it, and the answer has to come back.
  21. *
  22. * The figures are pinned: two parts at 900 and three hours at 800, so 1,800
  23. * and 2,400 make a subtotal of 4,200, and 25% on top makes 5,250. If one of
  24. * these moves, the quote path has changed.
  25. */
  26. test.describe.configure({ mode: 'serial' })
  27. const stamp = Date.now()
  28. const TITLE = `E2E quote ${stamp}`
  29. const PART = `E2E clutch kit ${stamp}\nLuK 624 3163 33\nwith release bearing`
  30. const LABOR = 'Replace the clutch\nand bleed the system'
  31. const CUSTOMER = `e2e-quote-${stamp}@example.com`
  32. /** What every copy of this quote has to say. */
  33. const FACTS = ['$1,800.00', '$2,400.00', '$4,200.00', '$5,250.00']
  34. let quoteUrl = ''
  35. let shareUrl = ''
  36. let downloaded: PdfContent
  37. let shared: PdfContent
  38. async function workshopPdf(page: Page): Promise<PdfContent> {
  39. const id = quoteUrl.split('/').pop()
  40. const response = await page.request.get(`/api/protected/quotes/${id}/pdf`, { timeout: 60_000 })
  41. expect(response.status()).toBe(200)
  42. expect(response.headers()['content-type']).toContain('application/pdf')
  43. return pdfContent(await response.body())
  44. }
  45. async function expectSaysEverything(pdf: PdfContent, whose: string) {
  46. expect(pdf.pages, `${whose} has pages`).toBeGreaterThan(0)
  47. expect(pdf.flat, `${whose} names the customer`).toContain('Mitchell')
  48. expect(pdf.flat, `${whose} names the vehicle`).toContain('Toyota Camry')
  49. for (const fact of FACTS) {
  50. expect(pdf.flat, `${whose} prints ${fact}`).toContain(fact)
  51. }
  52. // Both lines were written over several lines, and a quote is read as
  53. // carefully as an invoice.
  54. expect(pdf.text, `${whose} keeps the part on its three lines`).toContain(PART)
  55. expect(pdf.text, `${whose} keeps the labour on its two lines`).toContain(LABOR)
  56. }
  57. test.beforeAll(async ({ browser }) => {
  58. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  59. // The figures above are pinned, so the tax that makes them is set here. This
  60. // is also the setting the rest of the suite leaves behind.
  61. await setTax(page, { enabled: true, rate: 25, inclusive: false, label: '' })
  62. quoteUrl = await newQuote(page, TITLE)
  63. await addQuotePart(page, { name: PART, quantity: 2, unitPrice: 900 })
  64. await addQuoteLabor(page, { description: LABOR, hours: 3, rate: 800 })
  65. await saveQuote(page)
  66. await page.close()
  67. })
  68. test.describe('a quote as the customer receives it', () => {
  69. test('the editor adds up to the figures the quote will carry', async ({ page }) => {
  70. await page.goto(quoteUrl)
  71. // Loosely matched: the thousands separator follows the workshop's locale,
  72. // and this assertion is about arithmetic.
  73. for (const figure of [/1[\s.,]?800/, /2[\s.,]?400/, /4[\s.,]?200/, /5[\s.,]?250/]) {
  74. await expect(page.getByText(figure).first()).toBeVisible()
  75. }
  76. })
  77. test('the workshop can download it, and it says everything', async ({ page }) => {
  78. await page.goto(quoteUrl)
  79. downloaded = await workshopPdf(page)
  80. await expectSaysEverything(downloaded, "the workshop's copy")
  81. })
  82. test('the preview is that same download, not a second rendering', async ({ page }) => {
  83. await page.goto(quoteUrl)
  84. const id = quoteUrl.split('/').pop()
  85. const request = page.waitForRequest((r) => r.url().includes(`/quotes/${id}/pdf`))
  86. await expect(async () => {
  87. await page.getByRole('button', { name: 'Preview', exact: true }).click()
  88. await expect(page.getByRole('dialog', { name: 'PDF preview' })).toBeVisible({
  89. timeout: 2_000,
  90. })
  91. }).toPass({ timeout: 30_000 })
  92. await request
  93. await expect(page.getByText('Could not generate the PDF preview.')).toHaveCount(0)
  94. })
  95. test('the public link opens the same document, word for word', async ({ page }) => {
  96. await page.goto(quoteUrl)
  97. shareUrl = await quoteShareLink(page)
  98. // The page the customer opens.
  99. await page.goto(shareUrl)
  100. for (const fact of FACTS) {
  101. await expect(
  102. page.getByText(fact).filter({ visible: true }).first(),
  103. `${fact} on the shared quote`
  104. ).toBeVisible()
  105. }
  106. // And the PDF behind its download button.
  107. const response = await page.request.get(quotePdfUrl(shareUrl))
  108. expect(response.status()).toBe(200)
  109. shared = await pdfContent(await response.body())
  110. await expectSaysEverything(shared, "the customer's copy")
  111. expect(shared.pages).toBe(downloaded.pages)
  112. expect(shared.flat).toBe(downloaded.flat)
  113. expect(Math.abs(shared.size - downloaded.size)).toBeLessThan(2_048)
  114. })
  115. test('the emailed copy is that document again', async ({ page }) => {
  116. await clearMailbox()
  117. await page.goto(quoteUrl)
  118. await expect(async () => {
  119. await page.getByRole('button', { name: 'Email', exact: true }).click()
  120. await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
  121. }).toPass({ timeout: 30_000 })
  122. await page.locator('#email').fill(CUSTOMER)
  123. await page.getByRole('button', { name: 'Send Email', exact: true }).click()
  124. const mail = await waitForMail(CUSTOMER, { timeout: 30_000 })
  125. const file = attachment(mail, /\.pdf$/)
  126. expect(file.contentType).toContain('pdf')
  127. const emailed = await pdfContent(attachmentBytes(file))
  128. await expectSaysEverything(emailed, 'the emailed copy')
  129. expect(emailed.pages).toBe(shared.pages)
  130. expect(emailed.flat).toBe(shared.flat)
  131. })
  132. test('the customer accepts it, and the workshop sees the answer', async ({ page }) => {
  133. // Signed out, the way a customer opens a link.
  134. const customer = await page.context().browser()?.newContext()
  135. const customerPage = await (customer as NonNullable<typeof customer>).newPage()
  136. await customerPage.goto(shareUrl)
  137. await expect(async () => {
  138. await customerPage.getByRole('button', { name: 'Accept Quote' }).click()
  139. await expect(customerPage.getByText('Quote Accepted')).toBeVisible({ timeout: 3_000 })
  140. }).toPass({ timeout: 30_000 })
  141. await customer?.close()
  142. // The workshop's own page reads the new status.
  143. await page.goto(quoteUrl)
  144. await expect(page.getByText('Accepted').first()).toBeVisible()
  145. })
  146. })