quote-conversion.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import { expect, type Locator, type Page } from '@playwright/test'
  2. import { Client } from 'pg'
  3. import { ownerOrganizationId, userIdFor } from './db'
  4. import { settle } from './hydration'
  5. /**
  6. * Adding a quote to a work order the vehicle already has open.
  7. *
  8. * The fixtures are written straight into the database because they are not
  9. * what is under test: a vehicle of the spec's own (so no other spec's jobs are
  10. * on it, and `/service/new` cannot hand back another spec's five-second draft)
  11. * and a stocked part nobody else counts. Everything the feature does is then
  12. * driven through the page.
  13. */
  14. async function withDb<T>(fn: (db: Client) => Promise<T>): Promise<T> {
  15. const url = process.env.E2E_DATABASE_URL
  16. if (!url) throw new Error('E2E_DATABASE_URL is not set. See e2e/README.md.')
  17. const db = new Client({ connectionString: url })
  18. await db.connect()
  19. try {
  20. return await fn(db)
  21. } finally {
  22. await db.end()
  23. }
  24. }
  25. export interface PlantedVehicle {
  26. id: string
  27. /** Unique across runs, so the quote dialog's vehicle search finds this one only. */
  28. model: string
  29. }
  30. /** A customer and a vehicle of their own in the seeded workshop, with no jobs on it. */
  31. export async function plantVehicle(model: string): Promise<PlantedVehicle> {
  32. const organizationId = await ownerOrganizationId()
  33. const userId = await userIdFor('demo@torqvoice.com')
  34. return withDb(async (db) => {
  35. const customer = await db.query<{ id: string }>(
  36. `insert into customers (id, name, "userId", "organizationId", "updatedAt")
  37. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
  38. returning id`,
  39. [`${model} owner`, userId, organizationId]
  40. )
  41. const vehicle = await db.query<{ id: string }>(
  42. `insert into vehicles (id, make, model, year, "licensePlate", "userId", "organizationId", "customerId", "updatedAt")
  43. values (md5(random()::text || clock_timestamp()::text), 'E2E', $1, 2021, $2, $3, $4, $5, now())
  44. returning id`,
  45. [model, `E2E-${model.slice(-6)}`, userId, organizationId, customer.rows[0].id]
  46. )
  47. return { id: vehicle.rows[0].id, model }
  48. })
  49. }
  50. /** The number a quote was given, which is what the job's hero names it by. */
  51. export async function quoteNumberOf(quoteUrl: string): Promise<string> {
  52. const id = new URL(quoteUrl).pathname.split('/').pop()
  53. return withDb(async (db) => {
  54. const result = await db.query<{ quoteNumber: string | null }>(
  55. `select "quoteNumber" from quotes where id = $1`,
  56. [id]
  57. )
  58. const number = result.rows[0]?.quoteNumber
  59. if (!number) throw new Error(`quote ${id} has no number`)
  60. return number
  61. })
  62. }
  63. /** A stocked part of the spec's own, so no other spec moves its count. */
  64. export async function plantInventoryPart(part: {
  65. name: string
  66. quantity: number
  67. unitCost: number
  68. sellPrice: number
  69. }): Promise<string> {
  70. const organizationId = await ownerOrganizationId()
  71. const userId = await userIdFor('demo@torqvoice.com')
  72. return withDb(async (db) => {
  73. const result = await db.query<{ id: string }>(
  74. `insert into inventory_parts (id, name, quantity, "unitCost", "sellPrice", "userId", "organizationId", "updatedAt")
  75. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5, $6, now())
  76. returning id`,
  77. [part.name, part.quantity, part.unitCost, part.sellPrice, userId, organizationId]
  78. )
  79. return result.rows[0].id
  80. })
  81. }
  82. /** Takes a planted part out of the pickers again, keeping its ledger. */
  83. export async function archiveInventoryPart(id: string): Promise<void> {
  84. await withDb((db) =>
  85. db.query(`update inventory_parts set "isArchived" = true where id = $1`, [id])
  86. )
  87. }
  88. /** The convert dialog, by name: an announcement card is a dialog too. */
  89. export function convertDialog(page: Page): Locator {
  90. return page.getByRole('dialog', { name: 'Convert Quote to Work Order' })
  91. }
  92. /** Opens the convert dialog on the quote open in the page. */
  93. export async function openConvertDialog(page: Page): Promise<Locator> {
  94. await settle(page)
  95. const dialog = convertDialog(page)
  96. await expect(async () => {
  97. await page.getByRole('button', { name: 'Convert to Work Order', exact: true }).click()
  98. await expect(dialog).toBeVisible({ timeout: 2_000 })
  99. }).toPass({ timeout: 30_000 })
  100. return dialog
  101. }
  102. /** The two ways the dialog offers, as radios named by their labels. */
  103. export function newOrderRadio(dialog: Locator): Locator {
  104. return dialog.getByRole('radio', { name: 'Create a new work order' })
  105. }
  106. export function existingOrderRadio(dialog: Locator): Locator {
  107. return dialog.getByRole('radio', { name: 'Add to an existing work order' })
  108. }
  109. /**
  110. * The open-job picker. The dialog holds two comboboxes, the vehicle picker
  111. * first and this one under the "existing" radio, so it is the last.
  112. */
  113. export function workOrderPicker(dialog: Locator): Locator {
  114. return dialog.getByRole('combobox').last()
  115. }
  116. /**
  117. * Chooses "Add to an existing work order" and the job named by its label.
  118. * Returns the dialog's confirm button, which then reads "Add to work order".
  119. */
  120. export async function chooseExistingOrder(
  121. page: Page,
  122. dialog: Locator,
  123. label: string
  124. ): Promise<Locator> {
  125. const existing = existingOrderRadio(dialog)
  126. // Enabled once the vehicle's open jobs have loaded.
  127. await expect(existing).toBeEnabled({ timeout: 15_000 })
  128. await existing.check()
  129. const picker = workOrderPicker(dialog)
  130. await expect(picker).toBeVisible()
  131. await expect(async () => {
  132. await picker.click()
  133. await expect(page.getByRole('option', { name: label, exact: true })).toBeVisible({
  134. timeout: 2_000,
  135. })
  136. }).toPass({ timeout: 30_000 })
  137. await page.getByRole('option', { name: label, exact: true }).click()
  138. await expect(picker).toHaveText(label)
  139. return dialog.getByRole('button', { name: 'Add to work order', exact: true })
  140. }