110 lines
5.3 KiB
Python
110 lines
5.3 KiB
Python
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)
|