Spend vacation on the days worth having, and name the ones about to expire
The planner knew exactly how many semesterdagar a parent was about to forfeit and never placed a single day to prevent it. It also had no weekend-wrap logic at all: REASON_VACATION_WRAP_WEEKEND was stamped on every tier-3 interior vacation day, including a lone Tuesday in February. Two changes. Spend better. A workday adjacent to a free stretch, or in the middle of a gap of three or fewer fenced by free days, now takes vacation -- _vacation_bridge_reason, consulted for every parent type. This departs from spec 6.1's "wrapping suppressed for hourly": the FP-preservation argument is salary-type independent, only the pay argument was hourly-moot. In a both-parents window, both now take vacation on such a date rather than one paying with FP. After funding, reallocate_vacation_by_value swaps vacation onto higher-ranked dates 1-for-1 with FP days, so which dates hold vacation improves while how many never drops -- greedy first, re-timed after, never withheld. An invariant test asserts the count can only rise. Name the losses. A new pass proposes concrete dates for vacation that would otherwise expire, current year only, ranked red-day bridge then weekend bridge then interior, later dates first. They ride on PlanProposal.rescue_days, kept off `days` so nothing downstream commits them, and surface on the Dashboard with accept-one, accept-all (one batched /api/plan/apply, not N) and dismiss. Dismissal reuses the tip_dismissal table, keyed on (parent, year) identity rather than the group's dates -- those are recomputed from the whole plan's residual on every propose, so date-hashing made dismissal self-defeating. Dismiss does not bump the plan version; it changes no data and must not trigger a write. Measured on the seeded demo: 12 forfeiting days for one parent now yield 12 proposed dates, all Fridays or Mondays. Total vacation placed is unchanged at 203 days, confirming the reallocation only re-times. Three defects here were found by driving a browser, while 2273 unit tests passed -- all three lived in the seam between layers that unit tests mock away. Adds API-level tests over the real request sequence. Rate limiting is now off in tests (one file consumed the whole registration budget) with the suite's first real 429 assertion. PROPOSE_VS_SYNC_BUG.md documents a pre-existing, unfixed issue found along the way: Dashboard figures swing on the first sync with no user action. Backend 946 tests, frontend 1341, coverage above all gates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm
This commit is contained in:
@@ -6,12 +6,13 @@ Follow the `vacation_repo` / `vacation_service` pattern to add fixtures for
|
||||
other entities.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
@@ -22,6 +23,7 @@ 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.rate_limit import limiter
|
||||
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
|
||||
from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository
|
||||
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
||||
@@ -58,6 +60,51 @@ from app.services.vacation_days_service import VacationDaysService
|
||||
from app.services.workspace_service import WorkspaceService
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiting — OFF by default for the whole test session.
|
||||
#
|
||||
# `limiter` (app/rate_limit.py) keys on remote address via slowapi's
|
||||
# `get_remote_address`, which assumes one IP == one client. Every test in a
|
||||
# pytest run shares the same `app.main.app` instance and drives it through
|
||||
# `httpx.ASGITransport`, so the whole session looks like one client to the
|
||||
# limiter — the same problem `frontend/playwright.config.ts` already works
|
||||
# around for the E2E suite via `APP_RATE_LIMIT_ENABLED=false`. Left on here,
|
||||
# `tests/integration/test_user_journey.py` alone makes exactly ten
|
||||
# `/api/auth/register` calls, spending the entire 10/minute budget, so any
|
||||
# other integration test that registers a user 429s if it happens to run
|
||||
# afterwards in the same process — a suite that was only green by accident
|
||||
# of file ordering.
|
||||
#
|
||||
# We flip the already-constructed `limiter` object directly rather than
|
||||
# `settings.rate_limit_enabled`: `Limiter(..., enabled=...)` reads that
|
||||
# setting once at import time in app/rate_limit.py, so mutating `settings`
|
||||
# afterwards would no longer reach the singleton the app actually calls.
|
||||
# Production is unaffected — `settings.rate_limit_enabled` still defaults to
|
||||
# True, so a deployed process still builds its limiter enabled; only this
|
||||
# pytest process ever runs the line below.
|
||||
limiter.enabled = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rate_limiting_enabled() -> Iterator[None]:
|
||||
"""Opt-in fixture for a test that specifically exercises rate limiting.
|
||||
|
||||
Turns the shared limiter back on and clears its in-memory counters
|
||||
first — in case some earlier test's traffic ever slipped through while
|
||||
it was "enabled" — then restores both the disabled default and a clean
|
||||
counter set afterwards. `limiter` is a process-global in-memory
|
||||
limiter, so without this a test that enables it would leak state into
|
||||
whichever test runs next.
|
||||
"""
|
||||
limiter.reset()
|
||||
limiter.enabled = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
limiter.enabled = False
|
||||
limiter.reset()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeededIds:
|
||||
workspace_id: UUID
|
||||
|
||||
Reference in New Issue
Block a user