Files
NestQuest/tests/test_api_admin_presence.py
T
overseerandClaude Opus 5.5 b91ecbd5f6 Reject non-integer weekday values in presence pattern requests
The create and edit pattern models typed weekday values as lax int, so
JSON true, "1" and 1.0 were coerced to weekday 1. Type them StrictInt
so each is a 422 naming the pattern field, and add create and edit
regression cases for bool, string, float and out-of-range weekdays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:55:43 -04:00

1012 lines
36 KiB
Python

"""Admin-plane presence route tests (task e4a9d31b; schema 9 patterns).
These tests exercise the admin plane's presence routes 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 — signature, issuer, audience, expiry and the
nestquest-admins group — with NO network access.
Every done-condition case is proven against the DATABASE and the DOMAIN,
read through the SAME core copy the app uses (``nestquest_core.*``; see
the two-copy caveat in api/nestquest_core.py):
- Authentication runs through the router's ONE ``require_admin``
dependency on EVERY presence route (the four pattern routes and the
override routes): an admin JWT passes, a JWT without the group or the
panel service token is 403, an absent credential is 401 — and
nothing is written.
- ``POST /api/v1/admin/children/{id}/presence-patterns`` ADDS a
pattern: a child has zero or more, each create inserts a new row
(never replacing an earlier one). ``PATCH
/api/v1/admin/presence-patterns/{id}`` changes only the supplied
fields; ``DELETE`` removes the one pattern. The removed one-per-child
``/presence-schedule`` route no longer exists.
- ``POST /api/v1/admin/presence-overrides`` creates the override (its
``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}``
removes it again.
- The child's RESOLVED presence changes accordingly: the pure
:class:`~nestquest_core.presence.PresenceEngine` built from the
stored rows answers differently before/after each write — a home
pattern restricts the child's present weekdays, an away pattern beats
a home one, an override beats every pattern for its range, and
deleting the override (or the pattern) restores the earlier answer.
- The regeneration side effect lives in the core layer the route
calls: adding an all-absent home pattern through the route deletes
the child's future open quest instances and they are NOT rebuilt
while the child is absent every day; deleting that pattern rebuilds
them — the same proof the HA service test makes, through the API.
Error mapping (documented in api/routes_admin.py): a core ValueError
for a non-existent child (pattern or override create) is 404 "Child not
found", a non-existent pattern (edit/delete) 404 "Presence pattern not
found", a non-existent override (delete) 404; every other core
ValueError — a rejected pattern (name, kind, cycle length, pattern
coverage, anchor date) or override (malformed or inverted dates, an
overlapping range) — is 422 naming the field, the same failure class
FastAPI reports for malformed bodies.
"""
from __future__ import annotations
import datetime
import importlib
from types import SimpleNamespace
import pytest
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
# The presence model, DAOs, business layer and materializer through the
# SAME core copy the app uses (nestquest_core.*, NOT the conftest-stubbed
# custom_components copy) — used for the tests' database and resolved-
# presence assertions only; the routes reach them through api.nestquest_core.
_children = importlib.import_module("nestquest_core.children")
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
_materialize = importlib.import_module("nestquest_core.materialize")
_presence = importlib.import_module("nestquest_core.presence")
#: A Monday anchoring every pattern: cycle week 0 starts here.
ANCHOR_MONDAY = "2026-01-05"
#: The Tuesday of that week — absent under the test home pattern.
TUESDAY = "2026-01-06"
#: 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"
)
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _patterns_url(child_id: int) -> str:
"""The child-scoped pattern list/create route."""
return f"/api/v1/admin/children/{child_id}/presence-patterns"
def _pattern_url(pattern_id: int | str) -> str:
"""The id-scoped pattern edit/delete route."""
return f"/api/v1/admin/presence-patterns/{pattern_id}"
def _pattern_body(
cycle_length_weeks: object = 1,
pattern: dict[str, object] | None = None,
*,
name: object = "Mum's house",
kind: object = "home",
anchor_date: object = ANCHOR_MONDAY,
) -> dict[str, object]:
"""A valid pattern body anchored to the test Monday (Mon/Wed/Fri home)."""
return {
"name": name,
"kind": kind,
"cycle_length_weeks": cycle_length_weeks,
"anchor_date": anchor_date,
"pattern": {"0": [0, 2, 4]} if pattern is None else pattern,
}
def _override_body(
child_id: int,
*,
start_date: str = TUESDAY,
end_date: str = TUESDAY,
is_present: bool = True,
) -> dict[str, object]:
"""A valid single-day override body for the test child."""
return {
"child_id": child_id,
"start_date": start_date,
"end_date": end_date,
"is_present": is_present,
"note": "grandmother's week",
}
def _detail_names(response: object, field: str) -> bool:
"""True when a 422's detail names ``field``.
A core ValueError arrives as a string detail naming the field; a
Pydantic shape failure as a list of errors whose ``loc`` holds it.
"""
detail = response.json()["detail"]
if isinstance(detail, str):
return field in detail
return any(field in [str(part) for part in error["loc"]] for error in detail)
async def _create_child(client: object, name: str = "Ada") -> int:
"""Create one child through the admin children route; return the id."""
created = await client.post(
"/api/v1/admin/children",
json={"display_name": name},
headers=_admin_headers(),
)
assert created.status_code == 201
return created.json()["id"]
async def _create_pattern(
client: object, child_id: int, body: dict[str, object] | None = None
) -> dict[str, object]:
"""Create one pattern through the admin route; return the payload."""
created = await client.post(
_patterns_url(child_id),
json=_pattern_body() if body is None else body,
headers=_admin_headers(),
)
assert created.status_code == 201, created.text
return created.json()
def _engine(
database: object,
child_id: int,
pattern_records: list[object],
override_records: list[object],
) -> object:
"""Build the pure PresenceEngine from stored rows (the snapshot shape).
Mirrors what the core snapshot builder assembles: the child's
decoded patterns (none → present every day) plus its overrides,
so :meth:`PresenceEngine.is_present` answers from the SAME rows the
routes produced.
"""
patterns = {
child_id: [
_presence.PresencePattern.decode(
record.child_id,
record.name,
record.kind,
record.anchor_date,
record.pattern,
)
for record in pattern_records
]
}
overrides = {
child_id: [
_presence.PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
note=record.note,
)
for record in override_records
]
}
return _presence.PresenceEngine(patterns, overrides)
async def _child_state(
database: object, child_id: int
) -> tuple[list[object], list[object], object]:
"""The child's stored pattern records, overrides and PresenceEngine."""
pattern_records = await _dao_presence.PresencePatternsDao(
database
).list_by_child(child_id)
override_records = await _dao_presence.PresenceOverridesDao(
database
).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01")
engine = _engine(database, child_id, pattern_records, override_records)
return pattern_records, override_records, engine
@pytest.fixture
async def admin_presence(temp_db_path: str) -> SimpleNamespace:
"""An admin-plane app client plus its live database.
The same runner the auth, children and quest-definition tests use
(stubbed JWKS, no network); the fixture also exposes the app's ONE
database connection so tests assert the routes' effects on the
stored rows and the resolved presence built from them.
"""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
database=runner.app.state.db.database,
)
# --- authentication: the ONE require_admin dependency guards every route ---
async def test_presence_routes_refuse_non_admin_and_absent_credentials(
admin_presence: SimpleNamespace,
) -> None:
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
client = admin_presence.client
child_id = await _create_child(client)
non_admin = make_token(groups=("some-other-group",))
non_admin_headers = {"Authorization": f"Bearer {non_admin}"}
forbidden_pattern = await client.post(
_patterns_url(child_id),
json=_pattern_body(),
headers=non_admin_headers,
)
assert forbidden_pattern.status_code == 403
assert forbidden_pattern.json()["detail"] == FORBIDDEN_DETAIL
forbidden_override = await client.post(
"/api/v1/admin/presence-overrides",
json=_override_body(child_id),
headers=non_admin_headers,
)
assert forbidden_override.status_code == 403
forbidden_delete = await client.delete(
"/api/v1/admin/presence-overrides/1", headers=non_admin_headers
)
assert forbidden_delete.status_code == 403
unauthenticated_pattern = await client.post(
_patterns_url(child_id), json=_pattern_body()
)
assert unauthenticated_pattern.status_code == 401
unauthenticated_override = await client.post(
"/api/v1/admin/presence-overrides", json=_override_body(child_id)
)
assert unauthenticated_override.status_code == 401
unauthenticated_delete = await client.delete(
"/api/v1/admin/presence-overrides/1"
)
assert unauthenticated_delete.status_code == 401
# No route ran its handler: the child still has no pattern and no
# overrides (and is present every day, the no-pattern default).
patterns, overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert patterns == []
assert overrides == []
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
def _non_admin_headers() -> dict[str, str]:
return {
"Authorization": (
f"Bearer {make_token(groups=('some-other-group',))}"
)
}
_CREDENTIALS = {
"absent": (lambda: {}, 401, None),
"panel-token": (
lambda: {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
403,
PANEL_TOKEN_REJECTION_DETAIL,
),
"non-admin-jwt": (_non_admin_headers, 403, FORBIDDEN_DETAIL),
}
@pytest.mark.parametrize("credential", sorted(_CREDENTIALS))
@pytest.mark.parametrize("route", ["list", "create", "edit", "delete"])
async def test_pattern_routes_enforce_admin_auth(
admin_presence: SimpleNamespace, route: str, credential: str
) -> None:
"""Every pattern route rejects absent, panel and non-admin credentials.
Each refused request leaves the stored pattern exactly as it was: a
refused create adds no row, a refused edit changes nothing, a
refused delete removes nothing.
"""
client = admin_presence.client
child_id = await _create_child(client)
existing = await _create_pattern(client, child_id)
make_headers, status, detail = _CREDENTIALS[credential]
if route == "list":
response = await client.get(
_patterns_url(child_id), headers=make_headers()
)
elif route == "create":
response = await client.post(
_patterns_url(child_id),
json=_pattern_body(name="Dad's house"),
headers=make_headers(),
)
elif route == "edit":
response = await client.patch(
_pattern_url(existing["id"]),
json={"name": "Renamed"},
headers=make_headers(),
)
else:
response = await client.delete(
_pattern_url(existing["id"]), headers=make_headers()
)
assert response.status_code == status
if detail is not None:
assert response.json()["detail"] == detail
listed = await client.get(_patterns_url(child_id), headers=_admin_headers())
assert listed.json() == {"patterns": [existing]}
# --- the removed one-per-child schedule route ----------------------------------
async def test_old_presence_schedule_route_is_gone(
admin_presence: SimpleNamespace,
) -> None:
"""PUT/GET /presence-schedule no longer exist; nothing is written."""
client = admin_presence.client
child_id = await _create_child(client)
url = f"/api/v1/admin/children/{child_id}/presence-schedule"
put = await client.put(
url,
json={
"cycle_length_weeks": 1,
"anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [0]},
},
headers=_admin_headers(),
)
assert put.status_code in (404, 405)
get = await client.get(url, headers=_admin_headers())
assert get.status_code in (404, 405)
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert patterns == []
# --- pattern create ---------------------------------------------------------
async def test_pattern_create_persists_and_adds_a_row_per_create(
admin_presence: SimpleNamespace,
) -> None:
"""POST /presence-patterns persists; a second POST ADDS another row."""
client = admin_presence.client
child_id = await _create_child(client)
first = await client.post(
_patterns_url(child_id),
json=_pattern_body(),
headers=_admin_headers(),
)
assert first.status_code == 201
first_payload = first.json()
assert first_payload == {
"id": first_payload["id"],
"child_id": child_id,
"name": "Mum's house",
"kind": "home",
"cycle_length_weeks": 1,
"anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [0, 2, 4]},
}
# Database proof: ONE pattern row, stored as encoded CSV.
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert [record.id for record in patterns] == [first_payload["id"]]
assert patterns[0].name == "Mum's house"
assert patterns[0].kind == "home"
assert patterns[0].cycle_length_weeks == 1
assert patterns[0].anchor_date == ANCHOR_MONDAY
assert patterns[0].pattern == "0,2,4"
# Creating again ADDS a second pattern beside the first — the first
# row is untouched (schema 9 has no one-per-child constraint).
second = await client.post(
_patterns_url(child_id),
json=_pattern_body(
cycle_length_weeks=2,
pattern={"0": [], "1": [3, 1]},
name=" Holiday rota ",
kind="away",
),
headers=_admin_headers(),
)
assert second.status_code == 201
second_payload = second.json()
assert second_payload == {
"id": second_payload["id"],
"child_id": child_id,
"name": "Holiday rota",
"kind": "away",
"cycle_length_weeks": 2,
"anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [], "1": [1, 3]},
}
assert second_payload["id"] != first_payload["id"]
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert [record.id for record in patterns] == [
first_payload["id"],
second_payload["id"],
]
assert patterns[0].pattern == "0,2,4"
assert patterns[1].pattern == "|1,3"
async def test_pattern_changes_the_childs_resolved_presence(
admin_presence: SimpleNamespace,
) -> None:
"""The resolved presence (PresenceEngine) follows the stored patterns."""
client = admin_presence.client
child_id = await _create_child(client)
# Before any pattern the child is present every day.
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is True
# Home Mon/Wed/Fri only, week 0 of a one-week cycle.
await _create_pattern(client, child_id)
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is False
# An away pattern covering Monday beats the home pattern there.
await _create_pattern(
client,
child_id,
_pattern_body(pattern={"0": [0]}, name="Swim camp", kind="away"),
)
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is False
assert engine.is_present(child_id, "2026-01-07") is True # Wednesday
assert engine.is_present(child_id, TUESDAY) is False
async def test_pattern_writes_regenerate_future_instances(
admin_presence: SimpleNamespace,
) -> None:
"""An all-absent home pattern deletes the child's future instances.
The regeneration side effect lives in the core layer the route
calls: the child's open instances at or after today are deleted
across ALL its definitions and NOT rebuilt while the child is
absent every day; deleting the pattern rebuilds them.
"""
client = admin_presence.client
database = admin_presence.database
child_id = await _create_child(client)
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child_id],
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert created.status_code == 201
today = datetime.date.today()
start = today.isoformat()
end = (today + datetime.timedelta(days=14)).isoformat()
await _materialize.materialize(database, start, end, today=today)
instances = _dao_instances.QuestInstancesDao(database)
assert await instances.list_by_date_range(child_id, start, end)
# The route's write must regenerate: a home pattern covering no day
# makes the child absent every day and removes every open instance.
pattern = await _create_pattern(
client, child_id, _pattern_body(pattern={"0": []})
)
assert await instances.list_by_date_range(child_id, start, end) == []
# Deleting it regenerates too: present every day again, rebuilt.
deleted = await client.delete(
_pattern_url(pattern["id"]), headers=_admin_headers()
)
assert deleted.status_code == 200
assert await instances.list_by_date_range(child_id, start, end)
async def test_pattern_edit_regenerates_future_instances(
admin_presence: SimpleNamespace,
) -> None:
"""PATCH regenerates: flipping an empty home pattern to away rebuilds."""
client = admin_presence.client
database = admin_presence.database
child_id = await _create_child(client)
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child_id],
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert created.status_code == 201
today = datetime.date.today()
start = today.isoformat()
end = (today + datetime.timedelta(days=14)).isoformat()
pattern = await _create_pattern(
client, child_id, _pattern_body(pattern={"0": []})
)
await _materialize.materialize(database, start, end, today=today)
instances = _dao_instances.QuestInstancesDao(database)
assert await instances.list_by_date_range(child_id, start, end) == []
# An away pattern covering no day leaves no home pattern: present
# every day, so the edit's regeneration builds the instances.
edited = await client.patch(
_pattern_url(pattern["id"]),
json={"kind": "away"},
headers=_admin_headers(),
)
assert edited.status_code == 200
assert await instances.list_by_date_range(child_id, start, end)
# --- pattern edit (PATCH) ------------------------------------------------------
async def test_pattern_patch_changes_only_supplied_fields(
admin_presence: SimpleNamespace,
) -> None:
"""A partial PATCH changes the supplied fields and keeps the rest."""
client = admin_presence.client
child_id = await _create_child(client)
original = await _create_pattern(client, child_id)
sibling = await _create_pattern(
client, child_id, _pattern_body(name="Sibling", kind="away")
)
renamed = await client.patch(
_pattern_url(original["id"]),
json={"name": "Weekday home"},
headers=_admin_headers(),
)
assert renamed.status_code == 200
assert renamed.json() == dict(original, name="Weekday home")
reshaped = await client.patch(
_pattern_url(original["id"]),
json={"cycle_length_weeks": 2, "pattern": {"0": [1], "1": [6, 5]}},
headers=_admin_headers(),
)
assert reshaped.status_code == 200
assert reshaped.json() == dict(
original,
name="Weekday home",
cycle_length_weeks=2,
pattern={"0": [1], "1": [5, 6]},
)
moved = await client.patch(
_pattern_url(original["id"]),
json={"anchor_date": "2026-01-12", "kind": "away"},
headers=_admin_headers(),
)
assert moved.status_code == 200
expected = dict(
reshaped.json(), anchor_date="2026-01-12", kind="away"
)
assert moved.json() == expected
# Database proof: the edited row, same id, and the sibling untouched.
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert [record.id for record in patterns] == [
original["id"],
sibling["id"],
]
assert patterns[0].name == "Weekday home"
assert patterns[0].kind == "away"
assert patterns[0].cycle_length_weeks == 2
assert patterns[0].anchor_date == "2026-01-12"
assert patterns[0].pattern == "1|5,6"
listed = await client.get(_patterns_url(child_id), headers=_admin_headers())
assert listed.json() == {"patterns": [expected, sibling]}
async def test_pattern_patch_unknown_id_is_404(
admin_presence: SimpleNamespace,
) -> None:
"""Editing a pattern id that does not exist is 404."""
response = await admin_presence.client.patch(
_pattern_url(999), json={"name": "Nope"}, headers=_admin_headers()
)
assert response.status_code == 404
assert response.json()["detail"] == "Presence pattern not found"
malformed = await admin_presence.client.patch(
_pattern_url("not-an-id"),
json={"name": "Nope"},
headers=_admin_headers(),
)
assert malformed.status_code == 422
@pytest.mark.parametrize(
("edit", "field"),
[
({"name": ""}, "name"),
({"name": " "}, "name"),
({"name": None}, "name"),
({"kind": "sometimes"}, "kind"),
({"kind": None}, "kind"),
({"cycle_length_weeks": 0}, "cycle_length_weeks"),
({"cycle_length_weeks": 5}, "cycle_length_weeks"),
({"cycle_length_weeks": 2.5}, "cycle_length_weeks"),
({"cycle_length_weeks": "2"}, "cycle_length_weeks"),
({"cycle_length_weeks": None}, "cycle_length_weeks"),
# A longer cycle without a matching pattern leaves week 1 uncovered.
({"cycle_length_weeks": 2}, "pattern"),
({"anchor_date": "2026-13-40"}, "anchor_date"),
({"anchor_date": None}, "anchor_date"),
({"pattern": {}}, "pattern"),
({"pattern": None}, "pattern"),
({"pattern": {"0": [7]}}, "pattern"), # weekday out of 0..6
# Strict JSON integer weekdays: no bool, string or float coercion.
({"pattern": {"0": [True]}}, "pattern"),
({"pattern": {"0": [False]}}, "pattern"),
({"pattern": {"0": ["1"]}}, "pattern"),
({"pattern": {"0": [1.0]}}, "pattern"),
],
)
async def test_rejected_pattern_patch_is_422_and_changes_nothing(
admin_presence: SimpleNamespace, edit: dict[str, object], field: str
) -> None:
"""A rejected edit (incl. explicit null) is 422 naming the field."""
client = admin_presence.client
child_id = await _create_child(client)
original = await _create_pattern(client, child_id)
response = await client.patch(
_pattern_url(original["id"]), json=edit, headers=_admin_headers()
)
assert response.status_code == 422, response.text
assert _detail_names(response, field), response.json()
listed = await client.get(_patterns_url(child_id), headers=_admin_headers())
assert listed.json() == {"patterns": [original]}
# --- pattern delete ------------------------------------------------------------
async def test_pattern_delete_removes_one_pattern_and_presence_resolves(
admin_presence: SimpleNamespace,
) -> None:
"""DELETE removes the one pattern; its siblings and presence follow."""
client = admin_presence.client
child_id = await _create_child(client)
home = await _create_pattern(client, child_id)
away = await _create_pattern(
client,
child_id,
_pattern_body(pattern={"0": [0]}, name="Swim camp", kind="away"),
)
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is False
deleted = await client.delete(
_pattern_url(away["id"]), headers=_admin_headers()
)
assert deleted.status_code == 200
assert deleted.json() == {"status": "ok"}
assert (
await _dao_presence.PresencePatternsDao(admin_presence.database).get(
away["id"]
)
is None
)
patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert [record.id for record in patterns] == [home["id"]]
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is False
# Deleting the last pattern: present every day again.
deleted = await client.delete(
_pattern_url(home["id"]), headers=_admin_headers()
)
assert deleted.status_code == 200
patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert patterns == []
assert engine.is_present(child_id, TUESDAY) is True
# A second delete of the same id is 404: it no longer exists.
again = await client.delete(
_pattern_url(home["id"]), headers=_admin_headers()
)
assert again.status_code == 404
assert again.json()["detail"] == "Presence pattern not found"
async def test_delete_unknown_pattern_is_404(
admin_presence: SimpleNamespace,
) -> None:
"""Deleting a pattern id that does not exist is 404."""
response = await admin_presence.client.delete(
_pattern_url(999), headers=_admin_headers()
)
assert response.status_code == 404
assert response.json()["detail"] == "Presence pattern not found"
malformed = await admin_presence.client.delete(
_pattern_url("not-an-id"), headers=_admin_headers()
)
assert malformed.status_code == 422
# --- override create + delete -----------------------------------------------
async def test_override_is_created_then_deleted_and_presence_resolves(
admin_presence: SimpleNamespace,
) -> None:
"""POST creates the override, DELETE removes it; presence follows."""
client = admin_presence.client
database = admin_presence.database
child_id = await _create_child(client)
await _create_pattern(client, child_id)
# Create: the override comes back with its id and the posted shape.
created = await client.post(
"/api/v1/admin/presence-overrides",
json=_override_body(child_id),
headers=_admin_headers(),
)
assert created.status_code == 201
payload = created.json()
assert set(payload) == {
"id",
"child_id",
"start_date",
"end_date",
"is_present",
"note",
}
assert payload["child_id"] == child_id
assert payload["start_date"] == TUESDAY
assert payload["end_date"] == TUESDAY
assert payload["is_present"] is True
assert payload["note"] == "grandmother's week"
# Database proof: the row exists — and the RESOLVED presence for the
# affected date flips: the override beats the Tuesday-absent pattern.
_patterns, overrides, engine = await _child_state(database, child_id)
assert [record.id for record in overrides] == [payload["id"]]
assert engine.is_present(child_id, TUESDAY) is True
# A date the override does not cover keeps the pattern's answer.
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
# Delete: the row is gone and the resolved presence follows the
# pattern again.
deleted = await client.delete(
f"/api/v1/admin/presence-overrides/{payload['id']}",
headers=_admin_headers(),
)
assert deleted.status_code == 200
assert deleted.json() == {"status": "ok"}
assert (
await _dao_presence.PresenceOverridesDao(database).get(payload["id"])
is None
)
_patterns, overrides, engine = await _child_state(database, child_id)
assert overrides == []
assert engine.is_present(child_id, TUESDAY) is False
# --- error mapping: 404 vs 422 ----------------------------------------------
async def test_pattern_and_override_for_unknown_child_are_404(
admin_presence: SimpleNamespace,
) -> None:
"""A presence write naming a child that does not exist is 404."""
client = admin_presence.client
pattern = await client.post(
_patterns_url(999),
json=_pattern_body(),
headers=_admin_headers(),
)
assert pattern.status_code == 404
assert pattern.json()["detail"] == "Child not found"
override = await client.post(
"/api/v1/admin/presence-overrides",
json=_override_body(999),
headers=_admin_headers(),
)
assert override.status_code == 404
assert override.json()["detail"] == "Child not found"
@pytest.mark.parametrize(
("body", "field"),
[
(_pattern_body(cycle_length_weeks=0), "cycle_length_weeks"),
(_pattern_body(cycle_length_weeks=5), "cycle_length_weeks"),
# Strict JSON integers only: no float or string coercion.
(_pattern_body(cycle_length_weeks=2.5), "cycle_length_weeks"),
(_pattern_body(cycle_length_weeks="2"), "cycle_length_weeks"),
(_pattern_body(cycle_length_weeks=1.0), "cycle_length_weeks"),
# week 1 of the cycle missing:
(_pattern_body(cycle_length_weeks=2, pattern={"0": [0]}), "pattern"),
(_pattern_body(pattern={}), "pattern"),
(_pattern_body(anchor_date="2026-13-40"), "anchor_date"),
(_pattern_body(anchor_date="05/01/2026"), "anchor_date"),
(_pattern_body(pattern={"0": [7]}), "pattern"), # weekday out of 0..6
# Strict JSON integer weekdays: no bool, string or float coercion.
(_pattern_body(pattern={"0": [True]}), "pattern"),
(_pattern_body(pattern={"0": [False]}), "pattern"),
(_pattern_body(pattern={"0": ["1"]}), "pattern"),
(_pattern_body(pattern={"0": [1.0]}), "pattern"),
(_pattern_body(pattern={"nope": [0]}), "pattern"), # non-int week
(_pattern_body(pattern={"0": "0,2,4"}), "pattern"), # not a list
(_pattern_body(kind="sometimes"), "kind"),
(_pattern_body(kind="HOME"), "kind"),
(_pattern_body(name=""), "name"),
(_pattern_body(name=" "), "name"),
],
)
async def test_rejected_pattern_is_422(
admin_presence: SimpleNamespace, body: dict[str, object], field: str
) -> None:
"""A rejected pattern is 422 naming the field, and stores nothing."""
client = admin_presence.client
child_id = await _create_child(client)
response = await client.post(
_patterns_url(child_id),
json=body,
headers=_admin_headers(),
)
assert response.status_code == 422, response.text
assert _detail_names(response, field), response.json()
assert (
await _dao_presence.PresencePatternsDao(
admin_presence.database
).list_by_child(child_id)
== []
)
@pytest.mark.parametrize("missing", ["name", "kind"])
async def test_pattern_missing_required_field_is_422(
admin_presence: SimpleNamespace, missing: str
) -> None:
"""The new required fields cannot be omitted (an old schedule body)."""
client = admin_presence.client
child_id = await _create_child(client)
body = _pattern_body()
del body[missing]
response = await client.post(
_patterns_url(child_id), json=body, headers=_admin_headers()
)
assert response.status_code == 422
assert _detail_names(response, missing)
@pytest.mark.parametrize(
"body",
[
_override_body(1, start_date="2026-01-07", end_date="2026-01-06"),
_override_body(1, start_date="2026-02-30", end_date="2026-02-30"),
dict(_override_body(1), note=5),
dict(_override_body(1), child_id="one"),
],
)
async def test_rejected_override_is_422(
admin_presence: SimpleNamespace, body: dict[str, object]
) -> None:
"""A rejected override (inverted/malformed dates, bad note/id) is 422.
The shape failures (a non-string note, a non-integer child id) are
the body model's; the date failures are the PresenceOverride
model's. A string ``is_present`` like ``"yes"`` is deliberately
absent: Pydantic's lax bool COERCES it to ``True`` (the same design
as the children active route), so it never reaches the model's
real-bool check.
"""
response = await admin_presence.client.post(
"/api/v1/admin/presence-overrides", json=body, headers=_admin_headers()
)
assert response.status_code == 422
async def test_overlapping_override_is_422_and_first_survives(
admin_presence: SimpleNamespace,
) -> None:
"""An override overlapping the child's existing one is 422.
The conflicting write persists nothing: the child's original
override survives unchanged.
"""
client = admin_presence.client
child_id = await _create_child(client)
created = await client.post(
"/api/v1/admin/presence-overrides",
json=_override_body(child_id),
headers=_admin_headers(),
)
assert created.status_code == 201
response = await client.post(
"/api/v1/admin/presence-overrides",
json=_override_body(child_id, is_present=False),
headers=_admin_headers(),
)
assert response.status_code == 422
_patterns, overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert [record.id for record in overrides] == [created.json()["id"]]
async def test_delete_unknown_override_is_404(
admin_presence: SimpleNamespace,
) -> None:
"""Deleting an override id that does not exist is 404."""
response = await admin_presence.client.delete(
"/api/v1/admin/presence-overrides/999", headers=_admin_headers()
)
assert response.status_code == 404
assert response.json()["detail"] == "Presence override not found"
malformed = await admin_presence.client.delete(
"/api/v1/admin/presence-overrides/not-an-id",
headers=_admin_headers(),
)
assert malformed.status_code == 422