From 39f726a5e63b70d55f0e1b865f583a8504257e39 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 26 Jul 2026 13:54:42 +0200 Subject: [PATCH] test(db): /facets regression + S3a jsonb-null normalization (R6 S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../0060_plex_jsonb_null_normalize.py | 34 ++++++++++++++ backend/tests/test_plex_facets.py | 45 +++++++++++++++++++ backend/tests/test_plex_jsonb_normalize.py | 44 ++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 backend/alembic/versions/0060_plex_jsonb_null_normalize.py create mode 100644 backend/tests/test_plex_facets.py create mode 100644 backend/tests/test_plex_jsonb_normalize.py diff --git a/backend/alembic/versions/0060_plex_jsonb_null_normalize.py b/backend/alembic/versions/0060_plex_jsonb_null_normalize.py new file mode 100644 index 0000000..b87e99e --- /dev/null +++ b/backend/alembic/versions/0060_plex_jsonb_null_normalize.py @@ -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 diff --git a/backend/tests/test_plex_facets.py b/backend/tests/test_plex_facets.py new file mode 100644 index 0000000..a8eae42 --- /dev/null +++ b/backend/tests/test_plex_facets.py @@ -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"] == [] diff --git a/backend/tests/test_plex_jsonb_normalize.py b/backend/tests/test_plex_jsonb_normalize.py new file mode 100644 index 0000000..b709635 --- /dev/null +++ b/backend/tests/test_plex_jsonb_normalize.py @@ -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"