bmc_hub/tests/test_sag_v5.py

164 lines
6.6 KiB
Python
Raw Normal View History

"""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 '&lt;img' in note and '&lt;script&gt;' 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'