2026-08-18 20:57:49 +02:00
|
|
|
import asyncio
|
|
|
|
|
import importlib
|
2026-08-30 14:34:43 +02:00
|
|
|
from datetime import datetime, timedelta
|
2026-08-18 20:57:49 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
timetracking_router = importlib.import_module("app.timetracking.backend.router")
|
|
|
|
|
|
|
|
|
|
|
2026-08-30 14:34:43 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-18 20:57:49 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-20 03:33:10 +02:00
|
|
|
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"] == []
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 09:12:25 +02:00
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
2026-08-18 20:57:49 +02:00
|
|
|
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
|
2026-08-30 14:34:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|