- 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.
236 lines
9.1 KiB
Python
236 lines
9.1 KiB
Python
import asyncio
|
|
import importlib
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
timetracking_router = importlib.import_module("app.timetracking.backend.router")
|
|
|
|
|
|
def test_live_timer_rounds_started_minute_up_instead_of_zero():
|
|
started = datetime(2026, 8, 30, 10, 0, 0)
|
|
entry = {"start_tid": started, "pause_total_seconds": 0, "paused_at": None}
|
|
|
|
assert timetracking_router._elapsed_minutes_excluding_pause(entry, started) == 0
|
|
assert timetracking_router._elapsed_minutes_excluding_pause(entry, started + timedelta(seconds=1)) == 1
|
|
assert timetracking_router._elapsed_minutes_excluding_pause(entry, started + timedelta(seconds=60)) == 1
|
|
assert timetracking_router._elapsed_minutes_excluding_pause(entry, started + timedelta(seconds=61)) == 2
|
|
|
|
|
|
def test_manual_time_keeps_local_wall_clock_and_creates_one_entry_per_employee(monkeypatch):
|
|
captured = {}
|
|
|
|
monkeypatch.setattr(timetracking_router, "_resolve_case_customer_id", lambda *_args: 77)
|
|
|
|
def fake_execute_query(query, params):
|
|
captured["query"] = query
|
|
captured["params"] = params
|
|
employee_ids = params[-1]
|
|
return [{"id": index + 1, "medarbejder_id": employee_id} for index, employee_id in enumerate(employee_ids)]
|
|
|
|
monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query)
|
|
|
|
result = asyncio.run(
|
|
timetracking_router.create_manual_time_v1(
|
|
{
|
|
"sag_id": 88,
|
|
"medarbejder_ids": [12, 34],
|
|
"worked_date": "2026-08-18",
|
|
"start_tid": "2026-08-18T10:00:00",
|
|
"slut_tid": "2026-08-18T11:00:00",
|
|
"faktisk_tid_min": 60,
|
|
},
|
|
current_user={"id": 9, "username": "tester"},
|
|
)
|
|
)
|
|
|
|
assert result["count"] == 2
|
|
assert [row["medarbejder_id"] for row in result["created"]] == [12, 34]
|
|
assert captured["params"][-1] == [12, 34]
|
|
assert captured["params"][10] == datetime(2026, 8, 18, 10, 0)
|
|
assert captured["params"][11] == datetime(2026, 8, 18, 11, 0)
|
|
assert "UNNEST(%s::bigint[])" in captured["query"]
|
|
|
|
|
|
def test_manual_time_bulk_resolves_current_user_and_removes_duplicates(monkeypatch):
|
|
captured = {}
|
|
monkeypatch.setattr(timetracking_router, "_resolve_case_customer_id", lambda *_args: 77)
|
|
|
|
def fake_execute_query(_query, params):
|
|
captured["employee_ids"] = params[-1]
|
|
return [{"id": 1, "medarbejder_id": employee_id} for employee_id in params[-1]]
|
|
|
|
monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query)
|
|
|
|
result = asyncio.run(
|
|
timetracking_router.create_manual_time_v1(
|
|
{
|
|
"sag_id": 88,
|
|
"medarbejder_ids": [None, 9, 12, 12],
|
|
"faktisk_tid_min": 30,
|
|
},
|
|
current_user={"id": 9, "username": "tester"},
|
|
)
|
|
)
|
|
|
|
assert captured["employee_ids"] == [9, 12]
|
|
assert result["count"] == 2
|
|
|
|
|
|
def test_manual_internal_time_can_be_created_without_case_customer(monkeypatch):
|
|
captured = {}
|
|
monkeypatch.setattr(timetracking_router, "_resolve_case_customer_id", lambda *_args: None)
|
|
|
|
def fake_execute_query(_query, params):
|
|
captured["params"] = params
|
|
return [{"id": 1, "customer_id": None, "billable": False}]
|
|
|
|
monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query)
|
|
|
|
result = asyncio.run(
|
|
timetracking_router.create_manual_time_v1(
|
|
{
|
|
"sag_id": 177,
|
|
"medarbejder_ids": [None],
|
|
"faktisk_tid_min": 15,
|
|
"entry_status": "kladde",
|
|
},
|
|
current_user={"id": 9, "username": "tester"},
|
|
)
|
|
)
|
|
|
|
assert result["count"] == 1
|
|
assert captured["params"][2] is None
|
|
assert captured["params"][8] is False
|
|
assert captured["params"][9] == "internal"
|
|
assert captured["params"][13] == 0
|
|
|
|
|
|
def test_customerless_time_recommends_internal_settlement():
|
|
result = timetracking_router._settlement_options_for_customer(None)
|
|
|
|
assert result["recommended"]["method"] == "internal"
|
|
assert result["prepaid_cards"] == []
|
|
assert result["agreements"] == []
|
|
|
|
|
|
def test_settlement_choices_use_authoritative_case_customer_for_prepaid_card(monkeypatch):
|
|
card_query = {}
|
|
monkeypatch.setattr(
|
|
timetracking_router,
|
|
"execute_query_single",
|
|
lambda query, params: {"customer_id": 904} if "FROM sag_sager" in query else None,
|
|
)
|
|
|
|
def fake_execute_query(query, params):
|
|
if "FROM tticket_prepaid_cards" in query:
|
|
card_query["customer_id"] = params[0]
|
|
return [{"id": 72, "card_number": "CARD-TEST", "remaining_hours": 4.0}]
|
|
if "FROM customer_fixed_price_agreements" in query:
|
|
return []
|
|
raise AssertionError(query)
|
|
|
|
monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query)
|
|
options = timetracking_router._settlement_options_for_case_entry(
|
|
{"id": 12, "sag_id": 55, "customer_id": 3}
|
|
)
|
|
|
|
assert card_query["customer_id"] == 904
|
|
assert options["prepaid_cards"][0]["id"] == 72
|
|
assert options["recommended"]["method"] == "prepaid"
|
|
|
|
|
|
def test_patch_time_entry_refuses_settled_rows_without_changing_them(monkeypatch):
|
|
monkeypatch.setattr(
|
|
timetracking_router,
|
|
"execute_query_single",
|
|
lambda *_args: {
|
|
"id": 41,
|
|
"status": "billed",
|
|
"description": "Original",
|
|
"prepaid_card_id": None,
|
|
"economy_settled_at": None,
|
|
"economy_order_draft_id": None,
|
|
"billed_via_thehub_id": None,
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
timetracking_router,
|
|
"execute_query",
|
|
lambda *_args: (_ for _ in ()).throw(AssertionError("settled rows must not be updated")),
|
|
)
|
|
|
|
try:
|
|
asyncio.run(timetracking_router.patch_time_entry_v1(41, {"description": "Changed"}))
|
|
except Exception as exc:
|
|
assert getattr(exc, "status_code", None) == 409
|
|
assert "korrektion" in str(getattr(exc, "detail", "")).lower()
|
|
else:
|
|
raise AssertionError("settled time entry unexpectedly accepted an edit")
|
|
|
|
|
|
def test_patch_time_entry_logs_before_after_for_editable_rows(monkeypatch):
|
|
existing = {
|
|
"id": 42,
|
|
"status": "pending",
|
|
"description": "Before",
|
|
"prepaid_card_id": None,
|
|
"economy_settled_at": None,
|
|
"economy_order_draft_id": None,
|
|
"billed_via_thehub_id": None,
|
|
}
|
|
updated = {**existing, "description": "After"}
|
|
monkeypatch.setattr(timetracking_router, "execute_query_single", lambda *_args: existing)
|
|
monkeypatch.setattr(timetracking_router, "execute_query", lambda *_args: [updated])
|
|
captured = {}
|
|
monkeypatch.setattr(timetracking_router.audit, "log_event", lambda **kwargs: captured.update(kwargs) or 5)
|
|
|
|
result = asyncio.run(
|
|
timetracking_router.patch_time_entry_v1(
|
|
42,
|
|
{"description": "After", "reason": "Rettelse"},
|
|
current_user={"id": 9},
|
|
)
|
|
)
|
|
|
|
assert result["description"] == "After"
|
|
assert captured["event_type"] == "time_entry_updated"
|
|
assert captured["user_id"] == 9
|
|
assert captured["details"]["changes"]["description"] == {"before": "Before", "after": "After"}
|
|
assert captured["details"]["reason"] == "Rettelse"
|
|
|
|
|
|
def test_case_time_form_sends_local_time_and_multiple_employees():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
manual_form_script = template.split("async function createManualTimeV1(event)", 1)[1].split(
|
|
"document.addEventListener('DOMContentLoaded'", 1
|
|
)[0]
|
|
|
|
assert 'class="form-check-input mt-0 time-v1-employee-option"' in template
|
|
assert "{% for user in assignment_users %}" in template
|
|
assert "loadTimeV1EmployeeOptions" not in template
|
|
assert "medarbejder_ids: employeeIds" in manual_form_script
|
|
assert "startObj = `${dateVal}T${tStart}:00`;" in manual_form_script
|
|
assert "endObj = `${endDate}T${tEnd}:00`;" in manual_form_script
|
|
assert ".toISOString()" not in manual_form_script
|
|
|
|
quick_time_script = template.split("async function registerQuickTimeFromComment(content)", 1)[1].split(
|
|
"function resolveCommentAwaitCustomerStatus", 1
|
|
)[0]
|
|
assert "startIso = `${dateValue}T${startValue}:00`;" in quick_time_script
|
|
assert "endIso = addMinutesToTimeV1LocalIso(startIso, minutes);" in quick_time_script
|
|
assert ".toISOString()" not in quick_time_script
|
|
|
|
|
|
def test_bottom_bar_timer_changes_refresh_case_timer_panel():
|
|
bottom_bar = Path("static/js/bottom-bar.js").read_text()
|
|
case_template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
|
|
assert "notifyTimerStateChanged('paused')" in bottom_bar
|
|
assert "notifyTimerStateChanged('resumed'" in bottom_bar
|
|
assert "notifyTimerStateChanged('stopped')" in bottom_bar
|
|
assert "window.addEventListener('bb:timer-state-changed'" in case_template
|
|
assert "event.key === 'bmc:timer-state-changed'" in case_template
|
|
assert "timerChip.classList.toggle('is-hidden', !hasActiveTimer && !hasPausedTimer)" in bottom_bar
|
|
assert "timerText.textContent = 'Pauset · ' + name" in bottom_bar
|
|
assert "const visibleTimer = timer.active ? timer : (paused[0] || {})" in bottom_bar
|