subscription-handoff.spec.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { expect, test } from '@playwright/test'
  2. import { completeOnboarding, signUpWithPassword } from '../../support/cloud'
  3. import { giveProPlan, organizationIdFor, removePlan, setCancelAtPeriodEnd } from '../../support/db'
  4. import { settle } from '../../support/hydration'
  5. import {
  6. clearTorqvoiceComCalls,
  7. setTorqvoiceComLinked,
  8. torqvoiceComState,
  9. torqvoiceComUrl,
  10. } from '../../support/torqvoice-com'
  11. /**
  12. * Plans are sold on torqvoice.com, and this is the app's side of it.
  13. *
  14. * The site is played by e2e/torqvoice-com-standin.ts, which verifies every
  15. * token the way the site does, so landing on its checkout page proves the
  16. * handoff was signed with the shared secret, carries this organization and
  17. * this buyer, and names this app's origin. The stand-in also refuses the
  18. * app on request, which is what a wrong secret looks like: the subscription
  19. * page and its navigation entry must then not exist, since a self-hosted
  20. * install that copied TORQVOICE_MODE=cloud is in exactly that position.
  21. *
  22. * A workshop of its own is opened here by signing up, so nothing about the
  23. * seeded one changes.
  24. */
  25. test.describe.configure({ mode: 'serial', timeout: 180_000 })
  26. const stamp = Date.now()
  27. const EMAIL = `e2e-billing-${stamp}@example.com`
  28. const PASSWORD = `E2e-pass-${stamp}`
  29. const WORKSHOP = `E2E Billing Garage ${stamp}`
  30. const STATE = `e2e/.auth/cloud-billing-${stamp}.json`
  31. let organizationId = ''
  32. let planId = ''
  33. test.use({ storageState: STATE })
  34. test.beforeAll(async ({ browser }) => {
  35. const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
  36. const page = await context.newPage()
  37. await signUpWithPassword(page, { name: 'E2E Billing Owner', email: EMAIL, password: PASSWORD })
  38. await completeOnboarding(page, WORKSHOP, { sampleData: false })
  39. await context.storageState({ path: STATE })
  40. await context.close()
  41. organizationId = await organizationIdFor(EMAIL)
  42. await clearTorqvoiceComCalls()
  43. })
  44. test.afterAll(async () => {
  45. await setTorqvoiceComLinked(true)
  46. if (planId) await removePlan(organizationId, planId)
  47. })
  48. /** The app's link check is asked again after a second in this harness. */
  49. async function letTheAppAskAgain(page: import('@playwright/test').Page): Promise<void> {
  50. await page.waitForTimeout(1_500)
  51. // The first look after the answer may still serve the old one while the
  52. // fresh ask completes behind it; a second look sees the new answer.
  53. await page.reload()
  54. await settle(page)
  55. await page.waitForTimeout(500)
  56. }
  57. test.describe('the link to torqvoice.com', () => {
  58. test('hides the subscription page while the site refuses the app', async ({ page }) => {
  59. await setTorqvoiceComLinked(false)
  60. await page.goto('/settings')
  61. await settle(page)
  62. await letTheAppAskAgain(page)
  63. await page.reload()
  64. await settle(page)
  65. await expect(page.getByRole('link', { name: 'Subscription' })).toHaveCount(0)
  66. await page.goto('/settings/subscription')
  67. await page.waitForURL((url) => url.pathname === '/settings', { timeout: 15_000 })
  68. })
  69. test('shows it again once the site accepts the app', async ({ page }) => {
  70. await setTorqvoiceComLinked(true)
  71. await page.goto('/settings')
  72. await settle(page)
  73. await letTheAppAskAgain(page)
  74. await page.reload()
  75. await settle(page)
  76. await expect(page.getByRole('link', { name: 'Subscription' })).toBeVisible()
  77. })
  78. })
  79. test.describe('buying a plan', () => {
  80. test('hands the buyer to torqvoice.com with a signed checkout link', async ({ page }) => {
  81. await page.goto('/settings/subscription')
  82. await settle(page)
  83. await expect(page.getByText('You will be taken to torqvoice.com')).toBeVisible()
  84. await page.getByRole('button', { name: /Upgrade to Pro/ }).click()
  85. await page.waitForURL(`${torqvoiceComUrl()}/checkout?**`, { timeout: 30_000 })
  86. // The stand-in only renders this page for a token it could verify.
  87. await expect(page.getByRole('heading', { name: 'Stand-in checkout' })).toBeVisible()
  88. await expect(page.getByTestId('org')).toHaveText(organizationId)
  89. await expect(page.getByTestId('plan')).toHaveText('pro')
  90. await expect(page.getByTestId('email')).toHaveText(EMAIL)
  91. await expect(page.getByTestId('appUrl')).toHaveText(/^http/)
  92. })
  93. test('names this app as the origin of the purchase', async ({ page, baseURL }) => {
  94. await page.goto('/settings/subscription')
  95. await settle(page)
  96. await page
  97. .getByRole('button', { name: /Enterprise/ })
  98. .first()
  99. .click()
  100. await page.waitForURL(`${torqvoiceComUrl()}/checkout?**`, { timeout: 30_000 })
  101. await expect(page.getByTestId('plan')).toHaveText('enterprise')
  102. await expect(page.getByTestId('appUrl')).toHaveText(new URL(baseURL ?? '').origin)
  103. })
  104. })
  105. test.describe('managing a bought plan', () => {
  106. test.beforeAll(async () => {
  107. planId = await giveProPlan(organizationId, {
  108. subscriptionId: `sub_e2e_${stamp}`,
  109. customerId: `cus_e2e_${stamp}`,
  110. })
  111. })
  112. test('opens the billing portal through torqvoice.com', async ({ page, baseURL }) => {
  113. await clearTorqvoiceComCalls()
  114. await page.goto('/settings/subscription')
  115. await settle(page)
  116. await page.getByRole('button', { name: 'Manage Billing' }).click()
  117. await page.waitForURL(`${torqvoiceComUrl()}/portal/**`, { timeout: 30_000 })
  118. await expect(page.getByTestId('org')).toHaveText(organizationId)
  119. const { calls } = await torqvoiceComState()
  120. const portal = calls.find((c) => c.path === 'portal')
  121. expect(portal, 'the app asked torqvoice.com for the portal').toBeTruthy()
  122. expect(portal?.authorized).toBe(true)
  123. expect(portal?.body).toMatchObject({
  124. organizationId,
  125. appUrl: new URL(baseURL ?? '').origin,
  126. })
  127. })
  128. test('opens the account on torqvoice.com signed in as this person', async ({ page }) => {
  129. await page.goto('/settings/subscription')
  130. await settle(page)
  131. await page.getByRole('button', { name: 'Open your account on torqvoice.com' }).click()
  132. await page.waitForURL(`${torqvoiceComUrl()}/api/auth/sso/app-link?**`, { timeout: 30_000 })
  133. await expect(page.getByRole('heading', { name: 'Stand-in account' })).toBeVisible()
  134. await expect(page.getByTestId('email')).toHaveText(EMAIL)
  135. // Signed up with a password and never verified: the site is told so.
  136. await expect(page.getByTestId('emailVerified')).toHaveText('false')
  137. })
  138. test('cancels and resumes through torqvoice.com', async ({ page }) => {
  139. await clearTorqvoiceComCalls()
  140. await page.goto('/settings/subscription')
  141. await settle(page)
  142. await page.getByRole('button', { name: 'Cancel Subscription' }).click()
  143. const dialog = page.getByRole('alertdialog')
  144. await expect(dialog).toBeVisible()
  145. await dialog.getByRole('button', { name: 'Yes, Cancel' }).click()
  146. await expect(page.getByText('Subscription will cancel at end of billing period')).toBeVisible()
  147. let { calls } = await torqvoiceComState()
  148. expect(calls.find((c) => c.path === 'cancel')?.body).toMatchObject({ organizationId })
  149. // The stand-in writes nothing to the database, so the row is flagged the
  150. // way the real site would have flagged it, and the resume is exercised.
  151. await setCancelAtPeriodEnd(organizationId, true)
  152. await page.reload()
  153. await settle(page)
  154. await page.getByRole('button', { name: 'Resume Subscription' }).click()
  155. await expect(page.getByText('Subscription resumed successfully')).toBeVisible()
  156. ;({ calls } = await torqvoiceComState())
  157. expect(calls.find((c) => c.path === 'resume')?.body).toMatchObject({ organizationId })
  158. await setCancelAtPeriodEnd(organizationId, false)
  159. })
  160. test('shows the site refusing the app as a temporary failure, not as a lost session', async ({
  161. page,
  162. }) => {
  163. await setTorqvoiceComLinked(false)
  164. try {
  165. await page.goto('/settings/subscription')
  166. await settle(page)
  167. await page.getByRole('button', { name: 'Manage Billing' }).click()
  168. await expect(page.getByText('Billing is temporarily unavailable')).toBeVisible()
  169. await expect(page).toHaveURL(/\/settings\/subscription/)
  170. } finally {
  171. await setTorqvoiceComLinked(true)
  172. }
  173. })
  174. })