Files
hanzo.ai/e2e/gates/coupon.spec.ts
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

144 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from '@playwright/test'
import { serveExport } from './export'
import { signIn } from '../session'
import { AUP_VERSION, TERMS_VERSION } from '@hanzo/app/lib/terms'
/**
* AN OFFER LINK OPENS PAY'S PLANS WITH ITS CODE AND ITS CAMPAIGN.
*
* /onboarding?coupon= keeps the code in the tab and, for a stranger, sends the
* reader through the sign-up and back carrying it and the campaign. Signed in,
* past the terms where IAM says they are owed, a new account is asked its one
* question under the offer, with Continue with Free beside it, and either
* answer opens pay's plans (/pay/cart) with the code, the campaign, the
* organization worked in and the way back to the app; an account past its first
* run opens the plans at once. With no code nothing is quoted.
*/
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
const NOW = () => new Date(Date.now() - 60_000).toISOString()
const CAMPAIGN = { utm_source: 'hanzo', utm_medium: 'email', utm_campaign: 'poolparty' }
interface Heard {
/** Every catalog read, by its query. */
quotes: string[]
/** The organizations billing was asked about. */
tiers: string[]
}
/** The platform as a spec wants it: an account IAM made `made`, with the terms on record unless `owed`, on Free. */
async function platform(page: Page, { made = NOW(), owed = false }: { made?: string; owed?: boolean } = {}): Promise<Heard> {
const heard: Heard = { quotes: [], tiers: [] }
await page.route(
(u) => u.pathname.startsWith('/v1/') && !u.pathname.startsWith('/v1/iam/oauth/'),
(route) => {
const req = route.request()
const url = new URL(req.url())
const path = url.pathname
if (path === '/v1/billing/tier') {
heard.tiers.push(req.headers()['x-org-id'] ?? '-')
return route.fulfill({
json: {
user: 'gate',
plan: '',
tier: { name: 'free', displayName: 'free', dailyCreditsCents: 0, maxAgents: 1, unlimitedAgents: false },
balance: { currency: 'usd', prepaidAvailable: 0, creditsRemaining: 0, dailyRemaining: 0, effectiveAvailable: 0 },
},
})
}
if (path === '/v1/billing/balance') return route.fulfill({ json: { balance: 0, holds: 0, available: 0 } })
if (path === '/v1/iam/preferences') return route.fulfill({ json: owed ? {} : { terms: { terms: TERMS_VERSION, aup: AUP_VERSION } } })
if (path === '/v1/iam/terms') return route.fulfill({ json: { terms: TERMS_VERSION, aup: AUP_VERSION } })
if (path === '/v1/billing/plans') {
heard.quotes.push(url.search)
const coupon = url.searchParams.get('coupon')
return route.fulfill({
json: coupon ? [{ slug: 'dev', name: 'Pro', price: 2000, couponCode: coupon, couponPercent: 50, couponFirstCents: 1000 }] : [],
})
}
if (path === '/v1/iam/account') return route.fulfill({ json: { name: 'gate', displayName: '', email: '[email protected]', createdTime: made } })
if (path.endsWith('/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' })
return route.fulfill({ json: {} })
},
)
return heard
}
/** pay's plans, once the page has left for them: the query, read as one record. */
async function plans(page: Page): Promise<Record<string, string>> {
await page.waitForURL((u) => u.pathname === '/pay/cart', { timeout: 30_000 })
return Object.fromEntries(new URL(page.url()).searchParams)
}
// A stranger is asked the question too, under the offer, and signs in on pay's
// plans (b2de8afa): the tab keeps the code, and an answer opens the plans with
// it, the campaign and the way back.
test('a stranger with a coupon and a next is asked the question, and Personal opens pay\'s plans carrying the code and the way back', async ({ page }) => {
await platform(page)
await page.goto(`${base}/onboarding?${new URLSearchParams({ next: '/dev', coupon: '50OFF' })}`)
await expect(page.getByRole('heading', { name: 'How will you use Hanzo?' })).toBeVisible({ timeout: 30_000 })
await page.getByRole('link', { name: /^Personal/ }).click()
expect(await plans(page)).toEqual({ coupon: '50OFF', returnUrl: `${base}/dev`, tab: 'individual' })
})
test('a stranger with a coupon keeps it and the campaign, and Business opens pay\'s plans with both', async ({ page }) => {
await platform(page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${base}/onboarding?${new URLSearchParams({ coupon: '50OFF', ...CAMPAIGN })}`)
await expect(page.getByRole('heading', { name: 'How will you use Hanzo?' })).toBeVisible({ timeout: 30_000 })
await expect.poll(() => page.evaluate(() => sessionStorage.getItem('checkout.coupon')), { message: 'the tab keeps the code' }).toBe('50OFF')
await page.getByRole('link', { name: /^Business/ }).click()
expect(await plans(page)).toEqual({ coupon: '50OFF', ...CAMPAIGN, returnUrl: `${base}/chat`, tab: 'team' })
})
for (const width of [1440, 390]) {
test(`at ${width}px a new account with an offer link is asked its question under the offer, Free beside it, and an answer opens pay's plans with the code and campaign`, async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
await platform(page)
await page.setViewportSize({ width, height: 900 })
await page.goto(`${base}/onboarding?${new URLSearchParams({ coupon: '50OFF', ...CAMPAIGN })}`)
await expect(page.getByRole('heading', { name: 'How will you use Hanzo?' })).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('status').filter({ hasText: '50% off your first month of Pro' }), 'the offer, said as pay says it').toBeVisible()
// Free is chosen on pay's plans, which come back to the app (1aa7f96a); this page offers no Free button.
await expect(page.getByRole('button', { name: 'Continue with Free' })).toHaveCount(0)
await page.getByRole('link', { name: /^Personal/ }).click()
expect(await plans(page)).toEqual({ org: 'gate-org', coupon: '50OFF', ...CAMPAIGN, returnUrl: `${base}/chat`, tab: 'individual' })
})
}
test('a returning account with an offer link opens pay\'s plans with it at once', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
await platform(page, { made: '2024-01-01T00:00:00Z' })
await page.goto(`${base}/onboarding?coupon=50OFF`)
expect(await plans(page)).toEqual({ org: 'gate-org', coupon: '50OFF', returnUrl: `${base}/chat` })
})
test('a new account a provider made agrees to the terms before the offer’s question', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
await platform(page, { owed: true })
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${base}/onboarding?coupon=50OFF`)
await page.getByRole('checkbox', { name: /^I agree to Hanzo's Terms/ }).click({ timeout: 30_000 })
await page.getByRole('button', { name: 'Create account' }).click()
await page.getByRole('link', { name: /^Business/ }).click({ timeout: 15_000 })
expect(await plans(page)).toEqual({ org: 'gate-org', coupon: '50OFF', returnUrl: `${base}/chat`, tab: 'team' })
})
test('with no code nothing is quoted and the plans open plain', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
const heard = await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/onboarding?next=%2Fchat`)
await page.getByRole('link', { name: /^Personal/ }).click({ timeout: 30_000 })
expect(await plans(page)).toEqual({ org: 'gate-org', returnUrl: `${base}/chat`, tab: 'individual' })
expect(heard.quotes, 'no quote is asked for').toEqual([])
})