Files
F-r-ldraledigplannerare/backend/tests/unit/test_leave_wish_router.py
T
jacobh2 e4719cb9ae Rework balances around per-parent days left and deduce the partner from FK
Manual entry now takes one parent's sjukpenningnivå, lägstanivå and
dubbeldagar left (plus optional reserved days) and derives the rest in
build_snapshot, shared with the scraper. The scraper also reads the
family panel and exposes a partner deduction; with two parents it is
saved as a 'deduced' snapshot (migration 002). Parent count is the single
source of entitlement: one member gets 480 days and 96 savable at age 4,
two get 240 and 48 each. Workspaces are capped at two parents.

Also lands the in-flight squash of migrations 002–011 into the initial
schema and the removal of planning_mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 12:27:29 +02:00

436 lines
16 KiB
Python

"""API-level test for `/api/leave-wishes` POST and PUT routes.
Tests that `target_days` and other fields round-trip correctly through the
router (i.e. are passed from the request payload to the service). This guards
against the pattern where validation accepts a field but the router drops it
before calling the service.
"""
from datetime import date, datetime
from uuid import uuid4
import aiosqlite
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app.dependencies import get_connection, get_current_user
from app.entities.personal_data import PersonalData
from app.entities.user import User
from app.entities.workspace import Workspace
from app.main import app
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
# Fixed identity for the authenticated caller in every test in this file —
# each test must also create a `PersonalData` row with this uuid in the
# workspace it seeds, so `get_current_personal_data` resolves to that
# workspace (see CLAUDE.md T1 authz work / audit §4 blast radius).
_CURRENT_USER_PD_UUID = uuid4()
@pytest_asyncio.fixture
async def client(connection: aiosqlite.Connection) -> AsyncClient:
"""An httpx client against the real app: `get_connection` and
`get_current_user` are overridden to use the test's in-memory connection
and a fixed fake user.
"""
app.dependency_overrides[get_connection] = lambda: connection
app.dependency_overrides[get_current_user] = lambda: User(
id=uuid4(),
personal_data_uuid=_CURRENT_USER_PD_UUID,
email="test@example.com",
hashed_password="not-a-real-hash",
created_at=datetime.now(),
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.pop(get_connection, None)
app.dependency_overrides.pop(get_current_user, None)
class TestLeaveWishTargetDaysRoundTrip:
"""Test that `target_days` is correctly passed from router to service."""
async def test_create_leave_wish_with_target_days(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
"""POST /api/leave-wishes with target_days should store it and return
it in the response."""
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await SQLitePersonalDataRepository(connection).create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now
)
)
target_days_value = 15
response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vårlov",
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 5,
"window_end_day": 31,
"target_weeks": 2,
"target_days": target_days_value,
"recurrence": "once",
"first_year": date.today().year + 1,
},
)
assert response.status_code == 201
body = response.json()
assert body["target_days"] == target_days_value
# Verify the value persists: list and check
listed = await client.get(f"/api/leave-wishes/{ws.id}")
assert listed.status_code == 200
assert listed.json()[0]["target_days"] == target_days_value
async def test_create_leave_wish_without_target_days_defaults_to_null(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
"""POST without target_days should store NULL (defaults to None in
Pydantic)."""
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await SQLitePersonalDataRepository(connection).create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now
)
)
response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vårlov",
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 5,
"window_end_day": 31,
"target_weeks": 2,
"recurrence": "once",
"first_year": date.today().year + 1,
},
)
assert response.status_code == 201
body = response.json()
assert body["target_days"] is None
async def test_update_leave_wish_target_days(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
"""PUT /api/leave-wishes/{id} with target_days should update it."""
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await SQLitePersonalDataRepository(connection).create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now
)
)
# Create a wish without target_days
create_response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vårlov",
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 5,
"window_end_day": 31,
"target_weeks": 2,
"recurrence": "once",
"first_year": date.today().year + 1,
},
)
assert create_response.status_code == 201
wish_id = create_response.json()["id"]
assert create_response.json()["target_days"] is None
# Update with target_days
target_days_value = 20
update_response = await client.put(
f"/api/leave-wishes/{wish_id}",
json={"target_days": target_days_value},
)
assert update_response.status_code == 200
body = update_response.json()
assert body["target_days"] == target_days_value
# Verify persistence
listed = await client.get(f"/api/leave-wishes/{ws.id}")
assert listed.status_code == 200
assert listed.json()[0]["target_days"] == target_days_value
async def test_update_leave_wish_omitting_target_days_leaves_unchanged(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
"""PUT without target_days field should leave the stored value
unchanged."""
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await SQLitePersonalDataRepository(connection).create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now
)
)
# Create a wish with target_days
target_days_value = 25
create_response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vårlov",
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 5,
"window_end_day": 31,
"target_weeks": 2,
"target_days": target_days_value,
"recurrence": "once",
"first_year": date.today().year + 1,
},
)
assert create_response.status_code == 201
wish_id = create_response.json()["id"]
# Update other fields but omit target_days
update_response = await client.put(
f"/api/leave-wishes/{wish_id}",
json={"name": "Sommarvikarie"}, # Change name only
)
assert update_response.status_code == 200
body = update_response.json()
# target_days should be unchanged
assert body["target_days"] == target_days_value
# but name should be updated
assert body["name"] == "Sommarvikarie"
async def test_update_leave_wish_clear_target_days_to_null(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
"""PUT with target_days explicitly set to null should clear it."""
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await SQLitePersonalDataRepository(connection).create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now
)
)
# Create a wish with target_days
target_days_value = 30
create_response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vårlov",
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 5,
"window_end_day": 31,
"target_weeks": 2,
"target_days": target_days_value,
"recurrence": "once",
"first_year": date.today().year + 1,
},
)
assert create_response.status_code == 201
wish_id = create_response.json()["id"]
# Update with target_days explicitly set to null
update_response = await client.put(
f"/api/leave-wishes/{wish_id}",
json={"target_days": None},
)
assert update_response.status_code == 200
body = update_response.json()
# target_days should now be None
assert body["target_days"] is None
# Verify persistence
listed = await client.get(f"/api/leave-wishes/{ws.id}")
assert listed.status_code == 200
assert listed.json()[0]["target_days"] is None
class TestLeaveWishWantsDubbeldagRoundTrip:
"""§Decision A (co-leave, opt-in dubbeldagar) — `wants_dubbeldag` round-trips
through the router exactly like `target_days` above, and is rejected
when `parent_mode` isn't `'both'`.
"""
async def test_create_defaults_to_false(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await SQLitePersonalDataRepository(connection).create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now
)
)
response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vårlov",
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 5,
"window_end_day": 31,
"target_weeks": 2,
"recurrence": "once",
"first_year": date.today().year + 1,
},
)
assert response.status_code == 201
assert response.json()["wants_dubbeldag"] is False
async def test_create_with_both_parent_mode_can_opt_in(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await SQLitePersonalDataRepository(connection).create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now
)
)
response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vårlov",
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 5,
"window_end_day": 31,
"target_weeks": 2,
"recurrence": "once",
"first_year": date.today().year + 1,
"parent_mode": "both",
"wants_dubbeldag": True,
},
)
assert response.status_code == 201
assert response.json()["wants_dubbeldag"] is True
async def test_create_rejects_wants_dubbeldag_outside_both_mode(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await SQLitePersonalDataRepository(connection).create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now
)
)
response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vårlov",
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 5,
"window_end_day": 31,
"target_weeks": 2,
"recurrence": "once",
"first_year": date.today().year + 1,
"wants_dubbeldag": True, # parent_mode defaults to 'either'
},
)
assert response.status_code == 422
async def test_update_can_toggle_it(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await SQLitePersonalDataRepository(connection).create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now
)
)
create_response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vårlov",
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 5,
"window_end_day": 31,
"target_weeks": 2,
"recurrence": "once",
"first_year": date.today().year + 1,
"parent_mode": "both",
},
)
assert create_response.status_code == 201
wish_id = create_response.json()["id"]
assert create_response.json()["wants_dubbeldag"] is False
update_response = await client.put(
f"/api/leave-wishes/{wish_id}",
json={"wants_dubbeldag": True},
)
assert update_response.status_code == 200
assert update_response.json()["wants_dubbeldag"] is True