Files
textbee/web/e2e/account.spec.ts
isra el 18e7d1b3d8 perf(web): make dashboard navigation fast and cut session fetches
- 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>
2026-07-22 13:49:14 +03:00

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/)
})
})