Files
textbee/web/e2e/dashboard.spec.ts
isra el 2b766e8019 fix: drop the phantom device status and share one error state
The Device type declared a `status` field that the schema does not have
and the API never sends, so `device.status === 'online'` was always
false. The badge took its colour from that phantom field and its text
from `enabled`, so an enabled, working device was styled exactly like a
disabled one. Both now come from `enabled`.

Devices, API keys and webhooks each rendered a bare unstyled
"Error: {error.message}" with no way to recover, putting raw transport
strings like "Request failed with status code 500" in front of users.
formatError already handles axios rejections and rate limits and was
used by none of them.

Adds components/shared/error-state.tsx as the counterpart to the
existing EmptyState, routing all three through formatError and offering
a retry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:10:33 +03:00

177 lines
6.0 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { authenticate } from './session'
import { mockApi } from './mock-api'
test.describe('dashboard (mocked API, no real backend)', () => {
test('redirects unauthenticated users to login', async ({ page }) => {
await mockApi(page)
await page.goto('/dashboard')
await expect(page).toHaveURL(/\/login/)
})
test('redirects authenticated users away from login', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/login')
await expect(page).toHaveURL(/\/dashboard/)
})
test('renders the dashboard with mocked data for an authenticated user', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
await expect(
page.getByRole('heading', { name: 'Welcome back, Test', level: 2 })
).toBeVisible()
// Quick actions row.
await expect(page.getByRole('link', { name: 'Send SMS' })).toBeVisible()
// Total SMS sent stat from the mocked gateway stats fixture (12,840).
await expect(page.getByText('12,840')).toBeVisible()
// Onboarding card shows its progress bar (all 6 steps done in fixtures).
await expect(
page.getByRole('progressbar', { name: 'Setup progress' })
).toBeVisible()
await expect(page.getByText('6 of 6')).toBeVisible()
// Webhooks summary row keeps a mobile path to /dashboard/webhooks
// (fixtures have 1 active webhook).
await expect(
page.getByRole('link', { name: /active webhook/ })
).toBeVisible()
})
// "Add device" used to open the API key modal directly from the header,
// handing a first-time user a key with no mention of installing the app or
// granting SMS permissions. Both entry points now share one flow.
for (const entry of [
{ name: 'header quick action', nth: 0 },
{ name: 'devices card', nth: 1 },
]) {
test(`add device from the ${entry.name} explains the steps first`, async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
await page
.getByRole('button', { name: 'Add device' })
.nth(entry.nth)
.click()
const dialog = page.getByRole('dialog')
await expect(
dialog.getByRole('heading', { name: 'Add a device' })
).toBeVisible()
// The prerequisites a key modal never mentioned.
await expect(dialog.getByText(/grant SMS permissions/)).toBeVisible()
// Key generation is still reachable, just no longer the first thing.
await expect(
dialog.getByRole('heading', { name: 'Create new API Key' })
).toHaveCount(0)
await dialog.getByRole('button', { name: 'Continue' }).click()
await expect(
page.getByRole('heading', { name: 'Create new API Key' })
).toBeVisible()
})
}
test('new API key still goes straight to key generation', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
// That button asks for a key, so device instructions would be noise.
await page.getByRole('button', { name: 'New API key' }).click()
await expect(
page.getByRole('heading', { name: 'Create new API Key' })
).toBeVisible()
})
// These sections used to render a bare "Error: Request failed with status
// code 500" with no styling and no way to recover.
test('a failed devices request explains itself and offers a retry', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
// Held failing rather than failing once: react-query retries a failed
// query several times before surfacing an error, so a single failure would
// recover on its own and the error state would never render.
let failDevices = true
await page.route('**/api/v1/gateway/devices', (route) => {
if (route.request().method() !== 'GET' || !failDevices)
return route.fallback()
return route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Devices are temporarily unavailable' }),
})
})
await page.goto('/dashboard')
// Generous: the retries back off before the error is shown.
await expect(page.getByText("Couldn't load your devices")).toBeVisible({
timeout: 30000,
})
// The server's message, not the axios transport string.
await expect(
page.getByText('Devices are temporarily unavailable')
).toBeVisible()
await expect(page.getByText(/Request failed with status code/)).toHaveCount(
0
)
failDevices = false
await page.getByRole('button', { name: 'Try again' }).click()
await expect(page.getByText("Couldn't load your devices")).toHaveCount(0)
})
test('leads with real quota usage, not invented trends', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
// Daily and monthly windows straight from the subscription fixture:
// 320 of 5,000 today, 18,450 of 100,000 this month.
await expect(
page.getByRole('progressbar', { name: 'Today usage' })
).toBeVisible()
await expect(
page.getByRole('progressbar', { name: 'Last 30 days usage' })
).toBeVisible()
await expect(page.getByText('/ 5,000')).toBeVisible()
await expect(page.getByText('4,680 remaining')).toBeVisible()
// The quota counts inbound messages too (the backend counts SMS documents
// with no type filter), so the label must not claim these are only sends.
await expect(
page.getByText(/Counts messages sent and received/)
).toBeVisible()
// The old page decorated every stat with a green trend arrow and captioned
// all-time totals "Since last year". Nothing computed either.
await expect(page.getByText('Since last year')).toHaveCount(0)
await expect(page.getByText('Connected now')).toHaveCount(0)
})
})