718 lines
44 KiB
Python
718 lines
44 KiB
Python
|
|
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]
|