Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
432 lines
13 KiB
Python
432 lines
13 KiB
Python
"""Admin-plane occurrence-preview route tests (task 5f843564).
|
|
|
|
These tests exercise ``POST /api/v1/admin/quest-definitions/occurrences-preview``
|
|
against the ASGI app with httpx, reusing the shared local-RSA-key/
|
|
stubbed-JWKS harness (:mod:`tests.admin_jwt_harness`) so an admin JWT
|
|
genuinely authenticates with NO network access.
|
|
|
|
- Authentication runs through the router's ONE ``require_admin``
|
|
dependency: no credential is 401, the panel service token is 403
|
|
(refused as a credential type), a JWT without the admin group is
|
|
403, an admin JWT is 200.
|
|
- The returned dates are asserted EXACTLY for a weekly rule with an
|
|
interval and a start_date, a daily rule with an interval, a rule
|
|
with an end_date (the list stops at it), a month-end-clamped monthly
|
|
rule, and the default start (the pinned host clock's today); one
|
|
case also asserts parity with the core's own ``occurrences_between``
|
|
over the documented window.
|
|
- A rejected rule (the same core ``from_dict`` validation create/edit
|
|
use), a non-strict ``start_date`` and a ``count`` outside 1..50 are
|
|
422.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import importlib
|
|
from types import SimpleNamespace
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
|
|
from api import routes_admin
|
|
from tests.admin_jwt_harness import (
|
|
ADMIN_KEY,
|
|
KID,
|
|
AdminRunner,
|
|
jwks_for,
|
|
make_token,
|
|
)
|
|
|
|
# The recurrence engine through the SAME core copy the app uses
|
|
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
|
_recurrence = importlib.import_module("nestquest_core.recurrence")
|
|
|
|
PREVIEW_URL = "/api/v1/admin/quest-definitions/occurrences-preview"
|
|
|
|
#: The panel service token the harness app is configured with.
|
|
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
|
|
|
#: The 403 detail for the panel service token on an admin route.
|
|
PANEL_TOKEN_REJECTION_DETAIL = (
|
|
"This credential type is not accepted for admin access"
|
|
)
|
|
|
|
#: The 403 detail for a valid JWT without the admin group.
|
|
FORBIDDEN_DETAIL = (
|
|
"Admin access requires membership in the nestquest-admins group"
|
|
)
|
|
|
|
#: The pinned host instant: Wednesday 2026-09-23, noon UTC.
|
|
PINNED_NOW = datetime.datetime(2026, 9, 23, 12, 0, 0, tzinfo=ZoneInfo("UTC"))
|
|
|
|
|
|
def _admin_headers() -> dict[str, str]:
|
|
"""The Authorization header of a valid nestquest-admins JWT."""
|
|
return {"Authorization": f"Bearer {make_token()}"}
|
|
|
|
|
|
def _weekly_rule() -> dict[str, object]:
|
|
"""Every OTHER week's Tuesday and Friday, anchored on Tue 2026-03-03."""
|
|
return {
|
|
"rule_type": "weekly",
|
|
"interval": 2,
|
|
"weekday_set": [1, 4],
|
|
"start_date": "2026-03-03",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
async def preview(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
|
"""An admin-plane app client with the host clock pinned (and counted)."""
|
|
clock_reads: list[datetime.datetime] = []
|
|
|
|
def _admin_now() -> datetime.datetime:
|
|
clock_reads.append(PINNED_NOW)
|
|
return PINNED_NOW
|
|
|
|
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
|
|
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
|
async with runner as client:
|
|
yield SimpleNamespace(client=client, clock_reads=clock_reads)
|
|
|
|
|
|
async def _post(client: object, body: dict[str, object]) -> object:
|
|
"""POST a preview body with an admin JWT."""
|
|
return await client.post(PREVIEW_URL, json=body, headers=_admin_headers())
|
|
|
|
|
|
# --- authentication: the ONE require_admin dependency ----------------------
|
|
|
|
|
|
async def test_preview_without_credential_is_401(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""No Authorization header is the uniform 401."""
|
|
response = await preview.client.post(
|
|
PREVIEW_URL, json={"rule": _weekly_rule()}
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_preview_refuses_panel_service_token(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""The panel service token is refused as a credential type (403)."""
|
|
response = await preview.client.post(
|
|
PREVIEW_URL,
|
|
json={"rule": _weekly_rule()},
|
|
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
|
|
|
|
|
async def test_preview_refuses_non_admin_jwt(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""A valid JWT without the nestquest-admins group is 403."""
|
|
non_admin = make_token(groups=("some-other-group",))
|
|
response = await preview.client.post(
|
|
PREVIEW_URL,
|
|
json={"rule": _weekly_rule()},
|
|
headers={"Authorization": f"Bearer {non_admin}"},
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
|
|
|
|
|
async def test_preview_admin_jwt_is_200(preview: SimpleNamespace) -> None:
|
|
"""An admin JWT is 200 with the documented ``dates`` shape."""
|
|
response = await _post(
|
|
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
|
|
)
|
|
assert response.status_code == 200
|
|
assert set(response.json()) == {"dates"}
|
|
|
|
|
|
# --- the returned dates ----------------------------------------------------
|
|
|
|
|
|
async def test_weekly_interval_rule_from_before_its_start(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""Every other week's Tue/Fri, starting at the rule's own start_date.
|
|
|
|
The preview starts on Sun 2026-03-01, before the rule's anchor, so
|
|
the first date is the anchor itself; the off weeks (Mar 10-16,
|
|
Mar 24-30) are skipped.
|
|
"""
|
|
response = await _post(
|
|
preview.client,
|
|
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": 6},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["dates"] == [
|
|
"2026-03-03",
|
|
"2026-03-06",
|
|
"2026-03-17",
|
|
"2026-03-20",
|
|
"2026-03-31",
|
|
"2026-04-03",
|
|
]
|
|
|
|
|
|
async def test_weekly_interval_rule_from_mid_cadence(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""A start inside an on-week keeps the rule's anchor cadence."""
|
|
response = await _post(
|
|
preview.client,
|
|
{"rule": _weekly_rule(), "start_date": "2026-03-18", "count": 4},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["dates"] == [
|
|
"2026-03-20",
|
|
"2026-03-31",
|
|
"2026-04-03",
|
|
"2026-04-14",
|
|
]
|
|
|
|
|
|
async def test_daily_interval_rule(preview: SimpleNamespace) -> None:
|
|
"""Every third day from 2026-01-01, previewed from 2026-01-05."""
|
|
response = await _post(
|
|
preview.client,
|
|
{
|
|
"rule": {
|
|
"rule_type": "daily",
|
|
"interval": 3,
|
|
"start_date": "2026-01-01",
|
|
},
|
|
"start_date": "2026-01-05",
|
|
"count": 4,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["dates"] == [
|
|
"2026-01-07",
|
|
"2026-01-10",
|
|
"2026-01-13",
|
|
"2026-01-16",
|
|
]
|
|
|
|
|
|
async def test_rule_with_end_date_stops_at_it(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""A daily rule ending 2026-05-04 yields only its four dates."""
|
|
response = await _post(
|
|
preview.client,
|
|
{
|
|
"rule": {
|
|
"rule_type": "daily",
|
|
"start_date": "2026-05-01",
|
|
"end_date": "2026-05-04",
|
|
},
|
|
"start_date": "2026-04-28",
|
|
"count": 10,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["dates"] == [
|
|
"2026-05-01",
|
|
"2026-05-02",
|
|
"2026-05-03",
|
|
"2026-05-04",
|
|
]
|
|
|
|
|
|
async def test_monthly_day_rule_clamps_to_month_end(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""The core's month-end policy: the 31st fires on Feb 28th."""
|
|
response = await _post(
|
|
preview.client,
|
|
{
|
|
"rule": {
|
|
"rule_type": "monthly_day",
|
|
"day_of_month": 31,
|
|
"start_date": "2026-01-31",
|
|
},
|
|
"start_date": "2026-01-01",
|
|
"count": 3,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["dates"] == [
|
|
"2026-01-31",
|
|
"2026-02-28",
|
|
"2026-03-31",
|
|
]
|
|
|
|
|
|
async def test_default_start_is_host_today_with_default_count(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""No start_date: ONE clock read's date; no count: ten dates."""
|
|
response = await _post(preview.client, {"rule": {"rule_type": "daily"}})
|
|
assert response.status_code == 200
|
|
expected = [
|
|
(datetime.date(2026, 9, 23) + datetime.timedelta(days=n)).isoformat()
|
|
for n in range(10)
|
|
]
|
|
assert response.json()["dates"] == expected
|
|
assert preview.clock_reads == [PINNED_NOW]
|
|
|
|
|
|
async def test_supplied_start_date_reads_no_clock(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""A supplied start_date is used as-is: the host clock is not read."""
|
|
response = await _post(
|
|
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
|
|
)
|
|
assert response.status_code == 200
|
|
assert preview.clock_reads == []
|
|
|
|
|
|
async def test_dates_match_core_occurrences_between_over_window(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""The route's dates ARE the core's walk over the 366-day window."""
|
|
rule_body = {
|
|
"rule_type": "monthly_weekday",
|
|
"nth_weekday": -1,
|
|
"nth_weekday_weekday": 4,
|
|
"start_date": "2026-01-01",
|
|
}
|
|
response = await _post(
|
|
preview.client,
|
|
{"rule": rule_body, "start_date": "2026-02-10", "count": 50},
|
|
)
|
|
assert response.status_code == 200
|
|
core_dates = _recurrence.occurrences_between(
|
|
_recurrence.ScheduleRule.from_dict(rule_body),
|
|
"2026-02-10",
|
|
"2027-02-11",
|
|
)
|
|
assert response.json()["dates"] == core_dates
|
|
assert len(core_dates) == 12
|
|
|
|
|
|
async def test_yearly_rule_shows_next_occurrence_within_window(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""The 366-day window always reaches a yearly rule's next date."""
|
|
response = await _post(
|
|
preview.client,
|
|
{
|
|
"rule": {
|
|
"rule_type": "yearly",
|
|
"month": 9,
|
|
"day_of_month": 22,
|
|
"start_date": "2020-09-22",
|
|
},
|
|
"start_date": "2026-09-23",
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["dates"] == ["2027-09-22"]
|
|
|
|
|
|
async def test_start_near_calendar_end_is_422(
|
|
preview: SimpleNamespace,
|
|
) -> None:
|
|
"""A window that would reach 9999-12-31 is rejected, not clamped."""
|
|
response = await _post(
|
|
preview.client,
|
|
{"rule": {"rule_type": "daily"}, "start_date": "9999-12-30"},
|
|
)
|
|
assert response.status_code == 422
|
|
assert "too close to the end of the calendar" in response.json()["detail"]
|
|
|
|
|
|
async def test_latest_accepted_start_date(preview: SimpleNamespace) -> None:
|
|
"""The last start whose window ends before 9999-12-31 is accepted."""
|
|
response = await _post(
|
|
preview.client,
|
|
{
|
|
"rule": {"rule_type": "daily"},
|
|
"start_date": "9998-12-29",
|
|
"count": 1,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["dates"] == ["9998-12-29"]
|
|
|
|
|
|
# --- rejections: 422 -------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"rule",
|
|
[
|
|
{"rule_type": "weekly"}, # weekly without weekday_set
|
|
{"rule_type": "fortnightly"}, # unknown rule_type
|
|
{"rule_type": "daily", "interval": 0},
|
|
{"rule_type": "daily", "weekday_set": [1]}, # forbidden field
|
|
{"rule_type": "daily", "start_date": "2026-3-1"},
|
|
{
|
|
"rule_type": "daily",
|
|
"start_date": "2026-05-04",
|
|
"end_date": "2026-05-01",
|
|
},
|
|
],
|
|
)
|
|
async def test_malformed_rule_is_422(
|
|
preview: SimpleNamespace, rule: dict[str, object]
|
|
) -> None:
|
|
"""A rule the core's from_dict rejects is 422, as on create/edit."""
|
|
response = await _post(
|
|
preview.client, {"rule": rule, "start_date": "2026-03-01"}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
async def test_missing_rule_is_422(preview: SimpleNamespace) -> None:
|
|
"""The rule is required: the body model's 422."""
|
|
response = await _post(preview.client, {"start_date": "2026-03-01"})
|
|
assert response.status_code == 422
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"start_date", ["2026-3-1", "20260301", "tomorrow", "2026-02-30", ""]
|
|
)
|
|
async def test_non_strict_start_date_is_422(
|
|
preview: SimpleNamespace, start_date: str
|
|
) -> None:
|
|
"""start_date must be a strict YYYY-MM-DD date (the core's check)."""
|
|
response = await _post(
|
|
preview.client, {"rule": _weekly_rule(), "start_date": start_date}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
@pytest.mark.parametrize("count", [0, -1, 51, 1000, True, "5", 2.5])
|
|
async def test_count_out_of_range_is_422(
|
|
preview: SimpleNamespace, count: object
|
|
) -> None:
|
|
"""count must be a strict JSON integer in 1..50."""
|
|
response = await _post(
|
|
preview.client,
|
|
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": count},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
@pytest.mark.parametrize("count", [1, 50])
|
|
async def test_count_bounds_are_inclusive(
|
|
preview: SimpleNamespace, count: int
|
|
) -> None:
|
|
"""count 1 and 50 are accepted and truncate the list exactly."""
|
|
response = await _post(
|
|
preview.client,
|
|
{
|
|
"rule": {"rule_type": "daily"},
|
|
"start_date": "2026-01-01",
|
|
"count": count,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert len(response.json()["dates"]) == count
|