bmc_hub/tests/test_unified_planning.py

718 lines
44 KiB
Python
Raw Permalink Normal View History

from datetime import date, datetime, time, timedelta
from pathlib import Path
from uuid import uuid4
import os
import pytest
from fastapi import HTTPException
from app.modules.planner.backend import work, calendar, capacity
def test_readiness_respects_waiting_pause_future_start_and_dependencies():
now=datetime(2026,10,17,10,tzinfo=work.TZ)
assert work.ready({'status':'åben','work_status':'ready'},now)
for extra in ({'status':'afventer'},{'work_status':'paused'},
{'start_date':date(2026,10,18)}, {'snoozed_until':now+timedelta(hours=1)},
{'deferred_until':datetime(2026,10,17,11)},
{'deferred_until_case_id':12,'deferred_until_status':'afsluttet','deferred_case_status':'åben'}):
assert not work.ready({'status':'åben','work_status':'ready',**extra},now)
assert work.ready({'deferred_until_case_id':12,'deferred_until_status':'afsluttet, lukket','deferred_case_status':'lukket'},now)
def test_remaining_estimate_does_not_duplicate_unallocated_case_estimate():
assert work.remaining({'case_estimated_minutes':720,'actual_minutes':60}) is None
assert work.remaining({'estimated_minutes':120,'actual_minutes':90})==30
assert work.remaining({'estimated_minutes':120,'actual_minutes':180})==0
assert work.remaining({'remaining_minutes':45,'estimated_minutes':120,'actual_minutes':180})==45
def test_capacity_unions_busy_intervals_and_ignores_nights():
start=datetime(2026,10,19,tzinfo=work.TZ)
schedules=[{'weekday':0,'start_time':time(8),'end_time':time(16),'break_minutes':0}]
occupied=[(start.replace(hour=9),start.replace(hour=12)),(start.replace(hour=10),start.replace(hour=13)),
(start.replace(hour=20),start.replace(hour=22))]
assert capacity.working_capacity(schedules,occupied,start,start+timedelta(days=1))=={
'normal_minutes':480,'busy_minutes':240,'free_minutes':240}
def test_absence_only_registers_scheduled_days_and_partial_hours():
schedules=[{'weekday':d,'start_time':time(8),'end_time':time(16),'break_minutes':30} for d in range(5)]
period={'starts_at':datetime(2026,10,16,12,tzinfo=work.TZ),'ends_at':datetime(2026,10,19,12,tzinfo=work.TZ)}
days=calendar.absence_work_days(period,schedules)
assert [r[0] for r in days]==[date(2026,10,16),date(2026,10,19)]
assert [r[3] for r in days]==[240,240]
assert calendar.absence_work_minutes({'starts_at':datetime(2026,10,19,8,tzinfo=work.TZ),
'ends_at':datetime(2026,10,19,16,tzinfo=work.TZ)},schedules)==450
def test_calendar_rejects_naive_or_reversed_dates():
with pytest.raises(ValueError):
calendar.EventInput(title='Test',user_ids=[1],starts_at=datetime(2026,10,17),ends_at=datetime(2026,10,18))
with pytest.raises(ValueError):
calendar.AbsenceInput(user_id=1,kind='vacation',starts_at=datetime(2026,10,18,tzinfo=work.TZ),ends_at=datetime(2026,10,17,tzinfo=work.TZ))
@pytest.fixture
def planning_db():
if os.environ.get('BMC_PLANNING_DB_TESTS')!='1':
pytest.skip('Opt in to a dedicated local planning test database')
import psycopg2
from psycopg2.extensions import parse_dsn, make_dsn
from psycopg2.pool import ThreadedConnectionPool
from app.core import database
from app.core.config import settings
target=os.environ.get('BMC_PLANNING_TEST_DATABASE','')
if not target.startswith('bmc_planning_test_'):
pytest.fail('A dedicated bmc_planning_test_* database is required')
values=parse_dsn(settings.DATABASE_URL)
values.update(host='127.0.0.1',port='5433',dbname=target)
original=database.connection_pool
pool=ThreadedConnectionPool(1,6,dsn=make_dsn(**values))
database.connection_pool=pool
try:
from app.core.database import execute_query, execute_query_single, atomic_transaction
with atomic_transaction():
# Only the dedicated disposable database may have test schema adjusted.
execute_query('DROP INDEX IF EXISTS planner_actual_absence',fetch=False)
execute_query(Path('migrations/1070_unified_planning.sql').read_text(),fetch=False)
execute_query(Path('migrations/1071_case_confidentiality.sql').read_text(),fetch=False)
execute_query(Path('migrations/1072_absence_reports.sql').read_text(),fetch=False)
suffix=uuid4().hex[:10]
users=[]
for i in range(3):
users.append(execute_query_single("INSERT INTO users(username,email,password_hash,is_active) VALUES(%s,%s,'disabled-test-account',true) RETURNING user_id",(f'plan_{suffix}_{i}',f'plan_{suffix}_{i}@example.invalid'))['user_id'])
actor={'id':users[0],'is_superadmin':True,'permissions':[]}
cases=[]
for i in range(3):
cases.append(execute_query_single("INSERT INTO sag_sager(titel,created_by_user_id,status,type) VALUES(%s,%s,'åben','ticket') RETURNING id",(f'Planning test {suffix} {i}',users[0]))['id'])
yield actor,users,cases
finally:
database.connection_pool=original
pool.closeall()
def test_db_independent_queues_stale_versions_and_transaction_rollback(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query_single
for uid in users:
for cid in cases:
work.add_assignee(cid,work.AssignmentInput(user_id=uid),actor)
a=work.get_queue(users[0],100,0,actor)
b=work.get_queue(users[1],100,0,actor)
work.change_queue(users[0],cases[2],work.QueueChange(version=a['version'],before_sag_id=cases[0]),actor)
assert [r['sag_id'] for r in work.get_queue(users[0],100,0,actor)['items']]==[cases[2],cases[0],cases[1]]
assert [r['sag_id'] for r in work.get_queue(users[1],100,0,actor)['items']]==[r['sag_id'] for r in b['items']]
with pytest.raises(HTTPException) as error:
work.change_queue(users[0],cases[0],work.QueueChange(version=a['version'],pinned=True),actor)
assert error.value.status_code==409
assert execute_query_single('SELECT pinned FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(cases[0],users[0]))['pinned'] is False
def test_db_shared_event_preserves_participant_ids_and_registers_absence_once(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
start=datetime(2026,10,19,10,tzinfo=work.TZ)
payload=calendar.EventInput(title='Shared',sag_id=cases[0],user_ids=users,starts_at=start,ends_at=start+timedelta(hours=1))
event=calendar.create_event(payload,actor)['event']
before=execute_query('SELECT id FROM planner_allocations WHERE event_id=%s ORDER BY id',(event['id'],))
moved=payload.model_copy(update={'version':event['version'],'starts_at':start+timedelta(hours=2),'ends_at':start+timedelta(hours=3)})
event=calendar.update_event(event['id'],moved,actor)['event']
visible=calendar.schedule(start,start+timedelta(days=1),[users[0]],actor)
assert visible['bookings'][0]['participants']==sorted(users)
after=execute_query('SELECT id,starts_at FROM planner_allocations WHERE event_id=%s ORDER BY id',(event['id'],))
assert [r['id'] for r in before]==[r['id'] for r in after]
assert all(r['starts_at']==moved.starts_at for r in after)
assert len(work.get_queue(users[1],100,0,actor)['items'])==1
calendar.cancel_event(event['id'],event['version'],actor)
assert not execute_query('SELECT id FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL',(event['id'],))
for weekday in (0,1):
execute_query('INSERT INTO planner_work_schedules(user_id,weekday,break_minutes) VALUES(%s,%s,0)',(users[0],weekday),fetch=False)
absence=calendar.create_absence(calendar.AbsenceInput(user_id=users[0],kind='vacation',starts_at=datetime(2026,10,12,8,tzinfo=work.TZ),ends_at=datetime(2026,10,13,16,tzinfo=work.TZ)),actor)['absence']
one=calendar.register_absence(absence['id'],absence['version'],actor)
two=calendar.register_absence(absence['id'],absence['version'],actor)
assert one['time_entry_ids']==two['time_entry_ids']
assert len(one['time_entry_ids'])==2
assert two['already_registered']
rows=execute_query('SELECT billable,fakturerbar_tid_min FROM tmodule_times WHERE planner_absence_id=%s',(absence['id'],))
assert all(not r['billable'] and r['fakturerbar_tid_min']==0 for r in rows)
def test_db_confidentiality_hides_queue_calendar_api_and_allows_group(planning_db):
import asyncio
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core import case_visibility as policy
from app.modules.sag.backend import visibility
from app.core.database import execute_query,execute_query_single
actor,users,cases=planning_db
reader={'id':users[1],'permissions':['planner.view','planner.edit_own','cases.view']}
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[1]),actor)
start=datetime(2026,11,2,10,tzinfo=work.TZ)
calendar.create_event(calendar.EventInput(title='Confidential title',description='Private note',sag_id=cases[0],user_ids=[users[1]],starts_at=start,ends_at=start+timedelta(hours=1)),actor)
locked=visibility.update_visibility(cases[0],visibility.VisibilityInput(restricted=True,version=0,user_ids=[users[0]]),actor)
assert cases[0] in policy.hidden_case_ids(reader)
assert not work.get_queue(users[1],100,0,reader)['items']
busy=calendar.schedule(start,start+timedelta(days=1),[users[1]],reader)['bookings'][0]
assert busy['title']=='Optaget' and busy['private']
assert not {'sag_id','description','event_id'} & busy.keys()
with pytest.raises(HTTPException) as error:
visibility.get_visibility(cases[0],reader)
assert error.value.status_code==404
app=FastAPI()
@app.middleware('http')
async def privacy(request,call_next):
return await policy.protect_request(request,call_next,reader)
@app.get('/api/v1/search')
def search():
return {'items':[{'id':cases[0],'title':'Confidential title','url':f'/sag/{cases[0]}'}]}
@app.get('/sag/{sag_id}')
def details(sag_id:int):
return {'titel':'Confidential title'}
calls=[]
@app.post('/api/v1/test-write')
def write():
calls.append(True)
return {'ok':True}
with TestClient(app) as client:
assert client.get('/api/v1/search').json()=={'items':[]}
assert client.get(f'/sag/{cases[0]}').status_code==404
assert client.post('/api/v1/test-write',json={'sag_id':cases[0]}).status_code==404
assert not calls
group=execute_query_single('INSERT INTO groups(name) VALUES(%s) RETURNING id',(f'privacy_{uuid4().hex}',))['id']
execute_query('INSERT INTO user_groups(user_id,group_id) VALUES(%s,%s)',(users[1],group),fetch=False)
updated=visibility.update_visibility(cases[0],visibility.VisibilityInput(restricted=True,version=locked['version'],group_ids=[group]),actor)
assert cases[0] not in policy.hidden_case_ids(reader)
assert work.get_queue(users[1],100,0,reader)['items'][0]['sag_id']==cases[0]
# Unlocked/relocked cases keep a monotonic version (no stale version reuse).
unlocked=visibility.update_visibility(cases[0],visibility.VisibilityInput(restricted=False,version=updated['version']),actor)
assert unlocked['version']>updated['version']
def test_private_calendar_and_nested_notifications_are_scrubbed():
from app.core.case_visibility import filter_data
data={'sections':{'urgent':{'list':[{'id':174,'title':'Secret'}]}},
'notifications':[{'sag_id':174,'case_title':'Secret','message':'Private'}],
'bookings':[{'sag_id':174,'id':9,'case_title':'Secret','customer_name':'Secret customer',
'starts_at':'2026-10-17T10:00:00Z','ends_at':'2026-10-17T11:00:00Z'}]}
result=filter_data(data,{174})
assert result['sections']['urgent']['list']==[]
assert result['notifications']==[]
assert result['bookings'][0]['title']=='Optaget'
assert 'Secret' not in str(result)
def test_db_case_start_clock_and_stale_case_update(planning_db):
actor,users,cases=planning_db
row=work.case_planning(cases[0],actor)
result=work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=row['updated_at'],
start_date=date(2026,10,20),start_time=time(13),estimated_minutes=720),actor)
assert result['start_date']==date(2026,10,20) and result['start_time']==time(13)
with pytest.raises(HTTPException) as error:
work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=row['updated_at'],estimated_minutes=30),actor)
assert error.value.status_code==409
def test_db_atomic_individual_distribution_rolls_back_on_stale_queue(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query_single
for uid in users[:2]:
work.add_assignee(cases[0],work.AssignmentInput(user_id=uid,estimated_minutes=120),actor)
plan=work.case_planning(cases[0],actor)
versions={r['user_id']:r['queue_version'] for r in plan['assignees']}
work.change_queue(users[1],cases[0],work.QueueChange(version=versions[users[1]],pinned=True),actor)
payload=work.CasePlanningInput(updated_at=plan['updated_at'],estimated_minutes=600,
assignee_estimates=[work.AssigneeEstimateInput(user_id=uid,queue_version=versions[uid],estimated_minutes=200) for uid in users[:2]],
add_assignee=work.AssignmentInput(user_id=users[2],estimated_minutes=200))
with pytest.raises(HTTPException) as error:
work.update_case_planning(cases[0],payload,actor)
assert error.value.status_code==409
after=work.case_planning(cases[0],actor)
assert after['estimated_minutes']==plan['estimated_minutes']
assert len(after['assignees'])==2
assert all(r['estimated_minutes']==120 for r in after['assignees'])
for item in payload.assignee_estimates:
item.queue_version=work.get_queue(item.user_id,100,0,actor)['version']
work.update_case_planning(cases[0],payload,actor)
assert len(work.case_planning(cases[0],actor)['assignees'])==3
assert execute_query_single('SELECT COUNT(*) AS n FROM planner_allocation_history WHERE sag_id=%s AND action=\'case_plan_update\'',(cases[0],))['n']==1
def test_db_concurrent_queue_and_booking_updates_have_one_winner(planning_db):
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
actor,users,cases=planning_db
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[0]),actor)
version=work.get_queue(users[0],100,0,actor)['version']
barrier=Barrier(2)
def queue_change(value):
barrier.wait(timeout=5)
try:
work.change_queue(users[0],cases[0],work.QueueChange(version=version,estimated_minutes=value),actor)
return 200
except HTTPException as exc:
return exc.status_code
with ThreadPoolExecutor(2) as executor:
assert sorted(executor.map(queue_change,[60,120]))==[200,409]
start=datetime(2026,11,9,10,tzinfo=work.TZ)
payload=calendar.EventInput(title='Concurrent',user_ids=[users[0]],starts_at=start,ends_at=start+timedelta(hours=1))
event=calendar.create_event(payload,actor)['event']
barrier=Barrier(2)
def event_change(offset):
barrier.wait(timeout=5)
try:
calendar.update_event(event['id'],payload.model_copy(update={'version':event['version'],
'starts_at':start+timedelta(hours=offset),'ends_at':start+timedelta(hours=offset+1)}),actor)
return 200
except HTTPException as exc:
return exc.status_code
with ThreadPoolExecutor(2) as executor:
assert sorted(executor.map(event_change,[2,4]))==[200,409]
def test_db_deadline_scheduler_deduplicates_and_uses_existing_reminders(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
from app.modules.planner.backend.checks import run_planning_checks
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[0]),actor)
execute_query("UPDATE sag_sager SET deadline=(NOW() AT TIME ZONE 'UTC')+INTERVAL '2 hours' WHERE id=%s",(cases[0],),fetch=False)
run_planning_checks()
run_planning_checks()
result=execute_query_single("SELECT COUNT(*) AS n,BOOL_AND(notify_frontend AND NOT notify_email AND NOT notify_mattermost) AS local_only FROM sag_reminders WHERE sag_id=%s AND event_type='deadline'",(cases[0],))
assert result['n']==1 and result['local_only']
def test_db_transfer_failure_restores_source_and_responsible(planning_db,monkeypatch):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
execute_query('UPDATE sag_sager SET ansvarlig_bruger_id=%s WHERE id=%s',(users[0],cases[0]),fetch=False)
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[0],estimated_minutes=180),actor)
versions=[work.get_queue(uid,100,0,actor)['version'] for uid in users[:2]]
original=work.audit
def fail(*args,**kwargs):
raise RuntimeError('Synthetic audit failure')
monkeypatch.setattr(work,'audit',fail)
payload=work.TransferInput(source_user_id=users[0],target_user_id=users[1],source_version=versions[0],target_version=versions[1])
with pytest.raises(RuntimeError):
work.transfer(cases[0],payload,actor)
assert [r['user_id'] for r in execute_query('SELECT user_id FROM sag_case_assignees WHERE sag_id=%s',(cases[0],))]==[users[0]]
assert [work.get_queue(uid,100,0,actor)['version'] for uid in users[:2]]==versions
monkeypatch.setattr(work,'audit',original)
work.transfer(cases[0],payload,actor)
assert execute_query_single('SELECT ansvarlig_bruger_id FROM sag_sager WHERE id=%s',(cases[0],))['ansvarlig_bruger_id']==users[0]
assert [r['user_id'] for r in execute_query('SELECT user_id FROM sag_case_assignees WHERE sag_id=%s',(cases[0],))]==[users[1]]
def test_db_absence_reasons_are_masked_but_period_editing_is_allowed(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query
from app.core.case_visibility import scrub_absence_time
reader={'id':users[1],'permissions':['planner.view','planner.view_team','planner.edit_team','planner.absence_team','cases.view']}
execute_query('INSERT INTO planner_team_scope(editor_user_id,target_user_id) VALUES(%s,%s)',(users[1],users[0]),fetch=False)
start=datetime(2026,11,16,8,tzinfo=work.TZ)
item=calendar.create_absence(calendar.AbsenceInput(user_id=users[0],kind='sickness',note='Private medical note',starts_at=start,ends_at=start+timedelta(hours=8)),actor)['absence']
view=calendar.schedule(start,start+timedelta(days=1),[users[0]],reader)['absences'][0]
assert view['kind']=='unavailable' and not view['note']
result=calendar.update_absence(item['id'],calendar.AbsenceInput(user_id=users[0],version=item['version'],starts_at=start+timedelta(hours=1),ends_at=start+timedelta(hours=8)),reader)
assert result['absence']['kind']=='unavailable'
redacted=scrub_absence_time({'planner_absence_id':item['id'],'planner_activity_code':'sickness','medarbejder_id':users[0],'description':'Sygdom'},users[1],False)
assert redacted['description']=='Fravær' and redacted['planner_activity_code'] is None
def test_work_windows_respect_danish_clock_changes():
from datetime import timezone
schedules=[{'user_id':1,'weekday':6,'start_time':time(1),'end_time':time(4),'break_minutes':0}]
# On the winter transition 01:00-04:00 spans four real hours.
start=datetime(2026,10,25,1,tzinfo=work.TZ)
end=datetime(2026,10,25,4,tzinfo=work.TZ)
assert (end.astimezone(timezone.utc)-start.astimezone(timezone.utc)).total_seconds()==14400
assert not calendar.outside_work_hours(1,start,end,schedules)
assert capacity.working_capacity(schedules,[],start,end)['normal_minutes']==240
assert calendar.absence_work_minutes({'starts_at':start,'ends_at':end},schedules)==240
def test_db_parent_summary_deduplicates_shared_descendants(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query
for parent,child in [(cases[0],cases[1]),(cases[0],cases[2]),(cases[1],cases[2])]:
execute_query("INSERT INTO sag_relationer(kilde_sag_id,målsag_id,relationstype) VALUES(%s,%s,'undersag')",(parent,child),fetch=False)
for cid in cases:
execute_query('UPDATE sag_sager SET estimated_minutes=120 WHERE id=%s',(cid,),fetch=False)
execute_query("INSERT INTO tmodule_times(sag_id,medarbejder_id,description,original_hours,faktisk_tid_min,status) VALUES(%s,%s,'Synthetic time',1,60,'pending')",(cid,users[0]),fetch=False)
total=capacity.case_summary(cases[0],True,actor,'all')
children=capacity.case_summary(cases[0],True,actor,'children')
own=capacity.case_summary(cases[0],False,actor,'own')
assert total['case_count']==3 and total['actual_minutes']==180 and total['remaining_minutes']==180
assert children['case_count']==2 and children['actual_minutes']==120
assert own['case_count']==1 and own['actual_minutes']==60
def test_db_next_task_warns_and_does_not_create_time(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query_single
for cid,estimate in zip(cases,[180,5,None]):
work.add_assignee(cid,work.AssignmentInput(user_id=users[0],estimated_minutes=estimate),actor)
start=datetime.now(work.TZ)+timedelta(minutes=20)
count=execute_query_single('SELECT COUNT(*) AS n FROM tmodule_times')['n']
calendar.create_event(calendar.EventInput(title='Fixed meeting',user_ids=[users[0]],starts_at=start,ends_at=start+timedelta(hours=1)),actor)
result=work.next_task(actor)
assert result['task']['sag_id']==cases[0] and result['warning']
assert result['shorter_task']['sag_id']==cases[1]
assert execute_query_single('SELECT COUNT(*) AS n FROM tmodule_times')['n']==count
def test_db_queue_paginates_and_searches_two_thousand_cases(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
from time import monotonic
with work.atomic_transaction():
execute_query('''WITH new_cases AS (
INSERT INTO sag_sager(titel,created_by_user_id,status,type)
SELECT 'Large queue '||i,%s,'åben','ticket' FROM generate_series(1,2000) i RETURNING id)
INSERT INTO sag_case_assignees(sag_id,user_id) SELECT id,%s FROM new_cases''',(users[0],users[0]),fetch=False)
started=monotonic()
first=work.get_queue(users[0],100,0,actor)
second=work.get_queue(users[0],100,100,actor)
found=work.get_queue(users[0],100,0,actor,'Large queue 2000')
assert len(first['items'])==len(second['items'])==100
assert not {r['sag_id'] for r in first['items']} & {r['sag_id'] for r in second['items']}
assert len(found['items'])==1 and found['items'][0]['titel']=='Large queue 2000'
assert monotonic()-started<10 # A generous bound catches accidental per-row round trips.
def test_db_existing_calendar_feed_reads_shared_event_once(planning_db):
from types import SimpleNamespace
from app.modules.calendar.backend.router import _planning_feed_events,_build_ical_response
actor,users,cases=planning_db
from app.core.database import execute_query
execute_query('UPDATE users SET is_superadmin=TRUE WHERE user_id=%s',(users[0],),fetch=False)
start=datetime(2026,11,23,10,tzinfo=work.TZ)
calendar.create_event(calendar.EventInput(title='Shared feed event',sag_id=cases[0],user_ids=users,
starts_at=start,ends_at=start+timedelta(hours=1)),actor)
request=SimpleNamespace(state=SimpleNamespace(user_id=users[0]))
events=_planning_feed_events(request,start,start+timedelta(days=1),users[0],False,None,{'planner_booking'})
selected=[e for e in events if e.get('sag_id')==cases[0]]
assert len(selected)==1
assert _build_ical_response(selected,start).body.count(b'BEGIN:VEVENT')==1
def test_db_unassigned_search_paging_and_assignment(planning_db):
from app.core.database import execute_query
from app.modules.sag.backend import visibility
actor,users,cases=planning_db
marker=f'Unassigned-{cases[0]}'
for cid in cases:
execute_query('UPDATE sag_sager SET titel=%s,ansvarlig_bruger_id=%s WHERE id=%s',
(marker+' '+str(cid),users[0],cid),fetch=False)
first=capacity.unassigned(2,0,actor,marker)
assert len(first['items'])==2 and first['has_more']
assert all(row['responsible_name'] for row in first['items'])
second=capacity.unassigned(2,2,actor,marker)
assert len(second['items'])==1 and not second['has_more']
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[1],estimated_minutes=60),actor)
remaining=capacity.unassigned(25,0,actor,marker)
assert {row['id'] for row in remaining['items']}==set(cases[1:])
assert work.get_queue(users[1],100,0,actor)['items'][0]['estimated_minutes']==60
visibility.update_visibility(cases[1],visibility.VisibilityInput(restricted=True,user_ids=[users[0]],version=0),actor)
reader={'id':users[2],'permissions':['cases.view','planner.view','planner.allocate']}
hidden=capacity.unassigned(25,0,reader,marker)
assert [row['id'] for row in hidden['items']]==[cases[2]]
def test_db_drag_dates_preserve_clock_and_detect_stale_case(planning_db):
actor,users,cases=planning_db
original=work.case_planning(cases[0],actor)
initial=work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=original['updated_at'],
start_date=date(2026,10,23),start_time=time(9),deadline=datetime(2026,10,23,13,30,tzinfo=work.TZ)),actor)
moved=work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=initial['updated_at'],
deadline_date=date(2026,11,2)),actor)
assert moved['deadline']==datetime(2026,11,2,13,30,tzinfo=work.TZ)
assert moved['start_date']==date(2026,10,23) and moved['start_time']==time(9)
with pytest.raises(HTTPException) as error:
work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=initial['updated_at'],start_date=date(2026,11,1)),actor)
assert error.value.status_code==409
reader={'id':users[1],'permissions':['cases.view','planner.view']}
with pytest.raises(HTTPException) as error:
work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=moved['updated_at'],deadline_date=date(2026,11,3)),reader)
assert error.value.status_code==403
next_plan=work.case_planning(cases[1],actor)
new=work.update_case_planning(cases[1],work.CasePlanningInput(updated_at=next_plan['updated_at'],
start_date=date(2026,11,2),deadline_date=date(2026,11,3)),actor)
assert new['deadline'].hour==17
schedule=calendar.schedule(datetime(2026,11,1,tzinfo=work.TZ),datetime(2026,11,5,tzinfo=work.TZ),[users[0]],actor,True)
marker=next(m for m in schedule['milestones'] if m['sag_id']==cases[1] and m['kind']=='case_start')
assert marker['allDay'] and marker['case_updated_at']==new['updated_at']
def test_db_card_keeps_multiple_allocated_time_slots(planning_db):
actor,users,cases=planning_db
start=datetime(2026,11,16,10,tzinfo=work.TZ)
payload=calendar.EventInput(sag_id=cases[0],title='Multiple slots',user_ids=[users[0]],starts_at=start,ends_at=start+timedelta(hours=1))
first=calendar.create_event(payload,actor)['event']
second=calendar.create_event(payload.model_copy(update={'starts_at':start+timedelta(hours=2),'ends_at':start+timedelta(hours=3)}),actor)['event']
row=next(r for r in work.get_queue(users[0],100,0,actor)['items'] if r['sag_id']==cases[0])
assert row['future_planned_minutes']==120
assert {s['event_id'] for s in row['planned_slots']}=={first['id'],second['id']}
calendar.update_event(first['id'],payload.model_copy(update={'version':first['version'],
'starts_at':start+timedelta(hours=3),'ends_at':start+timedelta(hours=4)}),actor)
after=next(r for r in work.get_queue(users[0],100,0,actor)['items'] if r['sag_id']==cases[0])
assert len(after['planned_slots'])==2
original=next(s for s in row['planned_slots'] if s['event_id']==second['id'])
preserved=next(s for s in after['planned_slots'] if s['event_id']==second['id'])
assert original==preserved
def test_db_pause_resume_reuses_the_same_timer(planning_db):
import asyncio
from app.core.database import execute_query,execute_query_single
actor,users,cases=planning_db
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[0]),actor)
timer=execute_query_single('''INSERT INTO tmodule_times(sag_id,medarbejder_id,description,original_hours,
worked_date,status,billable,aktiv_timer,start_tid,entry_status)
VALUES(%s,%s,'Synthetic timer',0.01,CURRENT_DATE,'pending',false,true,NOW(),'kladde') RETURNING id''',(cases[0],users[0]))['id']
queue=work.get_queue(users[0],100,0,actor)
paused=asyncio.run(work.pause_work(cases[0],work.WorkAction(version=queue['version'],timer_action=True),actor))
assert paused['item']['work_status']=='paused'
assert execute_query_single('SELECT aktiv_timer FROM tmodule_times WHERE id=%s',(timer,))['aktiv_timer'] is False
resumed=asyncio.run(work.resume_work(cases[0],work.WorkAction(version=paused['version'],timer_action=True),actor))
assert resumed['item']['work_status']=='working'
assert resumed['timer']['id']==timer
def test_db_capacity_counts_booked_work_once_and_flags_absence_overlap(planning_db):
from app.core.database import execute_query,execute_query_single
actor,users,cases=planning_db
start=datetime(2026,11,2,8,tzinfo=work.TZ)
execute_query('INSERT INTO planner_work_schedules(user_id,weekday,start_time,end_time,break_minutes) VALUES(%s,0,%s,%s,0)',(users[0],time(8),time(16)),fetch=False)
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[0]),actor)
queue=work.get_queue(users[0],100,0,actor)
work.change_queue(users[0],cases[0],work.QueueChange(version=queue['version'],estimated_minutes=600),actor)
calendar.create_event(calendar.EventInput(title='Capacity test',sag_id=cases[0],user_ids=[users[0]],starts_at=start+timedelta(hours=2),ends_at=start+timedelta(hours=4)),actor)
calendar.create_absence(calendar.AbsenceInput(user_id=users[0],kind='vacation',starts_at=start+timedelta(hours=1),ends_at=start+timedelta(hours=3)),actor)
row=capacity.capacity(start,start+timedelta(hours=8),[users[0]],actor)['items'][0]
assert row['normal_minutes']==480 and row['busy_minutes']==180 and row['free_minutes']==300
assert row['queue_minutes']==480 and row['overload_minutes']==180
assert calendar.schedule(start,start+timedelta(hours=8),[users[0]],actor)['bookings'][0]['has_conflict'] is True
@pytest.mark.parametrize('action', ['transfer','remove'])
def test_db_transfer_offers_booking_choice_and_preserves_shared_participants(planning_db, action):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
start=datetime(2026,11,9,9,tzinfo=work.TZ)
for offset,participants in ((0,[users[0]]),(2,[users[0],users[2]])):
calendar.create_event(calendar.EventInput(title='Transfer reservation',sag_id=cases[0],
user_ids=participants,starts_at=start+timedelta(hours=offset),
ends_at=start+timedelta(hours=offset+1),confirm_conflicts=True),actor)
execute_query('UPDATE sag_sager SET ansvarlig_bruger_id=%s WHERE id=%s',(users[0],cases[0]),fetch=False)
old=execute_query('SELECT * FROM planner_allocations WHERE sag_id=%s AND user_id=%s ORDER BY id',(cases[0],users[0]))
body=dict(source_user_id=users[0],target_user_id=users[1],
source_version=work.get_queue(users[0],100,0,actor)['version'],
target_version=work.get_queue(users[1],100,0,actor)['version'])
with pytest.raises(HTTPException) as choice:
work.transfer(cases[0],work.TransferInput(**body),actor)
assert choice.value.status_code==409
assert choice.value.detail['code']=='future_bookings_choice'
assert choice.value.detail['booking_count']==2
payload=work.TransferInput(**body,future_bookings_action=action,
future_bookings=choice.value.detail['bookings'],confirm_conflicts=True)
work.transfer(cases[0],payload,actor)
changed=execute_query('SELECT * FROM planner_allocations WHERE id=ANY(%s) ORDER BY id',([r['id'] for r in old],))
for before,after in zip(old,changed):
assert after['starts_at']==before['starts_at'] and after['ends_at']==before['ends_at']
assert after['version']==before['version']+1
assert (after['user_id']==users[1] and after['deleted_at'] is None) if action=='transfer' else after['deleted_at'] is not None
assert execute_query_single('SELECT COUNT(*) AS n FROM planner_allocations WHERE sag_id=%s AND user_id=%s AND deleted_at IS NULL',(cases[0],users[2]))['n']==1
assert execute_query_single('SELECT ansvarlig_bruger_id FROM sag_sager WHERE id=%s',(cases[0],))['ansvarlig_bruger_id']==users[0]
assert {r['user_id'] for r in execute_query('SELECT user_id FROM sag_case_assignees WHERE sag_id=%s',(cases[0],))}=={users[1],users[2]}
def test_db_transfer_bookings_stale_conflict_and_audit_rollback(planning_db,monkeypatch):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
start=datetime(2026,11,9,9,tzinfo=work.TZ)
calendar.create_event(calendar.EventInput(title='Move',sag_id=cases[0],user_ids=[users[0]],
starts_at=start,ends_at=start+timedelta(hours=1),confirm_conflicts=True),actor)
calendar.create_event(calendar.EventInput(title='Busy',user_ids=[users[1]],
starts_at=start,ends_at=start+timedelta(hours=1),confirm_conflicts=True),actor)
body=dict(source_user_id=users[0],target_user_id=users[1],
source_version=work.get_queue(users[0],100,0,actor)['version'],
target_version=work.get_queue(users[1],100,0,actor)['version'])
with pytest.raises(HTTPException) as choice:
work.transfer(cases[0],work.TransferInput(**body),actor)
snapshot=choice.value.detail['bookings']
with pytest.raises(HTTPException) as conflict:
work.transfer(cases[0],work.TransferInput(**body,future_bookings_action='transfer',future_bookings=snapshot),actor)
assert conflict.value.detail['confirm_conflicts']
original_audit=work.audit
def fail(*args,**kwargs):
raise RuntimeError('Audit rollback')
monkeypatch.setattr(work,'audit',fail)
payload=work.TransferInput(**body,future_bookings_action='transfer',future_bookings=snapshot,confirm_conflicts=True)
with pytest.raises(RuntimeError):
work.transfer(cases[0],payload,actor)
row=execute_query_single('SELECT * FROM planner_allocations WHERE id=%s',(snapshot[0]['id'],))
assert row['user_id']==users[0] and row['version']==snapshot[0]['version']
assert execute_query_single('SELECT version FROM planner_events WHERE id=%s',(row['event_id'],))['version']==snapshot[0]['event_version']
assert work.get_queue(users[0],100,0,actor)['version']==body['source_version']
assert not execute_query_single('SELECT 1 FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(cases[0],users[1]))
monkeypatch.setattr(work,'audit',original_audit)
execute_query('UPDATE planner_events SET version=version+1 WHERE id=%s',(row['event_id'],),fetch=False)
with pytest.raises(HTTPException) as stale:
work.transfer(cases[0],payload,actor)
assert stale.value.status_code==409 and 'ændret' in stale.value.detail
def test_absence_whole_days_are_visible_and_follow_copenhagen_dst():
placeholder=datetime(2026,10,1,tzinfo=work.TZ)
row=calendar.AbsenceInput(user_id=1,kind='vacation',starts_at=placeholder,
ends_at=placeholder+timedelta(hours=1),whole_day_start=date(2026,10,24),whole_day_end=date(2026,10,26))
assert row.starts_at.utcoffset()==timedelta(hours=2)
assert row.ends_at.utcoffset()==timedelta(hours=1)
safe=calendar.visible_absence(dict(id=1,user_id=1,kind='vacation',note='Private',
starts_at=row.starts_at,ends_at=row.ends_at),{'id':1})
assert safe['all_day'] and safe['display_start']=='2026-10-24' and safe['display_end']=='2026-10-27'
safe=calendar.visible_absence(dict(id=1,user_id=1,kind='vacation',note='Private',
starts_at=row.starts_at.replace(hour=8),ends_at=row.ends_at.replace(hour=16)),{'id':2})
assert safe['kind']=='unavailable' and safe['note'] is None and safe['all_day']
assert safe['display_end']=='2026-10-28'
def test_db_absence_report_permissions_scope_masking_overlap_and_pagination(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query
from datetime import timezone
monday=datetime(2026,11,9,tzinfo=work.TZ)
for uid in users:
execute_query('DELETE FROM planner_work_schedules WHERE user_id=%s',(uid,),fetch=False)
for weekday in range(5):
execute_query("INSERT INTO planner_work_schedules(user_id,weekday,start_time,end_time,break_minutes) VALUES(%s,%s,'08:00','16:00',30)",(uid,weekday),fetch=False)
# Two overlapping periods must count as two rows, but only one day in totals.
for kind,start,end in [('vacation',monday,monday+timedelta(days=1)),
('sickness',monday.replace(hour=9),monday.replace(hour=12))]:
calendar.create_absence(calendar.AbsenceInput(user_id=users[1],kind=kind,
starts_at=start,ends_at=end,note='Never export this'),actor)
calendar.create_absence(calendar.AbsenceInput(user_id=users[2],kind='vacation',
starts_at=monday,ends_at=monday+timedelta(days=1)),actor)
reader={'id':users[0],'permissions':['planner.view','cases.view','planner.view_team'],
'_planner_team_scope_ids':{users[1]}}
with pytest.raises(HTTPException) as denied:
calendar.absence_report(monday.date(),monday.date(),None,50,0,reader)
assert denied.value.status_code==403
reader['permissions'].append('planner.absence_report')
result=calendar.absence_report(monday.date(),monday.date(),None,1,0,reader)
assert result['total']==2 and len(result['items'])==1
assert {r['id'] for r in result['employees']}=={users[0],users[1]}
assert result['totals']['planned_minutes']==450 and result['totals']['planned_days']==1
assert result['items'][0]['kind']=='unavailable' and result['items'][0]['kind_label']=='Fravær'
assert 'Never export this' not in str(result)
assert 'note' not in result['items'][0]
assert len(result['type_summary'])==1
masked=result['type_summary'][0]
assert masked['kind']=='unavailable' and masked['kind_label']=='Fravær'
assert masked['planned_minutes']==450 and masked['periods']==2
assert {r['user_id'] for r in masked['employees']}=={users[1]}
next_page=calendar.absence_report(monday.date(),monday.date(),None,1,1,reader)
assert next_page['items'][0]['id']!=result['items'][0]['id']
with pytest.raises(HTTPException) as denied:
calendar.absence_report(monday.date(),monday.date(),users[2],50,0,reader)
assert denied.value.status_code==403
reader['permissions'].append('planner.absence_reason')
result=calendar.absence_report(monday.date(),monday.date(),users[1],50,0,reader)
assert {r['kind_label'] for r in result['items']}=={'Ferie','Sygdom'}
grouped=calendar.absence_report(monday.date(),monday.date(),users[1],1,0,reader,group_by='type')
assert [r['kind_label'] for r in grouped['type_summary']]==['Ferie','Sygdom']
assert grouped['type_summary'][0]['planned_minutes']==450
assert grouped['type_summary'][1]['planned_minutes']==180
assert grouped['items'][0]['kind']=='vacation'
next_group=calendar.absence_report(monday.date(),monday.date(),users[1],1,1,reader,group_by='type')
assert next_group['items'][0]['kind']=='sickness'
assert next_group['type_summary']==grouped['type_summary']
# A range ending before the periods must contain no rows.
result=calendar.absence_report(date(2026,11,1),date(2026,11,8),users[1],50,0,reader)
assert result['total']==0 and result['totals']['planned_minutes']==0
def test_db_absence_report_keeps_actual_time_separate_and_filters_worked_dates(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query
for weekday in (0,1):
execute_query("INSERT INTO planner_work_schedules(user_id,weekday,start_time,end_time,break_minutes) VALUES(%s,%s,'08:00','16:00',30)",(users[0],weekday),fetch=False)
start=datetime(2020,1,6,tzinfo=work.TZ)
absence=calendar.create_absence(calendar.AbsenceInput(user_id=users[0],kind='vacation',
starts_at=start,ends_at=start+timedelta(days=2)),actor)['absence']
before=calendar.absence_report(date(2020,1,7),date(2020,1,7),users[0],50,0,actor)
assert before['totals']=={'planned_minutes':450,'planned_days':1,'actual_minutes':0}
assert not before['items'][0]['registered']
calendar.register_absence(absence['id'],absence['version'],actor)
after=calendar.absence_report(date(2020,1,7),date(2020,1,7),users[0],50,0,actor)
assert after['totals']=={'planned_minutes':450,'planned_days':1,'actual_minutes':450}
assert after['items'][0]['registered']
assert after['total']==1
def test_db_team_calendar_exposes_absence_periods_with_masked_reasons(planning_db):
actor,users,cases=planning_db
from app.modules.planner.backend.router import period
start=datetime(2026,11,9,tzinfo=work.TZ)
for uid in users:
calendar.create_absence(calendar.AbsenceInput(user_id=uid,kind='vacation',
starts_at=start,ends_at=start+timedelta(days=2),note='Private absence note'),actor)
reader={'id':users[0],'permissions':['planner.view','planner.view_team','cases.view'],
'_planner_team_scope_ids':{users[1]}}
result=period(start,start+timedelta(days=7),users[:2],reader)
assert len(result['absences'])==2
assert {r['user_id'] for r in result['absences']}==set(users[:2])
assert next(r for r in result['absences'] if r['user_id']==users[0])['kind_label']=='Ferie'
safe=next(r for r in result['absences'] if r['user_id']==users[1])
assert safe['kind_label']=='Fravær' and safe['kind']=='unavailable'
assert 'Private absence note' not in str(result['absences'])
assert all('note' not in r for r in result['absences'])
reader['permissions'].append('planner.absence_reason')
assert all(r['kind_label']=='Ferie' for r in period(start,start+timedelta(days=7),users[:2],reader)['absences'])
assert not period(start+timedelta(days=2),start+timedelta(days=3),users[:2],reader)['absences']
with pytest.raises(HTTPException) as denied:
period(start,start+timedelta(days=7),[users[2]],reader)
assert denied.value.status_code==403
def test_db_duplicate_absence_type_rejected_for_create_and_edit(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query_single
start=datetime(2026,11,9,8,tzinfo=work.TZ)
def payload(a,b,uid=users[0],kind='vacation',version=None):
return calendar.AbsenceInput(user_id=uid,kind=kind,starts_at=a,ends_at=b,version=version)
first=calendar.create_absence(payload(start,start+timedelta(hours=8)),actor)['absence']
for a,b in [(start,start+timedelta(hours=8)),(start+timedelta(hours=1),start+timedelta(hours=9)),
(start-timedelta(hours=1),start+timedelta(hours=1))]:
with pytest.raises(HTTPException) as denied:
calendar.create_absence(payload(a,b),actor)
assert denied.value.status_code==409 and denied.value.detail['code']=='absence_overlap'
# Touching periods and another employee's holiday remain valid.
next_period=calendar.create_absence(payload(start+timedelta(hours=8),start+timedelta(hours=10)),actor)['absence']
calendar.create_absence(payload(start,start+timedelta(hours=8),users[1]),actor)
calendar.update_absence(first['id'],payload(start,start+timedelta(hours=7),version=first['version']),actor)
with pytest.raises(HTTPException) as denied:
calendar.update_absence(next_period['id'],payload(start+timedelta(hours=1),start+timedelta(hours=9),version=next_period['version']),actor)
assert denied.value.status_code==409
assert execute_query_single('SELECT starts_at FROM planner_absences WHERE id=%s',(next_period['id'],))['starts_at']==next_period['starts_at']
calendar.cancel_absence(next_period['id'],next_period['version'],actor)
calendar.create_absence(payload(start+timedelta(hours=7),start+timedelta(hours=10)),actor)
def test_db_concurrent_duplicate_holidays_have_one_winner(planning_db):
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
actor,users,cases=planning_db
start=datetime(2026,11,9,8,tzinfo=work.TZ)
barrier=Barrier(2)
def create():
barrier.wait(timeout=10)
try:
calendar.create_absence(calendar.AbsenceInput(user_id=users[0],kind='vacation',starts_at=start,ends_at=start+timedelta(hours=8)),actor)
return 201
except HTTPException as error:
return error.status_code
with ThreadPoolExecutor(max_workers=2) as pool:
assert sorted(pool.map(lambda _:create(),range(2)))==[201,409]