lock.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import { expect, type Page, test } from '@playwright/test'
  2. import { settle } from '../../support/hydration'
  3. import { clearMailbox, waitForMail } from '../../support/mail'
  4. import { addQuotePart, newQuote, quoteShareLink, saveQuote } from '../../support/quote'
  5. import { setQuoteLock } from '../../support/settings'
  6. /**
  7. * A locked quote still becomes a work order.
  8. *
  9. * The lock freezes what a quote says it costs, and nothing else: converting it,
  10. * copying its link and sending it are not edits. The page used to disable the
  11. * whole details tab through one `<fieldset disabled>`, and a disabled fieldset
  12. * disables every button inside it too, so "Convert to Work Order" went grey
  13. * the moment a customer accepted. A workshop reported it as a glitch: the
  14. * quote they had just been told was accepted could not be turned into the job.
  15. *
  16. * The same report turned up two more things a locked quote has to allow: a
  17. * status that keeps it locked (a quote accepted over the phone), and dismissing
  18. * the customer's acceptance from the dashboard. Dismissing used to put the
  19. * quote back to draft, which erased the acceptance and released the lock, and
  20. * the row stayed on screen until the page was reloaded.
  21. *
  22. * Both triggers are walked, because they lock at different moments and the
  23. * button has to survive each of them. The quote setting is off by default and
  24. * is put back afterwards.
  25. */
  26. test.describe.configure({ mode: 'serial' })
  27. const stamp = Date.now()
  28. const CUSTOMER = `e2e-quote-lock-${stamp}@example.com`
  29. /** The seeded Camry belongs to James Mitchell, who is who the Share dialog notifies. */
  30. const SEEDED_CUSTOMER_EMAIL = 'james.mitchell@gmail.com'
  31. /** The editor's price fields are the part the lock is for, so they have to be frozen. */
  32. async function expectPricesFrozen(page: Page, banner: string) {
  33. await expect(page.getByText(banner, { exact: true })).toBeVisible()
  34. await expect(page.getByPlaceholder('Name *').first()).toBeDisabled()
  35. }
  36. /** Converts the open quote from its own page and checks the job carries its line. */
  37. async function convertToWorkOrder(page: Page, quoteUrl: string, partName: string) {
  38. const convert = page.getByRole('button', { name: 'Convert to Work Order', exact: true })
  39. await expect(convert).toBeEnabled()
  40. const dialog = page.getByRole('dialog', { name: 'Convert Quote to Work Order' })
  41. await expect(async () => {
  42. await convert.click()
  43. await expect(dialog).toBeVisible({ timeout: 2_000 })
  44. }).toPass({ timeout: 30_000 })
  45. // The quote's own vehicle is chosen already, so Convert is ready to press.
  46. const confirm = dialog.getByRole('button', { name: 'Convert', exact: true })
  47. await expect(confirm).toBeEnabled()
  48. await confirm.click()
  49. await page.waitForURL(/\/vehicles\/[^/]+\/service\/[^/]+$/, { timeout: 60_000 })
  50. await settle(page)
  51. await expect(page.getByPlaceholder('Name *').first()).toHaveValue(partName)
  52. // Back on the quote, it is marked converted and offers no second conversion.
  53. await page.goto(quoteUrl)
  54. await settle(page)
  55. await expect(page.getByText('Converted', { exact: true }).first()).toBeVisible()
  56. await expect(
  57. page.getByRole('button', { name: 'Convert to Work Order', exact: true })
  58. ).toHaveCount(0)
  59. }
  60. test.afterAll(async ({ browser }) => {
  61. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  62. await setQuoteLock(page, { enabled: false, trigger: 'accepted' })
  63. await page.close()
  64. })
  65. test.describe('a locked quote can still become a work order', () => {
  66. test('locked once sent: emailed to the customer, then converted', async ({ page }) => {
  67. await setQuoteLock(page, { enabled: true, trigger: 'sent' })
  68. const part = `E2E lock sent part ${stamp}`
  69. const quoteUrl = await newQuote(page, `E2E locked when sent ${stamp}`)
  70. await addQuotePart(page, { name: part, quantity: 1, unitPrice: 500 })
  71. await saveQuote(page)
  72. // Sent the way the workshop in the report sent theirs.
  73. await clearMailbox()
  74. await expect(async () => {
  75. await page.getByRole('button', { name: 'Email', exact: true }).click()
  76. await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
  77. }).toPass({ timeout: 30_000 })
  78. await page.locator('#email').fill(CUSTOMER)
  79. await page.getByRole('button', { name: 'Send Email', exact: true }).click()
  80. await waitForMail(CUSTOMER, { timeout: 30_000 })
  81. await page.goto(quoteUrl)
  82. await settle(page)
  83. await expectPricesFrozen(page, 'This quote is locked because it has been sent')
  84. // The customer says yes on the phone. A status that keeps the quote locked
  85. // is not an edit, and one that would release the lock is not offered.
  86. const status = page.getByRole('combobox', { name: 'Status' })
  87. await expect(status).toBeEnabled()
  88. await expect(async () => {
  89. await status.click()
  90. await expect(page.getByRole('option', { name: 'Accepted' })).toBeVisible({ timeout: 2_000 })
  91. }).toPass({ timeout: 30_000 })
  92. await expect(page.getByRole('option', { name: 'Draft' })).toHaveAttribute(
  93. 'aria-disabled',
  94. 'true'
  95. )
  96. await page.getByRole('option', { name: 'Accepted' }).click()
  97. await expect(status).toContainText('Accepted')
  98. // Disabled while the change is written, so enabled again means it landed.
  99. await expect(status).toBeEnabled()
  100. await page.reload()
  101. await settle(page)
  102. await expect(page.getByRole('combobox', { name: 'Status' })).toContainText('Accepted')
  103. await expectPricesFrozen(page, 'This quote is locked because it has been sent')
  104. await convertToWorkOrder(page, quoteUrl, part)
  105. })
  106. test('locked once accepted: the customer accepts by link, then converted', async ({ page }) => {
  107. await setQuoteLock(page, { enabled: true, trigger: 'accepted' })
  108. const part = `E2E lock accepted part ${stamp}`
  109. const title = `E2E locked when accepted ${stamp}`
  110. const quoteUrl = await newQuote(page, title)
  111. await addQuotePart(page, { name: part, quantity: 1, unitPrice: 700 })
  112. await saveQuote(page)
  113. const shareUrl = await quoteShareLink(page)
  114. // Telling the customer from the Share dialog closes it once the mail has
  115. // gone, instead of leaving a finished dialog on screen.
  116. await clearMailbox()
  117. const shareDialog = page.getByRole('dialog', { name: 'Share Quote' })
  118. await expect(async () => {
  119. await page.getByRole('button', { name: 'Share', exact: true }).click()
  120. await expect(shareDialog).toBeVisible({ timeout: 2_000 })
  121. }).toPass({ timeout: 30_000 })
  122. await shareDialog.locator('#notify-email-quote').click()
  123. await shareDialog.getByRole('button', { name: 'Send Notification', exact: true }).click()
  124. await expect(shareDialog).toBeHidden({ timeout: 30_000 })
  125. await waitForMail(SEEDED_CUSTOMER_EMAIL, { timeout: 30_000 })
  126. // Sharing sends it, and under this trigger a sent quote is still open.
  127. await page.goto(quoteUrl)
  128. await settle(page)
  129. await expect(page.getByPlaceholder('Name *').first()).toBeEnabled()
  130. // Signed out, the way a customer opens the link in their mail.
  131. const customer = await page
  132. .context()
  133. .browser()!
  134. .newContext({
  135. storageState: { cookies: [], origins: [] },
  136. })
  137. const customerPage = await customer.newPage()
  138. await customerPage.goto(shareUrl)
  139. await expect(async () => {
  140. await customerPage.getByRole('button', { name: 'Accept Quote' }).click()
  141. await expect(customerPage.getByText('Quote Accepted')).toBeVisible({ timeout: 3_000 })
  142. }).toPass({ timeout: 30_000 })
  143. await customer.close()
  144. await page.goto(quoteUrl)
  145. await settle(page)
  146. await expectPricesFrozen(page, 'This quote is locked because it has been accepted')
  147. // Copying the link is not an edit either.
  148. await expect(page.getByRole('button', { name: 'Copy link', exact: true })).toBeEnabled()
  149. // Dismissed from the dashboard, the response leaves the list without a
  150. // reload and stays gone, while the quote keeps the acceptance and its lock.
  151. await page.goto('/')
  152. await settle(page)
  153. const row = page.getByTestId('quote-response-row').filter({ hasText: title })
  154. await expect(row).toBeVisible()
  155. await expect(async () => {
  156. await row.getByRole('button', { name: 'Dismiss response' }).click()
  157. await expect(row).toHaveCount(0, { timeout: 2_000 })
  158. }).toPass({ timeout: 30_000 })
  159. await page.reload()
  160. await settle(page)
  161. await expect(page.getByTestId('quote-response-row').filter({ hasText: title })).toHaveCount(0)
  162. await page.goto(quoteUrl)
  163. await settle(page)
  164. await expectPricesFrozen(page, 'This quote is locked because it has been accepted')
  165. await convertToWorkOrder(page, quoteUrl, part)
  166. })
  167. })