"""Migration 0109's data step, DB-lane: the audit log's run rows move into `job_runs` with their outcome intact, and leave the audit log; an admin action stays where it was. The downgrade moves them back.""" import json from alembic import command from alembic.config import Config from sqlalchemy import text from app.db import engine def _audit(conn, actor, action, target, after): conn.execute( text( "INSERT INTO audit_log (actor_id, action, target_type, target_id, summary, after," " created_at) VALUES (:a, :act, 'job', :t, 's', CAST(:after AS json)," " CAST('2026-10-01T03:00:00+00:00' AS timestamptz))" ), {"a": actor, "act": action, "t": target, "after": json.dumps(after)}, ) def test_the_audit_logs_runs_move_into_the_run_table_and_back(db, make_user): uid = make_user().id cfg = Config("alembic.ini") command.downgrade(cfg, "0108_folder_links") try: with engine.begin() as conn: _audit( conn, None, "job_run", "rss_poll", {"status": "ok", "summary": "new=3", "result": {"new": 3}}, ) _audit( conn, uid, "job_run", "rss_poll", {"status": "error", "summary": "boom", "result": {"failure": "rss_all_failed"}}, ) _audit( conn, uid, "job_run", "backfill", {"status": "skipped", "summary": "sync paused"} ) _audit(conn, uid, "config_set", None, {"key": "x"}) finally: command.upgrade(cfg, "head") with engine.connect() as conn: runs = conn.execute( text( "SELECT job, trigger, actor_id, status, summary, result, error, failure," " to_char(started_at AT TIME ZONE 'UTC', 'MM-DD HH24'), finished_at = started_at" " FROM job_runs ORDER BY id" ) ).all() left = conn.execute(text("SELECT action FROM audit_log")).scalars().all() assert [tuple(r) for r in runs] == [ ("rss_poll", "scheduled", None, "ok", "new=3", {"new": 3}, None, None, "10-01 03", True), ( "rss_poll", "manual", uid, "error", None, None, "boom", {"failure": "rss_all_failed"}, "10-01 03", True, ), ("backfill", "manual", uid, "skipped", "sync paused", None, None, None, "10-01 03", True), ] assert left == ["config_set"] command.downgrade(cfg, "0108_folder_links") try: with engine.connect() as conn: back = conn.execute( text( "SELECT actor_id, target_id, after::jsonb FROM audit_log" " WHERE action = 'job_run' ORDER BY id" ) ).all() finally: command.upgrade(cfg, "head") assert [tuple(r) for r in back] == [ (None, "rss_poll", {"status": "ok", "summary": "new=3", "result": {"new": 3}}), ( uid, "rss_poll", {"status": "error", "summary": "boom", "result": {"failure": "rss_all_failed"}}, ), (uid, "backfill", {"status": "skipped", "summary": "sync paused"}), ] def test_a_down_and_up_round_trip_keeps_every_run(db, make_user): """A rollback then a redeploy: an interrupted run, a run caught mid-way and a user's run of one channel come back as runs — not stranded in the audit log under an action no code knows.""" uid = make_user().id with engine.begin() as conn: conn.execute( text( "INSERT INTO job_runs (job, trigger, actor_id, target, status, started_at," " finished_at) VALUES" " ('backfill', 'scheduled', NULL, NULL, 'interrupted', now(), now())," " ('maintenance', 'manual', :u, NULL, 'running', now(), NULL)," " ('channel_backfill', 'ad_hoc', :u, 'UC_x', 'ok', now(), now())" ), {"u": uid}, ) cfg = Config("alembic.ini") command.downgrade(cfg, "0108_folder_links") command.upgrade(cfg, "head") with engine.connect() as conn: runs = conn.execute( text("SELECT job, trigger, actor_id, target, status FROM job_runs ORDER BY id") ).all() stranded = conn.execute( text("SELECT count(*) FROM audit_log WHERE action = 'job_run'") ).scalar() assert [tuple(r) for r in runs] == [ ("backfill", "scheduled", None, None, "interrupted"), ("maintenance", "manual", uid, None, "interrupted"), # the rollback stopped it ("channel_backfill", "ad_hoc", uid, "UC_x", "ok"), ] assert stranded == 0