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"")) 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("", 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 " in template assert "Firma" in template assert "Hovedkontakt" in template assert "Afdeling" 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"(?]+>/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 "Hvornår:" in template assert "Hvem:" 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( '' '

Hej verden

' '' 'farligt link' ) assert result == ( '

Hej verden

' 'farligt link' ) 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 'Log tid' not in template assert 'Luk sag' not in template assert 'Afventer kunde' 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"
{initials}/{append_date}

{safe_addition}

"' 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 '' 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()">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'