All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m26s
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
136 lines
5.7 KiB
Python
136 lines
5.7 KiB
Python
"""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()
|