plan-gates.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { expect, test } from '@playwright/test'
  2. import { completeOnboarding, expectUpgradeOffered, signUpWithPassword } from '../../support/cloud'
  3. import {
  4. customerRows,
  5. giveProPlan,
  6. insertCustomers,
  7. organizationIdFor,
  8. removePlan,
  9. teamInvitations,
  10. userIdFor,
  11. vehicleRows,
  12. } from '../../support/db'
  13. import { settle } from '../../support/hydration'
  14. /**
  15. * What the free plan allows, and what happens at its edge.
  16. *
  17. * Only in cloud mode: a self-hosted install has every feature, which is why
  18. * the rest of the suite never reaches a limit. Two things are pinned here.
  19. * The limit counts what the workshop made and not the sample data onboarding
  20. * seeds, because counted, the samples ate three of the old five slots and a
  21. * new workshop was refused on its third real customer. And reaching a limit
  22. * offers an upgrade rather than an error: a red "Customer limit reached" with
  23. * no way forward is how a workshop that was ready to pay left instead.
  24. *
  25. * A workshop of its own is opened here by signing up, so nothing about the
  26. * seeded one changes.
  27. */
  28. // Signing up and onboarding a workshop in a hook takes longer than a test.
  29. test.describe.configure({ mode: 'serial', timeout: 180_000 })
  30. const stamp = Date.now()
  31. const EMAIL = `e2e-free-${stamp}@example.com`
  32. const PASSWORD = `E2e-pass-${stamp}`
  33. const WORKSHOP = `E2E Free Garage ${stamp}`
  34. const STATE = `e2e/.auth/cloud-free-${stamp}.json`
  35. /** The free plan's customer allowance, from PLAN_FEATURES.free in src/lib/features.ts. */
  36. const FREE_CUSTOMERS = 20
  37. let organizationId = ''
  38. let planId = ''
  39. test.use({ storageState: STATE })
  40. test.beforeAll(async ({ browser }) => {
  41. // Signed up the way a stranger does, sample data and all, and the session
  42. // kept for the tests below.
  43. const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
  44. const page = await context.newPage()
  45. await signUpWithPassword(page, { name: 'E2E Free Owner', email: EMAIL, password: PASSWORD })
  46. await completeOnboarding(page, WORKSHOP, { sampleData: true })
  47. await context.storageState({ path: STATE })
  48. await context.close()
  49. organizationId = await organizationIdFor(EMAIL)
  50. })
  51. test.afterAll(async () => {
  52. if (planId) await removePlan(organizationId, planId)
  53. })
  54. /** Opens the customer form and saves a customer with just a name. */
  55. async function addCustomer(page: import('@playwright/test').Page, name: string): Promise<void> {
  56. await page.goto('/customers')
  57. await settle(page)
  58. const dialog = page.getByRole('dialog', { name: 'Add New Customer' })
  59. await expect(async () => {
  60. await page
  61. .getByRole('button', { name: 'Add Customer' })
  62. .filter({ visible: true })
  63. .first()
  64. .click()
  65. await expect(dialog).toBeVisible({ timeout: 2_000 })
  66. }).toPass({ timeout: 30_000 })
  67. await dialog.locator('#name').fill(name)
  68. await dialog.locator('button[type="submit"]').click()
  69. }
  70. test.describe('the free plan', () => {
  71. test('counts only the customers the workshop made, not the samples', async ({ page }) => {
  72. // Onboarding seeded its sample customers. Real ones up to one short of
  73. // the allowance go in directly; the last one goes through the form.
  74. const samples = await customerRows(organizationId)
  75. expect(samples, 'onboarding left sample customers to look around with').toBeGreaterThan(0)
  76. await insertCustomers(organizationId, await userIdFor(EMAIL), FREE_CUSTOMERS - 1, 'E2E Real')
  77. // Twenty real customers is inside the plan, however many rows the
  78. // samples add on top.
  79. await addCustomer(page, `E2E Twentieth ${stamp}`)
  80. await expect(page.getByText('Customer created')).toBeVisible({ timeout: 30_000 })
  81. expect(await customerRows(organizationId)).toBe(samples + FREE_CUSTOMERS)
  82. })
  83. test('offers an upgrade at the customer after that, instead of an error', async ({ page }) => {
  84. const before = await customerRows(organizationId)
  85. await addCustomer(page, `E2E Twenty-first ${stamp}`)
  86. await expectUpgradeOffered(page, new RegExp(`up to ${FREE_CUSTOMERS} customers`))
  87. // Refused on the server, not just hidden: nothing was written.
  88. expect(await customerRows(organizationId)).toBe(before)
  89. await expect(page.getByText('Failed to save customer')).toHaveCount(0)
  90. })
  91. test('offers the same upgrade when a new work order creates the customer', async ({ page }) => {
  92. // The flow the getting-started checklist sends a new workshop to: vehicle,
  93. // customer and job from one dialog. Still at the limit, the customer is
  94. // refused, and with it the vehicle that would have belonged to them.
  95. const customers = await customerRows(organizationId)
  96. const vehicles = await vehicleRows(organizationId)
  97. await page.goto('/work-orders?new=1')
  98. await settle(page)
  99. await expect(async () => {
  100. await page
  101. .getByRole('dialog', { name: 'Select Vehicle' })
  102. .getByRole('button', { name: 'Add New Vehicle' })
  103. .first()
  104. .click()
  105. await expect(page.locator('#new-make')).toBeVisible({ timeout: 2_000 })
  106. }).toPass({ timeout: 30_000 })
  107. await page.locator('#new-make').fill('Volvo')
  108. await page.locator('#new-model').fill('V70')
  109. await page.locator('#new-year').fill('2015')
  110. await page.getByRole('combobox').filter({ hasText: 'Select a customer (optional)' }).click()
  111. await page.getByRole('option', { name: 'Create new customer' }).click()
  112. await page.locator('#new-customer-name').fill(`E2E Work order customer ${stamp}`)
  113. await page.getByRole('button', { name: 'Create & Continue' }).click()
  114. await expectUpgradeOffered(page, new RegExp(`up to ${FREE_CUSTOMERS} customers`))
  115. expect(await customerRows(organizationId), 'no customer written').toBe(customers)
  116. expect(await vehicleRows(organizationId), 'and no vehicle without an owner').toBe(vehicles)
  117. // Not taken on to a work order that has nobody to belong to.
  118. await expect(page).toHaveURL(/\/work-orders/)
  119. })
  120. test('offers an upgrade when inviting a colleague', async ({ page }) => {
  121. // The free plan is one person, so the first invitation is the limit.
  122. await page.goto('/settings/team')
  123. await settle(page)
  124. await expect(async () => {
  125. await page.getByRole('button', { name: 'Add', exact: true }).first().click()
  126. await expect(page.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
  127. }).toPass({ timeout: 30_000 })
  128. await page.getByText('Someone in the office').click()
  129. await page.locator('#member-email').fill(`e2e-colleague-${stamp}@example.com`)
  130. await page.getByRole('button', { name: 'Invite', exact: true }).click()
  131. await expectUpgradeOffered(page, /one team member/)
  132. expect(await teamInvitations(organizationId), 'no invitation went out').toBe(0)
  133. })
  134. test('keeps online payments locked, with the way to a plan', async ({ page }) => {
  135. // One of the settings pages gated by feature rather than by count: the
  136. // notice names Stripe and points at the plans, and the connection behind
  137. // it cannot be set up.
  138. await page.goto('/settings/integrations/stripe')
  139. await settle(page)
  140. await expect(page.getByText('Subscription required')).toBeVisible({ timeout: 30_000 })
  141. await expect(page.getByRole('heading', { name: 'Stripe' })).toBeVisible()
  142. // In terms of payments, not the calendars and video calls other
  143. // integrations are about.
  144. await expect(page.getByText(/pay their invoices online/)).toBeVisible()
  145. await expect(page.getByText(/calendars, video calls/)).toHaveCount(0)
  146. await expect(page.getByRole('link', { name: 'View plans' })).toHaveAttribute(
  147. 'href',
  148. '/settings/subscription'
  149. )
  150. // Nothing to type keys into, in front of the notice or behind it.
  151. await expect(page.locator('input[name="stripe-secretKey"]')).toHaveCount(0)
  152. await expect(page.getByRole('button', { name: 'Connect', exact: true })).toHaveCount(0)
  153. })
  154. })
  155. test.describe('a paid plan', () => {
  156. test('is not stopped where the free plan was', async ({ page }) => {
  157. planId = await giveProPlan(organizationId)
  158. const before = await customerRows(organizationId)
  159. await addCustomer(page, `E2E On Pro ${stamp}`)
  160. await expect(page.getByText('Customer created')).toBeVisible({ timeout: 30_000 })
  161. expect(await customerRows(organizationId)).toBe(before + 1)
  162. })
  163. test('opens online payments for connecting', async ({ page }) => {
  164. await page.goto('/settings/integrations/stripe')
  165. await settle(page)
  166. await expect(page.locator('input[name="stripe-secretKey"]')).toBeEditable({ timeout: 30_000 })
  167. await expect(page.getByRole('button', { name: 'Connect', exact: true })).toBeVisible()
  168. await expect(page.getByText('Subscription required')).toHaveCount(0)
  169. })
  170. })