import asyncio from contextlib import contextmanager from datetime import date, datetime, timezone import pytest from app.contacts.backend import lifecycle_service as service @pytest.mark.parametrize("event,lead,expected", [ (date(2026, 3, 30), 1, "2026-03-29T08:00:00+02:00"), (date(2026, 10, 26), 1, "2026-10-25T08:00:00+01:00"), (date(2026, 1, 3), 7, "2025-12-27T08:00:00+01:00"), ]) def test_calendar_days_preserve_eight_am_across_dst(event, lead, expected): assert service.scheduled_at(event, lead).isoformat() == expected def test_candidate_query_requires_person_customer_type_and_open_main_case(monkeypatch): calls = [] monkeypatch.setattr(service, "closed_statuses", lambda: ["closed"]) monkeypatch.setattr(service, "execute_query", lambda sql, params: calls.append((sql, params)) or []) assert service.find_parent_candidates(11, 22, "offboarding") == [] sql, params = calls[0] assert params == (22, ["closed"], 11, "offboarding", "offboarding", "offboarding") assert "s.customer_id=%s" in sql and "k.contact_id=%s" in sql assert "LOWER(s.type)=%s" in sql and "LOWER(TRIM(t.tag_navn))=%s" in sql assert "LIKE" not in sql and "'undersag'" in sql and "<> ALL" in sql @contextmanager def transaction(): yield def test_due_job_rechecks_locked_row_and_catches_up_once(monkeypatch): event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2026, 1, 1), lead_days=14) created = [] def single(sql, params=None): return {"ready": True} if "to_regclass" in sql else dict(event) async def create(row): created.append(row["id"]) event["case_id"] = 70 return True monkeypatch.setattr(service, "execute_query_single", single) monkeypatch.setattr(service, "execute_query", lambda *args: [{"id": 9}]) monkeypatch.setattr(service, "atomic_transaction", transaction) monkeypatch.setattr(service, "_create_case", create) now = datetime(2026, 11, 2, tzinfo=timezone.utc) assert asyncio.run(service.process_due_lifecycles(now)) == 1 assert asyncio.run(service.process_due_lifecycles(now)) == 0 assert created == [9] def test_locked_event_rescheduled_after_candidate_scan_is_not_created(monkeypatch): event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2027, 1, 1), lead_days=0) monkeypatch.setattr(service, "execute_query_single", lambda sql, *args: {"ready": True} if "to_regclass" in sql else event) monkeypatch.setattr(service, "execute_query", lambda *args: [{"id": 9}]) monkeypatch.setattr(service, "atomic_transaction", transaction) assert asyncio.run(service.process_due_lifecycles(datetime(2026, 11, 2, tzinfo=timezone.utc))) == 0 def test_ambiguous_parent_blocks_before_any_case_is_created(monkeypatch): calls = [] monkeypatch.setattr(service, "_actor", lambda event: {"id": 1, "is_superadmin": True}) monkeypatch.setattr(service, "execute_query_single", lambda *args: {"id": 1}) monkeypatch.setattr(service, "find_parent_candidates", lambda *args: [{"id": 10}, {"id": 20}]) monkeypatch.setattr(service, "execute_query", lambda sql, params: calls.append((sql, params))) event = dict(id=9, contact_id=11, customer_id=22, event_type="onboarding", parent_case_id=None) assert asyncio.run(service._create_case(event)) is False assert len(calls) == 1 and "status='needs_parent'" in calls[0][0] def test_date_change_of_closed_case_requires_manual_review(monkeypatch): from app.core import auth_service, case_visibility monkeypatch.setattr(auth_service.AuthService, "get_user_permissions", lambda uid: ["cases.edit"]) monkeypatch.setattr(case_visibility, "require_case", lambda *args: None) monkeypatch.setattr(service, "closed_statuses", lambda: ["lukket"]) monkeypatch.setattr(service, "execute_query_single", lambda *args: {"id": 70, "status": "Lukket", "deleted_at": None}) writes = [] monkeypatch.setattr(service, "execute_query", lambda *args: writes.append(args)) result = service.update_created_case_date(dict(case_id=70, event_date=date(2026, 12, 1), event_type="offboarding"), date(2026, 11, 1), 1) assert result["status"] == "manual_review" and not writes def test_template_failure_escapes_case_transaction_for_rollback(monkeypatch): from fastapi import HTTPException event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2026, 1, 1), lead_days=14) rolled_back = [] @contextmanager def rollback_transaction(): try: yield except Exception: rolled_back.append(True) raise async def fail(event): raise HTTPException(409, "Skabelon utilgængelig") writes = [] monkeypatch.setattr(service, "execute_query_single", lambda sql, *args: {"ready": True} if "to_regclass" in sql else event) monkeypatch.setattr(service, "execute_query", lambda sql, params: [{"id": 9}] if sql.lstrip().startswith("SELECT") else writes.append((sql, params))) monkeypatch.setattr(service, "atomic_transaction", rollback_transaction) monkeypatch.setattr(service, "_create_case", fail) assert asyncio.run(service.process_due_lifecycles(datetime(2026, 11, 2, tzinfo=timezone.utc))) == 0 assert rolled_back == [True] assert writes[0][1] == ("Skabelon utilgængelig", 9)