"""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 class TestLeaveWishScopeQueryParam: """`?scope=series` widens PUT/DELETE from one year to the whole series. The calendar's period popover offers "this year" vs "every year"; the scope has to reach the service or the choice is decorative. """ async def _workspace(self, connection: aiosqlite.Connection) -> Workspace: now = datetime.now() ws = await SQLiteWorkspaceRepository(connection).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 ) ) return ws async def _create_series(self, client: AsyncClient, ws: Workspace) -> list[dict[str, object]]: first_year = date.today().year + 1 response = await client.post( "/api/leave-wishes", json={ "workspace_id": str(ws.id), "name": "Höstlov", "window_start_month": 10, "window_start_day": 5, "window_end_month": 10, "window_end_day": 11, "target_weeks": 1, "recurrence": "yearly", "first_year": first_year, "last_year": first_year + 2, }, ) assert response.status_code == 201, response.text assert response.json()["series_id"] is not None listed = await client.get(f"/api/leave-wishes/{ws.id}") assert listed.status_code == 200, listed.text return sorted(listed.json(), key=lambda row: row["first_year"]) async def test_create_yearly_returns_a_series_and_lists_one_row_per_year( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: ws = await self._workspace(connection) rows = await self._create_series(client, ws) first_year = date.today().year + 1 assert [r["first_year"] for r in rows] == [first_year, first_year + 1, first_year + 2] assert len({r["series_id"] for r in rows}) == 1 # `recurrence` is an instruction, not a response field any more. assert "recurrence" not in rows[0] async def test_update_defaults_to_this_year_only( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: ws = await self._workspace(connection) rows = await self._create_series(client, ws) response = await client.put( f"/api/leave-wishes/{rows[1]['id']}", json={"name": "Bara detta år"} ) assert response.status_code == 200, response.text after = sorted( (await client.get(f"/api/leave-wishes/{ws.id}")).json(), key=lambda r: r["first_year"] ) assert [r["name"] for r in after] == ["Höstlov", "Bara detta år", "Höstlov"] async def test_update_with_scope_series_applies_to_every_year( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: ws = await self._workspace(connection) rows = await self._create_series(client, ws) response = await client.put( f"/api/leave-wishes/{rows[1]['id']}?scope=series", json={"name": "Alla år"} ) assert response.status_code == 200, response.text after = (await client.get(f"/api/leave-wishes/{ws.id}")).json() assert {r["name"] for r in after} == {"Alla år"} assert len(after) == 3 async def test_delete_defaults_to_this_year_only( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: ws = await self._workspace(connection) rows = await self._create_series(client, ws) response = await client.delete(f"/api/leave-wishes/{rows[1]['id']}") assert response.status_code == 204, response.text after = (await client.get(f"/api/leave-wishes/{ws.id}")).json() assert [r["id"] for r in sorted(after, key=lambda r: r["first_year"])] == [ rows[0]["id"], rows[2]["id"], ] async def test_delete_with_scope_series_removes_every_year( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: ws = await self._workspace(connection) rows = await self._create_series(client, ws) response = await client.delete(f"/api/leave-wishes/{rows[1]['id']}?scope=series") assert response.status_code == 204, response.text assert (await client.get(f"/api/leave-wishes/{ws.id}")).json() == [] async def test_an_unknown_scope_is_a_422( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: ws = await self._workspace(connection) rows = await self._create_series(client, ws) response = await client.delete(f"/api/leave-wishes/{rows[0]['id']}?scope=everything") assert response.status_code == 422, response.text