bmc_hub/tests/test_planner.py

114 lines
5.3 KiB
Python
Raw Normal View History

from datetime import datetime, time, timezone
from pathlib import Path
import sys
import pytest
from fastapi import HTTPException
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.modules.planner.backend import router as planner
def test_planner_migration_has_independent_versioned_allocations_and_capacity_sources():
sql = Path("migrations/240_planner.sql").read_text(encoding="utf-8")
for table in (
"planner_allocations", "planner_allocation_history", "planner_work_schedules",
"planner_absences", "planner_external_busy", "planner_integrations", "planner_settings",
):
assert f"CREATE TABLE IF NOT EXISTS {table}" in sql
assert "version INTEGER NOT NULL DEFAULT 1" in sql
assert "ALTER TABLE sag_sager" in sql
assert "estimated_minutes" in sql
assert "planner.edit_team" in sql
def test_allocation_input_rejects_zero_or_negative_duration():
with pytest.raises(Exception):
planner.AllocationInput(
sag_id=1, user_id=2,
starts_at=datetime(2026, 9, 8, 10), ends_at=datetime(2026, 9, 8, 10),
)
def test_capacity_uses_work_time_less_break_absence_and_external_busy(monkeypatch):
def fake_single(query, _params=None):
if "planner_settings" in query:
return {"warning_percent": 80, "full_percent": 100}
return None
def fake_query(query, _params=None):
if "planner_work_schedules" in query:
return [{"user_id": 7, "weekday": 1, "start_time": time(8), "end_time": time(16), "break_minutes": 30}]
if "planner_absences" in query:
return [{"user_id": 7, "starts_at": datetime(2026, 9, 8, 12, tzinfo=planner.TZ), "ends_at": datetime(2026, 9, 8, 13, tzinfo=planner.TZ)}]
return []
monkeypatch.setattr(planner, "execute_query_single", fake_single)
monkeypatch.setattr(planner, "execute_query", fake_query)
monkeypatch.setattr(planner.busy_time_provider, "get_busy", lambda *_: [{
"user_id": 7, "starts_at": datetime(2026, 9, 8, 14, tzinfo=planner.TZ),
"ends_at": datetime(2026, 9, 8, 15, tzinfo=planner.TZ), "provider": "outlook",
}])
allocations = [{"user_id": 7, "starts_at": datetime(2026, 9, 8, 8, tzinfo=planner.TZ), "ends_at": datetime(2026, 9, 8, 12, tzinfo=planner.TZ)}]
result = planner._capacity(
[7], datetime(2026, 9, 8, tzinfo=planner.TZ),
datetime(2026, 9, 9, tzinfo=planner.TZ), allocations,
)[0]
assert result["available_minutes"] == 330 # 8h - 30m break - 2h unavailable
assert result["allocated_minutes"] == 240
assert result["state"] == "green"
assert result["label"] == "4 af 5.5 timer planlagt"
assert result["registered_minutes"] == 0
assert result["registered_label"] == "0 timer registreret"
def test_own_and_team_edit_permissions_are_separate(monkeypatch):
own = {"id": 4, "permissions": ["planner.edit_own"]}
assert planner._can_edit(own, 4)
assert not planner._can_edit(own, 5)
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"allowed": 1})
assert planner._can_edit({"id": 4, "permissions": ["planner.edit_team"]}, 5)
def test_overlapping_allocations_each_consume_capacity():
start = datetime(2026, 9, 8, 8, tzinfo=planner.TZ)
end = datetime(2026, 9, 8, 12, tzinfo=planner.TZ)
assert planner._sum_minutes([(start, end), (start, end)], start, end) == 480
def test_planner_ui_exposes_views_filters_capacity_used_time_and_undo():
html = Path("app/modules/planner/templates/index.html").read_text(encoding="utf-8")
js = Path("static/js/planner.js").read_text(encoding="utf-8")
for text in ("Min plan", "Team", "Dag", "Uge", "Måned", "Ikke planlagt", "Delvist planlagt", "Mine sager", "Haster"):
assert text in html
for feature in ("application/x-case", "application/x-allocation", "confirm_conflicts", "undoMutation", "time-entries", "used_minutes", "estimated_minutes", "dialogLocked", "hourMarks", "blockStyle", "overlapLayout"):
assert feature in js
assert "Fastlås tidspunkt" in html
assert "planner.estimate.update" in Path("app/modules/planner/backend/router.py").read_text()
assert "registered-time" in html
assert "registeredHtml" in js
assert "time_entries" in js
assert "Ekstern travl tid" in js
assert "green" in html and "yellow" in html and "red" in html and "gray" in html
def test_closed_case_is_checked_before_allocation(monkeypatch):
monkeypatch.setattr(planner, "_can_edit", lambda *_: True)
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None})
payload = planner.AllocationInput(
sag_id=9, user_id=2, starts_at=datetime(2026, 9, 8, 10), ends_at=datetime(2026, 9, 8, 11)
)
with pytest.raises(HTTPException) as exc:
planner.create_allocation(payload, {"id": 2, "permissions": ["planner.edit_own"]})
assert exc.value.status_code == 409
def test_active_case_list_only_hides_closed_cases_that_already_have_time():
source = Path("app/modules/planner/backend/router.py").read_text(encoding="utf-8")
cases_query = source.split('@router.get("/cases")', 1)[1].split('@router.get("/cases/{sag_id}/time-entries")', 1)[0]
assert "NOT (LOWER(COALESCE(s.status,'')) = ANY(%s) AND COALESCE(used.used_minutes,0) > 0)" in cases_query
assert "AS is_closed" in cases_query