| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198 |
- import { expect, test } from '@playwright/test'
- import { completeOnboarding, signUpWithPassword } from '../../support/cloud'
- import { giveProPlan, organizationIdFor, removePlan, setCancelAtPeriodEnd } from '../../support/db'
- import { settle } from '../../support/hydration'
- import {
- clearTorqvoiceComCalls,
- setTorqvoiceComLinked,
- torqvoiceComState,
- torqvoiceComUrl,
- } from '../../support/torqvoice-com'
- /**
- * Plans are sold on torqvoice.com, and this is the app's side of it.
- *
- * The site is played by e2e/torqvoice-com-standin.ts, which verifies every
- * token the way the site does, so landing on its checkout page proves the
- * handoff was signed with the shared secret, carries this organization and
- * this buyer, and names this app's origin. The stand-in also refuses the
- * app on request, which is what a wrong secret looks like: the subscription
- * page and its navigation entry must then not exist, since a self-hosted
- * install that copied TORQVOICE_MODE=cloud is in exactly that position.
- *
- * A workshop of its own is opened here by signing up, so nothing about the
- * seeded one changes.
- */
- test.describe.configure({ mode: 'serial', timeout: 180_000 })
- const stamp = Date.now()
- const EMAIL = `e2e-billing-${stamp}@example.com`
- const PASSWORD = `E2e-pass-${stamp}`
- const WORKSHOP = `E2E Billing Garage ${stamp}`
- const STATE = `e2e/.auth/cloud-billing-${stamp}.json`
- let organizationId = ''
- let planId = ''
- test.use({ storageState: STATE })
- test.beforeAll(async ({ browser }) => {
- const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
- const page = await context.newPage()
- await signUpWithPassword(page, { name: 'E2E Billing Owner', email: EMAIL, password: PASSWORD })
- await completeOnboarding(page, WORKSHOP, { sampleData: false })
- await context.storageState({ path: STATE })
- await context.close()
- organizationId = await organizationIdFor(EMAIL)
- await clearTorqvoiceComCalls()
- })
- test.afterAll(async () => {
- await setTorqvoiceComLinked(true)
- if (planId) await removePlan(organizationId, planId)
- })
- /** The app's link check is asked again after a second in this harness. */
- async function letTheAppAskAgain(page: import('@playwright/test').Page): Promise<void> {
- await page.waitForTimeout(1_500)
- // The first look after the answer may still serve the old one while the
- // fresh ask completes behind it; a second look sees the new answer.
- await page.reload()
- await settle(page)
- await page.waitForTimeout(500)
- }
- test.describe('the link to torqvoice.com', () => {
- test('hides the subscription page while the site refuses the app', async ({ page }) => {
- await setTorqvoiceComLinked(false)
- await page.goto('/settings')
- await settle(page)
- await letTheAppAskAgain(page)
- await page.reload()
- await settle(page)
- await expect(page.getByRole('link', { name: 'Subscription' })).toHaveCount(0)
- await page.goto('/settings/subscription')
- await page.waitForURL((url) => url.pathname === '/settings', { timeout: 15_000 })
- })
- test('shows it again once the site accepts the app', async ({ page }) => {
- await setTorqvoiceComLinked(true)
- await page.goto('/settings')
- await settle(page)
- await letTheAppAskAgain(page)
- await page.reload()
- await settle(page)
- await expect(page.getByRole('link', { name: 'Subscription' })).toBeVisible()
- })
- })
- test.describe('buying a plan', () => {
- test('hands the buyer to torqvoice.com with a signed checkout link', async ({ page }) => {
- await page.goto('/settings/subscription')
- await settle(page)
- await expect(page.getByText('You will be taken to torqvoice.com')).toBeVisible()
- await page.getByRole('button', { name: /Upgrade to Pro/ }).click()
- await page.waitForURL(`${torqvoiceComUrl()}/checkout?**`, { timeout: 30_000 })
- // The stand-in only renders this page for a token it could verify.
- await expect(page.getByRole('heading', { name: 'Stand-in checkout' })).toBeVisible()
- await expect(page.getByTestId('org')).toHaveText(organizationId)
- await expect(page.getByTestId('plan')).toHaveText('pro')
- await expect(page.getByTestId('email')).toHaveText(EMAIL)
- await expect(page.getByTestId('appUrl')).toHaveText(/^http/)
- })
- test('names this app as the origin of the purchase', async ({ page, baseURL }) => {
- await page.goto('/settings/subscription')
- await settle(page)
- await page
- .getByRole('button', { name: /Enterprise/ })
- .first()
- .click()
- await page.waitForURL(`${torqvoiceComUrl()}/checkout?**`, { timeout: 30_000 })
- await expect(page.getByTestId('plan')).toHaveText('enterprise')
- await expect(page.getByTestId('appUrl')).toHaveText(new URL(baseURL ?? '').origin)
- })
- })
- test.describe('managing a bought plan', () => {
- test.beforeAll(async () => {
- planId = await giveProPlan(organizationId, {
- subscriptionId: `sub_e2e_${stamp}`,
- customerId: `cus_e2e_${stamp}`,
- })
- })
- test('opens the billing portal through torqvoice.com', async ({ page, baseURL }) => {
- await clearTorqvoiceComCalls()
- await page.goto('/settings/subscription')
- await settle(page)
- await page.getByRole('button', { name: 'Manage Billing' }).click()
- await page.waitForURL(`${torqvoiceComUrl()}/portal/**`, { timeout: 30_000 })
- await expect(page.getByTestId('org')).toHaveText(organizationId)
- const { calls } = await torqvoiceComState()
- const portal = calls.find((c) => c.path === 'portal')
- expect(portal, 'the app asked torqvoice.com for the portal').toBeTruthy()
- expect(portal?.authorized).toBe(true)
- expect(portal?.body).toMatchObject({
- organizationId,
- appUrl: new URL(baseURL ?? '').origin,
- })
- })
- test('opens the account on torqvoice.com signed in as this person', async ({ page }) => {
- await page.goto('/settings/subscription')
- await settle(page)
- await page.getByRole('button', { name: 'Open your account on torqvoice.com' }).click()
- await page.waitForURL(`${torqvoiceComUrl()}/api/auth/sso/app-link?**`, { timeout: 30_000 })
- await expect(page.getByRole('heading', { name: 'Stand-in account' })).toBeVisible()
- await expect(page.getByTestId('email')).toHaveText(EMAIL)
- // Signed up with a password and never verified: the site is told so.
- await expect(page.getByTestId('emailVerified')).toHaveText('false')
- })
- test('cancels and resumes through torqvoice.com', async ({ page }) => {
- await clearTorqvoiceComCalls()
- await page.goto('/settings/subscription')
- await settle(page)
- await page.getByRole('button', { name: 'Cancel Subscription' }).click()
- const dialog = page.getByRole('alertdialog')
- await expect(dialog).toBeVisible()
- await dialog.getByRole('button', { name: 'Yes, Cancel' }).click()
- await expect(page.getByText('Subscription will cancel at end of billing period')).toBeVisible()
- let { calls } = await torqvoiceComState()
- expect(calls.find((c) => c.path === 'cancel')?.body).toMatchObject({ organizationId })
- // The stand-in writes nothing to the database, so the row is flagged the
- // way the real site would have flagged it, and the resume is exercised.
- await setCancelAtPeriodEnd(organizationId, true)
- await page.reload()
- await settle(page)
- await page.getByRole('button', { name: 'Resume Subscription' }).click()
- await expect(page.getByText('Subscription resumed successfully')).toBeVisible()
- ;({ calls } = await torqvoiceComState())
- expect(calls.find((c) => c.path === 'resume')?.body).toMatchObject({ organizationId })
- await setCancelAtPeriodEnd(organizationId, false)
- })
- test('shows the site refusing the app as a temporary failure, not as a lost session', async ({
- page,
- }) => {
- await setTorqvoiceComLinked(false)
- try {
- await page.goto('/settings/subscription')
- await settle(page)
- await page.getByRole('button', { name: 'Manage Billing' }).click()
- await expect(page.getByText('Billing is temporarily unavailable')).toBeVisible()
- await expect(page).toHaveURL(/\/settings\/subscription/)
- } finally {
- await setTorqvoiceComLinked(true)
- }
- })
- })
|