test(db): /facets regression + S3a jsonb-null normalization (R6 S3)

First real customers of the DB lane:
- test_plex_facets: GET /api/plex/facets?scope=show returns 200 (not the
  "cannot extract elements from a scalar" 500) for a show whose genres is a JSONB
  scalar `null` — the 0.53.1 read-guard's regression test. Mutation-checked:
  removing the jsonb_typeof='array' guard turns it red with that exact error.
- migration 0060 (S3a): one-time UPDATE …=NULL WHERE jsonb_typeof=… 'null' over the
  plex_items/plex_shows tag columns, so pre-0.53.1 JSONB-null rows become uniform SQL
  NULL and the read-guards go belt-only. test_plex_jsonb_normalize verifies scalar-null
  → SQL NULL while a real array is preserved.

Gate: ruff clean; DB lane 176 passed; pure-logic lane unchanged (skips the DB tests).
This commit is contained in:
2026-07-26 13:54:42 +02:00
parent 4b6bc54aa8
commit 39f726a5e6
3 changed files with 123 additions and 0 deletions
@@ -0,0 +1,34 @@
"""normalize plex JSONB tag columns: scalar `null` -> SQL NULL (R6 S3a)
Pre-0.53.1 rows persisted a genre-less show/movie's genres/directors/cast_names/collection_keys as
a JSONB scalar `null` (SQLAlchemy wrote a Python None as JSON null before none_as_null=True). The
0.53.1 hotfix added none_as_null=True (new writes are SQL NULL) + jsonb_typeof read-guards, but the
existing rows stayed JSON `null`, which kept those read-guards load-bearing rather than belt-only.
This one-time data fixup rewrites those scalars to SQL NULL so the columns are uniform. Data-only,
forward-only: a SQL NULL is indistinguishable from the old JSON `null` to every reader (the guards
treat both as "not an array"), so there is nothing meaningful to downgrade. No schema change.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0060_plex_jsonb_null_normalize"
down_revision: Union[str, None] = "0059_dl_link_pw_version"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_TABLES = ("plex_items", "plex_shows")
_COLS = ("genres", "directors", "cast_names", "collection_keys")
def upgrade() -> None:
for table in _TABLES:
for col in _COLS:
op.execute(
f"UPDATE {table} SET {col} = NULL WHERE jsonb_typeof({col}) = 'null'"
)
def downgrade() -> None:
pass # forward-only: SQL NULL == the old JSON null for every reader
+45
View File
@@ -0,0 +1,45 @@
"""DB-backed regression for the 0.53.1 hotfix (R6 S3b-test): GET /api/plex/facets?scope=show
must return 200 — not the "cannot extract elements from a scalar" 500 — for a metadata-poor
library whose show has a JSONB-scalar `null` genres (a pre-0.53.1 row). The endpoint's
`jsonb_typeof(genres)='array'` read-guard is what makes it safe; this test would 500 without it.
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from fastapi.testclient import TestClient
from sqlalchemy import text
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexLibrary, PlexShow, User
def test_facets_survives_json_null_genres(db):
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
db.add(lib)
db.flush()
db.add(PlexShow(rating_key="s1", library_id=lib.id, title="No Genre Show"))
user = User(email="facets@example.com")
db.add(user)
# The request gate 503s an unconfigured instance; mark this throwaway DB configured.
state.mark_configured(db)
db.commit()
# Simulate a pre-0.53.1 row: genres persisted as a JSONB scalar `null` (what SQLAlchemy wrote
# before none_as_null=True), NOT SQL NULL — this is the value jsonb_array_elements_text() chokes
# on. A Python None now maps to SQL NULL, so we set the scalar directly.
db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 's1'"))
db.commit()
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
resp = client.get("/api/plex/facets?scope=show")
finally:
app.dependency_overrides.clear()
state._configured_cache = False # one-way process cache — don't leak "configured" to other tests
assert resp.status_code == 200, resp.text
# The scalar-null show contributes no genres — and, crucially, doesn't abort the whole endpoint.
assert resp.json()["genres"] == []
@@ -0,0 +1,44 @@
"""DB-backed test for the S3a data normalization (migration 0060): a JSONB scalar `null` in the
plex tag columns becomes SQL NULL, while a real array is left untouched. Runs the migration's exact
UPDATE statements against seeded rows (the migration itself is a no-op in the empty test DB).
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from sqlalchemy import text
from app.models import PlexLibrary, PlexShow
# Mirror of migration 0060's statements (plex_items runs against an empty table here — proving the
# SQL is valid for both tables; the assertions below check the seeded plex_shows rows).
_NORMALIZE = [
f"UPDATE {t} SET {c} = NULL WHERE jsonb_typeof({c}) = 'null'"
for t in ("plex_items", "plex_shows")
for c in ("genres", "directors", "cast_names", "collection_keys")
]
def test_scalar_null_normalized_array_preserved(db):
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
db.add(lib)
db.flush()
db.add_all(
[
PlexShow(rating_key="null_row", library_id=lib.id, title="Null genres"),
PlexShow(rating_key="array_row", library_id=lib.id, title="Real genres"),
]
)
db.commit()
# Set a pre-0.53.1 JSONB scalar null on one, a genuine array on the other.
db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 'null_row'"))
db.execute(text("""UPDATE plex_shows SET genres = '["Drama"]'::jsonb WHERE rating_key = 'array_row'"""))
db.commit()
assert db.scalar(text("SELECT jsonb_typeof(genres) FROM plex_shows WHERE rating_key='null_row'")) == "null"
for stmt in _NORMALIZE:
db.execute(text(stmt))
db.commit()
# The scalar null is now SQL NULL (jsonb_typeof of SQL NULL is itself NULL, not 'null')...
assert db.scalar(text("SELECT genres IS NULL FROM plex_shows WHERE rating_key='null_row'")) is True
# ...and a real array is untouched.
assert db.scalar(text("SELECT jsonb_typeof(genres) FROM plex_shows WHERE rating_key='array_row'")) == "array"