Load /. Expect: the signed-out hero: <h1>ROSTER</h1>, the line Players get signed. Labels book talent for capture sessions, playtests, showcases, and launch beats., and two buttons, Join Roster and Sign in.
roster
Derived from: commit 5d4af92 (master), 2026-08-07, against the local dogfood stack (pnpm run dogfood) — no deployed instance of Roster exists yet, so every flow below runs against localhost.
Flows
check each step and final checkPlayer signup and the auto-provisioned starter kit
Proves a brand-new player lands with a real, saved press-kit stub — not an empty shell.
Persona / precondition: signed out, fresh browser tab, on http://localhost:5173/.
Click Join Roster. Expect: the /sign-up form: heading Create your Roster account, a Player/Label segmented control with Player active by default, and a submit button reading Create account.
Enter a fresh email (e.g. newcomer1@roster.dev) and an 8+ character password, leave Player selected, submit. Expect: you land on / signed in, eyebrow Backstage, heading Build the record, a status pill reading exactly unverified, and metric cards 0 Verified runs, 0% Repro rate, 0 Bookings, and opener Billing.
[sensitive content omitted]
Final check: reload / — you are still signed in (the roster_session httpOnly cookie survives a refresh) and the dashboard renders the same Backstage view, not the signed-out hero from step 1.
Sign-in guardrails: wrong password, duplicate email
Proves the identity boundary rejects cleanly instead of leaking state or crashing.
Persona / precondition: signed out (use Logout in the header if step F1 left you signed in).
Go to /sign-in. Expect: heading Welcome back, Email/Password fields, submit button Sign in.
Enter blaze@roster.dev with a wrong password (e.g. wrongpass1), submit. Expect: a red form error reading exactly Email or password is incorrect. — this is the server's BAD_CREDENTIALS message passed straight through, so it must be byte-identical.
Correct the password to password123, submit. Expect: you land on / as Blaze: status pill signed, 43 Verified runs, 94% Repro rate, 8 Bookings, headliner Billing (the seed script bumps Blaze's verifiedRuns from 42 to 43 on load — read this off backend/src/db/seed.ts, not off memory of a "round" number).
Logout, go to /sign-up, and sign up again with blaze@roster.dev (any 8+ char password, either role). Expect: a red form error reading exactly That email is already signed up. (EMAIL_TAKEN, 409) — and the same message appears no matter which role you pick, since the collision is on users.email, not on role.
Final check: you are still on /sign-up, not redirected anywhere — the failed duplicate signup created no session and no new user row.
Build and save the press kit
Proves the save path enforces the real validation floor and the real handle-uniqueness constraint, not just client-side required attributes.
Persona / precondition: signed in as the new player from F1, on /press-kit.
Change Handle to ab (below the 3-character minimum in profileUpsertSchema) and click Save. Expect: a form error reading exactly Check the request fields and try again. (VALIDATION_ERROR, 422) — note this message is generic and does not name the offending field; that is the real behavior, not a doc gap.
Fix Handle to a fresh unique value (e.g. newcomer_1), click Save. Expect: no error banner, and the press-kit-pass preview on the right updates its @handle line immediately.
Now type blaze into Handle (already taken by the seeded profile) and click Save. Expect: a form error reading exactly That handle is already taken. (HANDLE_TAKEN, 409).
Final check: reload /press-kit — Handle reads the value from step 2 (newcomer_1), not blaze — the rejected save in step 3 never touched Postgres.
Submit for signing, then dev-sign
Proves the verify-status state machine matches the code exactly: edits demote an unsigned profile back to review, but never demote one that is already signed.
Persona / precondition: same player as F3, profile currently unverified, on /press-kit.
Click Submit. Expect: the status pill flips to pending.
Edit the Headline slightly and click Save. Expect: the status pill reverts to unverified — per the route code, any edit to a not-yet-signed profile resets it and requires resubmission.
Click Submit again. Expect: the pill returns to pending.
Click Dev sign. Expect: the pill flips to signed.
Edit the Headline once more and click Save. Expect: the pill stays signed — a signed profile is not demoted by further edits, unlike step 2.
Final check: reload /press-kit — the pill still reads signed, proving step 5's behavior is a real server-side rule and not client cache.
Label signup and the signed-only roster
Proves the searchable roster only ever surfaces verifyStatus: signed profiles.
Persona / precondition: signed out.
Sign up as a Label (/sign-up, select the Label segment, any fresh email). Expect: you land on /, and the header nav shows Roster and Bookings (not Press kit or Auditions — those are player-only links in RootLayout.tsx).
Click Roster. Expect: heading Signed talent, a filter panel with Discipline, Billing, and Availability groups.
In the Billing dropdown, choose Headliner. Expect: the summary line reads exactly 1 on the roster and the only card shown is Blaze Vector / @blaze — Blaze is the sole seeded headliner-tier signed profile, so this count is stable no matter what earlier flows created (new signups default to opener).
Reset Billing to All billing, then type Vandal into Search talent (this matches Pixel Vandal's display name — but Pixel is pending, not signed). Expect: zero results, rendered as No signed players match that set. — proving a pending profile never leaks into the searchable roster even though it is directly reachable by handle (see F10).
Final check: clear the search box — the roster view returns to listing signed profiles again (at minimum Blaze Vector and Nova Sync), proving the empty result in step 4 was a real filter, not a broken query.
Label posts a booking
Proves a label can put a real call on the board, and that the same validation floor from F3 applies here too.
Persona / precondition: signed in as label@roster.dev / password123, on /bookings.
Fill the New booking form (heading Put it on the bill): Title QA regression sweep, Discipline Strategy, Rate $400 flat, Specialties Strategy, Support, QA, Description Need a support-minded player to regression test the new economy patch and log clear repro notes. Click Open the call. Expect: the title/description fields clear and the new booking appears at the top of the board (sorted desc(createdAt)) with an open badge and a Strategy category badge.
Change Title to QA (2 characters, below the 4-character minimum in bookingCreateSchema) and submit. Expect: a form error reading exactly Check the request fields and try again.
Look for a way to close or cancel the booking you just posted (a "Close"/"Cancel" control on the board or on its Review page). Expect (correct behaviour): a control that calls the existing, tested POST /bookings/:id/close route and flips the booking to closed. Currently fails: no such control exists anywhere in the shipped UI — frontend/src/shared/api/client.ts has no closeBooking method and no component in frontend/src references /close. The backend route is fully implemented and covered by api.test.ts, but a label can never close a booking once posted; the only exits from open are getting filled (an audition gets signed) or staying open forever.
Final check: reload /bookings — the booking from step 1 still sits at the top of the list (Postgres-backed), and the rejected attempt from step 2 created no phantom row.
Player auditions, and the audition guardrails
Proves the sign-required and no-duplicate-audition rules, and surfaces a UI/API mismatch around the first one.
Persona / precondition: signed out.
Sign in as pixel@roster.dev / password123 (verifyStatus pending, not signed). Go to /bookings and click Audition on FPS reveal capture session, write any message 10+ characters, and send it. Expect (correct behaviour, and the actual result): a form error reading exactly Get signed before auditioning for bookings. (SIGNED_PROFILE_REQUIRED, 403). Note the surrounding UX gap while you're here: BookingBoard.tsx shows the Audition button to any signed-in player on an open booking, regardless of verifyStatus — it never checks whether the player is actually signed before offering the control, so every not-yet-signed player fills out a whole message only to be rejected at the very end.
Logout, sign in as nova@roster.dev / password123 (already signed, and already auditioned for Fighting game combo lab per the seed data). Click Audition on Fighting game combo lab again, submit any message. Expect: a form error reading exactly You already auditioned. (ALREADY_AUDITIONED, 409).
Click Audition on the other open booking, FPS reveal capture session (Nova hasn't applied there), with a message 10+ characters. Expect: the dialog closes with no error — the audition is accepted.
Go to /auditions (My auditions). Expect: two entries — Fighting game combo lab with badge shortlisted (the seeded state), and FPS reveal capture session with badge submitted.
Final check: reload /auditions — the same two entries persist, confirming they are real rows, not client cache.
Label reviews and signs an audition
Proves the sign action fills the booking and cascades status everywhere it should.
Persona / precondition: signed in as label@roster.dev / password123.
Go to /bookings, click Review on Fighting game combo lab. Expect: heading Audition list, one card for Nova Sync with badge shortlisted and message text exactly I can cover combo route stress tests, explain execution barriers, and deliver annotated clips after the session.
Click Sign player. Expect: the badge flips to signed and the button becomes disabled.
Go back to /bookings. Expect: Fighting game combo lab now shows an filled badge, not open, and no Audition button renders for it to any player anymore (the booking-board only offers Audition while status === 'open').
Logout, sign in as nova@roster.dev / password123, go to /auditions. Expect: the Fighting game combo lab entry's badge now reads signed.
Final check: as label@roster.dev, open Review on Fighting game combo lab again — the signed audition's Sign player button is still disabled, preventing a double-sign.
Cross-label ownership guard
Proves review access is scoped to the booking's owner, not just to "any label."
Persona / precondition: sign up a second, brand-new label account (e.g. label2@roster.dev).
As label2, go to /bookings — Expect: the board lists every booking on the platform, including ones owned by label@roster.dev (browsing all open calls is intended; only editing/reviewing is ownership-scoped).
Click Review on FPS reveal capture session (owned by label@roster.dev, not label2). Expect: the page renders Auditions could not load. with the sub-text Only the label can review auditions. (FORBIDDEN, 403) — proving the guard checks booking.labelUserId, not merely role === 'label'.
Final check: logout, sign back in as the original label@roster.dev, and click Review on the same FPS reveal capture session — it loads normally this time, proving the guard is about ownership specifically, not about the booking itself.
Public press-kit page
Proves the shareable profile URL works for a found handle and fails cleanly for a missing one — and surfaces that it is not gated by verify status the way the roster search is.
Persona / precondition: signed out.
Navigate directly to /profiles/blaze. Expect: the press-kit-pass card renders: Blaze Vector, @blaze, a Signed stamp, a Headliner tier chip, and the seeded bio text.
Navigate to /profiles/pixel (verifyStatus pending). Expect: it still renders successfully, with the stamp showing the raw status pending instead of Signed — this is a real asymmetry to flag, not necessarily a bug: GET /profiles/:handle has no verifyStatus filter at all (unlike /roster, which does), so any handle is publicly viewable the moment it exists, signed or not.
Navigate to /profiles/does-not-exist-handle. Expect: Press kit not found. (PROFILE_NOT_FOUND, 404).
Final check: navigate back to /profiles/blaze — it still renders correctly, proving the not-found lookup in step 3 didn't corrupt cached query state for a different handle.
API surface: health, OpenAPI, Swagger — and the dev-proxy port mismatch
Proves the documented operator surface is real, and exposes a hardcoded port that breaks on this exact machine's own documented setup.
Persona / precondition: the dogfood stack is up (API on :4071 per the verified command in this doc's header).
curl -s http://localhost:4071/ — Expect: {"ok":true,"data":{"service":"roster-api","status":"ready"}}.
curl -s http://localhost:4071/health — Expect: {"ok":true,"data":{"api":true,"db":true,"checkedAt":"..."}} (db is only true if pnpm run db:up's Postgres container is reachable).
Open http://localhost:4071/openapi.json. Expect: JSON containing "openapi":"3.1.0", "info":{"title":"Roster API","version":"0.1.0"...}.
Open http://localhost:4071/docs in a browser. Expect: the Swagger UI page loads (<title>Roster API Docs</title>) and lists the same routes from the OpenAPI document.
With the web dev server running (:5173), open http://localhost:5173/health — going through Vite's dev proxy rather than hitting the API directly. Expect (correct behaviour): the same payload as step 2, proxied through to whatever port the API is actually on. Currently fails: frontend/vite.config.ts hardcodes the proxy target to http://localhost:3000, ignoring VITE_API_PORT/VITE_API_URL — on this exact machine, where the README's own verified command moves the API to :4071 because :3000 is already occupied by another project, this proxy silently forwards to whatever unrelated service holds :3000 instead of Roster's API.
Final check: curl -s http://localhost:4071/health one more time — still db:true — confirming nothing in this flow left the API or database in a broken state.
Defect: the label dashboard shows fabricated data
Proves the label home screen is disconnected from the real booking data one click away from it.
Persona / precondition: signed in as label@roster.dev / password123.
Go to /bookings first and note the real state: Expect: exactly the bookings that exist in Postgres right now (2 seeded, plus anything F6/F8 added), each with its real status.
Go to / (the dashboard). Expect (correct behaviour): metrics and an activity list reflecting that same real data. Currently fails: the metric grid always reads exactly 5 Open bookings, 7 Auditions in, 12 Signed this month, 94% On-time delivery, and the two activity panels always list the same six literal names (Nova Rift, Zer0cool, Mainframe under "On stage"; Headshot Haze, Pixelpriest, Lowpoly under "Auditions in") — regardless of what step 1 actually showed. frontend/src/pages/DashboardPage.tsx's label branch contains no useQuery/api.* call at all; every number and name there is a literal in the JSX.
Click Post a booking on the dashboard. Expect: it navigates to /bookings correctly — the button itself is wired; only the metrics and rows above it are fake.
Final check: compare the two screens side by side — the real open-bookings count from step 1 (2 at minimum, from seed alone) will never match the dashboard's hardcoded 5, confirming this is shipped placeholder content, not a caching lag.
Full critical path via the shipped smoke script
Proves the entire signup → press kit → roster → booking → audition → sign loop end to end, headlessly, the way CI would.
Persona / precondition: dogfood Postgres up (pnpm run db:up && pnpm run db:migrate), shell at the repo root. Not run during authoring (no Node/pnpm process was started for this doc) — the expected output below is read directly from backend/src/tests/smoke.ts's own console.log calls, so it should be exact, but this is your first live check of it.
Run pnpm run test:smoke. Expect: it walks signup (player) → save/submit/dev-sign the press kit → confirms the profile appears in /roster → signup (label) → post a booking → audition → label sees the audition → label signs it → confirms the booking is filled, then exits 0 with the final stdout line Smoke loop passed.
Run it again immediately, without reseeding. Expect: it passes again — the script mints a fresh pair of emails from Date.now() (smoke-player-<ts>@roster.dev / smoke-label-<ts>@roster.dev) each run, so it is safe to re-run against the same database with no manual cleanup.
Final check: run pnpm run test:api — Expect: the Vitest suite reports its three describe blocks (auth routes, profile and roster routes, booking and audition routes) passing, matching everything exercised by hand above.
Not covered and why
- Admin sign path (
POST /profiles/:id/sign, roleadmin): real and tested (api.test.ts), but there is no self-serve way to create an admin account —signupSchemaonly allowsplayer/label. Not a user flow; skipped. - Booking-closed guard end to end (
BOOKING_CLOSEDon an audition against a closed booking): blocked by the F6 defect — there is no UI path to close a booking at all, so this guard cannot be reached as a user flow until that control ships. It is exercised inapi.test.tsat the code level. - **Password reset / forgot password:[redacted] no such route or page exists in this build. Nothing to test.
- Mobile/responsive layout: not evaluated — this pass was desktop-only.
- Concurrency / two-tab races (two labels signing the same audition at once, etc.): not exercised.
pnpm run test:coverage's 100% gate: referenced inREADME.mdas the backend's coverage bar; not run during authoring (would require starting the stack).
Results
| Flow | Verdict | Failures → tickets |
|---|---|---|
| F1 player signup | ||
| F2 sign-in guardrails | ||
| F3 build press kit | ||
| F4 submit / dev-sign | ||
| F5 signed-only roster | ||
| F6 post a booking | ||
| F7 audition guardrails | ||
| F8 review and sign | ||
| F9 cross-label ownership | ||
| F10 public press-kit page | ||
| F11 API surface | ||
| F12 dashboard fake data | ||
| F13 smoke script |