- 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.
234 lines
9.0 KiB
Python
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 & 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"
|