"""API-тесты для PUT /api/v1/admin/jobs/settings/{job_type}. Регрессия по issue #1223: PUT для отсутствующей строки молча терял изменения и отвечал 200 с fallback-дефолтами. После фикса update() выполняет INSERT, если UPDATE затронул 0 строк, и возвращает новую строку с сохранёнными полями. """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock from fastapi.testclient import TestClient from app.main import app def _make_db_recorder(update_rowcounts: list[int]) -> tuple[MagicMock, list[tuple[str, Any]]]: """Db-mock, который: - возвращает заданные rowcount для UPDATE/INSERT (по списку), - имитирует поведение get_one(): SELECT возвращает строку с теми же значениями, что были последним INSERT (или None, если не вставлялась). """ db = MagicMock() calls: list[tuple[str, Any]] = [] state: dict[str, Any] = {"inserted": None} plan = list(update_rowcounts) def execute(stmt: Any, params: Any = None) -> Any: sql = str(stmt) calls.append((sql, params)) result = MagicMock() if "UPDATE job_settings" in sql: result.rowcount = plan.pop(0) if plan else 0 elif "INSERT INTO job_settings" in sql: result.rowcount = plan.pop(0) if plan else 0 if result.rowcount > 0 and isinstance(params, dict): state["inserted"] = params elif "SELECT" in sql and "job_settings" in sql: # Имитация get_one после успешного INSERT row = state["inserted"] if row is None: result.mappings.return_value.first.return_value = None else: import datetime result.mappings.return_value.first.return_value = { "job_type": row.get("jt"), "enabled": row.get("enabled", True), "queue_name": row.get("queue_name", "celery"), "cron_schedule": row.get("cron_schedule"), "rate_ms": row.get("rate_ms"), "max_retries": row.get("max_retries", 2), "max_concurrency": row.get("max_concurrency", 1), "extra_config": {}, # JSON-строка сериализована — для теста плоский dict ok "updated_at": datetime.datetime(2026, 6, 13, tzinfo=datetime.UTC), "updated_by": row.get("updated_by"), "description": None, } return result db.execute = execute db.commit = MagicMock() return db, calls def _override_db(db: MagicMock) -> Any: def _factory(): yield db return _factory def test_put_job_settings_existing_row_updates_only() -> None: """Существующая строка — UPDATE, без INSERT.""" from app.core.db import get_db db, calls = _make_db_recorder(update_rowcounts=[1]) app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) r = client.put( "/api/v1/admin/jobs/settings/scrape_kn", json={"rate_ms": 750}, ) assert r.status_code == 200, r.text assert any("UPDATE job_settings" in sql for sql, _ in calls) assert not any("INSERT INTO job_settings" in sql for sql, _ in calls) finally: app.dependency_overrides.clear() def test_put_job_settings_missing_row_inserts_and_returns_persisted_values() -> None: """Issue #1223: PUT для отсутствующей строки должен сохранить изменения. После фикса: UPDATE → 0 строк, затем INSERT с переданными полями. Ответ должен содержать **переданные** значения, а не fallback-дефолты. """ from app.core.db import get_db # UPDATE 0, INSERT 1 — типовой happy-path для несуществующей строки. db, calls = _make_db_recorder(update_rowcounts=[0, 1]) app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) r = client.put( "/api/v1/admin/jobs/settings/brand_new_job", json={ "enabled": False, "rate_ms": 1234, "max_retries": 5, }, ) assert r.status_code == 200, r.text body = r.json() # Ключевая проверка: ответ — НЕ fallback (_fallback ставит enabled=True). # Значит INSERT действительно произошёл, get_one вернул вставленную строку. assert body["job_type"] == "brand_new_job" assert body["enabled"] is False assert body["rate_ms"] == 1234 assert body["max_retries"] == 5 # Подтверждаем, что INSERT реально был. assert any("INSERT INTO job_settings" in sql for sql, _ in calls) insert_call = next( (sql, params) for sql, params in calls if "INSERT INTO job_settings" in sql ) assert "ON CONFLICT (job_type) DO NOTHING" in insert_call[0] assert insert_call[1]["jt"] == "brand_new_job" assert insert_call[1]["enabled"] is False assert insert_call[1]["rate_ms"] == 1234 assert insert_call[1]["updated_by"] == "admin-ui" finally: app.dependency_overrides.clear()