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