Test plan
What needs to work, and what’s actually been confirmed.
atjam is in alpha. The lexicon is shipped end-to-end, most read paths render, and a few write paths exist (round creation, signup, submission). This page is the manual run-through that confirms each piece works.
The list is hand-maintained. Status markers go stale fast — they reflect the most recent walk-through, not the live deployment. Re-verify before any production-affecting decision.
Status legend
- ✓ Verified end-to-end at least once on production.
- ⏳ Implemented; not yet manually verified.
- ✗ Not yet implemented in the web app.
Site infrastructure
- ✓
/healthz.txtreturns 200 with body “ok”. - ✓
/oauth-client-metadata.jsonreturns 200 with the five-collection scope includingat.atjam.invitation. - ✓ Static pages render:
/lexicon,/privacy,/terms,/test-plan,/robots.txt,/favicon.svg. - ⏳ 404 catch-all (
$.tsx) renders on unknown paths. - ✓ NSID DNS:
dig +short TXT _lexicon.atjam.atreturns the authority DID. - ✓ Each of the five at.atjam.* schemas resolves at
at://<authority-did>/com.atproto.lexicon.schema/<nsid>. - ⏳ Security headers present on document responses:
Strict-Transport-Security,X-Content-Type-Options,X-Frame-Options,Referrer-Policy,Permissions-Policy.
Authentication
- ⏳ Sign in: type a handle in the AuthBar → redirect to the handle’s PDS → consent → return to
/oauth/callback→ land on home with handle in header. - ⏳ Resume: reload after sign-in; header still shows handle without re-auth.
- ⏳ Sign out: header reverts to handle input;
atjam:didcleared fromlocalStorage. - ⏳ Sign in with a
did:webaccount, not justdid:plc. - ⏳ Loopback dev: signing in at
http://127.0.0.1:3000uses the inlineclient_idpattern;http://localhost:3000should fail per OAuth spec. - ⏳ Stale session: invalidate the OAuth session on the PDS side; next page load gracefully returns to signed-out state rather than crashing.
Organizer flows
Today the only organizer write path is /new — create a round, optionally creating a new jam in the same submit.
- ⏳ One-shot: create a brand-new jam and its first round in one submit. Verify both records on the organizer’s PDS; verify redirect lands on the new round’s detail page.
- ⏳ Attach to existing: when the organizer already has jams, the form defaults to “attach to existing”; the round attaches to the selected jam.
- ⏳ Validation: empty assignment, missing deadline, and empty accepted-types are each blocked with an inline error.
- ⏳ Accepted submission types: comma-separated NSIDs are parsed and trimmed; the cleaned array is what lands in the record.
- ⏳ Default joinMode: rounds created via
/neware written without ajoinModefield; readers resolve to“open”. - ✗ Choose joinMode (
hostedornetwork) at round-creation time. - ✗ Choose
networkGate(signupvscontributed) at round-creation time. - ✗ Add a
signup-deadlinemilestone (currently only submission-deadline is exposed). - ✗ Write an
at.atjam.invitationrecord (no “invite a DID” form yet). - ✗ Edit a round’s milestones after creation.
- ✗ Delete a round you’ve created.
Participant flows
- ⏳ Sign up to an open round: button on the round page writes
at.atjam.signupon the participant’s PDS, strong-reffing the round. - ⏳ Idempotent signup: clicking sign-up twice — does the UI block the second attempt, or does it write a duplicate record?
- ⏳ Submit work: paste an AT URI for a record on another app (e.g. a Bluesky post). Writes
at.atjam.submissionwith the payload strong-ref. - ⏳ Submit a record whose
$typeisn’t in the round’sacceptedSubmissionTypes: the form should warn or block (the lexicon’s SHOULD). - ⏳ Submit to a round after its submission-deadline: the write succeeds at the protocol level but the UI should signal “late.”
- ✗ See invitations targeting your DID (no inbox view).
- ✗ Sign up to a
hostedround with a valid invitation strong-ref attached to your signup. - ✗ In a
networkround, invite someone else from your own UI.
Read flows
- ⏳ Home feed (
/): rounds fromVITE_KNOWN_ORGANIZERSplus the signed-in user’s own appear, sorted as expected. - ⏳ Jam detail (
/jam/$did/$rkey): renders jam name, description, and the list of its rounds. - ⏳ Round detail (
/round/$did/$rkey): renders assignment, milestones, signups, submissions; signup button for non-organizers; submit form for signed-up participants. - ⏳ OG/Twitter meta: view-source a jam page and a round page; confirm
og:titleandog:descriptioninclude the actual jam and round names. - ⏳ Round with no signups: shows an empty-state, not a crash.
- ⏳ Round with no submissions: shows an empty-state, not a crash.
- ⏳ Round whose owning PDS is offline / unreachable: page degrades gracefully with an error, not a blank screen.
Round state derivation
deriveState() turns milestones into a state string for the badge. Today /new only sets submission-deadline, so all UI-created rounds follow the two-state path (open → closed). The three-state path requires hand-crafted records until the UI exposes signup-deadline.
- ⏳ No milestones at all →
“open”forever. - ⏳ Submission-deadline in future → badge shows
“open”(emerald). - ⏳ Submission-deadline in past → badge shows
“closed”(stone). - ✗ Both signup-deadline + submission-deadline, between them → badge shows
“in-progress”(amber). - ✗ Only signup-deadline (no submission-deadline), after it →
“in-progress”indefinitely.
Invitation validation (consumer-side)
The lexicon defines who counts as a valid inviter; readers MUST validate the relationship. None of this is enforced in the web app yet — these tests target the validation logic when it lands.
- ✗ Hosted round + signup without an invitation strong-ref → reader treats as invalid.
- ✗ Hosted round + signup with an invitation whose creator DID matches the jam organizer → valid.
- ✗ Hosted round + signup with an invitation from a DID that is NOT the jam organizer → invalid.
- ✗ Invitation
inviteeDID does not match the signup’s creator DID → invalid (impostor signup). - ✗ Network round + invitation chain: organizer invites A; A invites B; B’s signup is valid.
- ✗ Network round + forged chain: someone fakes an invitation pretending to be from a participant who hasn’t actually signed up → invalid.
- ✗ Network round with
networkGate: "contributed": inviter has signed up but NOT submitted → their invitations are invalid. - ✗ Network round with
networkGate: "contributed": inviter has both signed up AND submitted → their invitations are valid.
Smoke tests after every deploy
The minimum to confirm a deploy didn’t break anything:
curl -I https://atjam.at/healthz.txt→ 200.curl -I https://atjam.at/oauth-client-metadata.json→ 200.curl -s https://atjam.at/oauth-client-metadata.json | grep at.atjam.invitation→ one match.curl -s https://atjam.at/ | head -c 200→ SSR’d HTML with the homepage hero.flyctl status→ machine instartedstate.
What this page isn’t
Not automated tests. There’s no test runner that ticks these off — each marker is the result of a human walk-through. Building an actual integration suite is its own piece of work, and the lexicon shape needs to stabilize first. Until then, this is the checklist.