Skip to content

v0.0.3 — songs are selectable, and a 500 you can report

Choose a tag to compare

@sredevopsorgagents sredevopsorgagents released this 08 Oct 09:33
· 35 commits to main since this release
0367704

v0.0.3 — the release where using the tool found what testing it missed

Three fixes, and all three were found by using metaedit rather than by its tests. That is worth naming, because it is the second time in this project it has been true.

Bulk editing over songs never worked

unknown selection kind 'song'; expected one of ['album', 'artist', 'track']

There are two names for one thing here, and they are deliberately different:

vocabulary values who reads it
query artist / album / song every human-facing surface: the library browse, the bulk selection, the harvest selection, the labels in the UI
archive artist / album / track the derived tables — lastfm_track, ReindexKind — someone writing SQL

The bulk selection took its enum from one side and its lookup table from the other. The UI offered "Songs", the schema accepted "song", and the service rejected it — every time.

Only songs broke, because it is the one word the two vocabularies spell differently. The same conflation for artists and albums happened to agree, which is exactly why it survived so long. Harvest had the identical bug through the same shared path.

Fixed by putting both mappings next to each other in one module with the distinction written down, and pinning the relationship in test_kind_vocabularies.py rather than the values — so adding a media type now fails in a test instead of in someone's browser.

A timestamp in an error detail turned the error into a 500

An exception handler builds its own response, so JSONResponse(content=...) runs plain json.dumps rather than FastAPI's jsonable_encoder. PremiereDate lives in the item state as a real datetime — deliberately, so date comparisons work — and that state travels in ConflictError.current so a stale-Etag 409 can show what changed. The body then failed to serialise.

The one error whose job is to explain a conflict was the one that could not render.

Songs were where it surfaced because a song usually carries a PremiereDate and an artist usually does not — verified against a real library, not assumed.

The bug was never a missing conversion. It was conversion being remembered at four of five places, so api/serialization.py now holds the single definition of "can this go in a response body", applied where the body is built.

A 500 you can report

before   {"error": {"code": "internal_error", "message": "Unexpected server error."}}
after    {"error": {"code": "internal_error",
                    "message": "Internal error. Reference 8d898cf2128d -- see the server log ...",
                    "reference": "8d898cf2128d",
                    "occurred": "TypeError",
                    "path": "GET /api/items/x/diff",
                    "retryable": false}}

The reference appears in the server log, so a user's screenshot is something an operator can grep. It is a structured field on SSE error events too, not only inside a sentence that is ours to reword.

The disclosure policy is unchanged: a message we wrote for a reader still passes through, and anything else still gets a reference instead of its str().

Also: harvest reported an unexpected failure as harvest_failed while bulk reported internal_error for the same RuntimeError — a label for the route being used as a code for the failure. Both report internal_error now.

Also in this release

  • The test suite runs in 38 s instead of 149 s. Every Postgres-backed test was replaying alembic upgrade head in a subprocess — 119 s of the 139 s the integration suite took. The migration chain now runs once per session into a template database, and each test's database is a CREATE DATABASE ... TEMPLATE copy. Every test still gets a private, fully migrated database; the isolation guarantee is now asserted rather than assumed.
  • The README is a landing page, with the detail split into docs/development.md, docs/api.md, docs/operations.md and docs/architecture.md. Two paragraphs were deleted rather than moved, because they had described the project as it was five phases earlier.
  • Every CI job runs on ubuntu-latest, and the pytest steps no longer append -q — addopts already sets one, and a second suppressed the summary line, so a green run never said how many tests ran.
  • .github/CODEOWNERS exists, so the ruleset's code-owner-review requirement names a real person instead of enforcing nothing.
  • .env.example no longer ships a two-release-old User-Agent naming a placeholder repository.

Verification

  • 703 tests pass (was 694), 20 deselected.
  • Ruff, format and strict mypy clean.
  • export_contract.py --check passes — the version is bumped in all five places it lives, including the OpenAPI document, which is why the contract check is part of the release rather than an afterthought.
  • Every fix has a test that fails without it, checked by reintroducing the defect rather than by asserting the fix is present.

Known limitations

  • The Playwright end-to-end suite is not run in CI and was not run for this release. Fifteen specs need a browser and a compose stack. A green CI run says nothing about them.
  • One JSONResponse site remains unconverted — the httpx.HTTPError handler builds a literal body and is safe today, but is one edit away from the same class of bug.