6d91f153d7
Vacation rescue/wrap in allocation_service, new plan models/endpoints, VacationRescueTip, useRescueDayActions, vacationSwaps util, sv+en i18n, Dashboard/Calendar wiring. Conflicts came from main's "day assertions" restructure and the earlier merges; both intents kept: - plan.py: both import sets; main's sync-with-wish handler kept next to the branch's new /rescue-days/dismiss endpoint; HTTP_422_UNPROCESSABLE_CONTENT applied to the branch's code (and a survivor in auth.py). - Accepting a rescue day now goes through POST /api/day-assertions (an ad-hoc 'vacation' assertion, no wish) instead of /plan/apply with an `is_pinned` flag that no longer exists: on main a pin IS a day assertion (see PlanningService.add_planned_day's docstring), and an assertion is what keeps a later sync from retyping the day. useRescueDayActions, its tests, VacationRescueTip's tests and the backend integration flow follow that contract; applyPlan stays as a client for the live endpoint minus the dropped field. The hook also gets the security branch's useLatestRequest guard with an unconditionally cleared busy flag. - Calendar.tsx: `locale` threaded into main's applyWishSyncResult call of buildSyncToastMessage (the branch's new parameter); the branch's swap-visibility toast tests drive the preset path through syncWithWish, which is how main saves a wish now. - types/api.ts: ProposedDayApplyItem kept (used by applyPlan) without isPinned; the Add/UpdatePlannedDayRequest types stay removed as on main. - fixtures/plan.ts: ResourceShortage import dropped (type removed on main). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
300 lines
14 KiB
Python
300 lines
14 KiB
Python
"""API-level flow tests for §vacation-rescue — the accept/dismiss/re-sync
|
|
|
|
sequence a real browser session drives, which no unit test walks (a unit
|
|
test mocks the seam between layers where all three defects below lived).
|
|
|
|
Three defects shipped in the vacation-rescue feature and were only caught by
|
|
manual browser testing, while 2273 unit tests stayed green:
|
|
|
|
1. Dismissing a rescue group did not stick — the dismissal fingerprinted the
|
|
group's volatile date list, and dismissing triggered a plan sync that
|
|
changed those dates, so the group always returned on the next propose.
|
|
Fixed by hashing the group's IDENTITY (`parent_personal_data_uuid`,
|
|
`year`) instead — see `allocation_service._rescue_group_hash_data`.
|
|
2. A dismiss triggered `POST /api/plan/sync`, which *writes* the plan, from
|
|
an action that should change no data.
|
|
3. The same parent-year vacation expiry was reported by three different
|
|
cards with three different numbers.
|
|
|
|
This file drives the real FastAPI app over HTTP (`ASGITransport`), reusing
|
|
`test_user_journey.py`'s `client` fixture and setup helpers — see that
|
|
file's own module docstring for why (every existing test below the API
|
|
layer exercises one layer in isolation and so never walked this sequence).
|
|
|
|
Rather than hand-building an `AllocationInput` with `vacation_forfeiting`
|
|
pre-populated (as the allocator's own unit tests do), every scenario here
|
|
gets a REAL forfeiture forecast for free: a workspace with a child and no
|
|
`vacation_days` row entered for the current year defaults to the statutory
|
|
entitlement (`settings.rules.statutory_vacation_days_per_year`, 25) with
|
|
`is_assumed=True`, and with zero vacation used so far this year, the
|
|
FIFO carry-forward simulation (`AnalyticsService._vacation_year_series`)
|
|
forfeits `earned - min_vacation_days_per_year` (25 - 20 = 20) days for
|
|
*every* parent with no explicit override — see
|
|
`AllocationInputLoader.load`'s `vacation_forfeiting` computation and
|
|
`_Ledger.build_rescue_days`. No `_set_vacation` call for the current year is
|
|
needed (or wanted) anywhere in this file for that reason.
|
|
|
|
Two independent (parent, year) rescue groups are needed — one to accept
|
|
down to zero (proving "shrinks then disappears" via a live, never-dismissed
|
|
propose response) and one to dismiss (proving persistence-across-recompute
|
|
and the no-write guarantee) without the two actions masking each other's
|
|
observable state. A single-parent workspace can't do that (there's only one
|
|
group), so the main scenario adds a placeholder partner (`Sam`) — the same
|
|
`POST /api/workspace/{id}/parents` endpoint `test_user_journey.py`'s
|
|
`TestWorkspaceParentsRenameAndRemove` exercises — giving two independent
|
|
forfeiture forecasts (Alex's and Sam's) off ONE registration.
|
|
`/api/auth/register` is rate-limited to 10/minute and shares its in-memory
|
|
counter with every other test in the process (see `test_user_journey.py`'s
|
|
own docstrings on this) — that file alone already uses all 10 registrations
|
|
in a single run, leaving no budget for a second file to register anyone
|
|
else, so this file makes exactly ONE registration total (the workspace-
|
|
isolation guard below uses fresh random uuids instead of a second tenant —
|
|
see that section's comment for why that's equally valid).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from typing import Any
|
|
from uuid import UUID, uuid4
|
|
|
|
import aiosqlite
|
|
from dateutil.relativedelta import relativedelta
|
|
from httpx import AsyncClient
|
|
|
|
from tests.integration.test_user_journey import ( # noqa: F401 (client is a fixture)
|
|
_add_child,
|
|
_list_planned_days,
|
|
_propose,
|
|
_register,
|
|
_sync,
|
|
client,
|
|
)
|
|
|
|
|
|
async def _dismiss(
|
|
http_client: AsyncClient,
|
|
headers: dict[str, str],
|
|
workspace_id: UUID,
|
|
parent_personal_data_uuid: UUID,
|
|
year: int,
|
|
) -> Any:
|
|
return await http_client.post(
|
|
"/api/plan/rescue-days/dismiss",
|
|
headers=headers,
|
|
json={
|
|
"workspace_id": str(workspace_id),
|
|
"parent_personal_data_uuid": str(parent_personal_data_uuid),
|
|
"year": year,
|
|
},
|
|
)
|
|
|
|
|
|
async def _accept_rescue_days(
|
|
http_client: AsyncClient,
|
|
headers: dict[str, str],
|
|
workspace_id: UUID,
|
|
rescue_days: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Accept a batch of `RescueDayResponse` entries as real vacation days —
|
|
|
|
one `POST /api/day-assertions` per day, exactly as the frontend's
|
|
`useRescueDayActions` does. A concrete `day_type` with `wish_id: None`
|
|
is the ad-hoc user assertion (restructure step 3b): it mirrors into
|
|
`planned_leave_day` and `PlanningService.pinned_keys` keeps a later sync
|
|
from ever retyping a hand-accepted rescue day.
|
|
"""
|
|
created: list[dict[str, Any]] = []
|
|
for r in rescue_days:
|
|
resp = await http_client.post(
|
|
"/api/day-assertions",
|
|
headers=headers,
|
|
json={
|
|
"workspace_id": str(workspace_id),
|
|
"child_id": r["child_id"],
|
|
"parent_personal_data_uuid": r["parent_personal_data_uuid"],
|
|
"date": r["date"],
|
|
"day_type": "vacation",
|
|
"is_dubbeldag": False,
|
|
"wish_id": None,
|
|
},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
created.append(resp.json())
|
|
return created
|
|
|
|
|
|
def _rescue_group(proposal: dict[str, Any], parent_uuid: UUID, year: int) -> list[dict[str, Any]]:
|
|
return [
|
|
r
|
|
for r in proposal["rescue_days"]
|
|
if r["parent_personal_data_uuid"] == str(parent_uuid) and r["year"] == year
|
|
]
|
|
|
|
|
|
async def _planned_leave_rows(connection: aiosqlite.Connection) -> list[tuple[Any, ...]]:
|
|
"""Every `planned_leave_day` row, as a stable, comparable tuple list —
|
|
|
|
used to assert a dismiss call is byte-identical no-op at the DB layer
|
|
(defect 2), not just "the response looked like a no-op".
|
|
"""
|
|
cursor = await connection.execute("SELECT * FROM planned_leave_day ORDER BY id")
|
|
rows = await cursor.fetchall()
|
|
return [tuple(row) for row in rows]
|
|
|
|
|
|
class TestVacationRescueApiFlow:
|
|
"""One registration (Alex + placeholder Sam + one child) walks every
|
|
|
|
rescue-day sequence a real browser session would: two independent
|
|
(parent, year) forfeiture groups let the accept-to-zero flow (Sam) and
|
|
the dismiss flow (Alex) run without either masking the other's
|
|
observable state — see this module's docstring for why both are needed
|
|
and why neither `_set_vacation` call for the current year appears.
|
|
"""
|
|
|
|
async def test_accept_dismiss_and_isolation_sequence(
|
|
self,
|
|
client: AsyncClient, # noqa: F811 (pytest fixture injection needs this exact name)
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
headers, ws_id, pd_alex = await _register(client, "vacation-rescue@example.com", "Alex")
|
|
birth_date = date.today() - relativedelta(months=6)
|
|
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
|
|
|
add_resp = await client.post(
|
|
f"/api/workspace/{ws_id}/parents", headers=headers, json={"display_name": "Sam"}
|
|
)
|
|
assert add_resp.status_code == 201, add_resp.text
|
|
pd_sam = UUID(add_resp.json()["uuid"])
|
|
|
|
current_year = date.today().year
|
|
|
|
proposal = await _propose(client, headers, ws_id)
|
|
group_alex = _rescue_group(proposal, pd_alex, current_year)
|
|
group_sam = _rescue_group(proposal, pd_sam, current_year)
|
|
assert group_alex, "expected a real current-year vacation-rescue group for Alex"
|
|
assert group_sam, "expected a real current-year vacation-rescue group for Sam"
|
|
# Vacation is taken per-parent, not a shared family resource, so the
|
|
# two groups' dates may legitimately coincide (same ranking, same
|
|
# eligible calendar) — only the (parent, year) IDENTITY need be
|
|
# independent, which `_rescue_group`'s own filtering already proves.
|
|
|
|
# -----------------------------------------------------------------
|
|
# 1) Accept, batched; the group shrinks; it disappears at residual 0;
|
|
# an accepted day survives a later sync. Uses Sam's group, which
|
|
# is NEVER dismissed, so its live propose count directly reflects
|
|
# the real residual throughout (a dismissed group reads empty
|
|
# regardless of residual — see the dismiss flow below — so it
|
|
# can't be used to observe shrinking).
|
|
# -----------------------------------------------------------------
|
|
sam_total = len(group_sam)
|
|
assert sam_total >= 2, (
|
|
f"need at least 2 eligible rescue-day candidates for Sam to exercise a partial "
|
|
f"accept before full acceptance, got {sam_total} "
|
|
f"(too few weekdays left in {current_year}?)"
|
|
)
|
|
first_batch = group_sam[: sam_total // 2]
|
|
assert first_batch
|
|
|
|
accepted_1 = await _accept_rescue_days(client, headers, ws_id, first_batch)
|
|
assert len(accepted_1) == len(first_batch)
|
|
assert all(a["day_type"] == "vacation" for a in accepted_1)
|
|
|
|
proposal_2 = await _propose(client, headers, ws_id)
|
|
group_sam_2 = _rescue_group(proposal_2, pd_sam, current_year)
|
|
assert len(group_sam_2) == sam_total - len(first_batch), (
|
|
"the group must shrink by exactly the number of accepted days"
|
|
)
|
|
assert {r["date"] for r in group_sam_2}.isdisjoint({r["date"] for r in first_batch})
|
|
# Alex's group is untouched by anything done to Sam's.
|
|
assert len(_rescue_group(proposal_2, pd_alex, current_year)) == len(group_alex)
|
|
|
|
accepted_2 = await _accept_rescue_days(client, headers, ws_id, group_sam_2)
|
|
assert len(accepted_2) == len(group_sam_2)
|
|
|
|
proposal_3 = await _propose(client, headers, ws_id)
|
|
assert _rescue_group(proposal_3, pd_sam, current_year) == [], (
|
|
"the group must disappear entirely once its residual reaches 0"
|
|
)
|
|
|
|
all_sam_dates = {r["date"] for r in group_sam}
|
|
planned = await _list_planned_days(client, headers, child_id)
|
|
sam_planned = {
|
|
d["date"]: d for d in planned if d["parent_personal_data_uuid"] == str(pd_sam)
|
|
}
|
|
assert set(sam_planned) == all_sam_dates, "every accepted rescue date must be persisted"
|
|
for row in sam_planned.values():
|
|
assert row["day_type"] == "vacation"
|
|
assert row["wish_id"] is None
|
|
assert row["is_pinned"] is True
|
|
|
|
# A following sync must leave the accepted (pinned) days untouched.
|
|
await _sync(client, headers, ws_id)
|
|
planned_after_sync = await _list_planned_days(client, headers, child_id)
|
|
sam_after_sync = {
|
|
d["date"]: d
|
|
for d in planned_after_sync
|
|
if d["parent_personal_data_uuid"] == str(pd_sam)
|
|
}
|
|
assert set(sam_after_sync) == all_sam_dates
|
|
for row in sam_after_sync.values():
|
|
assert row["day_type"] == "vacation"
|
|
assert row["is_pinned"] is True
|
|
|
|
# -----------------------------------------------------------------
|
|
# 2) Workspace isolation: a foreign workspace id or parent uuid is
|
|
# rejected and never touches the real (still fully intact,
|
|
# never-dismissed) Alex group. `check_workspace`/`check_member`
|
|
# (app/dependencies.py) 404 identically on a missing OR a
|
|
# genuinely foreign id BY DESIGN ("a foreign id can never be
|
|
# distinguished from a nonexistent one" — see their docstrings),
|
|
# so a fresh random uuid exercises exactly the same guard a real
|
|
# second tenant's id would, without a second `/api/auth/register`
|
|
# call: that endpoint is rate-limited to 10/minute and shares its
|
|
# in-memory counter with every other test in the process —
|
|
# `test_user_journey.py` alone already uses all 10 in one run, so
|
|
# this file keeps its own total to the ONE registration above.
|
|
# -----------------------------------------------------------------
|
|
foreign_workspace_resp = await _dismiss(client, headers, uuid4(), pd_alex, current_year)
|
|
assert foreign_workspace_resp.status_code == 404, foreign_workspace_resp.text
|
|
|
|
foreign_parent_resp = await _dismiss(client, headers, ws_id, uuid4(), current_year)
|
|
assert foreign_parent_resp.status_code == 404, foreign_parent_resp.text
|
|
|
|
proposal_after_guard = await _propose(client, headers, ws_id)
|
|
group_alex_after_guard = _rescue_group(proposal_after_guard, pd_alex, current_year)
|
|
assert {r["date"] for r in group_alex_after_guard} == {r["date"] for r in group_alex}, (
|
|
"a rejected dismiss attempt (foreign workspace or parent) must have no effect on "
|
|
"the real group"
|
|
)
|
|
|
|
# -----------------------------------------------------------------
|
|
# 3) A dismiss writes nothing at the DB layer (defect 2), and it
|
|
# persists across a recompute — including the exact repro step,
|
|
# a second propose after a sync (defect 1). Uses Alex's group.
|
|
# -----------------------------------------------------------------
|
|
before_rows = await _planned_leave_rows(connection)
|
|
|
|
legit_dismiss_resp = await _dismiss(client, headers, ws_id, pd_alex, current_year)
|
|
assert legit_dismiss_resp.status_code == 204, legit_dismiss_resp.text
|
|
assert legit_dismiss_resp.content == b""
|
|
|
|
after_rows = await _planned_leave_rows(connection)
|
|
assert after_rows == before_rows, (
|
|
"dismissing a rescue-day group must write NOTHING to planned_leave_day"
|
|
)
|
|
|
|
proposal_4 = await _propose(client, headers, ws_id)
|
|
assert _rescue_group(proposal_4, pd_alex, current_year) == [], (
|
|
"the dismissed group must not reappear on the very next propose"
|
|
)
|
|
|
|
await _sync(client, headers, ws_id)
|
|
|
|
proposal_5 = await _propose(client, headers, ws_id)
|
|
assert _rescue_group(proposal_5, pd_alex, current_year) == [], (
|
|
"Defect 1 repro: the dismissed group must stay gone through a propose -> sync -> "
|
|
"propose cycle, not resurface once the plan is recomputed"
|
|
)
|