Addresses every finding from the four-agent review of commit aa493b9.
Dashboard — honor redirect_to on subtab loadApp() (closes the silent
historical-slug redirect UX gap):
- queues/+page.svelte
- queues/[name]/+page.svelte
- files/+page.svelte
- dead-letters/+page.svelte
After a rename, the URL bar now reflects the canonical slug instead
of silently rendering the renamed app's data under the stale URL.
Mirrors the established pattern at apps/[slug]/+page.svelte:619-623.
manager-core:
- queues_api.rs IntoResponse now uses the JSON envelope shape
`{"error": "..."}` consistent with every sibling admin api file.
- triggers_api::delete_trigger reordered: cap check fires BEFORE the
trigger load, closing the 404-vs-403 existence side channel an
unauthorized caller could otherwise probe.
- InMemoryAppRepo mocks in topics_api + triggers_api now implement
get_by_slug + get_by_slug_or_history (previously
`unimplemented!()`), unblocking handler-level slug-input tests.
- Added 4 slug-acceptance tests to topics_api and 2 to triggers_api
(slug-resolves, unknown-slug-404, historical-slug-resolves,
create-via-slug). Also added delete-without-cap-is-forbidden test
pinning the new cap-first order.
e2e:
- navigation/tabs.spec.ts split per-tab so a regression on one tab
no longer masks regressions on the others.
- Negative assertion widened: captures every /api/v1/admin/apps/*
response and fails on any 4xx/5xx — not just the literal "Cannot
parse" string. Catches a broader regression shape.
- networkidle replaced with `expect(<main>).toBeVisible()` —
networkidle is officially discouraged for SPAs and was at risk of
timing out behind the queues auto-refresh.
- Cleanup registration moved BEFORE the create-app API call so a
flaky create still gets swept up.
- Queue drilldown route /queues/[name] now covered.
- Stable `data-testid="queues-empty-state"` replaces fragile
UI-copy substring match for the positive assertion.
- Header comment now spells out what this spec does and doesn't
catch.
docs:
- serverless_cloud_blueprint.md: slug-history described as
"200 OK + redirect_to" JSON envelope rather than "301 redirect"
— matches what apps_api actually implements (SPA can't honor a
mid-tree HTTP redirect).
Unit-test gap (acknowledged): queues_api, files_api, secrets_api,
dead_letters_api have zero in-process tests. Adding them properly
needs a shared mock-repo helper crate — the standalone trait surface
(QueueRepo + TriggerRepo + ScriptRepository + AuthzRepo + repo-
specific) is ~30 methods per file. Documented inline in queues_api.rs
near the resolver. Integration coverage via crates/picloud/tests/ and
the new e2e spec cover the same paths end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
5.9 KiB
TypeScript
158 lines
5.9 KiB
TypeScript
import { expect, type Page, type Response } from '@playwright/test';
|
|
import { test } from '../fixtures/ids';
|
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
|
import { adminApi } from '../fixtures/api';
|
|
|
|
// Regression coverage for the slug-vs-UUID admin-endpoint bug fixed by
|
|
// commit `aa493b9`: every per-app tab (`/admin/apps/[slug]/...`) must
|
|
// accept the URL slug, not just a UUID. The original report was the
|
|
// Queues tab; the underlying bug spanned 27 handlers across 6 backend
|
|
// files (queues_api, files_api, secrets_api, topics_api,
|
|
// dead_letters_api, triggers_api). This spec hits each tab in
|
|
// isolation against a freshly-created app — no dependence on the
|
|
// dev-seeded `default` app.
|
|
//
|
|
// What this catches:
|
|
// - The original parse error ("Cannot parse app_id" / "UUID parsing
|
|
// failed") surfaced as an error banner in the dashboard.
|
|
// - Any non-2xx response to /api/v1/admin/apps/... during page load —
|
|
// a broader net than the literal error text. A regression returning
|
|
// 500 with a different message would otherwise pass silently.
|
|
//
|
|
// What this does NOT catch:
|
|
// - Authz-role-dependent behavior. Admin-only coverage; members /
|
|
// viewers have their own specs.
|
|
// - The queue drilldown's data path (we visit the drilldown but the
|
|
// queue is empty, so the "no consumer" empty-state branch is what
|
|
// renders).
|
|
|
|
const PARSE_ERROR = /Cannot parse|UUID parsing failed/i;
|
|
|
|
interface AppCtx {
|
|
slug: string;
|
|
}
|
|
|
|
async function createApp(uniqueSlug: (p: string) => string, prefix: string): Promise<AppCtx> {
|
|
const slug = uniqueSlug(prefix);
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post('/api/v1/admin/apps', {
|
|
data: { slug, name: slug }
|
|
});
|
|
if (!res.ok()) {
|
|
throw new Error(`create app ${slug} failed: HTTP ${res.status()} ${await res.text()}`);
|
|
}
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
return { slug };
|
|
}
|
|
|
|
// Visit `path` and assert nothing broke. Captures every admin-API
|
|
// response and fails on any 4xx/5xx — a wider net than asserting on a
|
|
// specific error string. Also keeps the literal-parse-error check so
|
|
// the test reads as a regression for the original bug.
|
|
async function expectTabLoadsCleanly(page: Page, path: string): Promise<void> {
|
|
const failures: string[] = [];
|
|
const handler = (response: Response) => {
|
|
const url = response.url();
|
|
if (!url.includes('/api/v1/admin/apps/')) return;
|
|
const status = response.status();
|
|
if (status >= 400) {
|
|
failures.push(`${status} ${url}`);
|
|
}
|
|
};
|
|
page.on('response', handler);
|
|
try {
|
|
await page.goto(path);
|
|
// Wait for a structural marker instead of `networkidle` — that
|
|
// hook is officially discouraged for SPAs because background
|
|
// polling (e.g., the queues page's 5s auto-refresh) keeps the
|
|
// network "active" indefinitely. The <main> region is in
|
|
// +layout.svelte so it's reliable.
|
|
await expect(page.locator('main')).toBeVisible();
|
|
await expect(
|
|
page.getByText(PARSE_ERROR),
|
|
`literal parse error on ${path}`
|
|
).toHaveCount(0);
|
|
} finally {
|
|
page.off('response', handler);
|
|
}
|
|
if (failures.length > 0) {
|
|
throw new Error(`HTTP failures on ${path}:\n ${failures.join('\n ')}`);
|
|
}
|
|
}
|
|
|
|
const cleanup = new CleanupRegistry();
|
|
test.afterEach(async () => {
|
|
await cleanup.run();
|
|
});
|
|
|
|
// Each tab covers a different backend file:
|
|
// - `''` (root) → triggers_api, topics_api, secrets_api,
|
|
// files trigger metadata, dead-letter count
|
|
// - `/queues` → queues_api::list_queues
|
|
// - `/queues/<name>` → queues_api::get_queue (drilldown handler)
|
|
// - `/files` → files_api::list_files
|
|
// - `/dead-letters` → dead_letters_api::list + count
|
|
// - `/users` → users_admin_api::list_users (already
|
|
// lenient before the bug fix; included for
|
|
// completeness so the spec doesn't regress
|
|
// it later)
|
|
// - `/users/invitations` → app_user_invitation_repo via the same
|
|
// admin surface
|
|
const TABS: Array<{ subpath: string; label: string }> = [
|
|
{ subpath: '', label: 'main' },
|
|
{ subpath: '/queues', label: 'queues' },
|
|
{ subpath: '/queues/never-enqueued', label: 'queue drilldown' },
|
|
{ subpath: '/files', label: 'files' },
|
|
{ subpath: '/dead-letters', label: 'dead-letters' },
|
|
{ subpath: '/users', label: 'app users' },
|
|
{ subpath: '/users/invitations', label: 'app invitations' }
|
|
];
|
|
|
|
test.describe('per-app tab navigation accepts slug URLs', () => {
|
|
for (const tab of TABS) {
|
|
test(`${tab.label} loads cleanly`, async ({ page, uniqueSlug }) => {
|
|
// Register cleanup BEFORE the API call so a flaky create
|
|
// still gets swept up. CleanupRegistry tolerates 404s, so a
|
|
// no-op cleanup is harmless if creation failed entirely.
|
|
const slug = uniqueSlug('nav');
|
|
cleanup.app(slug);
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post('/api/v1/admin/apps', {
|
|
data: { slug, name: slug }
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
await expectTabLoadsCleanly(page, `/admin/apps/${slug}${tab.subpath}`);
|
|
});
|
|
}
|
|
|
|
test('queues tab specifically — the original bug report', async ({ page, uniqueSlug }) => {
|
|
// Focused regression test for the original bug report. The slug
|
|
// is unique (not "default") because dev seeding isn't guaranteed
|
|
// in CI, but the failure mode being reproduced is identical.
|
|
const slug = uniqueSlug('queues');
|
|
cleanup.app(slug);
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post('/api/v1/admin/apps', {
|
|
data: { slug, name: slug }
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
|
|
await expectTabLoadsCleanly(page, `/admin/apps/${slug}/queues`);
|
|
// Positive assertion via stable testid. If a future copy edit
|
|
// renames the empty-state text, this still anchors to the same
|
|
// DOM element.
|
|
await expect(page.getByTestId('queues-empty-state')).toBeVisible();
|
|
});
|
|
});
|