bmc_hub/tests/test_migration_center.py
Christian 28a92eeb55 Add tests for case tree structure, timeline UI, and relation tree
- 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.
2026-09-24 09:12:25 +02:00

234 lines
9.0 KiB
Python

import asyncio
from datetime import datetime, timezone
from decimal import Decimal
import jwt
import pytest
from fastapi import HTTPException
from app.modules.migration_center.backend import router
from app.modules.migration_center.backend.service import (
EconomicSnapshotRepository,
_resolve_source_customer,
preflight_token,
snapshot_hash,
verify_preflight,
)
def test_snapshot_hash_is_stable_for_key_order_and_decimal():
first = snapshot_hash({"amount": Decimal("10.00"), "nested": {"b": 2, "a": 1}})
second = snapshot_hash({"nested": {"a": 1, "b": 2}, "amount": Decimal("10.00")})
assert first == second
assert len(first) == 64
def test_normalizes_vtiger_subscription():
item = router._normalized_crm_record(
"vtiger",
{
"id": "72x123",
"account_id": "3x44",
"accountname": "Kunde A/S",
"subject": "Driftsaftale",
"total": "1.250",
"startdate": "2026-01-01",
"subscriptionstatus": "active",
},
)
assert item["entity_type"] == "subscription"
assert item["source_record_id"] == "72x123"
assert item["source_customer_id"] == "3x44"
assert item["product_name"] == "Driftsaftale"
assert item["period_from"].isoformat() == "2026-01-01"
assert len(item["source_hash"]) == 64
@pytest.mark.parametrize(
("source", "expected"),
[("Open", "Ny"), ("In Progress", "Igangværende"), ("Resolved", "Løst"), ("Closed", "Lukket")],
)
def test_vtiger_case_status_maps_to_hub_status(source, expected):
assert router._case_status_from_vtiger(source) == expected
def test_vtiger_case_import_requires_cc_ticket_number():
assert router.VtigerCaseImportRequest(ticket_number="cc12345").ticket_number == "CC12345"
with pytest.raises(ValueError):
router.VtigerCaseImportRequest(ticket_number="12345")
def test_vtiger_case_reference_and_text_fields_are_normalized():
assert router._vtiger_reference_id({"value": "11x123"}) == "11x123"
assert router._vtiger_reference_id("not-an-id") is None
assert router._vtiger_text({"value": "72x222", "label": "Jane Doe"}) == "Jane Doe"
def test_vtiger_case_preview_text_removes_html_without_losing_paragraphs():
source = "<p>Hej &amp; velkommen</p><ul><li>Første</li><li>Anden</li></ul>"
result = router._vtiger_plain_text(source)
assert result.startswith("Hej & velkommen\n")
assert "Første\nAnden" in result
assert "<" not in result
def test_vtiger_timelog_prefers_description_over_generic_title():
row = {"name": "migration", "description": "Konfigurerede firewall", "assigned_user_id": "19x1"}
assert router._timelog_description(row) == "Konfigurerede firewall"
assert router._timelog_description(row, legacy=True) == "migration"
def test_hub_case_export_maps_case_status_and_priority_to_vtiger_picklists():
assert router._hub_status_to_vtiger("Igangværende", {"New", "Open", "In Progress"}) == "In Progress"
assert router._hub_status_to_vtiger("Lukket", {"New", "Closed"}) == "Closed"
assert router._hub_priority_to_vtiger("normal", {"Low", "Medium", "High"}) == "Medium"
assert router._hub_priority_to_vtiger("urgent", {"Urgent", "High"}) == "Urgent"
def test_hub_case_export_time_payload_preserves_text_and_technician_without_billing_side_effects():
payload = router._export_time_payload(
{
"id": 44,
"description": "Konfigurerede firewall",
"worked_date": datetime(2026, 9, 23).date(),
"original_hours": Decimal("1.50"),
"faktisk_tid_min": 90,
"fakturerbar_tid_min": 60,
"billable": True,
"billing_method": "prepaid",
},
173,
"17x100",
"19x1",
)
assert payload["name"] == "Konfigurerede firewall"
assert payload["description"] == "Konfigurerede firewall"
assert payload["assigned_user_id"] == "19x1"
assert payload["duration"] == "5400"
assert payload["isbillable"] == "0"
assert payload["cf_timelog_invoiced"] == "0"
assert payload["timelog_status"] == "Completed"
def test_hub_case_export_matches_vtiger_user_by_email_then_exact_name():
users = [
{"id": "19x1", "first_name": "Christian", "last_name": "Thomas", "user_name": "ct", "email1": "ct@example.com"},
{"id": "19x2", "first_name": "Jane", "last_name": "Vinter", "user_name": "jv", "email1": "jv@example.com"},
]
assert router._match_vtiger_user({"technician_email": "ct@example.com", "technician_name": "Someone"}, users) == "19x1"
assert router._match_vtiger_user({"technician_name": "Jane Vinter"}, users) == "19x2"
assert router._match_vtiger_user({"technician_name": "Unknown"}, users) is None
def test_vtiger_case_bundle_filters_timelogs_to_requested_case(monkeypatch):
class FakeVTiger:
async def query(self, query):
if "FROM Cases" in query:
return [{"id": "17x100", "ticket_no": "CC12345", "parent_id": "11x20", "subject": "VPN"}]
if "FROM Timelog" in query:
return [
{"id": "31x1", "relatedto": "17x100", "duration": "600", "assigned_user_id": "19x1"},
{"id": "31x2", "relatedto": "17x999", "duration": "3600"},
]
if "FROM ModComments" in query:
return [{"id": "17x900", "related_to": "17x100"}, {"id": "17x901", "related_to": "17x999"}]
if "FROM Users" in query:
return [{"id": "19x1", "first_name": "Jane", "last_name": "Vinter", "user_name": "jvinter"}]
if "FROM Groups" in query:
return []
raise AssertionError(query)
monkeypatch.setattr(router, "get_vtiger_service", lambda: FakeVTiger())
monkeypatch.setattr(
router,
"execute_query_single",
lambda sql, params: (
{"id": 4, "name": "Kunde", "vtiger_id": "11x20"}
if "FROM customers" in sql
else None
),
)
bundle = asyncio.run(router._load_vtiger_case_bundle("CC12345"))
assert [row["id"] for row in bundle["timelogs"]] == ["31x1"]
assert [row["id"] for row in bundle["comments"]] == ["17x900"]
assert bundle["timelog_assignee_names"] == {"19x1": "Jane Vinter"}
def test_source_frequency_is_normalized_to_hub_interval():
assert router._hub_interval("monthly_3_last_day") == "monthly"
assert router._hub_interval("Quarterly") == "quarterly"
assert router._hub_interval("Annual") == "yearly"
def test_economic_repository_is_read_only_query(monkeypatch):
captured = {}
def fake_query(sql, params=None):
captured["sql"] = sql
captured["params"] = params
return []
monkeypatch.setattr(
"app.modules.migration_center.backend.service.execute_query",
fake_query,
)
assert EconomicSnapshotRepository.lines(42) == []
normalized = " ".join(captured["sql"].split()).upper()
assert normalized.startswith("SELECT")
assert all(keyword not in normalized for keyword in ("INSERT INTO", "UPDATE ", "DELETE FROM"))
assert captured["params"] == (42,)
assert "DATE_TRUNC('MONTH', CURRENT_DATE) - INTERVAL '12 MONTHS'" in normalized
def test_preflight_token_is_bound_to_item_hash_and_user(monkeypatch):
monkeypatch.setattr(
"app.modules.migration_center.backend.service.settings.JWT_SECRET_KEY",
"test-migration-secret",
)
item = {"id": 9, "source_hash": "a" * 64}
user = {"id": 7}
token = preflight_token(item, user)
verify_preflight(token, item, user)
with pytest.raises(HTTPException) as error:
verify_preflight(token, {**item, "source_hash": "b" * 64}, user)
assert error.value.status_code == 409
def test_economic_line_text_is_bounded_for_staging_columns():
item = __import__(
"app.modules.migration_center.backend.service",
fromlist=["_normalize_economic_line"],
)._normalize_economic_line(
{
"source_type": "booked", "source_invoice_number": "1", "invoice_id": 1,
"line_number": 1, "invoice_line_id": 2, "customer_number": 3,
"customer_name": "K" * 300, "description": "P" * 800,
"product_number": "X" * 150, "line_net_amount": 100, "quantity": 1,
}
)
assert len(item["customer_name"]) == 255
assert len(item["product_name"]) == 500
assert len(item["product_code"]) == 100
def test_customer_lock_blocker_states_are_explicit():
assert {"unlocked", "lock_failed"} == router.MUTABLE_LOCK_STATES
def test_simply_customer_resolution_reuses_existing_staging_mapping(monkeypatch):
monkeypatch.setattr(
"app.modules.migration_center.backend.service.execute_query_single",
lambda sql, params: {
"customer_name": "Korrekt Firma A/S",
"cvr": "12345678",
"hub_customer_id": 44,
},
)
result = _resolve_source_customer(
{"source_system": "simply", "source_customer_id": "11x123"}
)
assert result["hub_customer_id"] == 44
assert result["customer_name"] == "Korrekt Firma A/S"
assert result["rule"] == "Eksisterende Simply-kundemapping"