sending.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import { expect, type Page, test } from '@playwright/test'
  2. import { customerOfVehicle, forgetEmailTemplates, ownerOrganizationId } from '../../support/db'
  3. import { openPreset, preview, railBlock, saveDesign } from '../../support/email-designer'
  4. import { settle } from '../../support/hydration'
  5. import { clearMailbox, waitForMail } from '../../support/mail'
  6. import { addPart, newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
  7. /**
  8. * What the workshop designed is what the customer receives.
  9. *
  10. * The designer is only worth anything if the mail that goes out is the one on
  11. * screen, so this file designs a template with a sentence nothing else says,
  12. * makes it the one the workshop sends invoices with, and then sends real mail
  13. * and reads it out of the sink.
  14. *
  15. * Three things are asserted of every mail, because each has its own way of
  16. * going wrong: the words came from the active template rather than the
  17. * built-in preset, every tag was filled (a literal "{customer_name}" in a
  18. * customer's inbox is the failure this feature can produce), and the
  19. * plain-text half is there, since a mail with only an HTML part is what a
  20. * spam filter looks for.
  21. */
  22. test.describe.configure({ mode: 'serial' })
  23. const stamp = Date.now()
  24. const TEMPLATE = `E2E sent invoice ${stamp}`
  25. /** The workshop's own words, so a preset cannot pass for them. */
  26. const SENTENCE = `Thank you for your custom, ${stamp}.`
  27. const RECIPIENT = `e2e-mail-${stamp}@example.com`
  28. let jobUrl = ''
  29. let invoiceNumber = ''
  30. let customer: { name: string; email: string }
  31. /** Every literal tag left in a mail, which should be none. */
  32. function leftoverTags(...parts: string[]): string[] {
  33. return parts.flatMap((part) => part.match(/\{[a-z_]+\}/g) ?? [])
  34. }
  35. /** Opens the email dialog on the work order and sends it. */
  36. async function emailInvoice(page: Page, to: string, attachPdf: boolean): Promise<void> {
  37. await page.goto(jobUrl)
  38. await expect(async () => {
  39. await page.getByRole('button', { name: 'Email', exact: true }).click()
  40. await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
  41. }).toPass({ timeout: 30_000 })
  42. await page.locator('#email').fill(to)
  43. const attach = page.locator('#attach-pdf-send')
  44. await expect(attach).toBeVisible()
  45. // The workshop's default answers this, so it is set rather than clicked.
  46. if ((await attach.getAttribute('data-state')) !== (attachPdf ? 'checked' : 'unchecked')) {
  47. await attach.click()
  48. }
  49. await expect(attach).toHaveAttribute('data-state', attachPdf ? 'checked' : 'unchecked')
  50. await page.getByRole('button', { name: 'Send Email', exact: true }).click()
  51. }
  52. test.beforeAll(async ({ browser }) => {
  53. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  54. // A job worth a real invoice: one part, saved, so the mail has a number, a
  55. // vehicle and a total to talk about.
  56. const vehicleUrl = await seededVehicleUrl(page)
  57. customer = await customerOfVehicle(vehicleUrl.split('/').pop() ?? '')
  58. jobUrl = await newWorkOrder(page, vehicleUrl, `E2E mail job ${stamp}`)
  59. await addPart(page, { name: `E2E gasket ${stamp}`, quantity: 2, unitPrice: 250 })
  60. await saveWorkOrder(page)
  61. invoiceNumber = await page.getByLabel('Invoice Number').inputValue()
  62. // The workshop's own invoice mail: the preset's words replaced by one
  63. // sentence and the tags a customer's mail has to fill.
  64. await openPreset(page, 'invoice_sent')
  65. await expect(async () => {
  66. await railBlock(page, 'intro').click()
  67. await expect(page.getByLabel('Text', { exact: true })).toBeVisible({ timeout: 2_000 })
  68. }).toPass({ timeout: 30_000 })
  69. await page
  70. .getByLabel('Text', { exact: true })
  71. .fill(`${SENTENCE} {customer_name}, your {vehicle} is done. Total {total}.`)
  72. await expect(preview(page).locator('[data-block="intro"]').getByText(SENTENCE)).toBeVisible()
  73. await saveDesign(page, TEMPLATE)
  74. await page.close()
  75. })
  76. test.afterAll(async () => {
  77. // Back to the built-in preset, or every other spec's mail is this one.
  78. await forgetEmailTemplates('E2E ')
  79. })
  80. test.describe('a test send from the designer', () => {
  81. test('arrives marked as a test, in both halves, with nothing left unfilled', async ({ page }) => {
  82. await clearMailbox()
  83. await page.goto(`/settings/email-templates`)
  84. await settle(page)
  85. // The designer's own Send test, on the template that is now in use.
  86. await openPreset(page, 'quote_sent')
  87. await page.getByRole('button', { name: 'Send test' }).click()
  88. const dialog = page.getByRole('dialog', { name: 'Send a test email' })
  89. await dialog.getByPlaceholder('Email address').fill(RECIPIENT)
  90. await dialog.getByRole('button', { name: 'Send', exact: true }).click()
  91. const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
  92. // Marked, so a test sent to a customer by accident says what it is.
  93. expect(mail.subject).toContain('[Test]')
  94. expect(mail.html.length, 'the HTML half').toBeGreaterThan(200)
  95. expect(mail.text.length, 'the plain-text half').toBeGreaterThan(40)
  96. // Sample data fills the tags: a test mail full of braces tells a workshop
  97. // nothing about what a customer will see.
  98. expect(leftoverTags(mail.html, mail.text)).toEqual([])
  99. })
  100. })
  101. test.describe('an invoice a customer is sent', () => {
  102. test('is written with the workshop’s template, not the built-in one', async ({ page }) => {
  103. await clearMailbox()
  104. await emailInvoice(page, RECIPIENT, true)
  105. const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
  106. expect(mail.html, 'the words the workshop designed').toContain(SENTENCE)
  107. expect(mail.text, 'and in the plain-text half too').toContain(SENTENCE)
  108. // The tags filled from this job, not from sample data.
  109. expect(mail.html).toContain(invoiceNumber)
  110. expect(mail.html).toContain(customer.name)
  111. expect(leftoverTags(mail.html, mail.text)).toEqual([])
  112. // With the PDF attached, the mail says so.
  113. expect(mail.attachments.map((file) => file.filename).join(' ')).toContain('.pdf')
  114. expect(mail.html, 'the attachment note').toContain('attached')
  115. })
  116. test('says nothing about an attachment when it is sent as a link', async ({ page }) => {
  117. await clearMailbox()
  118. await emailInvoice(page, RECIPIENT, false)
  119. const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
  120. expect(mail.attachments, 'no PDF rode along').toHaveLength(0)
  121. expect(mail.html, 'and no note about one').not.toContain('A PDF copy is attached')
  122. // The link instead, which is the whole point of sending it this way.
  123. expect(mail.html).toMatch(/\/share\/invoice\//)
  124. expect(leftoverTags(mail.html, mail.text)).toEqual([])
  125. })
  126. })
  127. test.describe('a message to a customer', () => {
  128. test('is wrapped in the workshop’s mail, and the words are shown as words', async ({ page }) => {
  129. await clearMailbox()
  130. await page.goto(jobUrl)
  131. await settle(page)
  132. // Typed by a service adviser, including something that looks like markup:
  133. // it has to reach the customer as text, not as formatting.
  134. const typed = `Ready for collection <b>today</b> ${stamp}`
  135. await expect(async () => {
  136. await page.getByRole('button', { name: 'Notify' }).click()
  137. await expect(page.getByRole('dialog', { name: /^Notify / })).toBeVisible({ timeout: 2_000 })
  138. }).toPass({ timeout: 30_000 })
  139. const dialog = page.getByRole('dialog', { name: /^Notify / })
  140. await dialog.getByRole('textbox').first().fill(typed)
  141. const email = dialog.locator('#notify-email')
  142. if ((await email.getAttribute('data-state')) !== 'checked') await email.click()
  143. await dialog.getByRole('button', { name: 'Send', exact: true }).click()
  144. const mail = await waitForMail(customer.email, { timeout: 30_000 })
  145. // The workshop's message template around the adviser's words.
  146. expect(mail.text).toContain(`Ready for collection <b>today</b> ${stamp}`)
  147. expect(mail.html, 'the markup is escaped, not rendered').not.toContain('<b>today</b>')
  148. expect(mail.html).toContain('&lt;b&gt;today&lt;/b&gt;')
  149. expect(leftoverTags(mail.html, mail.text)).toEqual([])
  150. })
  151. })
  152. test.describe('a customer asking for a sign-in link', () => {
  153. /** The portal switch saves as it is turned, so there is no Save to press. */
  154. async function setPortal(page: Page, open: boolean): Promise<void> {
  155. await page.goto('/settings/customer-portal')
  156. await settle(page)
  157. const toggle = page.locator('#portal-enabled')
  158. await expect(toggle).toBeVisible()
  159. if ((await toggle.getAttribute('aria-checked')) === String(open)) return
  160. await expect(async () => {
  161. await toggle.click()
  162. await expect(toggle).toHaveAttribute('aria-checked', String(open), { timeout: 3_000 })
  163. }).toPass({ timeout: 30_000 })
  164. }
  165. let wasOpen = false
  166. test('is sent the portal template, with a link that signs them in', async ({ page, request }) => {
  167. await page.goto('/settings/customer-portal')
  168. await settle(page)
  169. wasOpen = (await page.locator('#portal-enabled').getAttribute('aria-checked')) === 'true'
  170. await setPortal(page, true)
  171. await clearMailbox()
  172. const organizationId = await ownerOrganizationId()
  173. // Asked the way the portal's own sign-in form asks, which is a public
  174. // route: no session, and a workshop id in the path.
  175. const asked = await request.post(`/api/public/portal/${organizationId}/auth/request`, {
  176. data: { email: customer.email },
  177. })
  178. expect(asked.ok(), 'the workshop accepted the request').toBe(true)
  179. const mail = await waitForMail(customer.email, { timeout: 30_000 })
  180. const link = (mail.html.match(/https?:\/\/[^"'\s]*\/auth\/verify\?token=[^"'\s&]+/) ?? [])[0]
  181. expect(link, 'the mail carries a sign-in link').toBeTruthy()
  182. if (!link) throw new Error(`no sign-in link in "${mail.subject}"`)
  183. expect(mail.text, 'and spells it out for a text-only client').toMatch(/https?:\/\//)
  184. expect(leftoverTags(mail.html, mail.text)).toEqual([])
  185. // Following it is the only proof that the mail is worth sending.
  186. const customerPage = await page
  187. .context()
  188. .browser()
  189. ?.newPage({
  190. storageState: { cookies: [], origins: [] },
  191. })
  192. if (!customerPage) throw new Error('no browser to open the link with')
  193. await customerPage.goto(link)
  194. await expect(customerPage, 'the link lands the customer inside the portal').toHaveURL(
  195. /\/portal\//,
  196. { timeout: 30_000 }
  197. )
  198. await expect(customerPage.getByText(/verify|invalid|expired/i)).toHaveCount(0)
  199. await customerPage.close()
  200. })
  201. test.afterAll(async ({ browser }) => {
  202. // Left as it was found: the portal is off in a seeded workshop.
  203. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  204. await setPortal(page, wasOpen)
  205. await page.close()
  206. })
  207. })
  208. test.describe('a logo in the mail', () => {
  209. test('is uploaded, prepared, and fetchable by a mail client with no session', async ({
  210. page,
  211. playwright,
  212. }) => {
  213. await openPreset(page, 'invoice_sent')
  214. await expect(async () => {
  215. await railBlock(page, 'header').click()
  216. await expect(page.getByRole('button', { name: 'Upload logo' })).toBeVisible({
  217. timeout: 2_000,
  218. })
  219. }).toPass({ timeout: 30_000 })
  220. // The workshop's mark, with transparent margins, as one arrives from a
  221. // designer. The route trims it and prepares it for mail.
  222. await page.locator('input[type="file"]').first().setInputFiles('e2e/fixtures/email-logo.png')
  223. const logo = preview(page).locator('[data-block="header"] img')
  224. await expect(logo, 'the header draws the logo instead of the name').toBeVisible({
  225. timeout: 30_000,
  226. })
  227. await clearMailbox()
  228. await page.getByRole('button', { name: 'Send test' }).click()
  229. const dialog = page.getByRole('dialog', { name: 'Send a test email' })
  230. await dialog.getByPlaceholder('Email address').fill(RECIPIENT)
  231. await dialog.getByRole('button', { name: 'Send', exact: true }).click()
  232. const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
  233. const src = (mail.html.match(/<img[^>]+src="([^"]+)"/) ?? [])[1]
  234. expect(src, 'the mail carries the logo').toBeTruthy()
  235. if (!src) throw new Error('no image in the test mail')
  236. // A mail client has no cookies, so a logo behind the app's session is a
  237. // broken picture in every customer's inbox. The address in the mail has
  238. // to be the public one, and it has to answer.
  239. //
  240. // The empty storage state is spelled out because a request context made
  241. // from the fixture inherits the project's, and the owner's cookie would
  242. // answer for a mail client that has none.
  243. expect(src, 'served from the public route').toContain('/api/public/email-')
  244. const stranger = await playwright.request.newContext({
  245. baseURL: new URL(src).origin,
  246. storageState: { cookies: [], origins: [] },
  247. })
  248. const fetched = await stranger.get(src)
  249. expect(fetched.status(), `${src} answers a mail client`).toBe(200)
  250. expect(fetched.headers()['content-type']).toContain('image/')
  251. expect(Number(fetched.headers()['content-length'] ?? '0'), 'small enough to mail').toBeLessThan(
  252. 1_000_000
  253. )
  254. await stranger.dispose()
  255. })
  256. })