bmc_hub/tests/test_project_ct_archive.py
Christian 1cfe5aee76 feat(migrations): add AI benchmark and vTiger archive tables
- Created tables for AI benchmark runs and results to facilitate model evaluation.
- Added expected answers column to benchmark results.
- Introduced tables for internet connection change cases and vTiger archive management, including records, relations, and checkpoints.
- Implemented triggers to enforce append-only behavior for vTiger archive records and files.
- Enhanced solution management with soft delete capabilities for sag_solutions and knowledge_articles.

feat(scripts): add CRM benchmarking script for Ollama models

- Developed a Python script to benchmark CRM models exposed through Ollama, including various test cases and scoring mechanisms.

test(tests): add comprehensive tests for new features

- Implemented tests for internet change case service, vTiger archive functionality, and sag solution knowledge management.
- Ensured coverage for edge cases and error handling in the new features.
2026-08-31 13:01:35 +02:00

258 lines
11 KiB
Python

import asyncio
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.admin import router
from app.admin import vtiger_archive as archive
from app.admin import hub_impact
from app.admin import archive_bundle
from app.services.vtiger_service import VTigerService
def test_hidden_admin_dependency_returns_404_for_non_superadmin():
with pytest.raises(HTTPException) as exc:
router.require_hidden_superadmin({'username': 'employee', 'is_superadmin': False})
assert exc.value.status_code == 404
def test_archive_record_is_append_only_and_deduplicated(monkeypatch):
writes = []
previous = {'revision_no': 1, 'payload_sha256': 'different'}
monkeypatch.setattr(archive, 'execute_query_single', lambda query, params=None: previous if 'payload_sha256' in query else None)
monkeypatch.setattr(archive, 'execute_query', lambda query, params=None, fetch=True: writes.append((query, params)))
inserted = archive.archive_record(3, 'Accounts', {
'id': '3x44', 'accountname': 'Test', 'contact_id': '4x99', 'modifiedtime': '2026-08-30 10:00:00',
})
assert inserted is True
assert any('INSERT INTO vtiger_archive_records' in sql for sql, _ in writes)
assert any('INSERT INTO vtiger_archive_relations' in sql for sql, _ in writes)
def test_unchanged_payload_does_not_create_revision(monkeypatch):
record = {'id': '3x44', 'accountname': 'Test'}
_, digest = archive._canonical_payload(record)
monkeypatch.setattr(archive, 'execute_query_single', lambda *args, **kwargs: {'revision_no': 2, 'payload_sha256': digest})
monkeypatch.setattr(archive, 'execute_query', lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('must not write')))
assert archive.archive_record(4, 'Accounts', record) is False
def test_readiness_requires_successful_approved_final(monkeypatch):
monkeypatch.setattr(archive, 'execute_query_single', lambda *args, **kwargs: {
'id': 9, 'status': 'completed', 'critical_errors': [], 'control_approved_at': '2026-08-30',
})
assert archive.termination_readiness()['ready'] is True
monkeypatch.setattr(archive, 'execute_query_single', lambda *args, **kwargs: None)
result = archive.termination_readiness()
assert result['ready'] is False
assert len(result['reasons']) == 2
def test_failed_vtiger_query_is_not_treated_as_empty_module(monkeypatch):
class Service:
last_query_error = {'message': 'rate limited'}
last_query_status = 429
async def query(self, query):
return []
monkeypatch.setattr(archive, 'get_vtiger_service', lambda: Service())
async def no_sleep(_seconds):
return None
monkeypatch.setattr(archive.asyncio, 'sleep', no_sleep)
with pytest.raises(RuntimeError):
asyncio.run(archive._fetch_module('Accounts'))
def test_failed_vtiger_count_cannot_pass_full_archive_control(monkeypatch):
class Service:
last_query_error = {'message': 'timeout'}
last_query_status = None
async def query(self, query):
return []
async def no_sleep(_seconds):
return None
monkeypatch.setattr(archive, 'get_vtiger_service', lambda: Service())
monkeypatch.setattr(archive.asyncio, 'sleep', no_sleep)
with pytest.raises(RuntimeError, match='kontrollere antal poster'):
asyncio.run(archive._source_module_count('Accounts'))
def test_vtiger_file_retrieve_accepts_list_result(monkeypatch):
import base64
class Response:
status = 200
async def __aenter__(self): return self
async def __aexit__(self, *args): return None
async def json(self, content_type=None):
return {'success': True, 'result': [{
'fileid': '7x2', 'filename': 'test.txt', 'filecontents': base64.b64encode(b'hello').decode(),
}]}
class Session:
async def __aenter__(self): return self
async def __aexit__(self, *args): return None
def get(self, *args, **kwargs): return Response()
monkeypatch.setattr('app.services.vtiger_service.aiohttp.ClientSession', Session)
service = VTigerService()
service.rest_endpoint = 'https://example.invalid'
service.api_key = 'key'
service.username = 'user'
result = asyncio.run(service.retrieve_file('7x2'))
assert result['content'] == b'hello'
def test_vtiger_archive_uses_offset_pagination(monkeypatch):
queries = []
class Service:
last_query_error = None
last_query_status = 200
async def query(self, query):
queries.append(query)
if 'LIMIT 0, 100' in query:
return [{'id': f'3x{i}'} for i in range(100)]
if 'LIMIT 100, 100' in query:
return [{'id': '3x101'}, {'id': '3x102'}]
return []
async def no_sleep(_seconds):
return None
monkeypatch.setattr(archive, 'get_vtiger_service', lambda: Service())
monkeypatch.setattr(archive.asyncio, 'sleep', no_sleep)
rows = asyncio.run(archive._fetch_module('Accounts'))
assert len(rows) == 102
assert 'LIMIT 0, 100' in queries[0]
assert 'LIMIT 100, 100' in queries[1]
assert 'LIMIT 102, 100' in queries[2]
def test_project_ct_migration_enforces_immutable_revisions():
sql = Path('migrations/1030_project_ct_vtiger_archive.sql').read_text()
assert 'vtiger_archive_versions' in sql
assert 'vtiger_archive_records' in sql
assert 'vtiger_archive_relations' in sql
assert 'vtiger_archive_checkpoints' in sql
assert 'hub_impact_reports' in sql
assert 'BEFORE UPDATE OR DELETE ON vtiger_archive_records' in sql
file_sql = Path('migrations/1032_project_ct_archive_files.sql').read_text()
assert 'vtiger_archive_files' in file_sql
assert 'BEFORE UPDATE OR DELETE ON vtiger_archive_files' in file_sql
assert 'source_module' in Path('migrations/1033_project_ct_archive_file_sources.sql').read_text()
def test_impact_periods_must_be_equal(monkeypatch):
from datetime import date
with pytest.raises(ValueError, match='lige lange'):
hub_impact.build_impact_report(1, date(2025, 1, 1), date(2025, 1, 10),
date(2026, 1, 1), date(2026, 1, 9))
def test_vtiger_employee_matching_and_long_entry_exclusion():
from datetime import date
records = [
{'module': 'Users', 'vtiger_id': '19x1', 'is_deleted': False,
'payload': {'id': '19x1', 'email1': 'TECH@EXAMPLE.COM', 'first_name': 'Old', 'last_name': 'Name'}},
{'module': 'Timelog', 'vtiger_id': '36x1', 'is_deleted': False,
'payload': {'createdtime': '2025-01-03 10:00:00', 'assigned_user_id': '19x1', 'time_spent': '02:30'}},
{'module': 'Timelog', 'vtiger_id': '36x2', 'is_deleted': False,
'payload': {'createdtime': '2025-01-04 10:00:00', 'assigned_user_id': '19x1', 'hours': '17'}},
{'module': 'Cases', 'vtiger_id': '17x3', 'is_deleted': False,
'payload': {'createdtime': '2025-01-05 10:00:00', 'assigned_user_id': '19x1'}},
]
result = hub_impact._finalize(
hub_impact._vtiger_metrics(records, date(2025, 1, 1), date(2025, 1, 10), {
'tech@example.com': {'user_id': 7, 'email': 'tech@example.com', 'full_name': 'Hub Technician'}
}), date(2025, 1, 1), date(2025, 1, 10),
)
assert result['totals']['hours'] == 2.5
assert result['totals']['time_entries'] == 1
assert result['totals']['cases'] == 1
assert result['employees'][0]['key'] == 'hub:7'
assert result['anomalies'][0]['type'] == 'over_16_hours'
def test_hub_active_timer_is_anomaly_not_kpi(monkeypatch):
from datetime import date
def fake_query(sql, params=None):
if 'FROM tmodule_times' in sql:
return [{'id': 1, 'medarbejder_id': 4, 'aktiv_timer': True, 'hours': 8},
{'id': 2, 'medarbejder_id': 4, 'aktiv_timer': False, 'hours': 2}]
return []
monkeypatch.setattr(hub_impact, 'execute_query', fake_query)
metrics = hub_impact._finalize(hub_impact._hub_metrics(
date(2026, 1, 1), date(2026, 1, 2),
{4: {'user_id': 4, 'email': 'a@example.com', 'full_name': 'A'}},
), date(2026, 1, 1), date(2026, 1, 2))
assert metrics['totals']['hours'] == 2
assert metrics['totals']['time_entries'] == 1
assert metrics['anomalies'][0]['type'] == 'active_timer'
def test_excel_export_contains_required_sheets():
from app.admin.router import _impact_workbook
load_workbook = pytest.importorskip('openpyxl').load_workbook
import io
result = {
'archive_version': {'id': 2},
'periods': {'vtiger': {'from': '2025-01-01', 'to': '2025-01-31'}, 'hub': {'from': '2026-01-01', 'to': '2026-01-31'}},
'comparison': {'hours': 1, 'time_entries': 2, 'cases': 3, 'orders': 4},
'vtiger': {'totals': {'hours': 1, 'time_entries': 1, 'cases': 1, 'orders': 1}, 'employees': [], 'anomalies': [], 'data_quality': {'missing_dates': 0, 'unmatched_employees': []}},
'hub': {'totals': {'hours': 2, 'time_entries': 3, 'cases': 4, 'orders': 5}, 'employees': [], 'anomalies': [], 'data_quality': {'missing_dates': 0, 'unmatched_employees': []}},
}
workbook = load_workbook(io.BytesIO(_impact_workbook(result)))
assert workbook.sheetnames == ['Overblik', 'Effekt', 'Pr medarbejder', 'Afvigelser', 'Datakvalitet']
def test_secret_page_and_profile_link_are_registered():
assert '@router.get("/admin/hub-impact"' in Path('app/admin/views.py').read_text()
base = Path('app/shared/frontend/base.html').read_text()
assert 'projectCtProfileLink' in base
assert "user.is_superadmin === true" in base
def test_bundle_checksum_rejects_changed_content(tmp_path):
import hashlib
import json
import zipfile
bundle = tmp_path / 'archive.zip'
with zipfile.ZipFile(bundle, 'w') as archive_file:
archive_file.writestr('records.jsonl', b'{"id":1}\n')
archive_file.writestr('manifest.json', json.dumps({
'schema_version': 1,
'entries': {'records.jsonl': {'sha256': hashlib.sha256(b'other').hexdigest()}},
}))
with zipfile.ZipFile(bundle) as archive_file:
manifest = json.loads(archive_file.read('manifest.json'))
with pytest.raises(ValueError, match='Checksumfejl'):
archive_bundle._verify_entry(archive_file, manifest, 'records.jsonl')
def test_bundle_transfer_contains_original_files_and_never_uses_vtiger():
source = Path('app/admin/archive_bundle.py').read_text()
assert 'document_policy' in source
assert 'metadata_and_original_bytes' in source
assert 'files.jsonl' in source
assert 'contacted_vtiger": False' in source
assert 'vtiger_service' not in source
router_source = Path('app/admin/router.py').read_text()
assert '/admin/vtiger-archive/bundle.zip' in router_source
assert '/admin/vtiger-archive/import-bundle' in router_source
def test_productivity_exposes_clear_daily_efficiency_metrics():
from datetime import date
metrics = {'employees': {
'hub:1': {'key': 'hub:1', 'name': 'A', 'email': 'a@example.com', 'hours': hub_impact.Decimal('8'),
'time_entries': 4, 'cases': 2, 'orders': 1}},
'anomalies': [], 'data_quality': {'unmatched_employees': [], 'missing_dates': 0}}
result = hub_impact._finalize(metrics, date(2026, 8, 31), date(2026, 8, 31))
assert result['totals']['productivity']['deliveries_per_workday'] == 7
assert result['totals']['productivity']['hours_per_case_or_order'] == 2.67