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"{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 '