mirror of
https://github.com/vernu/textbee.git
synced 2026-07-31 17:37:26 -04:00
- prefetch route-tab links so tab clicks reuse a cached payload instead of paying an uncached server round trip each time - add loading.tsx boundaries (dashboard root and each section) so clicks paint within a frame; section-level files keep the header and tabs mounted while only the content area swaps - link the sidebar Account item and the checkout page straight to /dashboard/account/billing. The /dashboard/account redirect stub stays for old links, but no internal link pays the extra hop anymore. The stub also dropped query params, which silently ate the plan-change success toast. - set QueryClient defaults (staleTime 60s, no focus refetch, retry 1); mutations already invalidate their keys, so the user's own changes stay instant. Device messages and webhook deliveries get a 15s staleTime since they change from outside the tab. - replace the axios getCachedSession TTL cache with a token seeded from the server session in Providers and kept in sync by a session bridge. Requests attach the token synchronously; /api/auth/session is only a deduped fallback, instead of a refetch every 2 minutes with a thundering herd on expiry. - swap the billing card's 16px loading spinner for a card-shaped skeleton so the tab no longer looks blank while loading Tests: interceptor seeding/dedupe/signed-out behavior, provider defaults and token seeding, nav active-state matching, tab prefetch, billing loading state, plus e2e coverage for direct-to-billing navigation and an at-most-one-session-call budget guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
6.1 KiB
TypeScript
189 lines
6.1 KiB
TypeScript
import { expect, test } from '@playwright/test'
|
|
import { authenticate } from './session'
|
|
import { mockApi } from './mock-api'
|
|
import { mockFreeSubscription } from '../test/fixtures'
|
|
|
|
test.describe('account settings (mocked API, no real backend)', () => {
|
|
test('/dashboard/account redirects to billing and shows the subscription', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/account')
|
|
|
|
await expect(page).toHaveURL(/\/dashboard\/account\/billing/)
|
|
const nav = page.getByRole('navigation', { name: 'Section navigation' })
|
|
await expect(
|
|
nav.getByRole('link', { name: 'Billing & plan' })
|
|
).toHaveAttribute('aria-current', 'page')
|
|
// Mocked subscription plan name renders.
|
|
await expect(page.getByRole('heading', { name: 'Pro' })).toBeVisible()
|
|
})
|
|
|
|
test('a subscriber sees their status, price and the next tier up', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/account/billing')
|
|
|
|
await expect(page.getByText('Active')).toBeVisible()
|
|
await expect(page.getByText('$9.99')).toBeVisible()
|
|
await expect(
|
|
page.getByRole('link', { name: /Upgrade to Scale/ })
|
|
).toBeVisible()
|
|
await expect(
|
|
page.getByRole('link', { name: /Manage subscription/i })
|
|
).toBeVisible()
|
|
})
|
|
|
|
// A free user has no subscription, so the payload carries no status. The
|
|
// page used to read that as a subscription whose status was unknown and
|
|
// told them so, alongside two "N/A" billing dates.
|
|
test('a free user is never told their status is unknown', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page, { subscription: mockFreeSubscription })
|
|
await page.goto('/dashboard/account/billing')
|
|
|
|
await expect(page.getByRole('heading', { name: 'Free' })).toBeVisible()
|
|
await expect(page.getByText('Unknown')).toHaveCount(0)
|
|
await expect(page.getByText('N/A')).toHaveCount(0)
|
|
await expect(page.getByText('Start date')).toHaveCount(0)
|
|
|
|
// Sold the next tier, not offered a portal for a subscription they do not
|
|
// have.
|
|
await expect(
|
|
page.getByRole('link', { name: /Upgrade to Pro/ })
|
|
).toBeVisible()
|
|
await expect(
|
|
page.getByRole('link', { name: /Manage subscription/i })
|
|
).toHaveCount(0)
|
|
})
|
|
|
|
// The sidebar used to link to /dashboard/account, whose page is a server
|
|
// redirect stub, so every click paid navigation + redirect + navigation.
|
|
test('the sidebar Account link goes straight to billing with no redirect hop', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
|
|
const stubHits: string[] = []
|
|
page.on('request', (request) => {
|
|
if (new URL(request.url()).pathname === '/dashboard/account') {
|
|
stubHits.push(request.url())
|
|
}
|
|
})
|
|
|
|
await page.goto('/dashboard')
|
|
await page
|
|
.getByRole('navigation', { name: 'Main' })
|
|
.getByRole('link', { name: 'Account' })
|
|
.click()
|
|
|
|
await expect(page).toHaveURL(/\/dashboard\/account\/billing$/)
|
|
await expect(page.getByRole('heading', { name: 'Pro' })).toBeVisible()
|
|
expect(stubHits).toEqual([])
|
|
})
|
|
|
|
test('the pricing page is reachable from billing on any plan', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page, { subscription: mockFreeSubscription })
|
|
await page.goto('/dashboard/account/billing')
|
|
|
|
await expect(
|
|
page.getByRole('link', { name: /Compare all plans/ })
|
|
).toHaveAttribute('href', 'https://textbee.dev/pricing')
|
|
})
|
|
|
|
// Password managers key off autoComplete to tell the three password boxes
|
|
// apart. Without it they fill the saved password into the wrong field.
|
|
test('password fields declare their autocomplete roles', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/account/security')
|
|
|
|
await expect(page.getByLabel('Old Password')).toHaveAttribute(
|
|
'autocomplete',
|
|
'current-password'
|
|
)
|
|
await expect(page.getByLabel('New Password')).toHaveAttribute(
|
|
'autocomplete',
|
|
'new-password'
|
|
)
|
|
await expect(page.getByLabel('Confirm Password')).toHaveAttribute(
|
|
'autocomplete',
|
|
'new-password'
|
|
)
|
|
})
|
|
|
|
// The most destructive action in the app had a label pointing at an id that
|
|
// did not exist and an email field with no label at all.
|
|
test('the delete-account fields are reachable by their labels', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/account/security')
|
|
|
|
await page.getByRole('button', { name: 'Delete Account' }).click()
|
|
const dialog = page.getByRole('dialog')
|
|
|
|
await dialog.getByLabel('Reason for deletion').fill('Testing labels')
|
|
await dialog
|
|
.getByLabel('Type your email address to confirm')
|
|
.fill('test@example.com')
|
|
|
|
await expect(dialog.getByLabel('Reason for deletion')).toHaveValue(
|
|
'Testing labels'
|
|
)
|
|
})
|
|
|
|
test('security deep link survives refresh and separates the danger zone', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/account/security')
|
|
|
|
const nav = page.getByRole('navigation', { name: 'Section navigation' })
|
|
await expect(nav.getByRole('link', { name: 'Security' })).toHaveAttribute(
|
|
'aria-current',
|
|
'page'
|
|
)
|
|
await expect(page.getByText('Password', { exact: true })).toBeVisible()
|
|
await expect(page.getByText('Danger zone')).toBeVisible()
|
|
await expect(
|
|
page.getByRole('heading', { name: 'Delete Account' })
|
|
).toBeVisible()
|
|
})
|
|
|
|
test('legacy routes redirect into the merged settings', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
|
|
await page.goto('/dashboard/account/change-password')
|
|
await expect(page).toHaveURL(/\/dashboard\/account\/security/)
|
|
|
|
await page.goto('/dashboard/account/edit-profile')
|
|
await expect(page).toHaveURL(/\/dashboard\/account\/profile/)
|
|
})
|
|
})
|