# 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).