- Added customer search functionality in the solution management editor, allowing users to select a customer. - Updated the API endpoint for incoming call logging to use the local recipient extension. - Implemented a new case number navigation feature in the global search. - Modified case creation logic to include relations and adjust module visibility. - Added tests for new features including customer selection in solutions and call logging URL changes.
164 lines
6.6 KiB
Python
164 lines
6.6 KiB
Python
"""V5 assignment persistence is atomic and leaves the existing APIs untouched."""
|
|
import asyncio
|
|
from collections import deque
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from starlette.requests import Request
|
|
|
|
# Reuse the established test module's optional-auth isolation.
|
|
from test_sag_module import sag_router
|
|
|
|
|
|
class Cursor:
|
|
def __init__(self, rows, fail_insert=False):
|
|
self.rows = deque(rows)
|
|
self.calls = []
|
|
self.fail_insert = fail_insert
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
return False
|
|
|
|
def execute(self, sql, params):
|
|
self.calls.append((sql, params))
|
|
if self.fail_insert and 'INSERT INTO sag_kommentarer' in sql:
|
|
raise RuntimeError('database failure')
|
|
|
|
def fetchone(self):
|
|
return self.rows.popleft()
|
|
|
|
|
|
def setup_transaction(monkeypatch, *, target=True, fail_insert=False):
|
|
rows = [
|
|
{'id': 12, 'ansvarlig_bruger_id': 2, 'assigned_group_id': 3},
|
|
{'name': 'Support'},
|
|
{'name': 'Maria'} if target else None,
|
|
{'name': 'Christian'}, {'name': 'Peter'}, {'name': 'Drift'},
|
|
{'id': 91, 'indhold': 'intern note', 'er_intern': True},
|
|
]
|
|
cursor = Cursor(rows, fail_insert)
|
|
conn = Mock()
|
|
conn.cursor.return_value = cursor
|
|
release = Mock()
|
|
monkeypatch.setattr(sag_router, 'get_db_connection', lambda: conn)
|
|
monkeypatch.setattr(sag_router, 'release_db_connection', release)
|
|
monkeypatch.setattr(sag_router, 'table_has_column', lambda *_: True)
|
|
monkeypatch.setattr(sag_router, '_get_user_id_from_request', lambda _: 1)
|
|
return conn, cursor, release
|
|
|
|
|
|
def run_assignment(**kwargs):
|
|
payload = sag_router.CaseAssignmentWithReason(
|
|
group_id=4, primary_user_id=5, reason=kwargs.pop('reason', ' Adgang til serveren '),
|
|
content=kwargs.pop('content', ' Netværk kontrolleret '), **kwargs,
|
|
)
|
|
request = Request({'type': 'http', 'method': 'POST', 'path': '/api/v1/sag/12/assign-with-reason', 'headers': []})
|
|
return asyncio.run(sag_router.assign_case_with_reason(12, payload, request, {"id": 1}))
|
|
|
|
|
|
def test_assignment_and_internal_reason_commit_together(monkeypatch):
|
|
conn, cursor, release = setup_transaction(monkeypatch)
|
|
result = run_assignment()
|
|
assert result['comment']['er_intern'] is True
|
|
conn.commit.assert_called_once()
|
|
conn.rollback.assert_not_called()
|
|
release.assert_called_once_with(conn)
|
|
insert = next(params for sql, params in cursor.calls if 'INSERT INTO sag_kommentarer' in sql)
|
|
assert insert[:2] == (12, 'Christian')
|
|
assert 'Tildeling: Peter → Maria' in insert[2]
|
|
assert 'Gruppe: Drift → Support' in insert[2]
|
|
assert 'Begrundelse: Adgang til serveren' in insert[2]
|
|
assert insert[2].startswith('Netværk kontrolleret\n')
|
|
# Compatible colleagues survive; the new primary is added without duplicates.
|
|
assert any('NOT EXISTS' in sql and 'DELETE FROM sag_case_assignees' in sql for sql, _ in cursor.calls)
|
|
assert any('ON CONFLICT' in sql for sql, _ in cursor.calls)
|
|
|
|
|
|
def test_invalid_group_member_does_not_change_assignment(monkeypatch):
|
|
conn, cursor, release = setup_transaction(monkeypatch, target=False)
|
|
with pytest.raises(HTTPException) as error:
|
|
run_assignment()
|
|
assert error.value.status_code == 400
|
|
assert not any('UPDATE sag_sager' in sql for sql, _ in cursor.calls)
|
|
conn.commit.assert_not_called()
|
|
conn.rollback.assert_called_once()
|
|
release.assert_called_once_with(conn)
|
|
|
|
|
|
def test_comment_failure_rolls_back_assignment(monkeypatch):
|
|
conn, _, release = setup_transaction(monkeypatch, fail_insert=True)
|
|
with pytest.raises(HTTPException) as error:
|
|
run_assignment()
|
|
assert error.value.status_code == 500
|
|
conn.commit.assert_not_called()
|
|
conn.rollback.assert_called_once()
|
|
release.assert_called_once_with(conn)
|
|
|
|
|
|
def test_assignment_reason_is_plain_text_in_legacy_comment_renderer(monkeypatch):
|
|
_, cursor, _ = setup_transaction(monkeypatch)
|
|
run_assignment(reason='<img src=x onerror=alert(1)>', content='<script>bad()</script>')
|
|
note = next(params[2] for sql, params in cursor.calls if 'INSERT INTO sag_kommentarer' in sql)
|
|
assert '<img' not in note and '<script' not in note
|
|
assert '<img' in note and '<script>' in note
|
|
|
|
|
|
@pytest.mark.parametrize('field', ['reason', 'content'])
|
|
def test_whitespace_is_rejected_before_database_access(monkeypatch, field):
|
|
connect = Mock()
|
|
monkeypatch.setattr(sag_router, 'get_db_connection', connect)
|
|
with pytest.raises(HTTPException) as error:
|
|
run_assignment(**{field: ' '})
|
|
assert error.value.status_code == 400
|
|
connect.assert_not_called()
|
|
|
|
|
|
def test_no_fallback_to_external_comment_on_old_schema(monkeypatch):
|
|
connect = Mock()
|
|
monkeypatch.setattr(sag_router, 'get_db_connection', connect)
|
|
monkeypatch.setattr(sag_router, 'table_has_column', lambda *_: False)
|
|
with pytest.raises(HTTPException) as error:
|
|
run_assignment()
|
|
assert error.value.status_code == 409
|
|
connect.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize('version', ['v4', 'v5'])
|
|
def test_case_view_selects_requested_version_without_changing_default(monkeypatch, version):
|
|
from app.modules.sag.frontend import views
|
|
from datetime import datetime
|
|
from starlette.responses import Response
|
|
|
|
case = {'id': 12, 'titel': 'Test', 'status': 'Ny', 'created_at': datetime(2026, 10, 3),
|
|
'customer_id': None, 'hovedkontakt_id': None, 'kontakt_id': None,
|
|
'beskrivelse': '', 'template_key': 'ticket', 'type': 'ticket'}
|
|
|
|
def query(sql, params=None):
|
|
if 'SELECT s.*,' in sql and 'WHERE s.id = %s' in sql:
|
|
return [case]
|
|
return []
|
|
|
|
rendered = []
|
|
monkeypatch.setattr(views, 'execute_query', query)
|
|
monkeypatch.setattr(views, '_fetch_case_status_options', lambda: ['Ny', 'Lukket'])
|
|
monkeypatch.setattr(views, '_fetch_assignment_users', lambda: [])
|
|
monkeypatch.setattr(views, '_fetch_assignment_groups', lambda: [])
|
|
|
|
class Templates:
|
|
def TemplateResponse(self, name, context):
|
|
rendered.append((name, context))
|
|
return Response('test')
|
|
|
|
monkeypatch.setattr(views, 'templates', Templates())
|
|
request = Request({'type': 'http', 'method': 'GET', 'path': f'/sag/12/{version}',
|
|
'scheme': 'http', 'server': ('localhost', 8001), 'headers': [], 'query_string': b''})
|
|
response = asyncio.run(views.sag_detaljer_v4(request, 12))
|
|
assert response.status_code == 200
|
|
assert rendered[0][0] == f'modules/sag/templates/detail_{version}.html'
|
|
assert rendered[0][1]['case']['id'] == 12
|
|
assert response.headers['cache-control'] == 'no-store, max-age=0'
|