bottleneckcrm/backend/cases/tests/test_analytics_service.py
Ashwin e26ab380fc Add unit tests for ticket board provider, status settable, web forms, CSV import, deal aging badge, and tickets selection
- Implement comprehensive tests for the ticket board provider to validate kanban and pipeline modes, including ticket movement and error handling.
- Create tests for ticket status settable to ensure proper status selection logic.
- Enhance web form model tests to cover ticket-specific fields and submission behavior.
- Introduce CSV import sheet tests to verify file handling and error reporting.
- Add deal aging badge tests to confirm correct display of aging statuses.
- Implement tickets selection tests to ensure UI responsiveness and functionality at various screen sizes.
2026-09-26 16:53:22 +05:30

272 lines
10 KiB
Python

"""Tests for the admin-only service-overview endpoint.
`GET /api/cases/analytics/service/` powers /v2/tickets/analytics. It differs
from the per-metric endpoints in two ways this file pins: it is a single call
returning the whole page shape, and it is *admin-only* (the per-metric
endpoints narrow a non-admin to their own cases; this one 403s them). The
window is "last N days" anchored to now, so fixtures use now-relative
timestamps rather than a fixed month.
"""
from __future__ import annotations
from datetime import timedelta
import pytest
from django.utils import timezone
from cases.models import Case
# A single reference instant for the whole fixture. Anchoring every timestamp
# to one `now` keeps the FRT deltas exact, deriving each from its own
# timezone.now() call drifts by microseconds, which would nudge a case that
# meets its SLA exactly (1h against a 1h target) just over the line.
_BASE = timezone.now()
def _ago(**kw):
return _BASE - timedelta(**kw)
@pytest.fixture
def service_cases(org_a, admin_profile):
"""Three recent cases spanning met / breached / in-flight first responses.
A (Urgent, Incident): responded in 1h against a 1h SLA → met; resolved 2h
after open. Assigned to the admin.
B (High, Question): responded in 8h against a 4h SLA → breach; still open.
Assigned to the admin.
C (Normal, Problem): no response yet, 1h old against an 8h SLA → in-flight
(neither met nor missed). Unassigned.
"""
a = Case.objects.create(
org=org_a,
name="A",
status="Closed",
priority="Urgent",
case_type="Incident",
sla_first_response_hours=1,
sla_resolution_hours=2,
closed_on=_BASE.date(),
)
b = Case.objects.create(
org=org_a,
name="B",
status="Assigned",
priority="High",
case_type="Question",
sla_first_response_hours=4,
sla_resolution_hours=24,
)
c = Case.objects.create(
org=org_a,
name="C",
status="New",
priority="Normal",
case_type="Problem",
sla_first_response_hours=8,
sla_resolution_hours=48,
)
Case.objects.filter(pk=a.pk).update(
created_at=_ago(hours=3),
first_response_at=_ago(hours=2), # FRT = 1h, meets 1h SLA
resolved_at=_ago(hours=1), # MTTR = 2h
)
Case.objects.filter(pk=b.pk).update(
created_at=_ago(hours=10),
first_response_at=_ago(hours=2), # FRT = 8h, breaches 4h SLA
)
Case.objects.filter(pk=c.pk).update(created_at=_ago(hours=1))
a.assigned_to.add(admin_profile)
b.assigned_to.add(admin_profile)
return a, b, c
# --------------------------------------------------------------------------
# Authorization: the admin-only gate (both directions)
class TestServiceAuthorization:
def test_unauthenticated_rejected(self, unauthenticated_client):
resp = unauthenticated_client.get("/api/cases/analytics/service/")
assert resp.status_code in (401, 403)
def test_non_admin_forbidden(self, user_client, service_cases):
"""A USER-role member gets 403, never a personal slice."""
resp = user_client.get("/api/cases/analytics/service/")
assert resp.status_code == 403
def test_admin_allowed(self, admin_client, service_cases):
resp = admin_client.get("/api/cases/analytics/service/")
assert resp.status_code == 200
assert set(resp.data) == {
"totals",
"volume",
"first_response",
"by_type",
"by_agent",
}
# --------------------------------------------------------------------------
# Cross-org isolation
class TestServiceIsolation:
def test_other_org_sees_nothing(self, org_b_client, service_cases):
resp = org_b_client.get("/api/cases/analytics/service/")
assert resp.status_code == 200
assert resp.data["totals"]["opened"] == 0
assert resp.data["totals"]["closed"] == 0
assert resp.data["by_type"] == []
assert resp.data["by_agent"] == []
# --------------------------------------------------------------------------
# Payload correctness
class TestServiceTotals:
def test_totals(self, admin_client, service_cases):
totals = admin_client.get("/api/cases/analytics/service/").data["totals"]
assert totals["opened"] == 3 # A, B, C created in window
assert totals["closed"] == 1 # A resolved
assert totals["open_now"] == 2 # B, C not terminal (A is Closed)
assert totals["median_resolution_hours"] == 2 # A: 2h
assert totals["window_days"] == 14
def test_window_days_param_clamped(self, admin_client, service_cases):
totals = admin_client.get("/api/cases/analytics/service/?days=999").data[
"totals"
]
assert totals["window_days"] == 90 # clamped to max
def test_volume_totals_match_counts(self, admin_client, service_cases):
data = admin_client.get("/api/cases/analytics/service/").data
assert sum(d["opened"] for d in data["volume"]) == 3
assert sum(d["closed"] for d in data["volume"]) == 1
assert len(data["volume"]) == 14 # one bucket per window day
class TestServiceFirstResponse:
def test_all_priorities_worst_first(self, admin_client, service_cases):
rows = admin_client.get("/api/cases/analytics/service/").data["first_response"]
assert [r["priority"] for r in rows] == ["Urgent", "High", "Normal", "Low"]
def test_met_missed_and_targets(self, admin_client, service_cases):
rows = {
r["priority"]: r
for r in admin_client.get("/api/cases/analytics/service/").data[
"first_response"
]
}
assert rows["Urgent"]["met"] == 1 and rows["Urgent"]["missed"] == 0
assert rows["Urgent"]["target_minutes"] == 60
assert rows["High"]["missed"] == 1 and rows["High"]["met"] == 0
assert rows["High"]["target_minutes"] == 240
# C is in-flight but not yet overdue: neither met nor missed.
assert rows["Normal"]["met"] == 0 and rows["Normal"]["missed"] == 0
assert rows["Normal"]["median_minutes"] is None
# No Low cases → stable zero row, not dropped.
assert rows["Low"]["met"] == 0 and rows["Low"]["missed"] == 0
def test_targets_come_from_the_orgs_policy(self, admin_client, org_a, org_b):
"""The card says its targets come from the escalation policy, so they do.
A policy's blank first-response target, an inactive policy, a priority
with no policy and another org's policy all leave the built-in default.
"""
from cases.models import EscalationPolicy
from conftest import rls_org
def policy(org, priority, **fields):
with rls_org(org):
EscalationPolicy.objects.create(
org=org,
priority=priority,
first_response_action="notify",
resolution_action="notify",
**fields,
)
policy(org_a, "Urgent", first_response_hours=2)
policy(org_a, "High", first_response_hours=None, resolution_hours=10)
policy(org_a, "Normal", first_response_hours=30, is_active=False)
policy(org_b, "Low", first_response_hours=50)
rows = {
r["priority"]: r["target_minutes"]
for r in admin_client.get("/api/cases/analytics/service/").data[
"first_response"
]
}
assert rows == {"Urgent": 120, "High": 240, "Normal": 480, "Low": 1440}
class TestServiceByType:
def test_case_type_mix(self, admin_client, service_cases):
by_type = {
t["case_type"]: t["count"]
for t in admin_client.get("/api/cases/analytics/service/").data["by_type"]
}
assert by_type == {"Incident": 1, "Question": 1, "Problem": 1}
def test_untyped_case_bucketed(self, admin_client, org_a, service_cases):
c = Case.objects.create(
org=org_a,
name="D",
status="New",
priority="Low",
sla_first_response_hours=24,
)
Case.objects.filter(pk=c.pk).update(created_at=_ago(hours=1))
by_type = {
t["case_type"]: t["count"]
for t in admin_client.get("/api/cases/analytics/service/").data["by_type"]
}
assert by_type["Uncategorized"] == 1
class TestServiceByAgent:
def test_agent_and_unassigned_rows(
self, admin_client, service_cases, admin_profile
):
by_agent = admin_client.get("/api/cases/analytics/service/").data["by_agent"]
admin_row = next(r for r in by_agent if r["id"] == str(admin_profile.id))
assert admin_row["open"] == 1 # B open (A closed)
assert admin_row["closed_this_week"] == 1 # A resolved 1h ago
assert admin_row["breached"] == 1 # B breached FRT
assert admin_row["median_first_response_minutes"] == 270 # median(60, 480)
unassigned = next(r for r in by_agent if r["id"] is None)
assert unassigned["name"] == "Unassigned"
assert unassigned["open"] == 1 # C
assert unassigned["breached"] == 0
def test_unassigned_row_hidden_when_empty(self, admin_client, org_a, admin_profile):
c = Case.objects.create(
org=org_a,
name="Solo",
status="New",
priority="Normal",
sla_first_response_hours=8,
)
Case.objects.filter(pk=c.pk).update(created_at=_ago(hours=1))
c.assigned_to.add(admin_profile)
by_agent = admin_client.get("/api/cases/analytics/service/").data["by_agent"]
assert all(r["id"] is not None for r in by_agent)
# --------------------------------------------------------------------------
# Empty org: the shape holds so the SSR page never divides by an absent array
class TestServiceEmpty:
def test_empty_org_stable_shape(self, admin_client):
data = admin_client.get("/api/cases/analytics/service/").data
assert data["totals"]["opened"] == 0
assert data["totals"]["median_resolution_hours"] == 0
assert len(data["volume"]) == 14
assert len(data["first_response"]) == 4 # all priorities still present
assert data["by_type"] == []
assert data["by_agent"] == []