2026-09-24 09:12:25 +02:00
|
|
|
import asyncio
|
2026-07-28 14:18:24 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 09:12:25 +02:00
|
|
|
@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 & 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"}
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
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"
|