1904 lines
69 KiB
Python
1904 lines
69 KiB
Python
from fastapi.testclient import TestClient
|
|
from datetime import date, timedelta
|
|
import holidays
|
|
import json
|
|
import re
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_engine
|
|
from app.models import TimeEntry, User
|
|
|
|
|
|
def test_vacation_reduces_weekly_target_and_month_report(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "vac@example.com", "password": "strongpasswordVac1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
add_vacation = client.post(
|
|
"/settings/vacations/add",
|
|
data={
|
|
"start_date": "2026-03-03",
|
|
"end_date": "2026-03-04",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_vacation.status_code == 303
|
|
|
|
week_report = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_report.status_code == 200
|
|
data = week_report.json()
|
|
assert data["vacation_days"] == 2
|
|
assert data["weekly_soll_minutes"] == 900
|
|
|
|
month_report = client.get("/reports/month", params={"month": "2026-03"})
|
|
assert month_report.status_code == 200
|
|
weeks = month_report.json()["weeks"]
|
|
assert any(item.get("vacation_days", 0) >= 2 for item in weeks)
|
|
|
|
|
|
def test_month_report_counts_partial_weeks_only_within_month(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "month-partial@example.com", "password": "strongpasswordMonth1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
# Entries in the previous month must not influence March partial week totals.
|
|
for day in ["2026-02-23", "2026-02-24", "2026-02-25", "2026-02-26", "2026-02-27"]:
|
|
create = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": day,
|
|
"start_time": "08:00",
|
|
"end_time": "14:00",
|
|
"break_minutes": 0,
|
|
},
|
|
)
|
|
assert create.status_code == 200
|
|
|
|
month_report = client.get("/reports/month", params={"month": "2026-03"})
|
|
assert month_report.status_code == 200
|
|
data = month_report.json()
|
|
|
|
first_week = next(item for item in data["weeks"] if item["week_start"] == "2026-02-23")
|
|
assert first_week["ist_minutes"] == 0
|
|
assert first_week["soll_minutes"] == 0
|
|
assert first_week["delta_minutes"] == 0
|
|
|
|
# March 2026 has 22 workdays; default target is 25h/week -> 5h/day (300 min).
|
|
assert data["month_soll_minutes"] == 22 * 300
|
|
assert data["month_delta_minutes"] == -(22 * 300)
|
|
|
|
|
|
def test_custom_working_days_affect_soll_and_month_partial_weeks(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "workdays@example.com", "password": "strongpasswordWork1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
set_target = client.post(
|
|
"/weekly-target",
|
|
data={
|
|
"week_start": "2026-03-02",
|
|
"weekly_target_hours": "30",
|
|
"scope": "all_weeks",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert set_target.status_code == 303
|
|
|
|
update_workdays = client.post(
|
|
"/settings/workdays",
|
|
data={
|
|
"working_days": ["0", "1", "2", "3"], # Mo-Do
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_workdays.status_code == 303
|
|
|
|
add_vacation = client.post(
|
|
"/settings/vacations/add",
|
|
data={
|
|
"start_date": "2026-03-03",
|
|
"end_date": "2026-03-04",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_vacation.status_code == 303
|
|
|
|
week_report = client.get("/reports/week", params={"date": "2026-03-02"})
|
|
assert week_report.status_code == 200
|
|
week_data = week_report.json()
|
|
assert week_data["weekly_soll_minutes"] == 900 # 2 verbleibende Arbeitstage * 7.5h
|
|
assert week_data["vacation_days"] == 2
|
|
|
|
month_report = client.get("/reports/month", params={"month": "2026-03"})
|
|
assert month_report.status_code == 200
|
|
month_data = month_report.json()
|
|
first_week = next(item for item in month_data["weeks"] if item["week_start"] == "2026-02-23")
|
|
assert first_week["soll_minutes"] == 0
|
|
|
|
|
|
def test_quick_vacation_toggle_for_day_and_week(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "quickvac@example.com", "password": "strongpasswordQuick1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
day_on = client.post(
|
|
"/vacation/day/toggle",
|
|
data={"date": "2026-03-03", "return_to": "/dashboard?date=2026-03-03", "csrf_token": csrf},
|
|
follow_redirects=False,
|
|
)
|
|
assert day_on.status_code == 303
|
|
week_after_day_on = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_after_day_on.status_code == 200
|
|
assert week_after_day_on.json()["vacation_days"] == 1
|
|
|
|
day_off = client.post(
|
|
"/vacation/day/toggle",
|
|
data={"date": "2026-03-03", "return_to": "/dashboard?date=2026-03-03", "csrf_token": csrf},
|
|
follow_redirects=False,
|
|
)
|
|
assert day_off.status_code == 303
|
|
week_after_day_off = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_after_day_off.status_code == 200
|
|
assert week_after_day_off.json()["vacation_days"] == 0
|
|
|
|
week_on = client.post(
|
|
"/vacation/week/toggle",
|
|
data={
|
|
"week_start": "2026-03-02",
|
|
"week_end": "2026-03-08",
|
|
"return_to": "/month?month=2026-03&view=flat",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert week_on.status_code == 303
|
|
week_after_week_on = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_after_week_on.status_code == 200
|
|
assert week_after_week_on.json()["vacation_days"] == 5
|
|
|
|
week_off = client.post(
|
|
"/vacation/week/toggle",
|
|
data={
|
|
"week_start": "2026-03-02",
|
|
"week_end": "2026-03-08",
|
|
"return_to": "/month?month=2026-03&view=flat",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert week_off.status_code == 303
|
|
week_after_week_off = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_after_week_off.status_code == 200
|
|
assert week_after_week_off.json()["vacation_days"] == 0
|
|
|
|
|
|
def test_week_vacation_toggle_uses_configured_workdays(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "weekworkdays@example.com", "password": "strongpasswordWeekWork1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
set_target = client.post(
|
|
"/weekly-target",
|
|
data={
|
|
"week_start": "2026-03-02",
|
|
"weekly_target_hours": "30",
|
|
"scope": "all_weeks",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert set_target.status_code == 303
|
|
|
|
update_workdays = client.post(
|
|
"/settings/workdays",
|
|
data={
|
|
"working_days": ["0", "1", "2", "3"], # Mo-Do
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_workdays.status_code == 303
|
|
|
|
week_on = client.post(
|
|
"/vacation/week/toggle",
|
|
data={
|
|
"week_start": "2026-03-02",
|
|
"week_end": "2026-03-08",
|
|
"return_to": "/dashboard?date=2026-03-02",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert week_on.status_code == 303
|
|
|
|
week_after_week_on = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_after_week_on.status_code == 200
|
|
payload = week_after_week_on.json()
|
|
assert payload["vacation_days"] == 4
|
|
assert payload["weekly_soll_minutes"] == 0
|
|
|
|
|
|
def test_settings_vacation_ranges_follow_configured_workdays(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "vac-ranges@example.com", "password": "strongpasswordVacRanges1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
update_allowance = client.post(
|
|
"/settings/vacation-allowance",
|
|
data={"vacation_days_total": "22", "csrf_token": csrf},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_allowance.status_code == 303
|
|
|
|
update_workdays = client.post(
|
|
"/settings/workdays",
|
|
data={
|
|
"working_days": ["0", "1", "2", "3"], # Mo-Do
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_workdays.status_code == 303
|
|
|
|
add_vacation = client.post(
|
|
"/settings/vacations/add",
|
|
data={
|
|
"start_date": "2026-03-02",
|
|
"end_date": "2026-03-15",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_vacation.status_code == 303
|
|
|
|
week_1 = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_1.status_code == 200
|
|
assert week_1.json()["vacation_days"] == 4
|
|
|
|
week_2 = client.get("/reports/week", params={"date": "2026-03-10"})
|
|
assert week_2.status_code == 200
|
|
assert week_2.json()["vacation_days"] == 4
|
|
|
|
dashboard = client.get("/dashboard", params={"date": "2026-03-10"})
|
|
assert dashboard.status_code == 200
|
|
# Resturlaub / Gesamturlaub: 22 - 8 = 14
|
|
assert "14/22" in dashboard.text
|
|
|
|
settings_page = client.get("/settings")
|
|
assert settings_page.status_code == 200
|
|
assert "02.03.2026 - 05.03.2026" in settings_page.text
|
|
assert "09.03.2026 - 12.03.2026" in settings_page.text
|
|
assert "07.03.2026 - 12.03.2026" not in settings_page.text
|
|
assert "14.03.2026 - 15.03.2026" not in settings_page.text
|
|
|
|
delete_second_range = client.post(
|
|
"/settings/vacations/delete-range",
|
|
data={
|
|
"start_date": "2026-03-09",
|
|
"end_date": "2026-03-12",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert delete_second_range.status_code == 303
|
|
|
|
week_2_after_delete = client.get("/reports/week", params={"date": "2026-03-10"})
|
|
assert week_2_after_delete.status_code == 200
|
|
assert week_2_after_delete.json()["vacation_days"] == 0
|
|
|
|
|
|
def test_vacation_allowance_is_saved_and_shows_remaining_days_in_header(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "allowance@example.com", "password": "strongpasswordAllow1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
update_allowance = client.post(
|
|
"/settings/vacation-allowance",
|
|
data={"vacation_days_total": "22", "csrf_token": csrf},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_allowance.status_code == 303
|
|
|
|
me = client.get("/me")
|
|
assert me.status_code == 200
|
|
assert me.json()["vacation_days_total"] == 22
|
|
|
|
current_year = date.today().year
|
|
target_day = date(current_year, 1, 1)
|
|
while target_day.weekday() > 4:
|
|
target_day += timedelta(days=1)
|
|
|
|
add_vacation = client.post(
|
|
"/settings/vacations/add",
|
|
data={
|
|
"start_date": target_day.isoformat(),
|
|
"end_date": target_day.isoformat(),
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_vacation.status_code == 303
|
|
|
|
dashboard = client.get("/dashboard")
|
|
assert dashboard.status_code == 200
|
|
assert "21/22" in dashboard.text
|
|
|
|
|
|
def test_federal_state_auto_holidays_skip_days_with_work_entries(app):
|
|
with TestClient(app) as client:
|
|
password = "strongpasswordState1"
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "state-holidays@example.com", "password": password},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
holiday_map = holidays.country_holidays("DE", subdiv="NW", years=[date.today().year, date.today().year + 1])
|
|
weekday_holidays = sorted([day for day in holiday_map.keys() if day.weekday() <= 4 and day >= date.today()])
|
|
assert len(weekday_holidays) >= 2
|
|
worked_holiday = weekday_holidays[0]
|
|
untouched_holiday = weekday_holidays[1]
|
|
|
|
create_entry = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": worked_holiday.isoformat(),
|
|
"start_time": "08:00",
|
|
"end_time": "12:00",
|
|
"break_minutes": 0,
|
|
},
|
|
)
|
|
assert create_entry.status_code == 200
|
|
|
|
update_profile = client.post(
|
|
"/settings/profile",
|
|
data={
|
|
"email": "state-holidays@example.com",
|
|
"federal_state": "NW",
|
|
"current_password": password,
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_profile.status_code == 303
|
|
|
|
worked_month = client.get("/reports/month", params={"month": worked_holiday.strftime("%Y-%m")})
|
|
assert worked_month.status_code == 200
|
|
worked_days = {item["date"]: item for item in worked_month.json()["days"]}
|
|
assert worked_days[worked_holiday.isoformat()]["special_status"] is None
|
|
|
|
untouched_month = client.get("/reports/month", params={"month": untouched_holiday.strftime("%Y-%m")})
|
|
assert untouched_month.status_code == 200
|
|
untouched_days = {item["date"]: item for item in untouched_month.json()["days"]}
|
|
assert untouched_days[untouched_holiday.isoformat()]["special_status"] == "holiday"
|
|
|
|
|
|
def test_federal_state_holidays_also_mark_non_configured_workdays(app):
|
|
with TestClient(app) as client:
|
|
password = "strongpasswordState2"
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "state-holidays-2@example.com", "password": password},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
# Restrict workdays to Mo-Do (Friday excluded).
|
|
update_workdays = client.post(
|
|
"/settings/workdays",
|
|
data={
|
|
"working_days": ["0", "1", "2", "3"],
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_workdays.status_code == 303
|
|
|
|
update_profile = client.post(
|
|
"/settings/profile",
|
|
data={
|
|
"email": "state-holidays-2@example.com",
|
|
"federal_state": "HH",
|
|
"current_password": password,
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_profile.status_code == 303
|
|
|
|
# 01.05.2026 is Friday and still should be marked as holiday.
|
|
may_report = client.get("/reports/month", params={"month": "2026-05"})
|
|
assert may_report.status_code == 200
|
|
days = {item["date"]: item for item in may_report.json()["days"]}
|
|
assert days["2026-05-01"]["special_status"] == "holiday"
|
|
|
|
|
|
def test_special_status_reduces_soll_without_counting_as_vacation(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "specialstatus@example.com", "password": "strongpasswordSpecial1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
add_holiday = client.post(
|
|
"/special-day/toggle",
|
|
data={
|
|
"date": "2026-03-03",
|
|
"status": "holiday",
|
|
"return_to": "/dashboard?date=2026-03-03",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_holiday.status_code == 303
|
|
|
|
week_data = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_data.status_code == 200
|
|
payload = week_data.json()
|
|
assert payload["vacation_days"] == 0
|
|
assert payload["weekly_soll_minutes"] == 1200
|
|
|
|
switch_to_sick = client.post(
|
|
"/special-day/toggle",
|
|
data={
|
|
"date": "2026-03-03",
|
|
"status": "sick",
|
|
"return_to": "/dashboard?date=2026-03-03",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert switch_to_sick.status_code == 303
|
|
|
|
week_after_switch = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_after_switch.status_code == 200
|
|
assert week_after_switch.json()["weekly_soll_minutes"] == 1200
|
|
|
|
remove_sick = client.post(
|
|
"/special-day/toggle",
|
|
data={
|
|
"date": "2026-03-03",
|
|
"status": "sick",
|
|
"return_to": "/dashboard?date=2026-03-03",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert remove_sick.status_code == 303
|
|
week_without_special = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_without_special.status_code == 200
|
|
assert week_without_special.json()["weekly_soll_minutes"] == 1500
|
|
|
|
|
|
def test_workhours_counter_settings_and_value(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "workcounter@example.com", "password": "strongpasswordCounter1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
entry_1 = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": "2026-03-02",
|
|
"start_time": "08:00",
|
|
"end_time": "13:00",
|
|
"break_minutes": 0,
|
|
},
|
|
)
|
|
assert entry_1.status_code == 200
|
|
entry_2 = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": "2026-03-03",
|
|
"start_time": "08:00",
|
|
"end_time": "13:00",
|
|
"break_minutes": 0,
|
|
},
|
|
)
|
|
assert entry_2.status_code == 200
|
|
weekend_entry = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": "2026-03-07",
|
|
"start_time": "08:00",
|
|
"end_time": "13:00",
|
|
"break_minutes": 0,
|
|
},
|
|
)
|
|
assert weekend_entry.status_code == 200
|
|
|
|
add_vacation = client.post(
|
|
"/settings/vacations/add",
|
|
data={
|
|
"start_date": "2026-03-04",
|
|
"end_date": "2026-03-04",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_vacation.status_code == 303
|
|
|
|
add_holiday = client.post(
|
|
"/special-day/toggle",
|
|
data={
|
|
"date": "2026-03-05",
|
|
"status": "holiday",
|
|
"return_to": "/dashboard?date=2026-03-05",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_holiday.status_code == 303
|
|
|
|
enable_counter = client.post(
|
|
"/settings/workhours-counter",
|
|
data={
|
|
"workhours_counter_enabled": "on",
|
|
"workhours_counter_start_date": "2026-03-01",
|
|
"workhours_counter_end_date": "2026-03-31",
|
|
"workhours_counter_manual_offset_hours": "2.5",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert enable_counter.status_code == 303
|
|
|
|
settings_page = client.get("/settings")
|
|
assert settings_page.status_code == 200
|
|
assert "Aktueller Stand im gewählten Zeitraum:" in settings_page.text
|
|
|
|
|
|
def test_workhours_counter_counts_flagged_non_working_days_as_regular_workdays(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "workcounter-flags@example.com", "password": "strongpasswordCounter2"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
entry_1 = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={"date": "2026-03-02", "start_time": "08:00", "end_time": "13:00", "break_minutes": 0},
|
|
)
|
|
assert entry_1.status_code == 200
|
|
entry_2 = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={"date": "2026-03-03", "start_time": "08:00", "end_time": "13:00", "break_minutes": 0},
|
|
)
|
|
assert entry_2.status_code == 200
|
|
|
|
update_workdays = client.post(
|
|
"/settings/workdays",
|
|
data={
|
|
"working_days": ["0", "1", "2", "3", "4"],
|
|
"count_vacation_as_worktime": "on",
|
|
"count_holiday_as_worktime": "on",
|
|
"count_sick_as_worktime": "on",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_workdays.status_code == 303
|
|
|
|
add_vacation = client.post(
|
|
"/settings/vacations/add",
|
|
data={"start_date": "2026-03-04", "end_date": "2026-03-04", "csrf_token": csrf},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_vacation.status_code == 303
|
|
|
|
add_holiday = client.post(
|
|
"/special-day/toggle",
|
|
data={"date": "2026-03-05", "status": "holiday", "return_to": "/dashboard?date=2026-03-05", "csrf_token": csrf},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_holiday.status_code == 303
|
|
|
|
add_sick = client.post(
|
|
"/special-day/toggle",
|
|
data={"date": "2026-03-06", "status": "sick", "return_to": "/dashboard?date=2026-03-06", "csrf_token": csrf},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_sick.status_code == 303
|
|
|
|
enable_counter = client.post(
|
|
"/settings/workhours-counter",
|
|
data={
|
|
"workhours_counter_enabled": "on",
|
|
"workhours_counter_start_date": "2026-03-01",
|
|
"workhours_counter_end_date": "2026-03-31",
|
|
"workhours_counter_manual_offset_hours": "2.5",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert enable_counter.status_code == 303
|
|
|
|
settings_page = client.get("/settings")
|
|
assert settings_page.status_code == 200
|
|
assert "Aktueller Stand im gewählten Zeitraum:" in settings_page.text
|
|
|
|
|
|
def test_automatic_break_rules_can_be_enabled_in_settings(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "auto-break-settings@example.com", "password": "strongpasswordBreak1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
update_break_settings = client.post(
|
|
"/settings/weekly-target",
|
|
data={
|
|
"weekly_target_hours": "25",
|
|
"automatic_break_rules_enabled": "on",
|
|
"default_break_minutes": "20",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_break_settings.status_code == 303
|
|
|
|
me = client.get("/me")
|
|
assert me.status_code == 200
|
|
assert me.json()["automatic_break_rules_enabled"] is True
|
|
assert me.json()["default_break_minutes"] == 20
|
|
|
|
|
|
def test_new_entry_uses_automatic_break_rules_for_new_entries(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "auto-break-new@example.com", "password": "strongpasswordBreak2"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
update_break_settings = client.post(
|
|
"/settings/weekly-target",
|
|
data={
|
|
"weekly_target_hours": "25",
|
|
"automatic_break_rules_enabled": "on",
|
|
"default_break_minutes": "20",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_break_settings.status_code == 303
|
|
|
|
create_entry = client.post(
|
|
"/entry/new",
|
|
data={
|
|
"date": "2026-03-03",
|
|
"start_time": "08:00",
|
|
"end_time": "14:01",
|
|
"break_minutes": "0",
|
|
"break_mode": "auto",
|
|
"notes": "",
|
|
"return_to": "/dashboard?date=2026-03-03",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert create_entry.status_code == 303
|
|
|
|
entries = client.get("/time-entries", params={"from": "2026-03-03", "to": "2026-03-03"})
|
|
assert entries.status_code == 200
|
|
payload = entries.json()["items"]
|
|
assert len(payload) == 1
|
|
assert payload[0]["break_minutes"] == 30
|
|
assert payload[0]["break_mode"] == "auto"
|
|
assert payload[0]["net_minutes"] == 331
|
|
|
|
|
|
def test_edit_entry_can_override_automatic_break_rules_manually(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "auto-break-edit@example.com", "password": "strongpasswordBreak3"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
update_break_settings = client.post(
|
|
"/settings/weekly-target",
|
|
data={
|
|
"weekly_target_hours": "25",
|
|
"automatic_break_rules_enabled": "on",
|
|
"default_break_minutes": "20",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_break_settings.status_code == 303
|
|
|
|
create_entry = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": "2026-03-03",
|
|
"start_time": "08:00",
|
|
"end_time": "14:30",
|
|
"break_mode": "auto",
|
|
},
|
|
)
|
|
assert create_entry.status_code == 200
|
|
entry_id = create_entry.json()["id"]
|
|
assert create_entry.json()["break_minutes"] == 30
|
|
assert create_entry.json()["break_mode"] == "auto"
|
|
|
|
edit_entry = client.post(
|
|
f"/entry/{entry_id}/edit",
|
|
data={
|
|
"date": "2026-03-03",
|
|
"start_time": "08:00",
|
|
"end_time": "15:30",
|
|
"break_minutes": "15",
|
|
"break_mode": "manual",
|
|
"notes": "",
|
|
"return_to": "/dashboard?date=2026-03-03",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert edit_entry.status_code == 303
|
|
|
|
updated = client.get("/time-entries", params={"from": "2026-03-03", "to": "2026-03-03"})
|
|
assert updated.status_code == 200
|
|
payload = updated.json()["items"]
|
|
assert len(payload) == 1
|
|
assert payload[0]["break_minutes"] == 15
|
|
assert payload[0]["break_mode"] == "manual"
|
|
|
|
|
|
def test_edit_entry_recalculates_auto_break_when_times_change(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "auto-break-recalc@example.com", "password": "strongpasswordBreak4"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
update_break_settings = client.post(
|
|
"/settings/weekly-target",
|
|
data={
|
|
"weekly_target_hours": "25",
|
|
"automatic_break_rules_enabled": "on",
|
|
"default_break_minutes": "20",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_break_settings.status_code == 303
|
|
|
|
create_entry = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": "2026-03-03",
|
|
"start_time": "08:00",
|
|
"end_time": "17:00",
|
|
"break_mode": "auto",
|
|
},
|
|
)
|
|
assert create_entry.status_code == 200
|
|
entry_id = create_entry.json()["id"]
|
|
assert create_entry.json()["break_minutes"] == 30
|
|
|
|
update_entry = client.patch(
|
|
f"/time-entries/{entry_id}",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"end_time": "17:30",
|
|
"break_mode": "auto",
|
|
},
|
|
)
|
|
assert update_entry.status_code == 200
|
|
assert update_entry.json()["break_minutes"] == 45
|
|
assert update_entry.json()["break_mode"] == "auto"
|
|
|
|
|
|
def test_new_entry_uses_configured_default_break_when_auto_break_is_disabled(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "manual-break-default@example.com", "password": "strongpasswordBreak5"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
update_break_settings = client.post(
|
|
"/settings/weekly-target",
|
|
data={
|
|
"weekly_target_hours": "25",
|
|
"default_break_minutes": "25",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_break_settings.status_code == 303
|
|
|
|
create_entry = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": "2026-03-03",
|
|
"start_time": "08:00",
|
|
"end_time": "14:00",
|
|
},
|
|
)
|
|
assert create_entry.status_code == 200
|
|
assert create_entry.json()["break_minutes"] == 25
|
|
assert create_entry.json()["break_mode"] == "manual"
|
|
|
|
|
|
def test_entry_form_renders_full_day_button(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "entry-form-fullday@example.com", "password": "strongpasswordBreak7"},
|
|
)
|
|
assert register.status_code == 200
|
|
|
|
entry_form = client.get("/entry/new?date=2026-03-03")
|
|
assert entry_form.status_code == 200
|
|
assert 'name="date"' in entry_form.text
|
|
assert 'value="2026-03-03"' in entry_form.text
|
|
assert 'data-action="entry-apply-full-day"' in entry_form.text
|
|
assert 'data-full-day-net-minutes="' in entry_form.text
|
|
|
|
|
|
def test_auto_break_setting_keeps_manual_default_break_value(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "manual-break-preserve@example.com", "password": "strongpasswordBreak6"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
save_manual_break = client.post(
|
|
"/settings/weekly-target",
|
|
data={
|
|
"weekly_target_hours": "25",
|
|
"default_break_minutes": "35",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert save_manual_break.status_code == 303
|
|
|
|
enable_auto_break = client.post(
|
|
"/settings/weekly-target",
|
|
data={
|
|
"weekly_target_hours": "25",
|
|
"automatic_break_rules_enabled": "on",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert enable_auto_break.status_code == 303
|
|
|
|
me = client.get("/me")
|
|
assert me.status_code == 200
|
|
assert me.json()["automatic_break_rules_enabled"] is True
|
|
assert me.json()["default_break_minutes"] == 35
|
|
|
|
|
|
def test_workhours_counter_target_warning_banner_is_rendered(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "workcounter-warning@example.com", "password": "strongpasswordCounterWarn1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
today = date.today()
|
|
start_date = (today - timedelta(days=14)).isoformat()
|
|
end_date = (today + timedelta(days=14)).isoformat()
|
|
|
|
enable_counter = client.post(
|
|
"/settings/workhours-counter",
|
|
data={
|
|
"workhours_counter_enabled": "on",
|
|
"workhours_counter_start_date": start_date,
|
|
"workhours_counter_end_date": end_date,
|
|
"workhours_counter_target_hours": "999",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert enable_counter.status_code == 303
|
|
|
|
dashboard = client.get("/dashboard")
|
|
assert dashboard.status_code == 200
|
|
assert "Achtung: Arbeitsstundenziel wird ggf. nicht erreicht" in dashboard.text
|
|
|
|
month = client.get("/month")
|
|
assert month.status_code == 200
|
|
assert "Achtung: Arbeitsstundenziel wird ggf. nicht erreicht" in month.text
|
|
|
|
|
|
def test_register_onboarding_applies_optional_settings(app):
|
|
with TestClient(app) as client:
|
|
register_page = client.get("/register")
|
|
assert register_page.status_code == 200
|
|
csrf_match = re.search(r'name="csrf_token" value="([^"]+)"', register_page.text)
|
|
assert csrf_match is not None
|
|
csrf = csrf_match.group(1)
|
|
|
|
register_submit = client.post(
|
|
"/register",
|
|
data={
|
|
"email": "onboarding@example.com",
|
|
"password": "strongpasswordOnboard1",
|
|
"federal_state": "HH",
|
|
"vacation_days_total": "22",
|
|
"vacation_show_in_header": "on",
|
|
"preferred_home_view": "month",
|
|
"entry_mode": "auto_until_today",
|
|
"overtime_start_date": "2026-02-02",
|
|
"overtime_expiry_days": "90",
|
|
"expire_negative_overtime": "on",
|
|
"workhours_counter_enabled": "on",
|
|
"workhours_counter_show_in_header": "on",
|
|
"workhours_counter_start_date": "2026-03-01",
|
|
"workhours_counter_end_date": "2026-03-31",
|
|
"workhours_counter_manual_offset_hours": "80",
|
|
"workhours_counter_target_hours": "120",
|
|
"workhours_counter_target_email_enabled": "on",
|
|
"working_days": ["0", "1", "2", "3"],
|
|
"mfa_preference": "none",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert register_submit.status_code == 303
|
|
assert register_submit.headers["location"].startswith("/month")
|
|
|
|
me = client.get("/me")
|
|
assert me.status_code == 200
|
|
payload = me.json()
|
|
assert payload["federal_state"] == "HH"
|
|
assert payload["vacation_days_total"] == 22
|
|
assert payload["vacation_show_in_header"] is True
|
|
assert payload["preferred_home_view"] == "month"
|
|
assert payload["entry_mode"] == "auto_until_today"
|
|
assert payload["overtime_start_date"] == "2026-02-02"
|
|
assert payload["overtime_expiry_days"] == 90
|
|
assert payload["expire_negative_overtime"] is True
|
|
assert payload["working_days"] == [0, 1, 2, 3]
|
|
assert payload["workhours_counter_enabled"] is True
|
|
assert payload["workhours_counter_show_in_header"] is True
|
|
assert payload["workhours_counter_start_date"] == "2026-03-01"
|
|
assert payload["workhours_counter_end_date"] == "2026-03-31"
|
|
assert payload["workhours_counter_manual_offset_minutes"] == 4800
|
|
assert payload["workhours_counter_target_minutes"] == 7200
|
|
|
|
|
|
def test_settings_export_all_supports_backup_and_existing_formats(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "settings-export@example.com", "password": "strongpasswordExportAll1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
create = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": "2026-03-03",
|
|
"start_time": "08:30",
|
|
"end_time": "15:00",
|
|
"break_minutes": 30,
|
|
},
|
|
)
|
|
assert create.status_code == 200
|
|
|
|
export_xlsx = client.post(
|
|
"/settings/export-all",
|
|
data={"format": "xlsx", "csrf_token": csrf},
|
|
)
|
|
assert export_xlsx.status_code == 200
|
|
assert "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" in export_xlsx.headers["content-type"]
|
|
|
|
export_pdf = client.post(
|
|
"/settings/export-all",
|
|
data={"format": "pdf", "csrf_token": csrf},
|
|
)
|
|
assert export_pdf.status_code == 200
|
|
assert "application/pdf" in export_pdf.headers["content-type"]
|
|
assert export_pdf.content.startswith(b"%PDF")
|
|
|
|
export_backup = client.post(
|
|
"/settings/export-all",
|
|
data={"format": "backup_json", "csrf_token": csrf},
|
|
)
|
|
assert export_backup.status_code == 200
|
|
assert "application/json" in export_backup.headers["content-type"]
|
|
payload = export_backup.json()
|
|
assert payload["backup_version"] == 2
|
|
assert "user" not in payload
|
|
assert payload["settings"]["weekly_target_minutes"] == 1500
|
|
assert len(payload["time_entries"]) == 1
|
|
assert "weekly_target_rules" in payload
|
|
assert "vacation_periods" in payload
|
|
assert "special_day_statuses" in payload
|
|
assert "overtime_adjustments" in payload
|
|
|
|
|
|
def test_settings_backup_import_preview_and_execute_merge(app):
|
|
with TestClient(app) as source_client:
|
|
register = source_client.post(
|
|
"/auth/register",
|
|
json={"email": "backup-source@example.com", "password": "strongpasswordBackup1"},
|
|
)
|
|
assert register.status_code == 200
|
|
source_csrf = register.json()["csrf_token"]
|
|
|
|
source_client.post(
|
|
"/settings/workdays",
|
|
data={"working_days": ["0", "1", "2", "3"], "csrf_token": source_csrf},
|
|
follow_redirects=False,
|
|
)
|
|
create_source_entry = source_client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": source_csrf},
|
|
json={
|
|
"date": "2026-03-04",
|
|
"start_time": "08:30",
|
|
"end_time": "14:30",
|
|
"break_minutes": 30,
|
|
},
|
|
)
|
|
assert create_source_entry.status_code == 200
|
|
export_backup = source_client.post(
|
|
"/settings/export-all",
|
|
data={"format": "backup_json", "csrf_token": source_csrf},
|
|
)
|
|
assert export_backup.status_code == 200
|
|
backup_content = export_backup.content
|
|
|
|
with TestClient(app) as target_client:
|
|
register = target_client.post(
|
|
"/auth/register",
|
|
json={"email": "backup-target@example.com", "password": "strongpasswordBackup2"},
|
|
)
|
|
assert register.status_code == 200
|
|
target_csrf = register.json()["csrf_token"]
|
|
|
|
conflicting_entry = target_client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": target_csrf},
|
|
json={
|
|
"date": "2026-03-04",
|
|
"start_time": "09:00",
|
|
"end_time": "15:00",
|
|
"break_minutes": 30,
|
|
},
|
|
)
|
|
assert conflicting_entry.status_code == 200
|
|
|
|
preview_response = target_client.post(
|
|
"/settings/import/preview",
|
|
data={"import_mode": "merge", "csrf_token": target_csrf},
|
|
files={"backup_file": ("stundenfuchs-backup.json", backup_content, "application/json")},
|
|
)
|
|
assert preview_response.status_code == 200
|
|
assert "Importvorschau" in preview_response.text
|
|
assert "Konflikte Arbeitszeiteinträge: 1" in preview_response.text
|
|
|
|
preview_id_match = re.search(r'name="preview_id" value="([^"]+)"', preview_response.text)
|
|
assert preview_id_match is not None
|
|
preview_id = preview_id_match.group(1)
|
|
|
|
execute_response = target_client.post(
|
|
"/settings/import/execute",
|
|
data={"preview_id": preview_id, "csrf_token": target_csrf},
|
|
)
|
|
assert execute_response.status_code == 200
|
|
assert "Backup importiert." in execute_response.text
|
|
|
|
me = target_client.get("/me")
|
|
assert me.status_code == 200
|
|
assert me.json()["working_days"] == [0, 1, 2, 3]
|
|
|
|
|
|
def test_register_can_import_backup_during_signup(app):
|
|
with TestClient(app) as source_client:
|
|
register = source_client.post(
|
|
"/auth/register",
|
|
json={"email": "register-import-source@example.com", "password": "strongpasswordImport1"},
|
|
)
|
|
assert register.status_code == 200
|
|
source_csrf = register.json()["csrf_token"]
|
|
|
|
source_client.post(
|
|
"/settings/preferences",
|
|
data={
|
|
"preferred_home_view": "month",
|
|
"preferred_month_view_mode": "weeks",
|
|
"entry_mode": "auto_until_today",
|
|
"csrf_token": source_csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
source_client.post(
|
|
"/settings/weekly-target",
|
|
data={
|
|
"weekly_target_hours": "25",
|
|
"automatic_break_rules_enabled": "on",
|
|
"default_break_minutes": "20",
|
|
"csrf_token": source_csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
source_client.post(
|
|
"/settings/workdays",
|
|
data={
|
|
"working_days": ["0", "1", "2", "3"],
|
|
"count_vacation_as_worktime": "on",
|
|
"csrf_token": source_csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
create_source_entry = source_client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": source_csrf},
|
|
json={
|
|
"date": "2026-03-03",
|
|
"start_time": "08:30",
|
|
"end_time": "15:00",
|
|
"break_minutes": 30,
|
|
},
|
|
)
|
|
assert create_source_entry.status_code == 200
|
|
export_backup = source_client.post(
|
|
"/settings/export-all",
|
|
data={"format": "backup_json", "csrf_token": source_csrf},
|
|
)
|
|
assert export_backup.status_code == 200
|
|
backup_content = export_backup.content
|
|
|
|
with TestClient(app) as target_client:
|
|
register_page = target_client.get("/register")
|
|
assert register_page.status_code == 200
|
|
csrf_match = re.search(r'name="csrf_token" value="([^"]+)"', register_page.text)
|
|
assert csrf_match is not None
|
|
register_csrf = csrf_match.group(1)
|
|
|
|
register_submit = target_client.post(
|
|
"/register",
|
|
data={
|
|
"email": "register-import-target@example.com",
|
|
"password": "strongpasswordImport2",
|
|
"entry_mode": "manual",
|
|
"mfa_preference": "none",
|
|
"csrf_token": register_csrf,
|
|
},
|
|
files={"backup_file": ("stundenfuchs-backup.json", backup_content, "application/json")},
|
|
follow_redirects=False,
|
|
)
|
|
assert register_submit.status_code == 303
|
|
|
|
me = target_client.get("/me")
|
|
assert me.status_code == 200
|
|
payload = me.json()
|
|
assert payload["preferred_home_view"] == "month"
|
|
assert payload["entry_mode"] == "auto_until_today"
|
|
assert payload["working_days"] == [0, 1, 2, 3]
|
|
assert payload["count_vacation_as_worktime"] is True
|
|
assert payload["automatic_break_rules_enabled"] is True
|
|
|
|
|
|
def test_settings_import_accepts_legacy_backup_version_one(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "legacy-import@example.com", "password": "strongpasswordLegacy1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
export_backup = client.post(
|
|
"/settings/export-all",
|
|
data={"format": "backup_json", "csrf_token": csrf},
|
|
)
|
|
assert export_backup.status_code == 200
|
|
payload = export_backup.json()
|
|
legacy_payload = {
|
|
**payload,
|
|
"backup_version": 1,
|
|
"user": {
|
|
"email": "legacy@example.com",
|
|
"created_at": "2026-03-01T12:00:00+00:00",
|
|
"settings": payload["settings"],
|
|
},
|
|
}
|
|
del legacy_payload["settings"]
|
|
|
|
preview_response = client.post(
|
|
"/settings/import/preview",
|
|
data={"import_mode": "merge", "csrf_token": csrf},
|
|
files={"backup_file": ("legacy-backup.json", json.dumps(legacy_payload).encode("utf-8"), "application/json")},
|
|
)
|
|
assert preview_response.status_code == 200
|
|
assert "Importvorschau" in preview_response.text
|
|
|
|
|
|
def test_user_can_delete_own_account_and_related_data(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "delete-me@example.com", "password": "strongpasswordDelete1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
user_id = register.json()["id"]
|
|
|
|
create = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": "2026-03-03",
|
|
"start_time": "08:30",
|
|
"end_time": "15:00",
|
|
"break_minutes": 30,
|
|
},
|
|
)
|
|
assert create.status_code == 200
|
|
|
|
delete_account = client.post(
|
|
"/settings/account/delete",
|
|
data={
|
|
"confirm_email": "delete-me@example.com",
|
|
"current_password": "strongpasswordDelete1",
|
|
"confirm_delete": "on",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert delete_account.status_code == 303
|
|
assert delete_account.headers["location"] == "/login?msg=account_deleted"
|
|
|
|
with Session(get_engine()) as db:
|
|
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
|
entries = db.execute(select(TimeEntry).where(TimeEntry.user_id == user_id)).scalars().all()
|
|
assert user is None
|
|
assert entries == []
|
|
|
|
|
|
def test_settings_default_view_redirect(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "prefs@example.com", "password": "strongpasswordPrefs1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
update_prefs = client.post(
|
|
"/settings/preferences",
|
|
data={
|
|
"preferred_home_view": "month",
|
|
"preferred_month_view_mode": "weeks",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_prefs.status_code == 303
|
|
|
|
root_redirect = client.get("/", follow_redirects=False)
|
|
assert root_redirect.status_code == 303
|
|
assert root_redirect.headers["location"].startswith("/month?view=weeks")
|
|
|
|
dashboard_redirect = client.get("/dashboard", follow_redirects=False)
|
|
assert dashboard_redirect.status_code == 303
|
|
assert dashboard_redirect.headers["location"].startswith("/month?view=weeks")
|
|
|
|
|
|
def test_main_navigation_uses_explicit_period_links(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "nav-periods@example.com", "password": "strongpasswordNav1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
update_prefs = client.post(
|
|
"/settings/preferences",
|
|
data={
|
|
"preferred_home_view": "month",
|
|
"preferred_month_view_mode": "flat",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_prefs.status_code == 303
|
|
|
|
month_page = client.get("/month", params={"month": "2026-03", "view": "flat"})
|
|
assert month_page.status_code == 200
|
|
assert f'href="/dashboard?date={date.today().isoformat()}"' in month_page.text
|
|
assert 'href="/month?month=2026-03&view=flat"' in month_page.text
|
|
|
|
|
|
def test_overtime_start_and_expiry_rules(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "overtime@example.com", "password": "strongpasswordOver1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
set_target = client.post(
|
|
"/weekly-target",
|
|
data={
|
|
"week_start": "2026-03-02",
|
|
"weekly_target_hours": "10",
|
|
"scope": "all_weeks",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert set_target.status_code == 303
|
|
|
|
entry_week1 = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": "2026-03-02",
|
|
"start_time": "08:00",
|
|
"end_time": "20:00",
|
|
"break_minutes": 0,
|
|
},
|
|
)
|
|
assert entry_week1.status_code == 200
|
|
|
|
entry_week2 = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": "2026-03-09",
|
|
"start_time": "08:00",
|
|
"end_time": "16:00",
|
|
"break_minutes": 0,
|
|
},
|
|
)
|
|
assert entry_week2.status_code == 200
|
|
|
|
baseline = client.get("/reports/week", params={"date": "2026-03-09"})
|
|
assert baseline.status_code == 200
|
|
assert baseline.json()["cumulative_delta_minutes"] == 0
|
|
|
|
set_start_date = client.post(
|
|
"/settings/overtime",
|
|
data={
|
|
"overtime_start_date": "2026-03-09",
|
|
"overtime_expiry_days": "",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert set_start_date.status_code == 303
|
|
|
|
with_start = client.get("/reports/week", params={"date": "2026-03-09"})
|
|
assert with_start.status_code == 200
|
|
assert with_start.json()["cumulative_delta_minutes"] == -120
|
|
|
|
week_before_start = client.get("/reports/week", params={"date": "2026-03-02"})
|
|
assert week_before_start.status_code == 200
|
|
assert week_before_start.json()["weekly_ist_minutes"] == 0
|
|
assert week_before_start.json()["weekly_soll_minutes"] == 0
|
|
assert week_before_start.json()["weekly_delta_minutes"] == 0
|
|
assert week_before_start.json()["cumulative_delta_minutes"] == 0
|
|
|
|
set_expiry_keep_negative = client.post(
|
|
"/settings/overtime",
|
|
data={
|
|
"overtime_start_date": "",
|
|
"overtime_expiry_days": "3",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert set_expiry_keep_negative.status_code == 303
|
|
|
|
expiry_keep_negative = client.get("/reports/week", params={"date": "2026-03-09"})
|
|
assert expiry_keep_negative.status_code == 200
|
|
assert expiry_keep_negative.json()["cumulative_delta_minutes"] == -960
|
|
|
|
set_expiry_drop_negative = client.post(
|
|
"/settings/overtime",
|
|
data={
|
|
"overtime_start_date": "",
|
|
"overtime_expiry_days": "3",
|
|
"expire_negative_overtime": "on",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert set_expiry_drop_negative.status_code == 303
|
|
|
|
expiry_drop_negative = client.get("/reports/week", params={"date": "2026-03-09"})
|
|
assert expiry_drop_negative.status_code == 200
|
|
assert expiry_drop_negative.json()["cumulative_delta_minutes"] == -240
|
|
|
|
|
|
def test_overtime_adjustment_counts_before_overtime_start_date(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "overtime-adjustment@example.com", "password": "strongpasswordAdjust1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
set_start_date = client.post(
|
|
"/settings/overtime",
|
|
data={
|
|
"overtime_start_date": "2026-03-09",
|
|
"overtime_expiry_days": "",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert set_start_date.status_code == 303
|
|
|
|
adjustment = client.post(
|
|
"/overtime-adjustment/set",
|
|
data={
|
|
"date": "2026-03-03",
|
|
"adjustment_mode": "manual",
|
|
"adjustment_value": "-02:00",
|
|
"return_to": "/entry/new?date=2026-03-03",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert adjustment.status_code == 303
|
|
|
|
week_before_start = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_before_start.status_code == 200
|
|
payload = week_before_start.json()
|
|
assert payload["weekly_ist_minutes"] == 0
|
|
assert payload["weekly_soll_minutes"] == 0
|
|
assert payload["weekly_delta_minutes"] == -120
|
|
assert payload["cumulative_delta_minutes"] == -120
|
|
assert payload["days"][1]["overtime_adjustment_minutes"] == -120
|
|
|
|
|
|
def test_overtime_adjustment_can_be_combined_with_holiday(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "overtime-adjustment-holiday@example.com", "password": "strongpasswordAdjust2"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
set_target = client.post(
|
|
"/weekly-target",
|
|
data={
|
|
"week_start": "2026-03-02",
|
|
"weekly_target_hours": "30",
|
|
"scope": "all_weeks",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert set_target.status_code == 303
|
|
|
|
update_workdays = client.post(
|
|
"/settings/workdays",
|
|
data={
|
|
"working_days": ["0", "1", "2", "3"],
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_workdays.status_code == 303
|
|
|
|
add_holiday = client.post(
|
|
"/special-day/toggle",
|
|
data={
|
|
"date": "2026-03-03",
|
|
"status": "holiday",
|
|
"return_to": "/dashboard?date=2026-03-03",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_holiday.status_code == 303
|
|
|
|
add_full_day_adjustment = client.post(
|
|
"/overtime-adjustment/set",
|
|
data={
|
|
"date": "2026-03-03",
|
|
"adjustment_mode": "full_day",
|
|
"full_day_direction": "negative",
|
|
"return_to": "/entry/new?date=2026-03-03",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_full_day_adjustment.status_code == 303
|
|
|
|
week_report = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_report.status_code == 200
|
|
payload = week_report.json()
|
|
assert payload["days"][1]["special_status"] == "holiday"
|
|
assert payload["days"][1]["overtime_adjustment_minutes"] == -450
|
|
assert payload["weekly_ist_minutes"] == 0
|
|
assert payload["weekly_soll_minutes"] == 1350
|
|
assert payload["weekly_delta_minutes"] == -1800
|
|
|
|
|
|
def test_overtime_adjustment_interval_mode_changes_delta(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "overtime-adjustment-interval@example.com", "password": "strongpasswordAdjust3"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
baseline = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert baseline.status_code == 200
|
|
|
|
add_interval_adjustment = client.post(
|
|
"/overtime-adjustment/set",
|
|
data={
|
|
"date": "2026-03-03",
|
|
"adjustment_mode": "interval",
|
|
"interval_start_time": "08:15",
|
|
"interval_end_time": "10:45",
|
|
"interval_direction": "positive",
|
|
"return_to": "/overtime-adjustment/edit?date=2026-03-03",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_interval_adjustment.status_code == 303
|
|
|
|
updated = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert updated.status_code == 200
|
|
updated_payload = updated.json()
|
|
assert updated_payload["weekly_delta_minutes"] == baseline.json()["weekly_delta_minutes"] + 150
|
|
assert updated_payload["days"][1]["overtime_adjustment_minutes"] == 150
|
|
|
|
|
|
def test_day_forms_are_split_by_function(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "focused-forms@example.com", "password": "strongpasswordForms1"},
|
|
)
|
|
assert register.status_code == 200
|
|
|
|
time_form = client.get("/entry/new", params={"date": "2026-03-03"})
|
|
assert time_form.status_code == 200
|
|
assert "Arbeitsbeginn" in time_form.text
|
|
assert "Tagesmodus" not in time_form.text
|
|
|
|
status_form = client.get("/day-status/edit", params={"date": "2026-03-03", "status": "holiday"})
|
|
assert status_form.status_code == 200
|
|
assert "Feiertag" in status_form.text
|
|
assert "Arbeitsbeginn" not in status_form.text
|
|
|
|
overtime_form = client.get("/overtime-adjustment/edit", params={"date": "2026-03-03"})
|
|
assert overtime_form.status_code == 200
|
|
assert "Von-Bis Uhrzeit" in overtime_form.text
|
|
assert "Arbeitsbeginn" not in overtime_form.text
|
|
|
|
|
|
def test_non_working_days_can_count_as_regular_workdays(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "count-special-days@example.com", "password": "strongpasswordCount1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
update_workdays = client.post(
|
|
"/settings/workdays",
|
|
data={
|
|
"working_days": ["0", "1", "2", "3"],
|
|
"count_vacation_as_worktime": "on",
|
|
"count_holiday_as_worktime": "on",
|
|
"count_sick_as_worktime": "on",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_workdays.status_code == 303
|
|
|
|
add_vacation = client.post(
|
|
"/vacation/day/toggle",
|
|
data={"date": "2026-03-03", "return_to": "/dashboard?date=2026-03-03", "csrf_token": csrf},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_vacation.status_code == 303
|
|
|
|
add_holiday = client.post(
|
|
"/special-day/toggle",
|
|
data={"date": "2026-03-04", "status": "holiday", "return_to": "/dashboard?date=2026-03-03", "csrf_token": csrf},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_holiday.status_code == 303
|
|
|
|
add_sick = client.post(
|
|
"/special-day/toggle",
|
|
data={"date": "2026-03-05", "status": "sick", "return_to": "/dashboard?date=2026-03-03", "csrf_token": csrf},
|
|
follow_redirects=False,
|
|
)
|
|
assert add_sick.status_code == 303
|
|
|
|
week_report = client.get("/reports/week", params={"date": "2026-03-03"})
|
|
assert week_report.status_code == 200
|
|
payload = week_report.json()
|
|
assert payload["weekly_soll_minutes"] == 1500
|
|
assert payload["weekly_ist_minutes"] == 1125
|
|
assert payload["weekly_delta_minutes"] == -375
|
|
|
|
|
|
def test_auto_entry_mode_prefills_only_until_today(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "auto-mode@example.com", "password": "strongpasswordAuto1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
set_auto_mode = client.post(
|
|
"/settings/preferences",
|
|
data={
|
|
"preferred_home_view": "week",
|
|
"preferred_month_view_mode": "flat",
|
|
"entry_mode": "auto_until_today",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert set_auto_mode.status_code == 303
|
|
|
|
today = date.today()
|
|
today_items = client.get(
|
|
"/time-entries",
|
|
params={"from": today.isoformat(), "to": today.isoformat()},
|
|
)
|
|
assert today_items.status_code == 200
|
|
today_payload = today_items.json()
|
|
if today.weekday() <= 4:
|
|
assert len(today_payload["items"]) == 1
|
|
assert today_payload["items"][0]["date"] == today.isoformat()
|
|
assert today_payload["items"][0]["start_time"] == "08:30"
|
|
else:
|
|
assert len(today_payload["items"]) == 0
|
|
|
|
future_workday = today + timedelta(days=1)
|
|
while future_workday.weekday() > 4:
|
|
future_workday += timedelta(days=1)
|
|
|
|
future_items = client.get(
|
|
"/time-entries",
|
|
params={"from": future_workday.isoformat(), "to": future_workday.isoformat()},
|
|
)
|
|
assert future_items.status_code == 200
|
|
assert len(future_items.json()["items"]) == 0
|
|
|
|
|
|
def test_deleting_auto_entry_keeps_day_empty_in_auto_mode(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "auto-delete@example.com", "password": "strongpasswordAutoDelete1"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
update_workdays = client.post(
|
|
"/settings/workdays",
|
|
data={
|
|
"working_days": ["0", "1", "2", "3", "4", "5", "6"],
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert update_workdays.status_code == 303
|
|
|
|
set_auto_mode = client.post(
|
|
"/settings/preferences",
|
|
data={
|
|
"preferred_home_view": "week",
|
|
"preferred_month_view_mode": "flat",
|
|
"entry_mode": "auto_until_today",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert set_auto_mode.status_code == 303
|
|
|
|
today = date.today().isoformat()
|
|
initial_items = client.get("/time-entries", params={"from": today, "to": today})
|
|
assert initial_items.status_code == 200
|
|
assert len(initial_items.json()["items"]) == 1
|
|
entry_id = initial_items.json()["items"][0]["id"]
|
|
|
|
delete_entry = client.delete(
|
|
f"/time-entries/{entry_id}",
|
|
headers={"x-csrf-token": csrf},
|
|
)
|
|
assert delete_entry.status_code == 200
|
|
|
|
after_delete = client.get("/time-entries", params={"from": today, "to": today})
|
|
assert after_delete.status_code == 200
|
|
assert after_delete.json()["items"] == []
|
|
|
|
|
|
def test_switching_modes_remove_future_auto_entries(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "auto-manual-switch@example.com", "password": "strongpasswordAuto2"},
|
|
)
|
|
assert register.status_code == 200
|
|
csrf = register.json()["csrf_token"]
|
|
|
|
today = date.today()
|
|
future_workday = today + timedelta(days=1)
|
|
while future_workday.weekday() > 4:
|
|
future_workday += timedelta(days=1)
|
|
|
|
create_future_auto_entry = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": future_workday.isoformat(),
|
|
"start_time": "08:30",
|
|
"end_time": "14:00",
|
|
"break_minutes": 0,
|
|
"notes": "Automatisch vorausgefuellt",
|
|
},
|
|
)
|
|
assert create_future_auto_entry.status_code == 200
|
|
|
|
before_switch = client.get(
|
|
"/time-entries",
|
|
params={"from": future_workday.isoformat(), "to": future_workday.isoformat()},
|
|
)
|
|
assert before_switch.status_code == 200
|
|
assert len(before_switch.json()["items"]) == 1
|
|
|
|
enable_auto_until_today = client.post(
|
|
"/settings/preferences",
|
|
data={
|
|
"preferred_home_view": "week",
|
|
"preferred_month_view_mode": "flat",
|
|
"entry_mode": "auto_until_today",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert enable_auto_until_today.status_code == 303
|
|
|
|
after_auto_until_today = client.get(
|
|
"/time-entries",
|
|
params={"from": future_workday.isoformat(), "to": future_workday.isoformat()},
|
|
)
|
|
assert after_auto_until_today.status_code == 200
|
|
assert len(after_auto_until_today.json()["items"]) == 0
|
|
|
|
recreate_future_auto_entry = client.post(
|
|
"/time-entries",
|
|
headers={"x-csrf-token": csrf},
|
|
json={
|
|
"date": future_workday.isoformat(),
|
|
"start_time": "08:30",
|
|
"end_time": "14:00",
|
|
"break_minutes": 0,
|
|
"notes": "Automatisch vorausgefuellt",
|
|
},
|
|
)
|
|
assert recreate_future_auto_entry.status_code == 200
|
|
|
|
disable_auto = client.post(
|
|
"/settings/preferences",
|
|
data={
|
|
"preferred_home_view": "week",
|
|
"preferred_month_view_mode": "flat",
|
|
"entry_mode": "manual",
|
|
"csrf_token": csrf,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert disable_auto.status_code == 303
|
|
|
|
after_disable = client.get(
|
|
"/time-entries",
|
|
params={"from": future_workday.isoformat(), "to": future_workday.isoformat()},
|
|
)
|
|
assert after_disable.status_code == 200
|
|
assert len(after_disable.json()["items"]) == 0
|
|
|
|
|
|
def test_help_page_is_available_for_authenticated_users(app):
|
|
with TestClient(app) as client:
|
|
register = client.post(
|
|
"/auth/register",
|
|
json={"email": "help-page@example.com", "password": "strongpasswordHelp1"},
|
|
)
|
|
assert register.status_code == 200
|
|
|
|
help_page = client.get("/hilfe")
|
|
assert help_page.status_code == 200
|
|
assert "Stundenausgleich (S)" in help_page.text
|
|
assert "Arbeitsstunden-Counter" in help_page.text
|
|
assert "Schritt-für-Schritt-Anleitungen" in help_page.text
|
|
assert "gesetzliche Mindestpause" in help_page.text
|
|
|
|
|
|
def test_root_renders_guest_landing(app):
|
|
with TestClient(app) as guest_client:
|
|
landing = guest_client.get("/")
|
|
assert landing.status_code == 200
|
|
assert "Arbeitszeit, Urlaub und Überstunden an einem Ort" in landing.text
|
|
assert "Jetzt registrieren" in landing.text
|
|
assert 'href="/register"' in landing.text
|
|
assert 'href="/login"' in landing.text
|