Link planned days to their wish, fix the age-4 cap, drop the planning goal
Three changes that together unblock treating a calendar period as one editable object. Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL) so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a day the user hand-edited, and `leave_wish.target_days` so a period drawn on a calendar need not be a whole number of weeks. Apply-time identity still keys on day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update rather than a collision. The age-4 savable ceiling was applying the per-child figure of 96 to each parent's pool, modelling a two-parent family as able to save 192 days past the child's fourth birthday. `resolve_age4_savable_cap` is now the single place that decides 48 (couple with two parents) versus 96 (everyone else), and both AnalyticsService and the allocator read it. Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`. The allocator's tier-3 policy collapses to one rule; `salary_type` still branches, since that reflects what a vacation day actually costs a parent rather than a user preference.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
"""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.user import User
|
||||
from app.entities.workspace import Workspace
|
||||
from app.main import app
|
||||
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
|
||||
|
||||
|
||||
@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=uuid4(),
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
# 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))
|
||||
|
||||
# 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))
|
||||
|
||||
# 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
|
||||
Reference in New Issue
Block a user