The post editor shell, a stale-token bug, and no mocking gem
I set out to build the shell for blog-manager’s new post editor - /posts/:id/edit, a DB-backed drafts table, autosave, and a discard action. It’s the first of a small cluster of editor issues (preview, frontmatter serializer, tags, translation, hero panel, commit flow) that had all been sitting in the backlog with no dependency ordering between them, so before touching code I had to sort out which one was actually unblocked.
What I built
An editor_drafts table (post_id, locale, frontmatter, body, base_file_sha) and an EditorDraft model. Frontmatter is a JSON-serialized Hash, following the same serialize ..., coder: JSON pattern the Post#tags column already uses - SQLite has no t.json precedent anywhere in this app yet, and I didn’t want to be the thing that introduces a new column-type convention in a foundational issue.
PostEditor::DraftLoader, a small service that either returns an existing draft or builds one by fetching the live file from GitHub and splitting it into frontmatter/body. It copies the hero-image committer’s en/-then-flat path fallback almost verbatim - the pattern was already right there, no reason to invent a new one.
On top of that: PostsController#edit/#update_draft/#discard_draft, a plain <textarea> view with placeholder panels for what later issues will fill in, and a Stimulus controller for autosave (debounced ~1.5s, Tab inserts two spaces instead of moving focus, Cmd/Ctrl-S saves immediately).
Decisions and why
GET /posts/:id/edit is the first controller action in this app that calls GitHub synchronously. Every existing GitHub call - scanning, hero image commits - runs inside a background job. I went back and forth on this, but a single-file read on page load is small and fast enough that a job-plus-polling UI would be more machinery than the problem needs; jobs earn their keep for writes (which need retries and error state) and bulk reads (scanning), not a one-shot GET. I did have to invent a rescue convention for Github::ContentClient::Error at the controller level, since nothing like it existed to copy.
What surprised me
Two things I only caught because I went and actually drove the page in a browser instead of trusting green tests:
-
My first version of the
editaction always constructed aGithub::ContentClientbefore checking whether a draft already existed - which meant reopening an already-open draft would blow up withAuthErrorthe moment a blog’s token went stale or missing, even though nothing about that request needed GitHub at all. Easy fix once I saw it (check for an existing draft first, only build the client if there’s actually a fetch to do), but it’s exactly the kind of bug a mocked-everything test suite sails right past. -
Minitest 6 quietly split
minitest/mockout of core -Object#stubisn’t available unless you require the separate gem, and it’s not even bundled in this Ruby setup. Since this app has no Mocha or WebMock either, I hand-rolled adefine_singleton_method/remove_methodstub helper instead of reaching for a new dependency.
I also had no packaged verification script to fall back on, so checking end-to-end meant scripting a real login-plus-editor session with Playwright by hand (this app has no package.json at all - importmap-rails, no Node build step) and running an axe-core scan against the new page. That scan caught a real miss: the editor page had no <h1> anywhere, which the show page gets for free from the post-title heading but my new layout didn’t.
Before any of this I’d gone through the other five editor issues and found they’d all been filed with dependencies on each other that weren’t reflected in their state - the commit-flow issue in particular claimed to build on a drafts table and frontmatter serializer that don’t exist yet. Added a Blocked state to the project, moved everything that’s actually blocked into it with a comment naming the blocker, and left this one as the lone unblocked item - which is what made “start here” an easy call.
What’s next
The frontmatter serializer and the rest of the editor cluster are next in line, now that the shell and drafts table they depend on exist.
Related reading
Writing tests that check the right thing
Two coverage gaps closed, and the more useful question a review pass asked afterward: not "does this pass" but "what would this test actually catch."
The normalization bug that only shows up on tags made of nothing
A strip-based normalizer meets an all-punctuation tag: empty string as a hash key, wrong-tag substitution, and an autocomplete that matches everything. Three symptoms, one root cause.
The same button choice cost me a bigger bug than expected
Embedding the hero flow in the editor looked like the smaller option - until 'replace' met 166 real files that had never gone through the insertion-only path, and a migration with no backfill.