bottleneckcrm/backend/cases/tests/test_cases_api.py
Ashwin dd6ca2958d Add unit tests for organization admin checks and saved views provider
- Implement tests for `isOrgAdminProvider` to validate admin roles based on organization membership and role.
- Create tests for `saved_views_provider` to ensure correct API interactions for saving, renaming, and deleting saved views.
- Enhance ticket tree tests to accurately reflect inactive and merged ticket handling.
- Update CSV import tests to verify error handling and file saving functionality.
- Introduce tests for deal cards to ensure correct display of next steps and task statuses.
2026-09-26 20:38:32 +05:30

1701 lines
62 KiB
Python

"""
Tests for the Case list and detail API views.
Run with: pytest cases/tests/test_cases_api.py -v
"""
import json
from datetime import timedelta
from unittest.mock import patch
import pytest
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from rest_framework import status
from accounts.models import Account
from cases.models import Case, CasePipeline, CaseStage, CaseWatcher, Solution
from common.models import Attachments, Comment, Tags, Teams
from contacts.models import Contact
CASES_LIST_URL = "/api/cases/"
def _detail_url(pk):
return f"/api/cases/{pk}/"
@pytest.mark.django_db
class TestCaseListView:
"""Tests for GET /api/cases/ and POST /api/cases/"""
def test_list_cases(self, admin_client, case_a):
response = admin_client.get(CASES_LIST_URL)
assert response.status_code == status.HTTP_200_OK
assert "cases" in response.data
assert response.data["cases_count"] >= 1
def test_list_excludes_soft_deleted_by_default(self, admin_client, case_a):
case_a.is_active = False
case_a.save()
response = admin_client.get(CASES_LIST_URL)
assert response.status_code == status.HTTP_200_OK
ids = [c["id"] for c in response.json().get("cases", [])]
assert str(case_a.id) not in ids
def test_list_includes_soft_deleted_when_admin_passes_flag(
self, admin_client, case_a
):
case_a.is_active = False
case_a.save()
response = admin_client.get(CASES_LIST_URL + "?include_deleted=true")
assert response.status_code == status.HTTP_200_OK
ids = [c["id"] for c in response.json().get("cases", [])]
assert str(case_a.id) in ids
@patch("cases.views.send_email_to_assigned_user")
def test_create_case(self, mock_email, admin_client):
response = admin_client.post(
CASES_LIST_URL,
{
"name": "New support ticket",
"status": "New",
"priority": "Normal",
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data["error"] is False
assert "id" in response.data
def test_create_case_unauthenticated(self, unauthenticated_client):
"""Asserted `pytest.raises(PermissionDenied)`, which can never hold
through an APIClient, DRF renders an APIException as a response
rather than letting it escape, so this had always failed. It now
checks the response the client actually receives."""
response = unauthenticated_client.post(
CASES_LIST_URL,
{
"name": "Should fail",
"status": "New",
"priority": "Normal",
},
format="json",
)
assert response.status_code in (
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
)
assert not Case.objects.filter(name="Should fail").exists()
def test_org_isolation(self, org_b_client, case_a):
"""org_b_client must not see cases belonging to org_a."""
response = org_b_client.get(CASES_LIST_URL)
assert response.status_code == status.HTTP_200_OK
case_ids = [c["id"] for c in response.data["cases"]]
assert str(case_a.id) not in case_ids
@patch("cases.views.send_email_to_assigned_user")
def test_create_case_invalid(self, mock_email, admin_client):
"""POST without the required 'name' field should return 400."""
response = admin_client.post(
CASES_LIST_URL,
{
"status": "New",
"priority": "Normal",
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["error"] is True
@patch("cases.views.send_email_to_assigned_user")
def test_create_case_with_all_fields(
self, mock_email, admin_client, admin_profile, admin_user, org_a
):
"""Create a case with contacts, teams, assigned_to, tags, and case_type."""
contact = Contact.objects.create(
first_name="Test",
last_name="Contact",
org=org_a,
created_by=admin_user,
)
team = Teams.objects.create(
name="Support Team", org=org_a, created_by=admin_user
)
tag = Tags.objects.create(name="critical", slug="critical", org=org_a)
response = admin_client.post(
CASES_LIST_URL,
{
"name": "Full Case",
"status": "New",
"priority": "Urgent",
"case_type": "Incident",
"description": "Full description",
"contacts": [str(contact.id)],
"teams": [str(team.id)],
"assigned_to": [str(admin_profile.id)],
"tags": [str(tag.id)],
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data["error"] is False
case = Case.objects.get(name="Full Case")
assert case.contacts.count() == 1
assert case.teams.count() == 1
assert case.assigned_to.count() == 1
assert case.tags.count() == 1
assert case.case_type == "Incident"
@patch("cases.views.send_email_to_assigned_user")
def test_create_case_duplicate_name_returns_400(
self, mock_email, admin_client, case_a
):
"""Creating a case with a duplicate name in the same org should fail."""
response = admin_client.post(
CASES_LIST_URL,
{
"name": "Bug in login page",
"status": "New",
"priority": "Normal",
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["error"] is True
def test_list_cases_filter_by_status(self, admin_client, admin_user, org_a):
"""Filter cases by status query param."""
Case.objects.create(
name="New Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
Case.objects.create(
name="Closed Case",
status="Closed",
priority="Normal",
closed_on="2026-01-01",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(f"{CASES_LIST_URL}?status=New")
assert response.status_code == status.HTTP_200_OK
names = [c["name"] for c in response.data["cases"]]
assert "New Case" in names
assert "Closed Case" not in names
def test_list_cases_filter_by_priority(self, admin_client, admin_user, org_a):
"""Filter cases by priority query param."""
Case.objects.create(
name="Urgent Case",
status="New",
priority="Urgent",
org=org_a,
created_by=admin_user,
)
Case.objects.create(
name="Low Case",
status="New",
priority="Low",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(f"{CASES_LIST_URL}?priority=Urgent")
assert response.status_code == status.HTTP_200_OK
names = [c["name"] for c in response.data["cases"]]
assert "Urgent Case" in names
assert "Low Case" not in names
def test_list_cases_filter_by_case_type(self, admin_client, admin_user, org_a):
"""Filter cases by case_type query param."""
Case.objects.create(
name="Question Case",
status="New",
priority="Normal",
case_type="Question",
org=org_a,
created_by=admin_user,
)
Case.objects.create(
name="Incident Case",
status="New",
priority="Normal",
case_type="Incident",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(f"{CASES_LIST_URL}?case_type=Question")
assert response.status_code == status.HTTP_200_OK
names = [c["name"] for c in response.data["cases"]]
assert "Question Case" in names
assert "Incident Case" not in names
def test_list_cases_filter_by_search(self, admin_client, admin_user, org_a):
"""Search filter should match case name."""
Case.objects.create(
name="Login Bug",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
Case.objects.create(
name="Payment Error",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(f"{CASES_LIST_URL}?search=Login")
assert response.status_code == status.HTTP_200_OK
names = [c["name"] for c in response.data["cases"]]
assert "Login Bug" in names
assert "Payment Error" not in names
def test_list_cases_filter_by_name(self, admin_client, admin_user, org_a):
"""Name filter should filter by name."""
Case.objects.create(
name="Alpha Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
Case.objects.create(
name="Beta Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(f"{CASES_LIST_URL}?name=Alpha")
assert response.status_code == status.HTTP_200_OK
names = [c["name"] for c in response.data["cases"]]
assert "Alpha Case" in names
assert "Beta Case" not in names
def test_list_cases_filter_by_assigned_to(
self, admin_client, admin_profile, admin_user, org_a
):
"""Filter cases by assigned_to query param."""
case = Case.objects.create(
name="Assigned Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
case.assigned_to.add(admin_profile)
Case.objects.create(
name="Unassigned Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(f"{CASES_LIST_URL}?assigned_to={admin_profile.id}")
assert response.status_code == status.HTTP_200_OK
names = [c["name"] for c in response.data["cases"]]
assert "Assigned Case" in names
assert "Unassigned Case" not in names
def test_list_cases_filter_by_tags(self, admin_client, admin_user, org_a):
"""Filter cases by tags query param."""
tag = Tags.objects.create(name="bug", slug="bug", org=org_a)
case = Case.objects.create(
name="Tagged Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
case.tags.add(tag)
Case.objects.create(
name="Untagged Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(f"{CASES_LIST_URL}?tags={tag.id}")
assert response.status_code == status.HTTP_200_OK
names = [c["name"] for c in response.data["cases"]]
assert "Tagged Case" in names
assert "Untagged Case" not in names
def test_list_cases_non_admin_sees_only_assigned(
self, user_client, regular_user, admin_user, org_a, user_profile
):
"""Non-admin user should only see cases they are assigned to.
Note: ORM-created objects have created_by=None (crum middleware not
active in tests), so only assigned_to filtering works for non-admin
visibility. The created_by=regular_user set in ORM gets overridden
by BaseModel.save() to None.
"""
Case.objects.create(
name="Admin Only Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
assigned_case = Case.objects.create(
name="User Assigned Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
assigned_case.assigned_to.add(user_profile)
response = user_client.get(CASES_LIST_URL)
assert response.status_code == status.HTTP_200_OK
names = [c["name"] for c in response.data["cases"]]
assert "User Assigned Case" in names
assert "Admin Only Case" not in names
def test_list_cases_response_includes_metadata(
self, admin_client, admin_user, org_a
):
"""Response should include status, priority, type_of_case, accounts_list, contacts_list."""
Case.objects.create(
name="Meta Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(CASES_LIST_URL)
assert response.status_code == status.HTTP_200_OK
assert "status" in response.data
assert "priority" in response.data
assert "type_of_case" in response.data
assert "accounts_list" in response.data
assert "contacts_list" in response.data
@pytest.mark.django_db
class TestCaseDetailView:
"""Tests for GET/PUT/DELETE/POST /api/cases/<pk>/"""
def test_get_detail(self, admin_client, case_a):
response = admin_client.get(_detail_url(case_a.pk))
assert response.status_code == status.HTTP_200_OK
assert response.data["cases_obj"]["id"] == str(case_a.id)
@patch("cases.views.send_email_to_assigned_user")
def test_update_case(self, mock_email, admin_client, case_a):
response = admin_client.put(
_detail_url(case_a.pk),
{
"name": "Updated case name",
"status": "Assigned",
"priority": "Urgent",
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data["error"] is False
case_a.refresh_from_db()
assert case_a.name == "Updated case name"
def test_delete_case(self, admin_client, case_a):
response = admin_client.delete(_detail_url(case_a.pk))
assert response.status_code == status.HTTP_200_OK
assert response.data["error"] is False
assert not Case.objects.filter(pk=case_a.pk).exists()
def test_cross_org_detail_returns_404(self, org_b_client, case_a):
"""A client from org_b cannot retrieve a case from org_a."""
response = org_b_client.get(_detail_url(case_a.pk))
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_add_comment(self, admin_client, case_a):
response = admin_client.post(
_detail_url(case_a.pk),
{"comment": "This is a test comment."},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert "comments" in response.data
@patch("cases.views.send_email_to_assigned_user")
def test_update_case_with_tags_and_assigned_to(
self, mock_email, admin_client, admin_profile, case_a, org_a
):
"""PUT should clear and re-set tags and assigned_to."""
tag1 = Tags.objects.create(name="tag1", slug="tag1", org=org_a)
tag2 = Tags.objects.create(name="tag2", slug="tag2", org=org_a)
case_a.tags.add(tag1)
case_a.assigned_to.add(admin_profile)
response = admin_client.put(
_detail_url(case_a.pk),
{
"name": "Bug in login page",
"status": "New",
"priority": "High",
"tags": [str(tag2.id)],
"assigned_to": [str(admin_profile.id)],
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
tag_ids = list(case_a.tags.values_list("id", flat=True))
assert tag2.id in tag_ids
assert tag1.id not in tag_ids
@patch("cases.views.send_email_to_assigned_user")
def test_update_case_with_contacts_and_teams(
self, mock_email, admin_client, admin_user, case_a, org_a
):
"""PUT should set contacts and teams."""
contact = Contact.objects.create(
first_name="Test",
last_name="Contact",
org=org_a,
created_by=admin_user,
)
team = Teams.objects.create(name="Support", org=org_a, created_by=admin_user)
response = admin_client.put(
_detail_url(case_a.pk),
{
"name": "Bug in login page",
"status": "New",
"priority": "High",
"contacts": [str(contact.id)],
"teams": [str(team.id)],
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.contacts.count() == 1
assert case_a.teams.count() == 1
@patch("cases.views.send_email_to_assigned_user")
def test_update_case_clears_m2m(
self, mock_email, admin_client, admin_profile, admin_user, case_a, org_a
):
"""PUT without M2M fields should clear them."""
contact = Contact.objects.create(
first_name="C", last_name="D", org=org_a, created_by=admin_user
)
case_a.contacts.add(contact)
case_a.assigned_to.add(admin_profile)
response = admin_client.put(
_detail_url(case_a.pk),
{
"name": "Bug in login page",
"status": "New",
"priority": "High",
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.contacts.count() == 0
assert case_a.assigned_to.count() == 0
assert case_a.tags.count() == 0
@patch("cases.views.send_email_to_assigned_user")
def test_update_case_invalid_returns_400(self, mock_email, admin_client, case_a):
"""PUT with invalid data should return 400."""
response = admin_client.put(
_detail_url(case_a.pk),
{"status": "New", "priority": "Normal"},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_update_case_non_admin_who_cannot_open_it_is_404(self, user_client, case_a):
"""Non-admin who is not creator/assignee/watcher cannot open it: 404."""
response = user_client.put(
_detail_url(case_a.pk),
{"name": "Hacked", "status": "New", "priority": "Normal"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_delete_case_non_admin_not_creator(self, user_client, case_a):
"""Non-admin who cannot open the case gets 404 on delete."""
response = user_client.delete(_detail_url(case_a.pk))
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_get_detail_non_admin_who_cannot_open_it_is_404(self, user_client, case_a):
"""Non-admin who is not creator/assignee/watcher gets a missing
ticket's 404. A 403 here confirmed the ticket existed."""
response = user_client.get(_detail_url(case_a.pk))
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data["detail"] == "No such case."
def test_get_detail_non_admin_as_assignee(
self, user_client, admin_user, user_profile, org_a
):
"""Non-admin who is assigned to the case should be able to see it.
Note: ORM-created objects have created_by=None (crum middleware not
active in tests), so creator-based permission doesn't work.
Testing with assigned_to instead.
"""
case = Case.objects.create(
name="User Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
case.assigned_to.add(user_profile)
response = user_client.get(_detail_url(case.pk))
assert response.status_code == status.HTTP_200_OK
assert response.data["cases_obj"]["name"] == "User Case"
def test_get_detail_response_fields(self, admin_client, case_a):
"""GET detail should return expected response fields."""
response = admin_client.get(_detail_url(case_a.pk))
assert response.status_code == status.HTTP_200_OK
assert "cases_obj" in response.data
assert "attachments" in response.data
assert "comments" in response.data
assert "comment_permission" in response.data
assert "users_mention" in response.data
assert "contacts" in response.data
assert "status" in response.data
assert "priority" in response.data
def test_patch_case_partial_update(self, admin_client, case_a):
"""PATCH should allow partial updates."""
response = admin_client.patch(
_detail_url(case_a.pk),
{"status": "Assigned"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data["error"] is False
case_a.refresh_from_db()
assert case_a.status == "Assigned"
assert case_a.name == "Bug in login page" # Unchanged
def test_patch_case_with_m2m_fields(
self, admin_client, admin_profile, case_a, org_a
):
"""PATCH should handle M2M fields when present."""
tag = Tags.objects.create(name="patchtag", slug="patchtag", org=org_a)
response = admin_client.patch(
_detail_url(case_a.pk),
{
"tags": [str(tag.id)],
"assigned_to": [str(admin_profile.id)],
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.tags.count() == 1
assert case_a.assigned_to.count() == 1
def test_patch_case_clear_m2m(self, admin_client, admin_profile, case_a):
"""PATCH with empty list for M2M should clear them."""
case_a.assigned_to.add(admin_profile)
response = admin_client.patch(
_detail_url(case_a.pk),
{"assigned_to": [], "tags": []},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.assigned_to.count() == 0
assert case_a.tags.count() == 0
def test_patch_case_non_admin_who_cannot_open_it_is_404(self, user_client, case_a):
"""Non-admin who cannot open the case gets 404 on PATCH."""
response = user_client.patch(
_detail_url(case_a.pk),
{"status": "Assigned"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_post_comment_non_admin_who_cannot_open_it_is_404(
self, user_client, case_a
):
"""Non-admin who cannot open the case gets 404 on POST (comment)."""
response = user_client.post(
_detail_url(case_a.pk),
{"comment": "Should not work"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_post_comment_non_admin_as_assignee(
self, user_client, admin_user, user_profile, org_a
):
"""Non-admin who is assigned should be able to post comments."""
case = Case.objects.create(
name="Assignee Comment Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
case.assigned_to.add(user_profile)
response = user_client.post(
_detail_url(case.pk),
{"comment": "Assignee comment"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert "comments" in response.data
@pytest.mark.django_db
class TestCaseCommentView:
"""Tests for PUT/PATCH/DELETE /api/cases/comment/<pk>/."""
def _create_case_with_comment(self, admin_user, admin_profile, org_a):
"""Helper to create a case with a comment."""
case = Case.objects.create(
name="Comment Test Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
ct = ContentType.objects.get_for_model(Case)
comment = Comment.objects.create(
content_type=ct,
object_id=case.id,
comment="Original comment",
commented_by=admin_profile,
org=org_a,
)
return case, comment
def test_update_comment_put_edits_the_text(
self, admin_client, admin_user, admin_profile, org_a
):
"""PUT with only `comment` edits the comment.
This used to assert a 400 and describe it as correct: the serializer
ran non-partial with `object_id` and `org` as required fields, so an
ordinary "fix my typo" edit failed validation. Those two are the
server's to set, are now read-only, and no longer stand between an
author and their own text.
"""
_case, comment = self._create_case_with_comment(
admin_user, admin_profile, org_a
)
response = admin_client.put(
f"/api/cases/comment/{comment.id}/",
{"comment": "Updated comment text"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
comment.refresh_from_db()
assert comment.comment == "Updated comment text"
def test_update_comment_patch(self, admin_client, admin_user, admin_profile, org_a):
"""Admin should be able to partially update via PATCH."""
_case, comment = self._create_case_with_comment(
admin_user, admin_profile, org_a
)
response = admin_client.patch(
f"/api/cases/comment/{comment.id}/",
{"comment": "Patched comment"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["error"] is False
assert response.json()["message"] == "Comment Updated"
def test_delete_comment(self, admin_client, admin_user, admin_profile, org_a):
"""Admin should be able to delete a comment."""
_case, comment = self._create_case_with_comment(
admin_user, admin_profile, org_a
)
response = admin_client.delete(f"/api/cases/comment/{comment.id}/")
assert response.status_code == status.HTTP_200_OK
assert response.json()["error"] is False
assert not Comment.objects.filter(id=comment.id).exists()
def test_update_comment_non_admin_forbidden(
self, user_client, user_profile, admin_user, admin_profile, org_a
):
"""Non-admin who did not create comment should get 403."""
_case, comment = self._create_case_with_comment(
admin_user, admin_profile, org_a
)
# A reader of the ticket (a watcher): someone who cannot open it
# gets 404 (`test_case_comment_attachment_scope`).
CaseWatcher.objects.create(case=_case, profile=user_profile, org=org_a)
response = user_client.put(
f"/api/cases/comment/{comment.id}/",
{"comment": "Should fail"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_delete_comment_non_admin_forbidden(
self, user_client, user_profile, admin_user, admin_profile, org_a
):
"""Non-admin who did not create the comment should get 403."""
_case, comment = self._create_case_with_comment(
admin_user, admin_profile, org_a
)
# A reader of the ticket (a watcher): someone who cannot open it
# gets 404 (`test_case_comment_attachment_scope`).
CaseWatcher.objects.create(case=_case, profile=user_profile, org=org_a)
response = user_client.delete(f"/api/cases/comment/{comment.id}/")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_put_comment_without_comment_field_is_400_not_403(
self, admin_client, admin_user, admin_profile, org_a
):
"""A missing `comment` is a bad request, not a permission problem.
This asserted 403 and called it correct. The `if params.get("comment")`
guard sat inside the authorization branch, so a body the serializer
would have rejected fell out of the branch entirely and hit the 403 at
the bottom of the handler: the caller was told they may not edit their
own comment, which is both wrong and unactionable.
"""
_case, comment = self._create_case_with_comment(
admin_user, admin_profile, org_a
)
response = admin_client.put(
f"/api/cases/comment/{comment.id}/",
{},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "comment" in response.json()["errors"]
def test_put_blank_comment_is_400(
self, admin_client, admin_user, admin_profile, org_a
):
"""The other half of the same guard: `""` is falsy too."""
_case, comment = self._create_case_with_comment(
admin_user, admin_profile, org_a
)
response = admin_client.put(
f"/api/cases/comment/{comment.id}/",
{"comment": ""},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
comment.refresh_from_db()
assert comment.comment == "Original comment"
def test_a_stranger_still_gets_403_for_an_empty_body(
self, user_client, user_profile, admin_user, admin_profile, org_a
):
"""Authorization is still checked first, and still wins.
The fix moved a validation failure out of the 403 branch. It must not
have moved the authorization check with it: someone who may not touch
this comment learns nothing about whether their body was well formed.
"""
_case, comment = self._create_case_with_comment(
admin_user, admin_profile, org_a
)
# A reader of the ticket (a watcher): someone who cannot open it
# gets 404 (`test_case_comment_attachment_scope`).
CaseWatcher.objects.create(case=_case, profile=user_profile, org=org_a)
response = user_client.put(
f"/api/cases/comment/{comment.id}/",
{},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
class TestCaseAttachmentView:
"""Tests for DELETE /api/cases/attachment/<pk>/."""
def test_delete_attachment(self, admin_client, admin_user, org_a):
"""Admin should be able to delete a case attachment."""
from django.core.files.uploadedfile import SimpleUploadedFile
case = Case.objects.create(
name="Attachment Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
ct = ContentType.objects.get_for_model(Case)
attachment = Attachments.objects.create(
content_type=ct,
object_id=case.id,
file_name="test.txt",
attachment=SimpleUploadedFile("test.txt", b"file content"),
created_by=admin_user,
org=org_a,
)
response = admin_client.delete(f"/api/cases/attachment/{attachment.id}/")
assert response.status_code == status.HTTP_200_OK
assert response.json()["error"] is False
assert not Attachments.objects.filter(id=attachment.id).exists()
def test_delete_attachment_non_admin_forbidden(
self, user_client, user_profile, admin_user, org_a
):
"""Non-admin who did not create attachment should get 403."""
from django.core.files.uploadedfile import SimpleUploadedFile
case = Case.objects.create(
name="Att Deny Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
ct = ContentType.objects.get_for_model(Case)
attachment = Attachments.objects.create(
content_type=ct,
object_id=case.id,
file_name="test.txt",
attachment=SimpleUploadedFile("test.txt", b"data"),
created_by=admin_user,
org=org_a,
)
# A reader of the ticket; one who cannot open it gets 404.
CaseWatcher.objects.create(case=case, profile=user_profile, org=org_a)
response = user_client.delete(f"/api/cases/attachment/{attachment.id}/")
assert response.status_code == status.HTTP_403_FORBIDDEN
# ---------------------------------------------------------------------------
# Additional coverage tests for uncovered lines in cases/views.py
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestCaseListFiltersCoverage:
"""Tests covering filter parameters that were previously uncovered
(lines 84-91, account filter).
"""
def test_filter_by_created_at_gte(self, admin_client, admin_user, org_a):
"""Filter cases by created_at__gte."""
Case.objects.create(
name="GTE Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(f"{CASES_LIST_URL}?created_at__gte=2020-01-01")
assert response.status_code == status.HTTP_200_OK
names = [c["name"] for c in response.data["cases"]]
assert "GTE Case" in names
def test_filter_by_created_at_lte(self, admin_client, admin_user, org_a):
"""Filter cases by created_at__lte."""
Case.objects.create(
name="LTE Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(f"{CASES_LIST_URL}?created_at__lte=2099-12-31")
assert response.status_code == status.HTTP_200_OK
names = [c["name"] for c in response.data["cases"]]
assert "LTE Case" in names
def test_filter_by_created_at_range(self, admin_client, admin_user, org_a):
"""Filter by created_at__gte and created_at__lte together."""
Case.objects.create(
name="Range Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(
f"{CASES_LIST_URL}?created_at__gte=2020-01-01&created_at__lte=2099-12-31"
)
assert response.status_code == status.HTTP_200_OK
assert response.data["cases_count"] >= 1
def test_filter_by_account(self, admin_client, admin_user, org_a):
"""Filter cases by account query param."""
account = Account.objects.create(
name="Filter Account", org=org_a, created_by=admin_user
)
Case.objects.create(
name="Account Case",
status="New",
priority="Normal",
account=account,
org=org_a,
created_by=admin_user,
)
Case.objects.create(
name="No Account Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
response = admin_client.get(f"{CASES_LIST_URL}?account={account.id}")
assert response.status_code == status.HTTP_200_OK
names = [c["name"] for c in response.data["cases"]]
assert "Account Case" in names
assert "No Account Case" not in names
@pytest.mark.django_db
class TestCaseCreateCoverage:
"""Tests for case creation covering previously uncovered lines
(contacts as JSON string, teams as JSON string, attachment upload, etc.).
"""
@patch("cases.views.send_email_to_assigned_user")
def test_create_case_with_contacts_as_json_string(
self, mock_email, admin_client, admin_user, org_a
):
"""Create case with contacts as JSON string (covers line 173)."""
contact = Contact.objects.create(
first_name="JSONStr",
last_name="Contact",
org=org_a,
created_by=admin_user,
)
response = admin_client.post(
CASES_LIST_URL,
{
"name": "JSON Contact Case",
"status": "New",
"priority": "Normal",
"contacts": json.dumps([str(contact.id)]),
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case = Case.objects.get(name="JSON Contact Case")
assert case.contacts.count() == 1
@patch("cases.views.send_email_to_assigned_user")
def test_create_case_with_teams_as_json_string(
self, mock_email, admin_client, admin_user, org_a
):
"""Create case with teams as JSON string (covers line 188)."""
team = Teams.objects.create(
name="JSON Team Case", org=org_a, created_by=admin_user
)
response = admin_client.post(
CASES_LIST_URL,
{
"name": "JSON Team Case",
"status": "New",
"priority": "Normal",
"teams": json.dumps([str(team.id)]),
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case = Case.objects.get(name="JSON Team Case")
assert case.teams.count() == 1
@patch("cases.views.send_email_to_assigned_user")
def test_create_case_with_assigned_to_as_json_string(
self, mock_email, admin_client, admin_profile, org_a
):
"""Create case with assigned_to as JSON string (covers line 201)."""
response = admin_client.post(
CASES_LIST_URL,
{
"name": "JSON Assigned Case",
"status": "New",
"priority": "Normal",
"assigned_to": json.dumps([str(admin_profile.id)]),
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case = Case.objects.get(name="JSON Assigned Case")
assert case.assigned_to.count() == 1
@patch("cases.views.send_email_to_assigned_user")
def test_create_case_with_tags_as_json_string(
self, mock_email, admin_client, admin_user, org_a
):
"""Create case with tags as JSON string (covers line 216)."""
tag = Tags.objects.create(name="jsoncasetag", slug="jsoncasetag", org=org_a)
response = admin_client.post(
CASES_LIST_URL,
{
"name": "JSON Tag Case",
"status": "New",
"priority": "Normal",
"tags": json.dumps([str(tag.id)]),
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case = Case.objects.get(name="JSON Tag Case")
assert case.tags.count() == 1
@patch("cases.views.send_email_to_assigned_user")
def test_create_case_with_attachment(
self, mock_email, admin_client, admin_user, org_a
):
"""Create case with file attachment (covers lines 227-234)."""
upload_file = SimpleUploadedFile(
"case_attach.txt", b"case file content", content_type="text/plain"
)
response = admin_client.post(
CASES_LIST_URL,
{
"name": "Attach Create Case",
"status": "New",
"priority": "Normal",
"case_attachment": upload_file,
},
format="multipart",
)
assert response.status_code == status.HTTP_200_OK
case = Case.objects.get(name="Attach Create Case")
ct = ContentType.objects.get_for_model(Case)
assert Attachments.objects.filter(content_type=ct, object_id=case.id).exists()
@pytest.mark.django_db
class TestCaseUpdateCoverage:
"""Tests for case PUT update covering previously uncovered lines
(JSON string m2m, attachment upload on update, etc.).
"""
@patch("cases.views.send_email_to_assigned_user")
def test_update_case_with_contacts_as_json_string(
self, mock_email, admin_client, admin_user, case_a, org_a
):
"""PUT with contacts as JSON string (covers line 318)."""
contact = Contact.objects.create(
first_name="UpdJSON",
last_name="Contact",
org=org_a,
created_by=admin_user,
)
response = admin_client.put(
_detail_url(case_a.pk),
{
"name": "Bug in login page",
"status": "New",
"priority": "High",
"contacts": json.dumps([str(contact.id)]),
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.contacts.count() == 1
@patch("cases.views.send_email_to_assigned_user")
def test_update_case_with_teams_as_json_string(
self, mock_email, admin_client, admin_user, case_a, org_a
):
"""PUT with teams as JSON string (covers line 334)."""
team = Teams.objects.create(
name="UpdJSON CaseTeam", org=org_a, created_by=admin_user
)
response = admin_client.put(
_detail_url(case_a.pk),
{
"name": "Bug in login page",
"status": "New",
"priority": "High",
"teams": json.dumps([str(team.id)]),
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.teams.count() == 1
@patch("cases.views.send_email_to_assigned_user")
def test_update_case_with_assigned_to_as_json_string(
self, mock_email, admin_client, admin_profile, case_a, org_a
):
"""PUT with assigned_to as JSON string (covers line 348)."""
response = admin_client.put(
_detail_url(case_a.pk),
{
"name": "Bug in login page",
"status": "New",
"priority": "High",
"assigned_to": json.dumps([str(admin_profile.id)]),
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.assigned_to.count() == 1
@patch("cases.views.send_email_to_assigned_user")
def test_update_case_with_tags_as_json_string(
self, mock_email, admin_client, admin_user, case_a, org_a
):
"""PUT with tags as JSON string (covers line 364)."""
tag = Tags.objects.create(
name="updjsoncasetag", slug="updjsoncasetag", org=org_a
)
response = admin_client.put(
_detail_url(case_a.pk),
{
"name": "Bug in login page",
"status": "New",
"priority": "High",
"tags": json.dumps([str(tag.id)]),
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.tags.count() == 1
@patch("cases.views.send_email_to_assigned_user")
def test_update_case_with_attachment(
self, mock_email, admin_client, admin_user, case_a, org_a
):
"""PUT with case_attachment file (covers lines 375-382)."""
upload_file = SimpleUploadedFile(
"update_attach.txt", b"updated content", content_type="text/plain"
)
response = admin_client.put(
_detail_url(case_a.pk),
{
"name": "Bug in login page",
"status": "New",
"priority": "High",
"case_attachment": upload_file,
},
format="multipart",
)
assert response.status_code == status.HTTP_200_OK
ct = ContentType.objects.get_for_model(Case)
assert Attachments.objects.filter(content_type=ct, object_id=case_a.id).exists()
@pytest.mark.django_db
class TestCasePatchCoverage:
"""Tests for case PATCH covering previously uncovered lines
(contacts/teams/assigned_to/tags as JSON strings in patch).
"""
def test_patch_with_contacts_as_json_string(
self, admin_client, admin_user, case_a, org_a
):
"""PATCH with contacts as JSON string (covers line 671)."""
contact = Contact.objects.create(
first_name="PatchJSON",
last_name="Contact",
org=org_a,
created_by=admin_user,
)
response = admin_client.patch(
_detail_url(case_a.pk),
{"contacts": json.dumps([str(contact.id)])},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.contacts.count() == 1
def test_patch_with_teams_as_json_string(
self, admin_client, admin_user, case_a, org_a
):
"""PATCH with teams as JSON string (covers line 687)."""
team = Teams.objects.create(
name="PatchJSON CaseTeam", org=org_a, created_by=admin_user
)
response = admin_client.patch(
_detail_url(case_a.pk),
{"teams": json.dumps([str(team.id)])},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.teams.count() == 1
def test_patch_with_assigned_to_as_json_string(
self, admin_client, admin_profile, case_a, org_a
):
"""PATCH with assigned_to as JSON string (covers line 703)."""
response = admin_client.patch(
_detail_url(case_a.pk),
{"assigned_to": json.dumps([str(admin_profile.id)])},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.assigned_to.count() == 1
def test_patch_with_tags_as_json_string(
self, admin_client, admin_user, case_a, org_a
):
"""PATCH with tags as JSON string (covers line 719)."""
tag = Tags.objects.create(
name="patchjsoncasetag", slug="patchjsoncasetag", org=org_a
)
response = admin_client.patch(
_detail_url(case_a.pk),
{"tags": json.dumps([str(tag.id)])},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.tags.count() == 1
def test_patch_invalid_returns_400(self, admin_client, case_a):
"""PATCH with invalid data returns 400 (covers line 734-736)."""
response = admin_client.patch(
_detail_url(case_a.pk),
{"status": "NotAValidStatus"},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["error"] is True
def test_patch_with_contacts_and_teams_cleared(
self, admin_client, admin_user, case_a, org_a
):
"""PATCH with empty contacts and teams clears them."""
contact = Contact.objects.create(
first_name="ToClear",
last_name="Contact",
org=org_a,
created_by=admin_user,
)
team = Teams.objects.create(
name="ToClear Team", org=org_a, created_by=admin_user
)
case_a.contacts.add(contact)
case_a.teams.add(team)
response = admin_client.patch(
_detail_url(case_a.pk),
{"contacts": [], "teams": []},
format="json",
)
assert response.status_code == status.HTTP_200_OK
case_a.refresh_from_db()
assert case_a.contacts.count() == 0
assert case_a.teams.count() == 0
@pytest.mark.django_db
class TestCaseDetailPostCoverage:
"""Tests for POST to case detail with attachment (lines 578-585)."""
def test_post_attachment_to_case(self, admin_client, admin_user, org_a):
"""POST with case_attachment file (covers lines 578-585)."""
case = Case.objects.create(
name="Attach Post Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
upload_file = SimpleUploadedFile(
"case_post_attach.txt", b"attachment content", content_type="text/plain"
)
response = admin_client.post(
_detail_url(case.pk),
{"case_attachment": upload_file},
format="multipart",
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert "attachments" in data
assert len(data["attachments"]) == 1
def test_post_comment_and_attachment(self, admin_client, admin_user, org_a):
"""POST with both comment and attachment on case.
Note: In multipart mode, the CommentSerializer validation fails because
it requires additional fields (object_id, org, commented_by). So the
comment is NOT saved, but the attachment IS saved.
"""
case = Case.objects.create(
name="Comment Attach Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
upload_file = SimpleUploadedFile(
"case_combo.pdf", b"pdf", content_type="application/pdf"
)
response = admin_client.post(
_detail_url(case.pk),
{"comment": "With attachment", "case_attachment": upload_file},
format="multipart",
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
# Comment serializer validation fails in multipart mode, so no comment saved
assert len(data["attachments"]) == 1
@pytest.mark.django_db
class TestCaseCommentPatchCoverage:
"""Additional tests for CaseCommentView PATCH edge cases."""
def _create_case_with_comment(self, admin_user, admin_profile, org_a):
case = Case.objects.create(
name="Patch Comment Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
ct = ContentType.objects.get_for_model(Case)
comment = Comment.objects.create(
content_type=ct,
object_id=case.id,
comment="Patch coverage comment",
commented_by=admin_profile,
org=org_a,
)
return case, comment
def test_patch_comment_non_admin_forbidden(
self, user_client, user_profile, admin_user, admin_profile, org_a
):
"""Non-admin who didn't create comment gets 403 on PATCH (line 824-830)."""
_case, comment = self._create_case_with_comment(
admin_user, admin_profile, org_a
)
# A reader of the ticket (a watcher): someone who cannot open it
# gets 404 (`test_case_comment_attachment_scope`).
CaseWatcher.objects.create(case=_case, profile=user_profile, org=org_a)
response = user_client.patch(
f"/api/cases/comment/{comment.id}/",
{"comment": "Patched by non-admin"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_patch_comment_by_commenter(
self, admin_client, admin_user, admin_profile, org_a
):
"""Commenter can PATCH their own comment."""
_case, comment = self._create_case_with_comment(
admin_user, admin_profile, org_a
)
response = admin_client.patch(
f"/api/cases/comment/{comment.id}/",
{"comment": "Patched by commenter"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["message"] == "Comment Updated"
# ---------------------------------------------------------------------------
# Tests for cases/models.py - uncovered model methods
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestCaseModelMethods:
"""Tests for Case model methods and properties covering uncovered lines."""
def test_str_method(self, admin_user, org_a):
"""Test __str__ returns the case name."""
case = Case.objects.create(
name="Str Test Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
assert str(case) == "Str Test Case"
def test_clean_closed_without_date_raises(self, admin_user, org_a):
"""Case.clean() raises ValidationError when status=Closed but no closed_on."""
case = Case(
name="Close No Date",
status="Closed",
priority="Normal",
org=org_a,
)
with pytest.raises(ValidationError) as exc_info:
case.clean()
assert "closed_on" in exc_info.value.message_dict
def test_clean_closed_with_date_valid(self, admin_user, org_a):
"""Case.clean() passes when status=Closed and closed_on is set."""
case = Case(
name="Close With Date",
status="Closed",
priority="Normal",
closed_on="2026-01-01",
org=org_a,
)
# Should not raise
case.clean()
def test_save_applies_priority_specific_sla_defaults(self, admin_user, org_a):
"""New case picks up the priority-specific SLA defaults.
See cases/workflow.py: Urgent gets 1h first-response / 4h resolution.
Resolution happens in `cases.signals.case_pre_save_sla_targets`, which
consults the org's EscalationPolicy first; this org has none, so the
defaults apply. Targets configured per org are covered in
cases/tests/test_sla_targets.py.
"""
case = Case.objects.create(
name="Urgent SLA Case",
status="New",
priority="Urgent",
org=org_a,
created_by=admin_user,
)
assert case.sla_first_response_hours == 1
assert case.sla_resolution_hours == 4
def test_save_preserves_custom_sla(self, admin_user, org_a):
"""Custom SLA values (non-default) are preserved on save."""
case = Case(
name="Custom SLA Case",
status="New",
priority="High",
org=org_a,
sla_first_response_hours=10,
sla_resolution_hours=50,
)
case.save()
assert case.sla_first_response_hours == 10
assert case.sla_resolution_hours == 50
def test_is_sla_first_response_breached_when_responded(self, admin_user, org_a):
"""first_response_at is set, so SLA is not breached."""
case = Case.objects.create(
name="Responded Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
first_response_at=timezone.now(),
)
assert case.is_sla_first_response_breached is False
def test_is_sla_first_response_breached_when_overdue(self, admin_user, org_a):
"""No first_response_at and past deadline => breached."""
case = Case.objects.create(
name="Overdue Response Case",
status="New",
priority="Urgent",
org=org_a,
created_by=admin_user,
)
# Artificially set created_at to the past
Case.objects.filter(pk=case.pk).update(
created_at=timezone.now() - timedelta(hours=100)
)
case.refresh_from_db()
assert case.is_sla_first_response_breached is True
def test_is_sla_first_response_not_breached_within_time(self, admin_user, org_a):
"""No first_response_at but within deadline => not breached."""
case = Case.objects.create(
name="Within Time Response Case",
status="New",
priority="Low",
org=org_a,
created_by=admin_user,
)
# Low priority has 24h first response SLA, just created, so not breached
assert case.is_sla_first_response_breached is False
def test_is_sla_resolution_breached_when_resolved(self, admin_user, org_a):
"""resolved_at is set, so SLA is not breached."""
case = Case.objects.create(
name="Resolved Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
resolved_at=timezone.now(),
)
assert case.is_sla_resolution_breached is False
def test_is_sla_resolution_breached_when_overdue(self, admin_user, org_a):
"""No resolved_at and past deadline => breached."""
case = Case.objects.create(
name="Overdue Resolution Case",
status="New",
priority="Urgent",
org=org_a,
created_by=admin_user,
)
Case.objects.filter(pk=case.pk).update(
created_at=timezone.now() - timedelta(hours=100)
)
case.refresh_from_db()
assert case.is_sla_resolution_breached is True
def test_is_sla_resolution_not_breached_within_time(self, admin_user, org_a):
"""No resolved_at but within deadline => not breached."""
case = Case.objects.create(
name="Within Time Resolution Case",
status="New",
priority="Low",
org=org_a,
created_by=admin_user,
)
assert case.is_sla_resolution_breached is False
def test_first_response_sla_deadline(self, admin_user, org_a):
"""first_response_sla_deadline returns a datetime."""
case = Case.objects.create(
name="Deadline Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
deadline = case.first_response_sla_deadline
assert deadline is not None
assert deadline > case.created_at
def test_first_response_sla_deadline_none_without_created_at(self, org_a):
"""first_response_sla_deadline returns None if created_at is None."""
case = Case(
name="No Created At",
status="New",
priority="Normal",
org=org_a,
)
# created_at is None before saving
assert case.first_response_sla_deadline is None
def test_resolution_sla_deadline(self, admin_user, org_a):
"""resolution_sla_deadline returns a datetime."""
case = Case.objects.create(
name="Resolution Deadline Case",
status="New",
priority="Normal",
org=org_a,
created_by=admin_user,
)
deadline = case.resolution_sla_deadline
assert deadline is not None
assert deadline > case.created_at
def test_resolution_sla_deadline_none_without_created_at(self, org_a):
"""resolution_sla_deadline returns None if created_at is None."""
case = Case(
name="No Created At Resolution",
status="New",
priority="Normal",
org=org_a,
)
assert case.resolution_sla_deadline is None
@pytest.mark.django_db
class TestSolutionModel:
"""Tests for the Solution model methods."""
def test_str_method(self, admin_user, org_a):
solution = Solution.objects.create(
title="KB Article",
description="Helpful article",
org=org_a,
created_by=admin_user,
)
assert str(solution) == "KB Article"
def test_publish_when_approved(self, admin_user, org_a):
"""publish() sets is_published=True when status is approved."""
solution = Solution.objects.create(
title="Approved Solution",
description="Ready",
status="approved",
org=org_a,
created_by=admin_user,
)
solution.publish()
solution.refresh_from_db()
assert solution.is_published is True
def test_publish_when_not_approved(self, admin_user, org_a):
"""publish() does nothing when status is not approved."""
solution = Solution.objects.create(
title="Draft Solution",
description="Not ready",
status="draft",
org=org_a,
created_by=admin_user,
)
solution.publish()
solution.refresh_from_db()
assert solution.is_published is False
def test_unpublish(self, admin_user, org_a):
"""unpublish() sets is_published=False."""
solution = Solution.objects.create(
title="Published Solution",
description="Published",
status="approved",
is_published=True,
org=org_a,
created_by=admin_user,
)
solution.unpublish()
solution.refresh_from_db()
assert solution.is_published is False
@pytest.mark.django_db
class TestCasePipelineAndStageModels:
"""Tests for CasePipeline and CaseStage model methods."""
def test_pipeline_str(self, admin_user, org_a):
pipeline = CasePipeline.objects.create(
name="Support Pipeline",
org=org_a,
created_by=admin_user,
)
assert str(pipeline) == f"Support Pipeline ({org_a.name})"
def test_stage_str(self, admin_user, org_a):
pipeline = CasePipeline.objects.create(
name="Dev Pipeline",
org=org_a,
created_by=admin_user,
)
stage = CaseStage.objects.create(
pipeline=pipeline,
name="Triage",
order=1,
org=org_a,
created_by=admin_user,
)
assert str(stage) == "Dev Pipeline - Triage"
def test_stage_auto_sets_org_from_pipeline(self, admin_user, org_a):
"""CaseStage.save() auto-sets org from pipeline if not provided."""
pipeline = CasePipeline.objects.create(
name="Auto Org Pipeline",
org=org_a,
created_by=admin_user,
)
stage = CaseStage(
pipeline=pipeline,
name="Auto Org Stage",
order=1,
)
stage.save()
assert stage.org_id == org_a.id