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