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
186 lines
7.7 KiB
Python
186 lines
7.7 KiB
Python
"""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
|