bmc_hub/tests/test_sag_module.py
Christian 85e019783b Add comprehensive test coverage for case creation, assignment, and remote recovery
- Introduced tests for case creation commands, ensuring proper handling of UUIDs and payloads.
- Added tests for v5 assignment functionality, validating employee identification and group assignment.
- Implemented tests for remote recovery scenarios, including handling network failures and response validation.
- Created tests for hardware label generation and validation, ensuring proper URL formatting and QR code generation.
- Developed extensive tests for remote control functionality, covering connection isolation, command execution, and user permissions.
- Added tests for workflow timer registration, ensuring correct handling of timer states and ownership validation.
2026-10-15 12:17:09 +02:00

951 lines
41 KiB
Python

from datetime import datetime
from io import BytesIO
from pathlib import Path
import asyncio
import re
import sys
import types
import pytest
from fastapi import HTTPException, UploadFile, Request
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.utils.safe_html import sanitize_safe_html
# Keep these focused unit tests independent of optional auth runtime packages.
auth_dependencies_stub = types.ModuleType("app.core.auth_dependencies")
def _allow_test_user(*_permissions):
async def dependency(_request: Request):
return {"id": 1, "username": "test", "permissions": []}
return dependency
auth_dependencies_stub.require_any_permission = _allow_test_user
async def _get_test_user(_request: Request):
return {
"id": 1,
"username": "test",
"permissions": ["cases.view", "cases.create", "cases.edit", "cases.delete"],
}
auth_dependencies_stub.get_current_user = _get_test_user
sys.modules.setdefault("app.core.auth_dependencies", auth_dependencies_stub)
from app.modules.sag.backend import router as sag_router
def test_normalize_timestamp_converts_offset_to_utc():
value = sag_router._normalize_optional_timestamp(
"2026-07-27T12:00:00+02:00",
"deadline",
)
assert value == "2026-07-27 10:00:00"
def test_normalize_timestamp_accepts_naive_datetime():
value = sag_router._normalize_optional_timestamp(
datetime(2026, 7, 27, 12, 30),
"deadline",
)
assert value == "2026-07-27 12:30:00"
def test_normalize_timestamp_rejects_invalid_value():
with pytest.raises(HTTPException) as exc_info:
sag_router._normalize_optional_timestamp("not-a-date", "deadline")
assert exc_info.value.status_code == 400
def test_attachment_path_rejects_escape_from_upload_root():
with pytest.raises(HTTPException) as exc_info:
sag_router._resolve_attachment_path("../../outside.txt")
assert exc_info.value.status_code == 400
def test_attachment_path_accepts_case_subdirectory():
path = sag_router._resolve_attachment_path("sag_files/example.txt")
path.relative_to(sag_router.UPLOAD_BASE_PATH)
def test_upload_rejects_disallowed_extension():
upload = UploadFile(filename="payload.html", file=BytesIO(b"<script>alert(1)</script>"))
with pytest.raises(HTTPException) as exc_info:
sag_router._store_upload_file(upload, sag_router.SAG_FILE_SUBDIR)
assert exc_info.value.status_code == 400
assert "not allowed" in str(exc_info.value.detail)
def test_relation_input_normalizes_valid_relation():
target_id, relation_type = sag_router._normalize_relation_input(
10,
{"målsag_id": "11", "relationstype": " Blokkerer "},
)
assert target_id == 11
assert relation_type == "Blokkerer"
@pytest.mark.parametrize(
("raw_type", "expected"),
[
("Relateret til", "Relateret til"),
("Afledt af", "Afledt af"),
("Årsag til", "Årsag til"),
("afledt_af", "Afledt af"),
("afhænger af", "afhænger af"),
("undersag", "undersag"),
("duplikat", "duplikat"),
],
)
def test_relation_input_accepts_ui_and_existing_database_types(raw_type, expected):
_, relation_type = sag_router._normalize_relation_input(
10,
{"målsag_id": 11, "relationstype": raw_type},
)
assert relation_type == expected
def test_relation_quick_task_uses_todo_steps_api():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "fetch(`/api/v1/sag/${caseId}/todo-steps`" in template
assert "fetch(`/api/v1/sag/${caseId}/todos`" not in template
assert "due_date: due" in template
def test_add_primary_case_contact_clears_previous_primary(monkeypatch):
queries = []
updates = []
def fake_query(query, params=None, **_kwargs):
queries.append((query, params))
if "SELECT id FROM sag_kontakter" in query:
return []
if "INSERT INTO sag_kontakter" in query:
return [{"id": 12, "sag_id": 177, "contact_id": 2011, "is_primary": True}]
raise AssertionError(query)
monkeypatch.setattr(sag_router, "execute_query", fake_query)
monkeypatch.setattr(sag_router, "execute_update", lambda query, params: updates.append((query, params)))
result = asyncio.run(sag_router.add_case_contact(177, {"contact_id": 2011, "is_primary": True}))
assert result["contact_id"] == 2011
assert updates == [("UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s", (177,))]
def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "openForwardLinkedEmail('latest')" in template
assert "openForwardLinkedEmail('thread')" in template
assert "stripQuotedEmailHistory" in template
assert "caseEmailComposeMode = 'forward'" in template
assert "thread_email_id: isNewThread ? null" in template
assert "linkedEmailsCache\n .filter" in template
def test_case_detail_disables_browser_cache_for_fresh_inline_ui():
source = Path("app/modules/sag/frontend/views.py").read_text()
assert 'response.headers["Cache-Control"] = "no-store, max-age=0"' in source
def test_v5_is_the_canonical_case_view_and_v3_redirects_to_it():
source = Path("app/modules/sag/frontend/views.py").read_text()
assert 'async def sag_detaljer_v4' in source
assert 'async def sag_detaljer_v3_legacy' in source
assert 'RedirectResponse(url=f"/sag/{sag_id}/v5", status_code=307)' in source
def test_v4_case_upload_is_available_from_topbar_and_empty_file_module_is_content_driven():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'bi bi-paperclip' in template
assert "document.getElementById('fileInput').click()" in template
assert "'files'" in template
assert "v4ContentDrivenModules" in template
assert "caseTopbarUploadBtn" in template
assert "handleTopbarAttachmentFiles" in template
assert "uploadEmailFile(emailFile)" in template
assert "fil${count === 1 ? '' : 'er'} uploadet" in template
def test_v4_file_module_renders_clickable_inline_preview_thumbnails():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "case-file-thumbnail" in template
assert "previewFileById(${fileId})" in template
assert "preview-image?page=1&scale=1" in template
assert "bi bi-eye" in template
assert "previewAdjacentFile(-1)" in template
assert "previewAdjacentFile(1)" in template
assert "event.key === 'ArrowLeft'" in template
assert "event.key === 'ArrowRight'" in template
def test_v4_email_view_can_recover_missing_declared_attachments():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "const attachmentsMissing" in template
assert "Hent vedhæftninger" in template
assert "/recover-attachments" in template
assert "recoverLinkedEmailAttachments" in template
def test_v4_header_has_no_duplicate_status_or_type_and_uses_contact_widget():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
header = template.split("<!-- WIDGET DASHBOARD -->", 1)[0]
assert 'id="caseStatusBadge"' not in header
assert 'id="caseTypeDropdownBtn"' not in header
assert "showCustomerSearch('replace')" not in header
assert "showContactSearch('replace')" not in header
assert "showAfdelingModal()" not in header
assert "Kontakt\n </div>" in template
assert "Firma</span>" in template
assert "Hovedkontakt</span>" in template
assert "Afdeling</span>" in template
assert "Start VoIP-kald" in template
assert "Send SMS" in template
def test_v4_contact_picker_loads_company_contacts_before_global_search():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "loadCustomerContactsForContactSearch" in template
assert "/api/v1/customers/${customerId}/contacts" in template
assert "Firmaets kontakter" in template
assert "Andre kontakter" in template
assert "Søg efter anden kontakt" in template
def test_uploaded_email_attachments_are_copied_to_case_files():
router_source = Path("app/modules/sag/backend/router.py").read_text()
assert "_copy_uploaded_email_attachments_to_case" in router_source
assert 'attachment_files_created' in router_source
assert 'source_type")' in router_source
def test_buzzwords_preference_is_per_user_without_changing_legacy_module_preferences():
router_source = Path("app/modules/sag/backend/router.py").read_text()
migration = Path("migrations/1042_user_sag_module_prefs.sql").read_text()
assert "user_sag_module_prefs" in migration
assert "UNIQUE (user_id, sag_id, module_key)" in migration
assert "module_key <> 'buzzwords'" in router_source
assert "INSERT INTO user_sag_module_prefs" in router_source
def test_case_create_lists_contacts_for_selected_customer():
template = Path("app/modules/sag/templates/create.html").read_text()
assert 'id="contactSearchContext"' in template
assert "loadSelectedCustomerContacts(id);" in template
assert "`/api/v1/customers/${customerId}/contacts`" in template
assert "if (type === 'contact' && selectedCustomer)" in template
assert "renderCustomerContactResults(contactInput.value);" in template
assert "resetCustomerContactSearch();" in template
def test_case_create_uses_simple_type_first_flow_with_required_description():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
script = Path("static/js/case-create.js").read_text(encoding="utf-8")
assert 'id="beskrivelse"' in template
assert 'placeholder="Beskriv sagen. Skriv / på en ny linje for handlinger…" required' in template
assert 'id="advancedCaseFields"' in template
assert "keys.map(button).join('')" in script
assert "keys.slice(0,4)" not in script
assert "Beskrivelse er påkrævet" in script
assert "type → required fields → chosen type's fields" in script
assert 'id="typeModuleAction"' in template
assert "data-case-module" in script
assert "Ticket-oplysninger" in script
assert "['ticket', 'bogholderi', 'admin']" in script
assert "Virksomhed er påkrævet for denne sagstype" in script
assert "Kontakt er påkrævet for denne sagstype" in script
def test_case_create_defaults_responsible_to_current_user():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
create_script = Path("static/js/case-create.js").read_text(encoding="utf-8")
assert "selectCurrentUserAsResponsible()" in template
assert "if (!select || select.value) return" not in template
assert "updateCaseTypeSections(false);" in template
assert "raw_responsible = data.get" in router
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
assert 'if case_type == "intern_ticket":' in router
assert 'data["customer_id"] = 8' in router
assert "raw_responsible = current_user_id" in router
assert '_validate_customer_id(data.get("customer_id"))' in router
assert "selectedCustomer = {id: 8, name: 'BMC Networks'}" in create_script
assert "window.caseCreateCurrentUserId" in create_script
def test_case_create_sends_relations_in_atomic_create_payload():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
assert "contact_ids: Object.keys(selectedContacts)" in template
assert "telefoni_opkald_id: telefoniPrefill.callId" in template
assert 'raw_contact_ids = data.get("contact_ids")' in router
assert "INSERT INTO sag_kontakter" in router
assert "UPDATE telefoni_opkald" in router
assert "window.location.href = `/sag/${result.id}/v5`;" in template
assert "Omdirigerer..." not in template
def test_case_can_link_internet_connection_without_customer_allocation():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
sag_router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
internet_router = Path("app/modules/internet_connections/backend/router.py").read_text(encoding="utf-8")
detail = Path("app/modules/internet_connections/templates/detail.html").read_text(encoding="utf-8")
migration = Path("migrations/1034_sag_internet_connections.sql").read_text(encoding="utf-8")
assert "internet_connection_ids" in template
assert "INSERT INTO sag_internet_connections" in sag_router
assert "/sag/{sag_id}/internet-connections" in sag_router
assert "/internet-connections/{connection_id:int}/cases" in internet_router
assert "Sager på forbindelsen" in detail
assert "sag_internet_connections" in migration
def test_case_detail_has_no_overwriting_relation_functions_or_blocking_alerts():
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert len(re.findall(r"function\s+removeContact\s*\(", template)) == 1
assert len(re.findall(r"function\s+removeCustomer\s*\(", template)) == 1
assert "#caseAddWorkspaceFooter .btn-primary" in template
assert 'id="caseAddWorkspaceBody"' in template
assert not re.search(r"(?<![\w.])alert\(", template)
assert "reloadCasePreservingContext" in template
def test_case_list_has_direct_entity_links_and_inline_updates():
template = Path("app/modules/sag/templates/index.html").read_text(encoding="utf-8")
assert 'href="/customers/{{ sag.customer_id }}"' in template
assert 'href="/contacts/{{ sag.kontakt_id }}"' in template
assert "updateCaseListField({{ sag.id }}, 'status'" not in template
assert "updateCaseListField({{ sag.id }}, 'ansvarlig_bruger_id'" in template
def test_case_optional_data_endpoints_do_not_use_expected_404s():
settings_router = Path("app/settings/backend/router.py").read_text(encoding="utf-8")
subscriptions_router = Path("app/subscriptions/backend/router.py").read_text(encoding="utf-8")
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert '"time_multiplier_presets"' in settings_router
assert '"change_request": None' in subscriptions_router
assert "if (changePayload.change_request)" in template
def test_case_v3_contact_actions_and_company_link_include_case_context():
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert 'href="/customers/{{ customer.id }}"' in template
assert "sag_id: {{ case.id }}" in template
assert "contact_id: opts.contactId || null" in template
assert 'title="Ring til mobil"' in template
assert 'title="Send SMS"' in template
assert 'id="caseCallHistoryBody"' in template
def test_time_employee_picker_is_clearly_separate_from_live_tracking():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "Manuel registrering for" in template
assert "Mig · vælg flere" in template
assert "assignment_users|length" in template
assert "#timetracking .time-v1-entry-card" in template
assert "overflow: visible !important" in template
def test_case_email_snippet_removes_embedded_css_and_scripts():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "function emailSnippetAsPlainText(email)" in template
assert "style, script, noscript, template, head" in template
assert "const snippet = emailSnippetAsPlainText(e).slice(0, 130);" in template
assert "snippetSource.replace(/<[^>]+>/g" not in template
def test_comment_composer_can_close_case_or_wait_for_customer():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert 'id="commentActionCloseCase"' in template
assert 'id="commentActionAwaitCustomer"' in template
assert "actions.push('close_case')" in template
assert "actions.push('await_customer')" in template
assert "await changeCaseStatusFromComment('lukket')" in template
assert "resolveCommentAwaitCustomerStatus()" in template
assert "awaitCustomerCheckbox.checked = false" in template
assert "closeCaseCheckbox.checked = false" in template
def test_comment_actions_use_styled_accessible_chips():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert 'class="comment-action-chips"' in template
assert template.count('class="comment-action-chip action-') == 5
assert '.comment-action-chip:has(input:checked)' in template
assert 'for="commentActionCloseCase"' in template
assert 'for="commentActionAwaitCustomer"' in template
def test_case_history_shows_actor_timestamp_and_all_case_activity_filters():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "<strong>Hvornår:</strong>" in template
assert "<strong>Hvem:</strong>" in template
assert 'id="historyFilterCaseActivity"' in template
assert "['case', 'tag', 'todo', 'relation']" in template
def test_case_timeline_uses_real_actors_and_action_timestamps():
source = Path("app/modules/sag/backend/router.py").read_text()
assert '"title": "Sag oprettet"' in source
assert 'r.created_at' in source
assert 'employee.user_id = t.medarbejder_id' in source
assert 'LOWER(recorder.username) = LOWER(t.user_name)' in source
assert 'Registreret for: {employee_name}' in source
assert '"forfatter": str(row.get("actor_name") or "System")' in source
assert '"event_type": "tag"' in source
assert '"event_type": "todo"' in source
assert '"event_type": "relation"' in source
def test_safe_case_html_renders_formatting_and_drops_executable_code():
result = sanitize_safe_html(
'<style>body{display:none}</style>'
'<p onclick="steal()">Hej <strong>verden</strong></p>'
'<script>alert("xss")</script>'
'<a href="javascript:alert(1)">farligt link</a>'
)
assert result == (
'<p>Hej <strong>verden</strong></p>'
'<a target="_blank" rel="noopener noreferrer">farligt link</a>'
)
assert "display:none" not in result
assert "alert" not in result
assert "onclick" not in result
@pytest.mark.parametrize(
"payload",
[
{"målsag_id": 10, "relationstype": "barn"},
{"målsag_id": 11, "relationstype": "ukendt"},
{"målsag_id": "ikke-et-tal", "relationstype": "barn"},
],
)
def test_relation_input_rejects_invalid_relation(payload):
with pytest.raises(HTTPException) as exc_info:
sag_router._normalize_relation_input(10, payload)
assert exc_info.value.status_code == 400
def _request(method: str, path: str) -> Request:
return Request(
{
"type": "http",
"method": method,
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"headers": [],
"scheme": "https",
"server": ("testserver", 443),
"client": ("127.0.0.1", 1234),
}
)
def test_case_route_access_allows_view_permission_for_get():
user = {"permissions": ["cases.view"]}
result = asyncio.run(
sag_router.case_route_access(_request("GET", "/api/v1/sag/10"), user)
)
assert result is user
def test_v4_case_tree_contains_parent_and_all_child_branches_and_stops_cycles(monkeypatch):
calls = iter([
[{"id": 1, "titel": "Aktuel"}, {"id": 2, "titel": "Parent"}, {"id": 3, "titel": "Barn A"}, {"id": 4, "titel": "Barn B"}],
[
{"parent_id": 2, "child_id": 1, "relationstype": "undersag"},
{"parent_id": 1, "child_id": 3, "relationstype": "undersag"},
{"parent_id": 1, "child_id": 4, "relationstype": "undersag"},
{"parent_id": 3, "child_id": 1, "relationstype": "undersag"},
],
])
monkeypatch.setattr(sag_router, "execute_query", lambda *_args, **_kwargs: next(calls))
tree, truncated = sag_router._load_case_tree(1)
assert truncated is False
assert tree[0]["id"] == 2
assert tree[0]["children"][0]["id"] == 1
current = tree[0]["children"][0]
assert current["is_current"] is True
assert {child["id"] for child in current["children"]} == {3, 4}
def test_v4_timeline_ui_uses_tree_filters_and_cursor_paging():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
router_source = Path("app/modules/sag/backend/router.py").read_text()
assert 'id="caseTreeContent"' in template
assert 'id="historyRelationFilter"' in template
assert 'id="historyEventFilter"' in template
assert 'id="historyTimelineMore"' in template
assert "whole_tree:'true'" in template
assert "relation_types" in router_source
assert "event_types" in router_source
assert "Invalid timeline cursor" in router_source
def test_v4_relation_tree_is_own_tab_with_search_filters_and_inline_module_content():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'data-bs-target="#relations-tree"' in template
assert 'id="relations-tree"' in template
assert 'id="history"' in template
assert template.index('id="relations-tree"') < template.index('id="history"')
assert 'id="relationTreeSearch"' in template
assert 'id="relationTreeType"' in template
assert 'id="relationTreePosition"' in template
assert 'id="relationTreeStatusFilter"' in template
assert "['comments', 'Kommentarer'" in template
assert "['todos', 'Opgaver'" in template
assert "['files', 'Filer'" in template
assert "['relations', 'Relationer'" in template
def test_case_route_access_requires_edit_for_nested_post():
user = {"permissions": ["cases.view", "cases.create"]}
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
sag_router.case_route_access(
_request("POST", "/api/v1/sag/10/tags"),
user,
)
)
assert exc_info.value.status_code == 403
def test_case_sms_listener_does_not_break_click_to_call_script():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "console.warn('Kunne ikke logge sendt SMS i kommentarer', err);\n }\n });" in template
assert "async function ringOutFromCase(number, opts = {})" in template
assert "alert(error?.message" not in template
def test_linked_calls_and_anydesk_sessions_are_normalized_for_case_activity(monkeypatch):
monkeypatch.setattr(sag_router, "_table_exists", lambda name: name in {"telefoni_opkald", "anydesk_sessions"})
def fake_execute_query(query, params=None):
if "FROM telefoni_opkald" in query:
return [{
"id": 11,
"sag_id": 42,
"direction": "outbound",
"ekstern_nummer": "+4530480748",
"intern_extension": "204",
"started_at": datetime(2026, 8, 25, 10, 0),
"ended_at": datetime(2026, 8, 25, 10, 5),
"duration_sec": 300,
"actor_name": "Christian",
"contact_name": "Thomas",
}]
if "FROM anydesk_sessions" in query:
return [{
"id": 12,
"anydesk_session_id": "ad-12",
"sag_id": 42,
"started_at": datetime(2026, 8, 25, 11, 0),
"ended_at": datetime(2026, 8, 25, 11, 30),
"duration_minutes": 30,
"status": "completed",
"actor_name": "Christian",
"contact_name": "Thomas",
}]
return []
monkeypatch.setattr(sag_router, "execute_query", fake_execute_query)
activities = sag_router._get_case_linked_activities([42])
assert [item["activity_type"] for item in activities] == ["anydesk", "call"]
assert activities[0]["duration_minutes"] == 30
assert activities[1]["duration_minutes"] == 5
assert activities[1]["title"] == "Udgående opkald"
def test_time_tab_and_history_include_linked_case_activities():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
assert 'id="caseLinkedActivitiesList"' in template
assert "/linked-activities" in template
assert "renderCaseLinkedActivities(linkedActivities)" in template
assert 'event_type": activity_type' in source
assert 'source": "call" if activity_type == "call" else "anydesk"' in source
def test_sag_list_has_per_user_column_preferences():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
migration = Path("migrations/1023_user_sag_list_columns.sql").read_text()
assert 'id="sagColumnList"' in template
assert 'id="saveSagColumnsBtn"' in template
assert 'draggable="true"' in template
assert "function applySagColumnPreferences()" in template
assert "function saveSagColumnPreferences()" in template
assert "column_order: sagColumnOrder" in template
assert "hidden_columns: Array.from(sagHiddenColumns)" in template
assert "column_order: Optional[List[str]] = None" in source
assert "hidden_columns: Optional[List[str]] = None" in source
assert "ON CONFLICT (user_id)" in source
assert "ADD COLUMN IF NOT EXISTS column_order JSONB" in migration
assert "ADD COLUMN IF NOT EXISTS hidden_columns JSONB" in migration
def test_sag_list_status_dropdown_receives_options_and_links_are_styled():
template = Path("app/modules/sag/templates/index.html").read_text()
views = Path("app/modules/sag/frontend/views.py").read_text()
assert '"status_options": status_options' in views
assert 'class="status-badge sag-status-label"' in template
assert 'class="sag-entity-link"' in template
assert 'class="sag-id"' in template
assert ".sag-entity-link:hover" in template
assert "sag-inline-select sag-status-select" not in template
assert "sag-inline-select sag-owner-select" in template
assert "function applyStatusSelectTone(control)" in template
def test_v4_compact_time_module_is_moved_below_tags_and_creates_drafts():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'id="caseQuickTimeMinutes"' in template
assert 'id="caseQuickTimeType"' in template
assert 'id="caseQuickTimeDescription"' in template
assert 'onclick="startQuickTimerV4()"' in template
assert "tagsModule.insertAdjacentElement('afterend', timeModule);" in template
assert "entry_status: 'kladde'" in template
assert "medarbejder_ids: [null]" in template
def test_new_case_starts_with_only_core_fields_and_reveals_type_modules():
template = Path("app/modules/sag/templates/create.html").read_text()
script = Path("static/js/case-create.js").read_text()
assert 'id="caseRelationsSection" class="d-none"' in template
assert 'id="caseGroupField"' in template
assert 'id="caseTagsSection"' in template
assert 'id="caseTagsButton"' in template
assert "window.tagPicker.showSelection(addTag);" in script
assert "const availableModules = visiblePanels;" in script
assert "relationsSection?.classList.toggle('d-none'" in script
assert "addEventListener('change', () => updateCaseTypeSections(true))" in template
def test_global_search_enter_opens_exact_hash_case_number_in_v5():
template = Path("app/shared/frontend/base.html").read_text()
assert "if (navigateToCaseNumber(globalSearchInput.value))" in template
assert "match(/^#\\s*(\\d+)$/)" in template
assert "window.location.href = `/sag/${sagId}/v5`;" in template
def test_postit_keeps_tags_time_and_full_time_tab_available():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert ':not([data-module="tags"]):not([data-module="time"])' in template
assert "target !== '#details' && target !== '#timetracking' && target !== '#planner'" in template
assert "moduleName !== 'tags' && moduleName !== 'time'" in template
def test_v4_timeline_date_menus_escape_hero_stacking_context():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert template.count("data-v4-date-dropdown") >= 2
assert "case-v4-floating-date-menu" in template
assert "document.body.appendChild(menu);" in template
assert "toggle.insertAdjacentElement('afterend', menu);" in template
def test_v4_group_assignment_loader_is_available_to_page_initialization():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "window.loadCaseAssignments = loadCaseAssignments;" in template
assert "window.persistCaseAssignees = persistCaseAssignees;" in template
assert "window.loadCaseAssignments?.().catch" in template
assert "await persistCaseAssignees([], null, null);" in template
assert "await saveCaseAssignees([], null, null);" not in template
def test_v4_deferred_modal_is_compact_and_saves_atomically():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'class="modal fade deferred-v4-modal" id="deferredModal"' in template
assert "bootstrap.Modal.getOrCreateInstance(modalElement).show();" in template
assert "async function persistDeferredSettings" in template
assert "await persistDeferredSettings(input.value || null, caseSelect.value || null, statuses);" in template
assert "deferred_until_status: relatedCaseId && normalizedStatuses.length" in template
assert "updateDeferredUntil(input.value);" not in template
def test_v4_comment_actions_use_compact_icons_and_can_append_to_case():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
router = Path("app/modules/sag/backend/router.py").read_text()
assert 'action-time is-icon-only' in template
assert 'action-close is-icon-only' in template
assert 'action-wait is-icon-only' in template
assert '<span>Log tid</span>' not in template
assert '<span>Luk sag</span>' not in template
assert '<span>Afventer kunde</span>' not in template
assert 'id="commentActionAppendCase"' in template
assert "actions.push('append_case')" in template
assert "body: JSON.stringify({ beskrivelse: content, append: true })" in template
assert "await appendCommentToCaseDescription(content);" in template
assert "append: bool = False" in router
assert 'append_date = datetime.now().strftime("%d/%m")' in router
assert 'append_fragment = f"<hr><h6>{initials}/{append_date}</h6><p>{safe_addition}</p>"' in router
def test_v4_comment_composer_prioritizes_writing_space():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'min-height: 160px;' in template
assert 'rows="6"' in template
assert '.comment-composer textarea:focus' in template
assert 'align-items: flex-end;' in template
def test_v4_relations_are_compact_and_created_inline():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'Relationer til #{{ case.id }}' in template
assert 'id="relationInlineCreate"' in template
assert 'onclick="showInlineRelationRow()"' in template
assert 'id="inlineRelatedCaseType"' in template
assert 'id="inlineRelatedCaseTitle"' in template
assert 'id="inlineRelationType"' in template
assert 'id="inlineRelatedCaseUser"' in template
assert 'id="inlineRelatedCaseGroup"' in template
assert 'async function saveInlineRelation()' in template
assert "const caseResponse = await fetch('/api/v1/sag'" in template
assert 'målsag_id: newCase.id' in template
assert 'placeholder="Sag #"' not in template
assert 'Skriv et gyldigt sagsnummer.' not in template
assert 'ansvarlig_bruger_id: responsibleUserId' in template
assert 'assigned_group_id: assignedGroupId' in template
assert "node.case.ansvarlig_navn or 'Ingen ansvarlig'" in template
assert "node.case.gruppe_navn or 'Ingen gruppe'" in template
assert 'class="btn btn-outline-primary btn-rel-action rel-related-add-btn"' in template
assert 'onclick=\'openRelQaMenu({{ node.case.id }}' in template
assert '.compact-relations-table .btn-rel-action.rel-related-add-btn' in template
assert 'href="/sag/{{ node.case.id }}/v5" class="compact-relation-id"' in template
assert '<tr class="{{ \'d-none\' if node.is_current else \'\' }}">' in template
def test_v4_embeds_case_planner_with_direct_subcase_creation():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'id="planner-tab" data-bs-target="#planner"' in template
assert 'id="casePlannerAllocationForm"' in template
assert 'id="casePlannerSubcaseForm"' in template
assert "async function createEmbeddedCaseAllocation" in template
assert "async function createPlannerSubcase" in template
assert "relationstype:'undersag'" in template
assert "`/api/v1/planner/cases/${caseIds}/allocations`" in template
assert 'id="casePlannerDeferredNotice"' in template
assert "Venter på sag #${deferred.case_id}" in template
assert "subcase.deferred_until_case_id" in template
assert "window.loadEmbeddedCasePlanner?.();" in template
assert 'id="casePlannerWeek"' in template
assert "function renderEmbeddedPlannerCalendar()" in template
assert "function shiftEmbeddedPlannerWeek(direction)" in template
assert "Klik på en dag for at allokere tid" in template
assert 'id="casePlannerSlotModal"' in template
assert "function renderEmbeddedPlannerSubcaseCalendar()" in template
assert "function openEmbeddedPlannerSubcaseSlot(dateKey)" in template
assert "async function saveEmbeddedPlannerSubcaseSlot(event)" in template
assert "subcase.allocations || []" in template
assert "Tid er allokeret til undersag" in template
assert 'id="casePlannerSubcaseWeek"' in template
assert "function openEmbeddedPlannerSlot(dateKey)" in template
assert "async function saveEmbeddedPlannerSlot(event)" in template
assert "Dette er planlagt tid" in template
assert "Der oprettes ingen tidsregistrering" in template
assert "konfliktadvarsel før overbooking" in template
def test_v4_comment_source_filters_start_off_and_use_compact_icons():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
for filter_id in ("Email", "Sms", "Call", "Module"):
marker = f'id="commentSourceFilter{filter_id}"'
line = next(line for line in template.splitlines() if marker in line)
assert "checked" not in line
for icon in ("bi-envelope", "bi-chat-dots", "bi-telephone", "bi-grid"):
assert icon in template
assert ".comment-category-check:has(input:checked)" in template
assert "const sourceFilterIsActive = selectedSources.size > 0;" in template
assert "const matchesSource = !sourceFilterIsActive || selectedSources.has(source);" in template
def test_legacy_v3_route_renders_the_active_v4_template():
views = Path("app/modules/sag/frontend/views.py").read_text()
assert 'template_name = "modules/sag/templates/detail_v4.html"' in views
assert 'else "modules/sag/templates/detail_v3.html"' not in views
def test_support_case_close_without_time_requires_explicit_confirmation():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
assert '"close_without_time_confirmation_required"' in source
assert 'SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s)' in source
assert 'confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True' in source
assert "detail?.code === 'close_without_time_confirmation_required'" in template
assert "body.confirm_close_without_time = true" in template
assert 'id="closeCaseWithoutTimeModal"' in template
assert "await confirmCloseCaseWithoutTime(caseId, detail.message)" in template
def test_v4_uses_fixed_case_statuses_and_blocks_unfinished_time_on_close():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
views = Path("app/modules/sag/frontend/views.py").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
expected = '["Ny", "Tildelt", "Igangværende", "Afventer", "Løst", "Kunde svaret", "Lukket"]'
assert f"return {expected}" in views
assert "case_close_blocked_unfinished_time" in source
assert "entry_status" in source
assert "<> 'godkendt'" in source
assert "slut_tid IS NULL" in source
assert "const closesCase = nextStatus.toLowerCase() === 'lukket';" in template
assert "Sagen kan kun lukkes, når alle tidsregistreringer er færdigbehandlede" in template
assert "showToast(e.message || 'Kunne ikke gemme status', 'danger');" in template
assert "select.value = previousStatus;" in template
def test_v4_does_not_add_legacy_current_status_to_trigger_options():
views = Path("app/modules/sag/frontend/views.py").read_text()
v4_block = views.split('template_name = "modules/sag/templates/detail_v4.html"', 1)[0].rsplit(
"status_options = _fetch_case_status_options()", 1
)[1].split('is_deadline_overdue', 1)[0]
assert "status_options.append(current_status)" not in v4_block
def test_v4_uses_a_scoped_visual_system_for_consistent_case_ui():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "--case-primary: #0f4c75;" in template
assert "--case-primary-soft: #eef6fa;" in template
assert ".case-detail-page-shell .case-v4-widget-dashboard .card" in template
assert ".case-detail-page-shell .btn-outline-success" in template
assert "#caseTabsContent .tab-pane:not(#details) .history-timeline-shell" in template
assert "#caseTabsContent .tab-pane:not(#details) .table-hover > tbody > tr:hover" in template
assert ".case-relation-hero { display:flex" in template
assert "background:var(--case-primary-soft, #eef6fa);" in template
def test_v4_relation_tree_can_create_related_cases_from_the_tree():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'onclick="showCreateRelatedModal()"><i class="bi bi-plus-lg me-1"></i>Opret relateret sag' in template
assert 'data-relation-create="${safeId}"' in template
assert "window.showCreateRelatedModalForCase" in template
assert "relatedCaseSourceId = Number(sourceCaseId) || caseId;" in template
assert "const sourceCaseId = Number(relatedCaseSourceId) || caseId;" in template
def test_v4_relation_tree_exposes_module_content_as_a_clear_preview():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "Indhold på sag #${safeId}" in template
assert "loadRelationModuleOverview" in template
assert "const nonEmpty = results.filter(Boolean);" in template
assert "relation-tree-module-section" in template
def test_sag_list_has_smart_toolbar_search_and_employee_quick_filters():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
assert 'data-quick-filter="mine-open"' in template
assert 'data-quick-filter="overdue"' in template
assert 'data-quick-filter="my-groups"' in template
assert 'data-quick-filter="unassigned"' in template
assert 'id="clearSearchBtn"' in template
assert "search.split(/\\s+/).filter(Boolean).every" in template
assert "/sag/me/quick-filter-context" in source
assert "SELECT group_id FROM user_groups WHERE user_id = %s" in source
def test_overdue_active_cases_have_red_row_shadow():
template = Path("app/modules/sag/templates/index.html").read_text()
assert ".sag-table tbody tr.sag-deadline-overdue" in template
assert "function updateOverdueDeadlineMarker(row)" in template
assert "!closedStatuses.has(status)" in template
assert "row.classList.toggle('sag-deadline-overdue', isOverdue)" in template
@pytest.mark.parametrize('deferred,deadline', [
('2026-10-06', '2026-10-06'), ('2026-10-05', '2026-10-06'),
(None, '2026-10-06'), ('2026-10-06', None),
])
def test_deferred_date_may_equal_deadline_or_be_unset(deferred, deadline):
sag_router._validate_deferred_deadline(deferred, deadline)
def test_deferred_date_cannot_exceed_deadline():
with pytest.raises(HTTPException) as error:
sag_router._validate_deferred_deadline(datetime(2026, 10, 7), '2026-10-06')
assert error.value.status_code == 400
assert error.value.detail == 'Udsat må ikke ligge efter deadline'
def test_time_summary_loads_billing_review_helper_before_rendering():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert template.index('/static/js/time-billing-review.js') < template.index('const timeCaseId =')
assert template.index('/static/js/time-billing-review.js') < template.index('TimeBillingReview.badge(entry)')
assert Path("static/js/time-billing-review.js").is_file()
def test_default_case_routes_redirect_to_v5():
from app.modules.sag.frontend.views import sag_detaljer, sag_detaljer_v3_legacy
request=Request({'type':'http','method':'GET','path':'/sag/196','headers':[]})
assert asyncio.run(sag_detaljer(request,196)).headers['location']=='/sag/196/v5'
assert asyncio.run(sag_detaljer_v3_legacy(196)).headers['location']=='/sag/196/v5'