gendesign/backend/tests/api/v1/test_admin_jobs_settings.py
Light1YT a5ff281bad
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m37s
Deploy / build-worker (push) Successful in 2m46s
Deploy / deploy (push) Successful in 1m19s
fix(admin-jobs): UPSERT в PUT /admin/jobs/settings — не терять изменения (#1223)
UPDATE без проверки rowcount затрагивал 0 строк для несуществующего
job_type; get_one возвращал hardcoded _fallback. PUT отвечал 200,
админ считал настройку сохранённой — но она терялась (новый job_type
до seed-миграции, опечатка в path).

Patch: update() проверяет result.rowcount: при 0 делает INSERT с
переданными колонками (непереданные → дефолты таблицы из м.81).
INSERT использует ON CONFLICT (job_type) DO NOTHING + повторный
UPDATE как защита от гонки. Поведение для существующих строк не
изменилось. 8 новых тестов (6 service + 2 API).

Closes #1223
2026-06-13 08:36:23 +00:00

136 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()