014503e89b
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
56 lines
2.2 KiB
Python
56 lines
2.2 KiB
Python
"""Real 429s from the shared slowapi limiter.
|
|
|
|
`tests/conftest.py` turns rate limiting OFF by default for the whole test
|
|
session (see the comment there) — every ASGI test client in a pytest run
|
|
would otherwise share one process-global in-memory limiter keyed by remote
|
|
address, and look like a single client to it. This file is the one place
|
|
that turns the limiter back on (via the `rate_limiting_enabled` fixture) to
|
|
prove the feature itself still works end-to-end: hit `/api/auth/login`
|
|
past its declared 10/minute budget and confirm slowapi actually returns a
|
|
429, not just that our own code assumes it would.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
|
|
import aiosqlite
|
|
import pytest
|
|
import pytest_asyncio
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.dependencies import get_connection
|
|
from app.main import app
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def client(connection: aiosqlite.Connection) -> AsyncIterator[AsyncClient]:
|
|
"""Same technique as `tests/integration/test_user_journey.py`'s `client`
|
|
fixture: a real app over HTTP, with only the DB connection swapped for
|
|
the test's in-memory one.
|
|
"""
|
|
app.dependency_overrides[get_connection] = lambda: connection
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
|
yield ac
|
|
app.dependency_overrides.pop(get_connection, None)
|
|
|
|
|
|
@pytest.mark.usefixtures("rate_limiting_enabled")
|
|
async def test_login_returns_429_once_the_per_minute_budget_is_spent(
|
|
client: AsyncClient,
|
|
) -> None:
|
|
payload = {"email": "nobody@example.com", "password": "wrong password entirely"}
|
|
|
|
# /api/auth/login is limited to 10/minute (app/routers/auth.py,
|
|
# `@limiter.limit("10/minute")`). The credentials are deliberately
|
|
# wrong — slowapi counts the call before the handler body runs, and a
|
|
# 401 has no side effects to clean up afterwards, unlike registering
|
|
# ten real users would.
|
|
for _ in range(10):
|
|
resp = await client.post("/api/auth/login", json=payload)
|
|
assert resp.status_code == 401, resp.text
|
|
|
|
resp = await client.post("/api/auth/login", json=payload)
|
|
assert resp.status_code == 429, resp.text
|
|
assert "Rate limit exceeded" in resp.json()["error"]
|