- Implement tests to ensure the case tree contains parent and child branches while preventing cycles. - Verify that the timeline UI utilizes tree filters and cursor paging. - Confirm that the relation tree is displayed as a separate tab with appropriate search filters and inline module content. Enhance time tracking with prepaid card usage alerts - Create a migration to track alerts for prepaid card usage at 80% and 100%. - Implement triggers to generate cases when usage thresholds are reached, ensuring proper notifications and case management. Introduce audit logging for time entry edits - Modify the sync log to include time entry updates in the audit stream without altering existing records. Establish tracking for vTiger case imports and exports - Create tables to track cases imported from vTiger into Hub and exports from Hub to vTiger, ensuring idempotency and proper reference management.
852 lines
36 KiB
Python
852 lines
36 KiB
Python
from datetime import datetime
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
import asyncio
|
|
import re
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
from fastapi import HTTPException, UploadFile, Request
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from app.utils.safe_html import sanitize_safe_html
|
|
|
|
# Keep these focused unit tests independent of optional auth runtime packages.
|
|
auth_dependencies_stub = types.ModuleType("app.core.auth_dependencies")
|
|
|
|
|
|
def _allow_test_user(*_permissions):
|
|
async def dependency(_request: Request):
|
|
return {"id": 1, "username": "test", "permissions": []}
|
|
|
|
return dependency
|
|
|
|
|
|
auth_dependencies_stub.require_any_permission = _allow_test_user
|
|
|
|
|
|
async def _get_test_user(_request: Request):
|
|
return {
|
|
"id": 1,
|
|
"username": "test",
|
|
"permissions": ["cases.view", "cases.create", "cases.edit", "cases.delete"],
|
|
}
|
|
|
|
|
|
auth_dependencies_stub.get_current_user = _get_test_user
|
|
sys.modules.setdefault("app.core.auth_dependencies", auth_dependencies_stub)
|
|
|
|
from app.modules.sag.backend import router as sag_router
|
|
|
|
|
|
def test_normalize_timestamp_converts_offset_to_utc():
|
|
value = sag_router._normalize_optional_timestamp(
|
|
"2026-07-27T12:00:00+02:00",
|
|
"deadline",
|
|
)
|
|
|
|
assert value == "2026-07-27 10:00:00"
|
|
|
|
|
|
def test_normalize_timestamp_accepts_naive_datetime():
|
|
value = sag_router._normalize_optional_timestamp(
|
|
datetime(2026, 7, 27, 12, 30),
|
|
"deadline",
|
|
)
|
|
|
|
assert value == "2026-07-27 12:30:00"
|
|
|
|
|
|
def test_normalize_timestamp_rejects_invalid_value():
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
sag_router._normalize_optional_timestamp("not-a-date", "deadline")
|
|
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
def test_attachment_path_rejects_escape_from_upload_root():
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
sag_router._resolve_attachment_path("../../outside.txt")
|
|
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
def test_attachment_path_accepts_case_subdirectory():
|
|
path = sag_router._resolve_attachment_path("sag_files/example.txt")
|
|
|
|
path.relative_to(sag_router.UPLOAD_BASE_PATH)
|
|
|
|
|
|
def test_upload_rejects_disallowed_extension():
|
|
upload = UploadFile(filename="payload.html", file=BytesIO(b"<script>alert(1)</script>"))
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
sag_router._store_upload_file(upload, sag_router.SAG_FILE_SUBDIR)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "not allowed" in str(exc_info.value.detail)
|
|
|
|
|
|
def test_relation_input_normalizes_valid_relation():
|
|
target_id, relation_type = sag_router._normalize_relation_input(
|
|
10,
|
|
{"målsag_id": "11", "relationstype": " Blokkerer "},
|
|
)
|
|
|
|
assert target_id == 11
|
|
assert relation_type == "Blokkerer"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw_type", "expected"),
|
|
[
|
|
("Relateret til", "Relateret til"),
|
|
("Afledt af", "Afledt af"),
|
|
("Årsag til", "Årsag til"),
|
|
("afledt_af", "Afledt af"),
|
|
("afhænger af", "afhænger af"),
|
|
("undersag", "undersag"),
|
|
("duplikat", "duplikat"),
|
|
],
|
|
)
|
|
def test_relation_input_accepts_ui_and_existing_database_types(raw_type, expected):
|
|
_, relation_type = sag_router._normalize_relation_input(
|
|
10,
|
|
{"målsag_id": 11, "relationstype": raw_type},
|
|
)
|
|
|
|
assert relation_type == expected
|
|
|
|
|
|
def test_relation_quick_task_uses_todo_steps_api():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
|
|
assert "fetch(`/api/v1/sag/${caseId}/todo-steps`" in template
|
|
assert "fetch(`/api/v1/sag/${caseId}/todos`" not in template
|
|
assert "due_date: due" in template
|
|
|
|
|
|
def test_add_primary_case_contact_clears_previous_primary(monkeypatch):
|
|
queries = []
|
|
updates = []
|
|
|
|
def fake_query(query, params=None, **_kwargs):
|
|
queries.append((query, params))
|
|
if "SELECT id FROM sag_kontakter" in query:
|
|
return []
|
|
if "INSERT INTO sag_kontakter" in query:
|
|
return [{"id": 12, "sag_id": 177, "contact_id": 2011, "is_primary": True}]
|
|
raise AssertionError(query)
|
|
|
|
monkeypatch.setattr(sag_router, "execute_query", fake_query)
|
|
monkeypatch.setattr(sag_router, "execute_update", lambda query, params: updates.append((query, params)))
|
|
|
|
result = asyncio.run(sag_router.add_case_contact(177, {"contact_id": 2011, "is_primary": True}))
|
|
|
|
assert result["contact_id"] == 2011
|
|
assert updates == [("UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s", (177,))]
|
|
|
|
|
|
def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
|
|
assert "openForwardLinkedEmail('latest')" in template
|
|
assert "openForwardLinkedEmail('thread')" in template
|
|
assert "stripQuotedEmailHistory" in template
|
|
assert "caseEmailComposeMode = 'forward'" in template
|
|
assert "thread_email_id: isNewThread ? null" in template
|
|
assert "linkedEmailsCache\n .filter" in template
|
|
|
|
|
|
def test_case_detail_disables_browser_cache_for_fresh_inline_ui():
|
|
source = Path("app/modules/sag/frontend/views.py").read_text()
|
|
|
|
assert 'response.headers["Cache-Control"] = "no-store, max-age=0"' in source
|
|
|
|
|
|
def test_v4_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}/v4", status_code=307)' in source
|
|
|
|
|
|
def test_v4_case_upload_is_available_from_topbar_and_empty_file_module_is_content_driven():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert 'bi bi-paperclip' in template
|
|
assert "document.getElementById('fileInput').click()" in template
|
|
assert "'files'" in template
|
|
assert "v4ContentDrivenModules" in template
|
|
assert "caseTopbarUploadBtn" in template
|
|
assert "handleTopbarAttachmentFiles" in template
|
|
assert "uploadEmailFile(emailFile)" in template
|
|
assert "fil${count === 1 ? '' : 'er'} uploadet" in template
|
|
|
|
|
|
def test_v4_file_module_renders_clickable_inline_preview_thumbnails():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert "case-file-thumbnail" in template
|
|
assert "previewFileById(${fileId})" in template
|
|
assert "preview-image?page=1&scale=1" in template
|
|
assert "bi bi-eye" in template
|
|
assert "previewAdjacentFile(-1)" in template
|
|
assert "previewAdjacentFile(1)" in template
|
|
assert "event.key === 'ArrowLeft'" in template
|
|
assert "event.key === 'ArrowRight'" in template
|
|
|
|
|
|
def test_v4_email_view_can_recover_missing_declared_attachments():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert "const attachmentsMissing" in template
|
|
assert "Hent vedhæftninger" in template
|
|
assert "/recover-attachments" in template
|
|
assert "recoverLinkedEmailAttachments" in template
|
|
|
|
|
|
def test_v4_header_has_no_duplicate_status_or_type_and_uses_contact_widget():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
header = template.split("<!-- WIDGET DASHBOARD -->", 1)[0]
|
|
|
|
assert 'id="caseStatusBadge"' not in header
|
|
assert 'id="caseTypeDropdownBtn"' not in header
|
|
assert "showCustomerSearch('replace')" not in header
|
|
assert "showContactSearch('replace')" not in header
|
|
assert "showAfdelingModal()" not in header
|
|
assert "Kontakt\n </div>" in template
|
|
assert "Firma</span>" in template
|
|
assert "Hovedkontakt</span>" in template
|
|
assert "Afdeling</span>" in template
|
|
assert "Start VoIP-kald" in template
|
|
assert "Send SMS" in template
|
|
|
|
|
|
def test_v4_contact_picker_loads_company_contacts_before_global_search():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert "loadCustomerContactsForContactSearch" in template
|
|
assert "/api/v1/customers/${customerId}/contacts" in template
|
|
assert "Firmaets kontakter" in template
|
|
assert "Andre kontakter" in template
|
|
assert "Søg efter anden kontakt" in template
|
|
|
|
|
|
def test_uploaded_email_attachments_are_copied_to_case_files():
|
|
router_source = Path("app/modules/sag/backend/router.py").read_text()
|
|
|
|
assert "_copy_uploaded_email_attachments_to_case" in router_source
|
|
assert 'attachment_files_created' in router_source
|
|
assert 'source_type")' in router_source
|
|
|
|
|
|
def test_buzzwords_preference_is_per_user_without_changing_legacy_module_preferences():
|
|
router_source = Path("app/modules/sag/backend/router.py").read_text()
|
|
migration = Path("migrations/1042_user_sag_module_prefs.sql").read_text()
|
|
|
|
assert "user_sag_module_prefs" in migration
|
|
assert "UNIQUE (user_id, sag_id, module_key)" in migration
|
|
assert "module_key <> 'buzzwords'" in router_source
|
|
assert "INSERT INTO user_sag_module_prefs" in router_source
|
|
|
|
|
|
def test_case_create_lists_contacts_for_selected_customer():
|
|
template = Path("app/modules/sag/templates/create.html").read_text()
|
|
|
|
assert 'id="contactSearchContext"' in template
|
|
assert "loadSelectedCustomerContacts(id);" in template
|
|
assert "`/api/v1/customers/${customerId}/contacts`" in template
|
|
assert "if (type === 'contact' && selectedCustomer)" in template
|
|
assert "renderCustomerContactResults(contactInput.value);" in template
|
|
assert "resetCustomerContactSearch();" in template
|
|
|
|
|
|
def test_case_create_uses_simple_type_first_flow_with_required_description():
|
|
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
|
|
script = Path("static/js/case-create.js").read_text(encoding="utf-8")
|
|
|
|
assert 'id="beskrivelse"' in template
|
|
assert 'placeholder="Beskriv 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
|
|
|
|
|
|
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}/v4`;" in template
|
|
assert "Omdirigerer..." not in template
|
|
|
|
|
|
def test_case_can_link_internet_connection_without_customer_allocation():
|
|
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
|
|
sag_router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
|
|
internet_router = Path("app/modules/internet_connections/backend/router.py").read_text(encoding="utf-8")
|
|
detail = Path("app/modules/internet_connections/templates/detail.html").read_text(encoding="utf-8")
|
|
migration = Path("migrations/1034_sag_internet_connections.sql").read_text(encoding="utf-8")
|
|
assert "internet_connection_ids" in template
|
|
assert "INSERT INTO sag_internet_connections" in sag_router
|
|
assert "/sag/{sag_id}/internet-connections" in sag_router
|
|
assert "/internet-connections/{connection_id:int}/cases" in internet_router
|
|
assert "Sager på forbindelsen" in detail
|
|
assert "sag_internet_connections" in migration
|
|
|
|
|
|
def test_case_detail_has_no_overwriting_relation_functions_or_blocking_alerts():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
|
|
assert len(re.findall(r"function\s+removeContact\s*\(", template)) == 1
|
|
assert len(re.findall(r"function\s+removeCustomer\s*\(", template)) == 1
|
|
assert "#caseAddWorkspaceFooter .btn-primary" in template
|
|
assert 'id="caseAddWorkspaceBody"' in template
|
|
assert not re.search(r"(?<![\w.])alert\(", template)
|
|
assert "reloadCasePreservingContext" in template
|
|
|
|
|
|
def test_case_list_has_direct_entity_links_and_inline_updates():
|
|
template = Path("app/modules/sag/templates/index.html").read_text(encoding="utf-8")
|
|
assert 'href="/customers/{{ sag.customer_id }}"' in template
|
|
assert 'href="/contacts/{{ sag.kontakt_id }}"' in template
|
|
assert "updateCaseListField({{ sag.id }}, 'status'" in template
|
|
assert "updateCaseListField({{ sag.id }}, 'ansvarlig_bruger_id'" in template
|
|
|
|
|
|
def test_case_optional_data_endpoints_do_not_use_expected_404s():
|
|
settings_router = Path("app/settings/backend/router.py").read_text(encoding="utf-8")
|
|
subscriptions_router = Path("app/subscriptions/backend/router.py").read_text(encoding="utf-8")
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
|
|
assert '"time_multiplier_presets"' in settings_router
|
|
assert '"change_request": None' in subscriptions_router
|
|
assert "if (changePayload.change_request)" in template
|
|
|
|
|
|
def test_case_v3_contact_actions_and_company_link_include_case_context():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
|
|
assert 'href="/customers/{{ customer.id }}"' in template
|
|
assert "sag_id: {{ case.id }}" in template
|
|
assert "contact_id: opts.contactId || null" in template
|
|
assert 'title="Ring til mobil"' in template
|
|
assert 'title="Send SMS"' in template
|
|
assert 'id="caseCallHistoryBody"' in template
|
|
|
|
|
|
def test_time_employee_picker_is_clearly_separate_from_live_tracking():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
|
|
assert "Manuel registrering for" in template
|
|
assert "Mig · vælg flere" in template
|
|
assert "assignment_users|length" in template
|
|
assert "#timetracking .time-v1-entry-card" in template
|
|
assert "overflow: visible !important" in template
|
|
|
|
|
|
def test_case_email_snippet_removes_embedded_css_and_scripts():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
|
|
assert "function emailSnippetAsPlainText(email)" in template
|
|
assert "style, script, noscript, template, head" in template
|
|
assert "const snippet = emailSnippetAsPlainText(e).slice(0, 130);" in template
|
|
assert "snippetSource.replace(/<[^>]+>/g" not in template
|
|
|
|
|
|
def test_comment_composer_can_close_case_or_wait_for_customer():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
|
|
assert 'id="commentActionCloseCase"' in template
|
|
assert 'id="commentActionAwaitCustomer"' in template
|
|
assert "actions.push('close_case')" in template
|
|
assert "actions.push('await_customer')" in template
|
|
assert "await changeCaseStatusFromComment('lukket')" in template
|
|
assert "resolveCommentAwaitCustomerStatus()" in template
|
|
assert "awaitCustomerCheckbox.checked = false" in template
|
|
assert "closeCaseCheckbox.checked = false" in template
|
|
|
|
|
|
def test_comment_actions_use_styled_accessible_chips():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
|
|
assert 'class="comment-action-chips"' in template
|
|
assert template.count('class="comment-action-chip action-') == 5
|
|
assert '.comment-action-chip:has(input:checked)' in template
|
|
assert 'for="commentActionCloseCase"' in template
|
|
assert 'for="commentActionAwaitCustomer"' in template
|
|
|
|
|
|
def test_case_history_shows_actor_timestamp_and_all_case_activity_filters():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
|
|
assert "<strong>Hvornår:</strong>" in template
|
|
assert "<strong>Hvem:</strong>" in template
|
|
assert 'id="historyFilterCaseActivity"' in template
|
|
assert "['case', 'tag', 'todo', 'relation']" in template
|
|
|
|
|
|
def test_case_timeline_uses_real_actors_and_action_timestamps():
|
|
source = Path("app/modules/sag/backend/router.py").read_text()
|
|
|
|
assert '"title": "Sag oprettet"' in source
|
|
assert 'r.created_at' in source
|
|
assert 'employee.user_id = t.medarbejder_id' in source
|
|
assert 'LOWER(recorder.username) = LOWER(t.user_name)' in source
|
|
assert 'Registreret for: {employee_name}' in source
|
|
assert '"forfatter": str(row.get("actor_name") or "System")' in source
|
|
assert '"event_type": "tag"' in source
|
|
assert '"event_type": "todo"' in source
|
|
assert '"event_type": "relation"' in source
|
|
|
|
|
|
def test_safe_case_html_renders_formatting_and_drops_executable_code():
|
|
result = sanitize_safe_html(
|
|
'<style>body{display:none}</style>'
|
|
'<p onclick="steal()">Hej <strong>verden</strong></p>'
|
|
'<script>alert("xss")</script>'
|
|
'<a href="javascript:alert(1)">farligt link</a>'
|
|
)
|
|
|
|
assert result == (
|
|
'<p>Hej <strong>verden</strong></p>'
|
|
'<a target="_blank" rel="noopener noreferrer">farligt link</a>'
|
|
)
|
|
assert "display:none" not in result
|
|
assert "alert" not in result
|
|
assert "onclick" not in result
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{"målsag_id": 10, "relationstype": "barn"},
|
|
{"målsag_id": 11, "relationstype": "ukendt"},
|
|
{"målsag_id": "ikke-et-tal", "relationstype": "barn"},
|
|
],
|
|
)
|
|
def test_relation_input_rejects_invalid_relation(payload):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
sag_router._normalize_relation_input(10, payload)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
def _request(method: str, path: str) -> Request:
|
|
return Request(
|
|
{
|
|
"type": "http",
|
|
"method": method,
|
|
"path": path,
|
|
"raw_path": path.encode(),
|
|
"query_string": b"",
|
|
"headers": [],
|
|
"scheme": "https",
|
|
"server": ("testserver", 443),
|
|
"client": ("127.0.0.1", 1234),
|
|
}
|
|
)
|
|
|
|
|
|
def test_case_route_access_allows_view_permission_for_get():
|
|
user = {"permissions": ["cases.view"]}
|
|
|
|
result = asyncio.run(
|
|
sag_router.case_route_access(_request("GET", "/api/v1/sag/10"), user)
|
|
)
|
|
|
|
assert result is user
|
|
|
|
|
|
def test_v4_case_tree_contains_parent_and_all_child_branches_and_stops_cycles(monkeypatch):
|
|
calls = iter([
|
|
[{"id": 1, "titel": "Aktuel"}, {"id": 2, "titel": "Parent"}, {"id": 3, "titel": "Barn A"}, {"id": 4, "titel": "Barn B"}],
|
|
[
|
|
{"parent_id": 2, "child_id": 1, "relationstype": "undersag"},
|
|
{"parent_id": 1, "child_id": 3, "relationstype": "undersag"},
|
|
{"parent_id": 1, "child_id": 4, "relationstype": "undersag"},
|
|
{"parent_id": 3, "child_id": 1, "relationstype": "undersag"},
|
|
],
|
|
])
|
|
monkeypatch.setattr(sag_router, "execute_query", lambda *_args, **_kwargs: next(calls))
|
|
|
|
tree, truncated = sag_router._load_case_tree(1)
|
|
|
|
assert truncated is False
|
|
assert tree[0]["id"] == 2
|
|
assert tree[0]["children"][0]["id"] == 1
|
|
current = tree[0]["children"][0]
|
|
assert current["is_current"] is True
|
|
assert {child["id"] for child in current["children"]} == {3, 4}
|
|
|
|
|
|
def test_v4_timeline_ui_uses_tree_filters_and_cursor_paging():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
router_source = Path("app/modules/sag/backend/router.py").read_text()
|
|
|
|
assert 'id="caseTreeContent"' in template
|
|
assert 'id="historyRelationFilter"' in template
|
|
assert 'id="historyEventFilter"' in template
|
|
assert 'id="historyTimelineMore"' in template
|
|
assert "whole_tree:'true'" in template
|
|
assert "relation_types" in router_source
|
|
assert "event_types" in router_source
|
|
assert "Invalid timeline cursor" in router_source
|
|
|
|
|
|
def test_v4_relation_tree_is_own_tab_with_search_filters_and_inline_module_content():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert 'data-bs-target="#relations-tree"' in template
|
|
assert 'id="relations-tree"' in template
|
|
assert 'id="history"' in template
|
|
assert template.index('id="relations-tree"') < template.index('id="history"')
|
|
assert 'id="relationTreeSearch"' in template
|
|
assert 'id="relationTreeType"' in template
|
|
assert 'id="relationTreePosition"' in template
|
|
assert 'id="relationTreeStatusFilter"' in template
|
|
assert 'data-relation-module="comments"' in template
|
|
assert 'data-relation-module="todos"' in template
|
|
assert 'data-relation-module="files"' in template
|
|
assert 'data-relation-module="relations"' 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 "{% for status_option in status_options %}" 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" 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_postit_keeps_tags_time_and_full_time_tab_available():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert ':not([data-module="tags"]):not([data-module="time"])' in template
|
|
assert "target !== '#details' && target !== '#timetracking' && target !== '#planner'" in template
|
|
assert "moduleName !== 'tags' && moduleName !== 'time'" in template
|
|
|
|
|
|
def test_v4_timeline_date_menus_escape_hero_stacking_context():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert template.count("data-v4-date-dropdown") >= 2
|
|
assert "case-v4-floating-date-menu" in template
|
|
assert "document.body.appendChild(menu);" in template
|
|
assert "toggle.insertAdjacentElement('afterend', menu);" in template
|
|
|
|
|
|
def test_v4_group_assignment_loader_is_available_to_page_initialization():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert "window.loadCaseAssignments = loadCaseAssignments;" in template
|
|
assert "window.persistCaseAssignees = persistCaseAssignees;" in template
|
|
assert "window.loadCaseAssignments?.().catch" in template
|
|
assert "await persistCaseAssignees([], null, null);" in template
|
|
assert "await saveCaseAssignees([], null, null);" not in template
|
|
|
|
|
|
def test_v4_deferred_modal_is_compact_and_saves_atomically():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert 'class="modal fade deferred-v4-modal" id="deferredModal"' in template
|
|
assert "bootstrap.Modal.getOrCreateInstance(modalElement).show();" in template
|
|
assert "async function persistDeferredSettings" in template
|
|
assert "await persistDeferredSettings(input.value || null, caseSelect.value || null, statuses);" in template
|
|
assert "deferred_until_status: relatedCaseId && normalizedStatuses.length" in template
|
|
assert "updateDeferredUntil(input.value);" not in template
|
|
|
|
|
|
def test_v4_comment_actions_use_compact_icons_and_can_append_to_case():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
router = Path("app/modules/sag/backend/router.py").read_text()
|
|
|
|
assert 'action-time is-icon-only' in template
|
|
assert 'action-close is-icon-only' in template
|
|
assert 'action-wait is-icon-only' in template
|
|
assert '<span>Log tid</span>' not in template
|
|
assert '<span>Luk sag</span>' not in template
|
|
assert '<span>Afventer kunde</span>' not in template
|
|
assert 'id="commentActionAppendCase"' in template
|
|
assert "actions.push('append_case')" in template
|
|
assert "body: JSON.stringify({ beskrivelse: content, append: true })" in template
|
|
assert "await appendCommentToCaseDescription(content);" in template
|
|
assert "append: bool = False" in router
|
|
assert 'append_date = datetime.now().strftime("%d/%m")' in router
|
|
assert 'append_fragment = f"<hr><h6>{initials}/{append_date}</h6><p>{safe_addition}</p>"' in router
|
|
|
|
|
|
def test_v4_comment_composer_prioritizes_writing_space():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert 'min-height: 160px;' in template
|
|
assert 'rows="6"' in template
|
|
assert '.comment-composer textarea:focus' in template
|
|
assert 'align-items: flex-end;' in template
|
|
|
|
|
|
def test_v4_relations_are_compact_and_created_inline():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert 'Relationer til #{{ case.id }}' in template
|
|
assert 'id="relationInlineCreate"' in template
|
|
assert 'onclick="showInlineRelationRow()"' in template
|
|
assert 'id="inlineRelatedCaseType"' in template
|
|
assert 'id="inlineRelatedCaseTitle"' in template
|
|
assert 'id="inlineRelationType"' in template
|
|
assert 'id="inlineRelatedCaseUser"' in template
|
|
assert 'id="inlineRelatedCaseGroup"' in template
|
|
assert 'async function saveInlineRelation()' in template
|
|
assert "const caseResponse = await fetch('/api/v1/sag'" in template
|
|
assert 'målsag_id: newCase.id' in template
|
|
assert 'placeholder="Sag #"' not in template
|
|
assert 'Skriv et gyldigt sagsnummer.' not in template
|
|
assert 'ansvarlig_bruger_id: responsibleUserId' in template
|
|
assert 'assigned_group_id: assignedGroupId' in template
|
|
assert "node.case.ansvarlig_navn or 'Ingen ansvarlig'" in template
|
|
assert "node.case.gruppe_navn or 'Ingen gruppe'" in template
|
|
assert 'class="btn btn-outline-primary btn-rel-action rel-related-add-btn"' in template
|
|
assert 'onclick=\'openRelQaMenu({{ node.case.id }}' in template
|
|
assert '.compact-relations-table .btn-rel-action.rel-related-add-btn' in template
|
|
assert 'href="/sag/{{ node.case.id }}/v4" class="compact-relation-id"' in template
|
|
assert '<tr class="{{ \'d-none\' if node.is_current else \'\' }}">' in template
|
|
|
|
|
|
def test_v4_embeds_case_planner_with_direct_subcase_creation():
|
|
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
|
|
|
assert 'id="planner-tab" data-bs-target="#planner"' in template
|
|
assert 'id="casePlannerAllocationForm"' in template
|
|
assert 'id="casePlannerSubcaseForm"' in template
|
|
assert "async function createEmbeddedCaseAllocation" in template
|
|
assert "async function createPlannerSubcase" in template
|
|
assert "relationstype:'undersag'" in template
|
|
assert "`/api/v1/planner/cases/${caseIds}/allocations`" in template
|
|
assert 'id="casePlannerDeferredNotice"' in template
|
|
assert "Venter på sag #${deferred.case_id}" in template
|
|
assert "subcase.deferred_until_case_id" in template
|
|
assert "window.loadEmbeddedCasePlanner?.();" in template
|
|
assert 'id="casePlannerWeek"' in template
|
|
assert "function renderEmbeddedPlannerCalendar()" in template
|
|
assert "function shiftEmbeddedPlannerWeek(direction)" in template
|
|
assert "Klik på en dag for at allokere tid" in template
|
|
assert 'id="casePlannerSlotModal"' in template
|
|
assert "function 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
|
|
|
|
|
|
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
|