# Manual Testing Checklist
Living document. Each phase appends its own section — do not delete older
sections when the code behind them changes; mark items as superseded
instead so the audit trail stays intact.
Run the site locally before walking through the list:
```bash
source venv/bin/activate
uvicorn app.main:app --reload
```
Then open `http://127.0.0.1:8000/` in a real browser (not just curl).
---
## Phase 1 — Public Site Skeleton
### Home (`/`)
- [ ] Page returns 200 and renders without console errors.
- [ ] Header shows the Chicken Babies R Us logo at ~48px tall.
- [ ] `` `alt` attribute reads **Chicken Babies R Us**.
- [ ] Nav items appear in order: Home · About · Contact · Shop.
- [ ] "Home" is visibly the active nav link and carries `aria-current="page"`.
- [ ] "Shop" nav link is visually muted (lower contrast) but still clickable.
- [ ] Page intro ("Welcome to Chicken Babies R Us") is present.
- [ ] With no posts in the DB, the empty-state reads **"No posts yet — check back soon!"**.
- [ ] Footer shows "Chicken Babies R Us · Morrison, Tennessee".
- [ ] No street address is visible anywhere on the page (CLAUDE.md constraint).
### About (`/about`)
- [ ] Page returns 200 and renders without console errors.
- [ ] H1 reads **"About the farm"**.
- [ ] Copy mentions Morrison, Tennessee by name.
- [ ] Copy name-checks Head Hen.
- [ ] No street address appears anywhere.
- [ ] Nav marks "About" as active (`aria-current="page"`).
### Contact (`/contact`)
- [ ] Page returns 200 and renders without console errors.
- [ ] H1 reads **"Get in touch"**.
- [ ] When `ADMIN_CONTACT_EMAIL` is set, a `mailto:` link renders above the form.
- [ ] When `ADMIN_CONTACT_EMAIL` is unset, the muted placeholder sentence appears and no `mailto:` link renders.
- [ ] The note **"Secure contact form coming soon"** is visible.
- [ ] Form fields (name, email, message) are visually disabled and cannot be typed into.
- [ ] "Send message" button is visually disabled.
- [ ] Form has no `method="POST"` attribute (view source).
- [ ] Nav marks "Contact" as active.
### Shop (`/shop`)
- [ ] Page returns 200 and renders without console errors.
- [ ] H1 reads **"Shop"**.
- [ ] "Coming soon" card is visible with mention of eggs, chicks, and waterfowl.
- [ ] Nav marks "Shop" as active.
### Responsive
Use the browser devtools responsive toolbar.
- [ ] **360 × 800 (mobile):** nav collapses behind a hamburger toggle; toggle opens/closes on click; logo remains legible; no horizontal scroll.
- [ ] **768 × 1024 (tablet):** nav appears inline; layout uses full container width; no horizontal scroll.
- [ ] **1280 × 800 (desktop):** content capped at `--max-width` (68rem ≈ 1088px); generous whitespace either side.
### Accessibility
- [ ] Tab-key order from top of page: skip link → logo → nav links → main content.
- [ ] Pressing **Tab** from a cold page load reveals the skip link in the top-left corner.
- [ ] Activating the skip link jumps focus into ``.
- [ ] Logo has a non-empty `alt` attribute ("Chicken Babies R Us").
- [ ] Navigating with a screen reader announces each landmark (`header`, `nav`, `main`, `footer`).
- [ ] Spot-check color contrast of `--c-ink` (#2B3A42) on `--c-cream` (#FAF3E7) — should be comfortably above WCAG AA for body text.
### Assets
- [ ] `/static/img/logo.png` loads and is roughly 256px tall.
- [ ] `/static/img/logo.webp` loads with content-type `image/webp`.
- [ ] `/static/img/favicon.ico` is requested by the browser and returns 200.
- [ ] `/static/img/apple-touch-icon.png` is 180×180 and has a cream (#FAF3E7) background.
### Ops smoke
- [ ] `pytest -q` passes locally.
- [ ] `python -c "from app.main import app"` exits cleanly.
- [ ] `python scripts/generate_static_assets.py` regenerates the four asset files without error.
- [ ] `docker compose config` still parses cleanly.
---
## Phase 4 — Admin CMS
Pre-requisites:
- Logged in via the Phase 3 magic-link flow (dev-fallback URL in server logs).
- Landed on the Phase 4 dashboard at `/admin`.
### Dashboard (`/admin`)
- [ ] Page returns 200.
- [ ] Page title reads **"Dashboard"**.
- [ ] Signed-in email appears in the greeting.
- [ ] Posts table lists the seeded **"Welcome to the Farm"** row with status **Published**.
- [ ] Each row shows Edit / Publish-or-Unpublish / Delete buttons.
- [ ] Delete click triggers a confirmation dialog.
- [ ] "New post" button is visible and links to `/admin/posts/new`.
- [ ] "Edit About" button links to `/admin/pages/about/edit`.
- [ ] `` is present in the rendered HTML (view source).
- [ ] The `cb_csrf` cookie is set with `SameSite=Lax`, `HttpOnly=false` (readable by JS).
### Create a post (`/admin/posts/new`)
- [ ] Form renders without errors.
- [ ] Title + status + body fields visible; preview pane on the right.
- [ ] Drop zone is visible below the textarea with prompt copy.
- [ ] Typing in the textarea causes the preview to update within ~300ms.
- [ ] Dragging a JPG / PNG / WebP image onto the drop zone uploads it; a Markdown image tag is inserted at the cursor.
- [ ] Dropping a GIF, plain text file, or anything >8 MB triggers the `.is-error` state on the drop zone.
- [ ] Submitting with a blank title re-renders with "Title is required." and preserves other fields.
- [ ] Submitting a valid form 303-redirects to `/admin?msg=created`.
### Edit a post (`/admin/posts/{id}/edit`)
- [ ] Form is pre-populated with the post's current title + body.
- [ ] Slug is rendered read-only below the title.
- [ ] For a published post, the UI notes the slug is locked.
- [ ] Saving updates the row; public `/` reflects the change immediately (no caching delay).
### Publish / unpublish / delete
- [ ] Publish button on a draft row flips the status to published and shows a "published" flash on the next dashboard load.
- [ ] Unpublish button on a published row flips the status back to draft; `published_at` is preserved in the DB (check via `sqlite3 data/app.db`).
- [ ] Delete button on any row removes it entirely after confirmation.
### About page edit (`/admin/pages/about/edit`)
- [ ] Form renders with the current About title and body.
- [ ] There is no slug editor.
- [ ] Saving updates the row and the public `/about` page on next load.
### Media upload
- [ ] `data/media///` is created lazily the first time an image is saved.
- [ ] Uploaded files are stored under a random filename ending in `.jpg` regardless of the source format.
- [ ] Hitting the `/media///.jpg` URL directly serves the image at HTTP 200.
- [ ] An uploaded transparent PNG comes through as an RGB JPEG (transparent areas become white).
- [ ] Uploading an animated GIF is rejected with a generic error.
- [ ] Uploading a >8 MB file is rejected.
- [ ] Uploading while the `X-CSRF-Token` header is missing returns 403.
### CSRF
- [ ] Admin POST routes (`/admin/logout`, `/admin/posts`, `/admin/posts/*/delete`, `/admin/posts/*/publish`, `/admin/pages/about`, `/admin/media/upload`, `/admin/preview`) all return 403 when the submitted token does not match the cookie.
- [ ] A legitimate form submission succeeds because both the cookie and the form field were issued during the previous GET.
### Public site regression
- [ ] `/` shows only published posts (newly created drafts do NOT appear).
- [ ] A newly-published post shows at the top of `/` within one request.
- [ ] `/about` shows the most recently edited copy.
- [ ] No admin-facing text (status, dashboard wording) leaks into the public HTML.
---
## Phase 5 — Contact Form
Pre-requisites:
- `ADMIN_CONTACT_EMAIL` set in `.env` (the destination inbox).
- For the production-like happy path: `RESEND_API_KEY` + `RESEND_FROM`
set; otherwise the send path logs `contact_notification_dev_fallback`
and the admin inbox will not actually receive mail.
- Optionally set `HCAPTCHA_SITE_KEY` + `HCAPTCHA_SECRET` to exercise
the real widget; with both unset the dev fallback auto-passes and
logs `hcaptcha_dev_fallback`.
### GET `/contact`
- [ ] Page returns 200 and renders without console errors.
- [ ] H1 reads **"Get in touch"**.
- [ ] Name, email, and message fields render as editable inputs (no `disabled` attribute).
- [ ] "Send message" button is enabled.
- [ ] Form has `method="POST"` and `action="/contact"` (view source).
- [ ] Honeypot `` is present in the markup but
wrapped in a `.visually-hidden` container marked
`aria-hidden="true"` — it is invisible to sighted users.
- [ ] When `HCAPTCHA_SITE_KEY` is set, the `h-captcha` div and the
`https://js.hcaptcha.com/1/api.js` script appear. When unset,
neither appears.
- [ ] Nav marks "Contact" as active.
### Happy path
- [ ] Fill in Name, Email, Message (>= 10 chars) and submit.
- [ ] Response is HTTP 200 and renders **"Thanks for reaching out"**.
- [ ] `sqlite3 data/app.db "SELECT id, name, email, length(message), handled FROM contact_submissions"` shows the new row with `handled=0`.
- [ ] Server log contains a `contact_submitted` structured event with a
`message_preview` at most 40 chars long (no full body).
- [ ] With `RESEND_API_KEY` set: admin inbox receives the notification
email. `From:` matches `RESEND_FROM`; `Reply-To:` matches the
submitted email; subject is `New contact submission from {name}`.
- [ ] Without `RESEND_API_KEY`: server log contains
`contact_notification_dev_fallback` with the submitter's name,
email, and message length.
### Validation errors
- [ ] Submitting with a blank name shows **"Please enter your name."** inline.
- [ ] Submitting with `not-an-email` shows **"Please enter a valid email address."**.
- [ ] Submitting with a 9-character message shows
**"Message must be at least 10 characters."**.
- [ ] Submitting with a > 4000-character message shows
**"Message must be 4000 characters or fewer."**.
- [ ] Submitting with a > 80-character name shows
**"Name must be 80 characters or fewer."**.
- [ ] The response status code on every validation failure is **400**.
- [ ] Prior valid values remain filled in so the user doesn't retype.
### Spam paths
- [ ] Filling the honeypot `website` field and submitting returns
**"Thanks for reaching out"** (same as success) AND no row is
persisted AND an audit row with
`event_type='contact_spam_rejected'` and `reason=honeypot` exists.
- [ ] With a real hCaptcha configured: submitting without solving the
widget returns the same generic thank-you page. Audit row:
`contact_spam_rejected` / `reason=hcaptcha`. No DB row.
### Rate limit
- [ ] Submit the form **4 times** from the same browser session within
an hour. The fourth submission returns HTTP **429** and renders
the "Too many attempts" template. A `rate_limited` audit row is
added with `scope=ip` and `endpoint=/contact`.
### Email send failure
- [ ] With a valid form but `RESEND_API_KEY` pointed at an invalid key:
the user still sees **"Thanks for reaching out"**, the DB row is
created, and the server log contains
`contact_notification_failed` (logged by EmailService). The user
experience is indistinguishable from success.
### Ops smoke
- [ ] `pytest -q tests/test_hcaptcha_service.py tests/test_contact_service.py tests/test_contact_routes.py` passes.
- [ ] `python -c "from app.main import app; print(len(app.routes))"` prints a count greater than the Phase 4 count (the new `POST /contact` adds one route).