gendesign/backend/tests/services/test_job_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

186 lines
7.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.

"""Unit-тесты для app.services.job_settings.update (UPSERT поведение).
Покрывают issue #1223: PUT /admin/jobs/settings/{job_type} для несуществующей
строки молча терял изменения. После фикса update() выполняет INSERT, если
UPDATE затронул 0 строк.
DB здесь полностью замокана — мы проверяем, что service:
1. Делает UPDATE сначала и фиксирует rowcount.
2. При rowcount == 0 делает INSERT с переданными колонками.
3. При rowcount > 0 НЕ делает INSERT (PATCH-семантика сохранена).
4. Защищён от гонки UPDATE→INSERT через ON CONFLICT DO NOTHING.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import pytest
from app.services import job_settings as svc
class _ExecRecorder:
"""Имитация db.execute() с задаваемым rowcount и записью всех вызовов."""
def __init__(self, plan: list[int]) -> None:
# plan: список rowcount-ов для последовательных execute() вызовов.
# get_one() в конце update() тоже бьёт execute() — этот вызов покрывается
# последним элементом или дефолтным MagicMock (см. __call__).
self._plan = list(plan)
self.calls: list[tuple[str, dict[str, Any]]] = []
def __call__(self, stmt: Any, params: Any = None) -> Any:
sql = str(stmt)
self.calls.append((sql, dict(params) if isinstance(params, dict) else {}))
result = MagicMock()
# rowcount только для DML; для SELECT/get_one — не важен.
result.rowcount = self._plan.pop(0) if self._plan else 0
# get_one() ожидает .mappings().first() → None (→ fallback).
result.mappings.return_value.first.return_value = None
return result
@pytest.fixture
def _patched_get_one(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
"""get_one() имеет свою БД-логику; патчим, чтобы не мешала assert-ам на execute()."""
mock = MagicMock(return_value={"job_type": "x", "stub": True})
monkeypatch.setattr(svc, "get_one", mock)
return mock
def _count_statements(calls: list[tuple[str, dict[str, Any]]], keyword: str) -> int:
return sum(1 for sql, _ in calls if keyword in sql)
def test_update_existing_row_only_updates(_patched_get_one: MagicMock) -> None:
"""Когда UPDATE затронул 1 строку — INSERT НЕ выполняется (regression guard)."""
db = MagicMock()
exec_rec = _ExecRecorder(plan=[1]) # UPDATE → 1 row affected
db.execute = exec_rec
svc.update("scrape_kn", db, rate_ms=500, updated_by="admin-ui")
assert _count_statements(exec_rec.calls, "UPDATE job_settings") == 1
assert _count_statements(exec_rec.calls, "INSERT INTO job_settings") == 0
db.commit.assert_called_once()
def test_update_missing_row_inserts_with_provided_fields(
_patched_get_one: MagicMock,
) -> None:
"""Issue #1223: PUT для несуществующей строки должен сохранять изменения.
UPDATE возвращает rowcount=0, после чего service делает INSERT с теми же
колонками, что были в SET-списке.
"""
db = MagicMock()
exec_rec = _ExecRecorder(plan=[0, 1]) # UPDATE 0, INSERT 1
db.execute = exec_rec
svc.update(
"new_job_type",
db,
enabled=False,
queue_name="celery",
cron_schedule="*/5 * * * *",
rate_ms=1000,
max_retries=3,
max_concurrency=2,
extra_config={"key": "value"},
updated_by="admin-ui",
)
# Должен быть ровно один UPDATE и ровно один INSERT.
assert _count_statements(exec_rec.calls, "UPDATE job_settings") == 1
assert _count_statements(exec_rec.calls, "INSERT INTO job_settings") == 1
insert_sql, insert_params = next(
(sql, params) for sql, params in exec_rec.calls if "INSERT" in sql
)
assert "ON CONFLICT (job_type) DO NOTHING" in insert_sql
assert insert_params["jt"] == "new_job_type"
assert insert_params["enabled"] is False
assert insert_params["queue_name"] == "celery"
assert insert_params["cron_schedule"] == "*/5 * * * *"
assert insert_params["rate_ms"] == 1000
assert insert_params["max_retries"] == 3
assert insert_params["max_concurrency"] == 2
assert insert_params["updated_by"] == "admin-ui"
# extra_config сериализуется в JSON-строку — приводится через CAST.
assert "CAST(:extra_config AS jsonb)" in insert_sql
assert '"key": "value"' in insert_params["extra_config"]
db.commit.assert_called_once()
def test_update_missing_row_minimal_payload(_patched_get_one: MagicMock) -> None:
"""INSERT для пустого payload (только updated_by из роута) — лишь job_type/updated_by.
Остальные колонки должны получить таблицы-дефолты — мы их не упоминаем в
INSERT, проверяем что они НЕ попали в params.
"""
db = MagicMock()
exec_rec = _ExecRecorder(plan=[0, 1])
db.execute = exec_rec
svc.update("brand_new", db, updated_by="admin-ui")
insert_sql, insert_params = next(
(sql, params) for sql, params in exec_rec.calls if "INSERT" in sql
)
assert insert_params == {"jt": "brand_new", "updated_by": "admin-ui"}
# В INSERT не должно быть колонок, которые мы не передали.
assert "enabled" not in insert_sql
assert "queue_name" not in insert_sql
assert "extra_config" not in insert_sql
def test_update_insert_race_falls_back_to_second_update(
_patched_get_one: MagicMock,
) -> None:
"""Гонка UPDATE→INSERT: параллельный PUT вставил строку между нашими запросами.
INSERT ... ON CONFLICT DO NOTHING вернёт rowcount=0; service должен повторить
UPDATE, чтобы наши изменения не потерялись.
"""
db = MagicMock()
# UPDATE→0, INSERT→0 (конфликт), second UPDATE→1.
exec_rec = _ExecRecorder(plan=[0, 0, 1])
db.execute = exec_rec
svc.update("racing_jt", db, enabled=True, updated_by="admin-ui")
assert _count_statements(exec_rec.calls, "UPDATE job_settings") == 2
assert _count_statements(exec_rec.calls, "INSERT INTO job_settings") == 1
db.commit.assert_called_once()
def test_update_cron_empty_string_becomes_null(_patched_get_one: MagicMock) -> None:
"""cron_schedule='' → NULL и при UPDATE, и при INSERT (manual-only режим)."""
db = MagicMock()
# UPDATE→0 → попадаем в INSERT-ветку.
exec_rec = _ExecRecorder(plan=[0, 1])
db.execute = exec_rec
svc.update("nspd_geo", db, cron_schedule="", updated_by="admin-ui")
update_params = exec_rec.calls[0][1]
insert_params = exec_rec.calls[1][1]
assert update_params["cron_schedule"] is None
assert insert_params["cron_schedule"] is None
def test_update_rate_ms_zero_becomes_null(_patched_get_one: MagicMock) -> None:
"""rate_ms=0 → NULL (no throttle)."""
db = MagicMock()
exec_rec = _ExecRecorder(plan=[0, 1])
db.execute = exec_rec
svc.update("nspd_geo", db, rate_ms=0, updated_by="admin-ui")
update_params = exec_rec.calls[0][1]
insert_params = exec_rec.calls[1][1]
assert update_params["rate_ms"] is None
assert insert_params["rate_ms"] is None