diff --git a/tradein-mvp/backend/data/sql/100_enable_deactivate_stale_avito.sql b/tradein-mvp/backend/data/sql/100_enable_deactivate_stale_avito.sql new file mode 100644 index 00000000..989b0044 --- /dev/null +++ b/tradein-mvp/backend/data/sql/100_enable_deactivate_stale_avito.sql @@ -0,0 +1,19 @@ +-- #759: deactivate_stale_avito schedule seeded enabled=false → task never ran on prod. +-- +-- Root cause: migration 090 (ON CONFLICT (source) DO NOTHING) was a no-op because +-- the row 'deactivate_stale_avito' already existed in scrape_schedules with +-- enabled=false when 090 ran on prod (inserted by a pre-release iteration or manual +-- seed). The scheduler's WHERE enabled=true filter excluded it on every tick, so +-- last_run_id stayed NULL and next_run_at=2026-06-01 expired without any runs. +-- +-- Fix: idempotent UPDATE that only fires when the row is still disabled. +-- Re-running this migration after the row is already enabled → 0 rows matched, safe. +-- +-- No bind params → no CAST(:x AS type) needed; BEGIN/COMMIT per sql.md conventions. +BEGIN; +UPDATE scrape_schedules + SET enabled = true, + next_run_at = NOW() + WHERE source = 'deactivate_stale_avito' + AND enabled = false; +COMMIT; diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_avito.py b/tradein-mvp/backend/tests/test_deactivate_stale_avito.py index aed99581..ee1da4ab 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_avito.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_avito.py @@ -252,3 +252,53 @@ def test_failure_path_rollback_and_mark_failed(monkeypatch: pytest.MonkeyPatch) assert db.rolled_back is True assert failed["run_id"] == 7 + + +# ── Migration 100: prod hot-fix — flip enabled=false → true ────────────────── + +_MIGRATION_100 = _SQL_DIR / "100_enable_deactivate_stale_avito.sql" + + +def test_migration_100_exists() -> None: + """Prod hot-fix migration must be present so deploy auto-applies it.""" + assert _MIGRATION_100.is_file(), f"missing migration: {_MIGRATION_100}" + + +def test_migration_100_updates_correct_source() -> None: + sql = _MIGRATION_100.read_text("utf-8") + assert "'deactivate_stale_avito'" in sql + + +def test_migration_100_sets_enabled_true() -> None: + sql = _MIGRATION_100.read_text("utf-8") + # UPDATE … SET enabled = true + assert re.search( + r"enabled\s*=\s*true", sql, re.IGNORECASE + ), "migration 100 must SET enabled = true" + + +def test_migration_100_is_idempotent() -> None: + """WHERE enabled = false ensures re-running after fix → 0 rows matched.""" + sql = _MIGRATION_100.read_text("utf-8") + assert re.search( + r"enabled\s*=\s*false", sql, re.IGNORECASE + ), "migration 100 must guard with AND enabled = false for idempotency" + + +def test_migration_100_is_transactional() -> None: + sql = _MIGRATION_100.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_100_no_psycopg_trap() -> None: + """Plain DML — no bind params — guard :x::type trap anyway.""" + sql = _MIGRATION_100.read_text("utf-8") + assert not re.search(r":\w+::", sql) + + +def test_migration_100_also_resets_next_run_at() -> None: + """next_run_at = NOW() ensures the scheduler fires on the next tick.""" + sql = _MIGRATION_100.read_text("utf-8") + assert "next_run_at" in sql + assert "NOW()" in sql