fix(migrations): dropping the demo also takes its reset job's interval-change audit rows

0098 deleted the reset job's run rows but not the scheduler_interval rows that changing its interval
wrote; with the job's label gone they rendered as a raw id. The delete now takes every job-targeted
row for it, and a test seeds both kinds below 0098 and upgrades (red on the old filter). The test
has its own file so the removed-features guard can name it.
This commit is contained in:
2026-10-05 00:19:50 +02:00
parent 2f901ad643
commit eecc195b7e
3 changed files with 42 additions and 5 deletions
@@ -5,9 +5,9 @@ schedule — goes with its code. Prod held the demo user and 10 whitelist rows.
The upgrade deletes the demo user first (its playlists, states and subscriptions cascade; audit and
quota rows keep their history with the actor nulled, as for any deleted account), then the traces
that only the demo code could render: its audit rows (whitelist edits, manual resets, the scheduled
reset's job runs), the reset job's interval override and its completion notices. Nothing is
migrated anywhere; the nightly backup holds the rows.
that only the demo code could render: its audit rows (whitelist edits, manual resets, and every row
about the reset job — its runs and its interval changes), the reset job's interval override and its
completion notices. Nothing is migrated anywhere; the nightly backup holds the rows.
The downgrade restores the schema as it stood (0014), empty: the demo user is created lazily on its
first sign-in, so an empty `demo_whitelist` is the whole of its state.
@@ -26,7 +26,7 @@ def upgrade() -> None:
op.execute("DELETE FROM users WHERE is_demo")
op.execute(
"DELETE FROM audit_log WHERE action IN ('demo_add', 'demo_remove', 'demo_reset')"
" OR (action = 'job_run' AND target_id = 'demo_reset')"
" OR (target_type = 'job' AND target_id = 'demo_reset')"
)
op.execute("DELETE FROM scheduler_settings WHERE job_id = 'demo_reset'")
op.execute("DELETE FROM notifications WHERE data->>'job_id' = 'demo_reset'")
+35
View File
@@ -0,0 +1,35 @@
"""Migration 0098's data cleanup, DB-lane.
The schema half of every migration is checked by test_migrations.py; this one also deletes rows, and
a delete that misses a row is invisible there. Its own file so the removed-features guard can name it.
"""
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from app.db import engine
def test_dropping_the_demo_takes_every_audit_row_about_its_reset_job(db):
# The reset job's interval could be changed from the scheduler page like any job's; once its
# name is gone from the UI, a left-behind row would render as a raw job id.
cfg = Config("alembic.ini")
command.downgrade(cfg, "0097_drop_messages")
try:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO audit_log (action, target_type, target_id, created_at) VALUES"
" ('job_run', 'job', 'demo_reset', now()),"
" ('scheduler_interval', 'job', 'demo_reset', now()),"
" ('scheduler_interval', 'job', 'sync_feed', now())"
)
)
finally:
command.upgrade(cfg, "head")
with engine.connect() as conn:
left = conn.execute(
text("SELECT action, target_id FROM audit_log WHERE target_type = 'job' ORDER BY id")
).all()
assert [tuple(r) for r in left] == [("scheduler_interval", "sync_feed")]
@@ -9,7 +9,8 @@ import { describe, expect, it } from "vitest";
//
// Not scanned: the Alembic migrations (0014 added the account and 0098 removed it, and both stay true
// as history) and the release notes, which record what shipped when. `audit_metrics.py` counts the
// footprint by this same word, so it names it on purpose.
// footprint by this same word, and test_migration_0098.py tests the removal itself, so both name it
// on purpose.
const REPO = new URL("../../../", import.meta.url);
const ROOTS = ["backend/app", "backend/tests", "frontend/src", "frontend/e2e", "scripts"];
@@ -18,6 +19,7 @@ const EXTENSIONS = /\.(py|ts|tsx|js|mjs|json|css|md|sh)$/;
const ALLOWED = new Set([
"frontend/src/lib/removedFeatures.contract.test.ts",
"scripts/audit_metrics.py",
"backend/tests/test_migration_0098.py",
]);
// "demo" as a word or an identifier part (`is_demo`, `DemoBanner`, `tryDemo`) — not "demote" (a role
// change) and not "Demokratikus" (a country's Hungarian name in the vocab).