"""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