2026-07-28 14:18:24 +02:00
|
|
|
from datetime import datetime
|
|
|
|
|
from io import BytesIO
|
2026-01-31 23:16:24 +01:00
|
|
|
from pathlib import Path
|
2026-07-28 14:18:24 +02:00
|
|
|
import asyncio
|
2026-08-30 14:34:43 +02:00
|
|
|
import re
|
2026-07-28 14:18:24 +02:00
|
|
|
import sys
|
|
|
|
|
import types
|
2026-01-31 23:16:24 +01:00
|
|
|
|
|
|
|
|
import pytest
|
2026-07-28 14:18:24 +02:00
|
|
|
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
|
2026-08-17 19:27:43 +02:00
|
|
|
|
|
|
|
|
|
2026-09-23 00:23:30 +02:00
|
|
|
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,))]
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 19:27:43 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-18 20:57:49 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-08 08:58:02 +02:00
|
|
|
def test_v5_is_the_canonical_case_view_and_v3_redirects_to_it():
|
2026-09-23 00:23:30 +02:00
|
|
|
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
|
2026-10-08 08:58:02 +02:00
|
|
|
assert 'RedirectResponse(url=f"/sag/{sag_id}/v5", status_code=307)' in source
|
2026-09-23 00:23:30 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-18 20:57:49 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-23 00:23:30 +02:00
|
|
|
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 problemstillingen detaljeret..." 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
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 20:49:55 +02:00
|
|
|
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")
|
2026-09-20 03:33:10 +02:00
|
|
|
create_script = Path("static/js/case-create.js").read_text(encoding="utf-8")
|
2026-09-08 01:55:47 +02:00
|
|
|
assert "selectCurrentUserAsResponsible()" in template
|
2026-09-20 03:33:10 +02:00
|
|
|
assert "if (!select || select.value) return" not in template
|
|
|
|
|
assert "updateCaseTypeSections(false);" in template
|
2026-08-28 20:49:55 +02:00
|
|
|
assert "raw_responsible = data.get" in router
|
|
|
|
|
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
|
2026-09-20 03:33:10 +02:00
|
|
|
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
|
2026-08-28 20:49:55 +02:00
|
|
|
|
|
|
|
|
|
2026-08-30 14:34:43 +02:00
|
|
|
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
|
2026-10-08 08:58:02 +02:00
|
|
|
assert "window.location.href = `/sag/${result.id}/v5`;" in template
|
2026-08-30 14:34:43 +02:00
|
|
|
assert "Omdirigerer..." not in template
|
|
|
|
|
|
|
|
|
|
|
2026-08-31 13:01:35 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-30 14:34:43 +02:00
|
|
|
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
|
2026-10-08 08:58:02 +02:00
|
|
|
assert "updateCaseListField({{ sag.id }}, 'status'" not in template
|
2026-08-30 14:34:43 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 20:49:55 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-18 20:57:49 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 19:27:43 +02:00
|
|
|
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
|
2026-07-28 14:18:24 +02:00
|
|
|
|
|
|
|
|
|
2026-08-25 01:09:45 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 09:12:25 +02:00
|
|
|
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
|
2026-10-03 14:06:51 +02:00
|
|
|
assert "['comments', 'Kommentarer'" in template
|
|
|
|
|
assert "['todos', 'Opgaver'" in template
|
|
|
|
|
assert "['files', 'Filer'" in template
|
|
|
|
|
assert "['relations', 'Relationer'" in template
|
2026-09-24 09:12:25 +02:00
|
|
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
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
|
2026-08-25 15:39:38 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-25 16:04:28 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-30 14:34:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-08 08:58:02 +02:00
|
|
|
assert 'class="status-badge sag-status-label"' in template
|
2026-08-30 14:34:43 +02:00
|
|
|
assert 'class="sag-entity-link"' in template
|
|
|
|
|
assert 'class="sag-id"' in template
|
|
|
|
|
assert ".sag-entity-link:hover" in template
|
2026-10-08 08:58:02 +02:00
|
|
|
assert "sag-inline-select sag-status-select" not in template
|
2026-08-30 14:34:43 +02:00
|
|
|
assert "sag-inline-select sag-owner-select" in template
|
|
|
|
|
assert "function applyStatusSelectTone(control)" in template
|
|
|
|
|
|
|
|
|
|
|
2026-09-20 03:33:10 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-03 14:06:51 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-08 08:58:02 +02:00
|
|
|
def test_global_search_enter_opens_exact_hash_case_number_in_v5():
|
2026-10-03 14:06:51 +02:00
|
|
|
template = Path("app/shared/frontend/base.html").read_text()
|
|
|
|
|
|
|
|
|
|
assert "if (navigateToCaseNumber(globalSearchInput.value))" in template
|
|
|
|
|
assert "match(/^#\\s*(\\d+)$/)" in template
|
2026-10-08 08:58:02 +02:00
|
|
|
assert "window.location.href = `/sag/${sagId}/v5`;" in template
|
2026-10-03 14:06:51 +02:00
|
|
|
|
|
|
|
|
|
2026-09-20 03:33:10 +02:00
|
|
|
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
|
2026-10-08 08:58:02 +02:00
|
|
|
assert 'href="/sag/{{ node.case.id }}/v5" class="compact-relation-id"' in template
|
2026-09-20 03:33:10 +02:00
|
|
|
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
|
2026-10-03 14:06:51 +02:00
|
|
|
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
|
2026-09-20 03:33:10 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-30 14:34:43 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-20 03:33:10 +02:00
|
|
|
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
|
2026-10-03 14:06:51 +02:00
|
|
|
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
|
2026-09-20 03:33:10 +02:00
|
|
|
|
|
|
|
|
|
2026-08-30 14:34:43 +02:00
|
|
|
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
|
2026-10-08 08:58:02 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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'
|