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 = "

Hej & velkommen

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