Feature: multiple presence patterns per child (schema 9) (#232)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #232.
This commit is contained in:
+584
-144
@@ -1,4 +1,4 @@
|
||||
"""Admin-plane presence route tests (task e4a9d31b).
|
||||
"""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
|
||||
@@ -11,31 +11,38 @@ 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: an admin JWT passes, a JWT without the group is 403, an
|
||||
absent credential is 401 — and nothing is written.
|
||||
- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the
|
||||
first set persists the schedule, setting again REPLACES it — one
|
||||
schedule row per child, never a duplicate.
|
||||
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 schedule
|
||||
restricts the child's present weekdays, an override beats the pattern
|
||||
for its range, and deleting the override restores the pattern's
|
||||
answer.
|
||||
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: setting an all-absent schedule through the route deletes the
|
||||
child's future open quest instances and they are NOT rebuilt while
|
||||
the child is absent every day — the same proof the HA service test
|
||||
makes, through the API instead.
|
||||
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 (schedule or override create) or override
|
||||
(delete) is 404; every other core ValueError — a rejected schedule
|
||||
(cycle length, pattern coverage, anchor date) or override (malformed or
|
||||
inverted dates, an overlapping range) — is 422, the same failure class
|
||||
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
|
||||
@@ -64,24 +71,54 @@ _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 schedule: cycle week 0 starts here.
|
||||
#: A Monday anchoring every pattern: cycle week 0 starts here.
|
||||
ANCHOR_MONDAY = "2026-01-05"
|
||||
#: The Tuesday of that week — absent under the test schedule.
|
||||
#: 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 _schedule_body(
|
||||
cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None
|
||||
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 schedule body anchored to the test Monday."""
|
||||
"""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_MONDAY,
|
||||
"anchor_date": anchor_date,
|
||||
"pattern": {"0": [0, 2, 4]} if pattern is None else pattern,
|
||||
}
|
||||
|
||||
@@ -103,6 +140,18 @@ def _override_body(
|
||||
}
|
||||
|
||||
|
||||
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(
|
||||
@@ -114,26 +163,44 @@ async def _create_child(client: object, name: str = "Ada") -> int:
|
||||
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,
|
||||
schedule_record: object | None,
|
||||
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 schedule (absent → present every day) plus its overrides,
|
||||
decoded patterns (none → present every day) plus its overrides,
|
||||
so :meth:`PresenceEngine.is_present` answers from the SAME rows the
|
||||
routes produced.
|
||||
"""
|
||||
schedules: dict[int, object] = {}
|
||||
if schedule_record is not None:
|
||||
schedules[child_id] = _presence.PresenceSchedule.decode(
|
||||
schedule_record.child_id,
|
||||
schedule_record.anchor_date,
|
||||
schedule_record.pattern,
|
||||
)
|
||||
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(
|
||||
@@ -146,21 +213,21 @@ def _engine(
|
||||
for record in override_records
|
||||
]
|
||||
}
|
||||
return _presence.PresenceEngine(schedules, overrides)
|
||||
return _presence.PresenceEngine(patterns, overrides)
|
||||
|
||||
|
||||
async def _child_state(
|
||||
database: object, child_id: int
|
||||
) -> tuple[object | None, list[object], object]:
|
||||
"""The child's stored schedule record, overrides and PresenceEngine."""
|
||||
schedule_record = await _dao_presence.PresenceSchedulesDao(
|
||||
) -> tuple[list[object], list[object], object]:
|
||||
"""The child's stored pattern records, overrides and PresenceEngine."""
|
||||
pattern_records = await _dao_presence.PresencePatternsDao(
|
||||
database
|
||||
).get_by_child(child_id)
|
||||
).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, schedule_record, override_records)
|
||||
return schedule_record, override_records, engine
|
||||
engine = _engine(database, child_id, pattern_records, override_records)
|
||||
return pattern_records, override_records, engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -192,15 +259,13 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials(
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
non_admin_headers = {"Authorization": f"Bearer {non_admin}"}
|
||||
|
||||
forbidden_schedule = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
forbidden_pattern = await client.post(
|
||||
_patterns_url(child_id),
|
||||
json=_pattern_body(),
|
||||
headers=non_admin_headers,
|
||||
)
|
||||
assert forbidden_schedule.status_code == 403
|
||||
assert forbidden_schedule.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
assert forbidden_pattern.status_code == 403
|
||||
assert forbidden_pattern.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
forbidden_override = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
@@ -214,11 +279,10 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials(
|
||||
)
|
||||
assert forbidden_delete.status_code == 403
|
||||
|
||||
unauthenticated_schedule = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
unauthenticated_pattern = await client.post(
|
||||
_patterns_url(child_id), json=_pattern_body()
|
||||
)
|
||||
assert unauthenticated_schedule.status_code == 401
|
||||
assert unauthenticated_pattern.status_code == 401
|
||||
|
||||
unauthenticated_override = await client.post(
|
||||
"/api/v1/admin/presence-overrides", json=_override_body(child_id)
|
||||
@@ -230,113 +294,225 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials(
|
||||
)
|
||||
assert unauthenticated_delete.status_code == 401
|
||||
|
||||
# No route ran its handler: the child still has no schedule and no
|
||||
# overrides (and is present every day, the no-schedule default).
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
# 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 _schedule is None
|
||||
assert _overrides == []
|
||||
assert patterns == []
|
||||
assert overrides == []
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
|
||||
|
||||
# --- schedule upsert --------------------------------------------------------
|
||||
def _non_admin_headers() -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": (
|
||||
f"Bearer {make_token(groups=('some-other-group',))}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
async def test_schedule_upserts_and_replaces_one_row_per_child(
|
||||
_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 /presence-schedule persists, and setting again REPLACES it."""
|
||||
"""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.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
first = await client.post(
|
||||
_patterns_url(child_id),
|
||||
json=_pattern_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.json() == {
|
||||
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: exactly ONE schedule row (the schema's
|
||||
# UNIQUE(child_id) makes a second row unrepresentable), stored as
|
||||
# encoded CSV.
|
||||
schedule, _overrides, _engine = await _child_state(
|
||||
# Database proof: ONE pattern row, stored as encoded CSV.
|
||||
patterns, _overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert schedule is not None
|
||||
row_id = schedule.id
|
||||
assert schedule.cycle_length_weeks == 1
|
||||
assert schedule.anchor_date == ANCHOR_MONDAY
|
||||
assert schedule.pattern == "0,2,4"
|
||||
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"
|
||||
|
||||
# Setting again UPSERTS: the SAME row is updated in place — new
|
||||
# values, unchanged id — never a duplicate or a stale first set.
|
||||
second = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(
|
||||
cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]}
|
||||
# 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 == 200
|
||||
assert second.json() == {
|
||||
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]},
|
||||
}
|
||||
schedule, _overrides, _engine = await _child_state(
|
||||
assert second_payload["id"] != first_payload["id"]
|
||||
patterns, _overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert schedule is not None
|
||||
assert schedule.id == row_id
|
||||
assert schedule.cycle_length_weeks == 2
|
||||
assert schedule.pattern == "|1,3"
|
||||
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_schedule_changes_the_childs_resolved_presence(
|
||||
async def test_pattern_changes_the_childs_resolved_presence(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The resolved presence (PresenceEngine) follows the stored schedule."""
|
||||
"""The resolved presence (PresenceEngine) follows the stored patterns."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
|
||||
# Before any schedule the child is present every day.
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
# 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
|
||||
|
||||
# Mon/Wed/Fri only, week 0 of a one-week cycle.
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
# 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_schedule_upsert_regenerates_future_instances(
|
||||
|
||||
async def test_pattern_writes_regenerate_future_instances(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Setting an all-absent schedule deletes the child's future instances.
|
||||
"""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 — the same proof the HA service test makes,
|
||||
exercised here through the API route instead.
|
||||
absent every day; deleting the pattern rebuilds them.
|
||||
"""
|
||||
client = admin_presence.client
|
||||
database = admin_presence.database
|
||||
@@ -360,16 +536,257 @@ async def test_schedule_upsert_regenerates_future_instances(
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
assert await instances.list_by_date_range(child_id, start, end)
|
||||
|
||||
# The route's write must regenerate: absent every day removes every
|
||||
# open future instance.
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(pattern={"0": []}),
|
||||
# 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 response.status_code == 200
|
||||
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 -----------------------------------------------
|
||||
|
||||
@@ -381,12 +798,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
|
||||
client = admin_presence.client
|
||||
database = admin_presence.database
|
||||
child_id = await _create_child(client)
|
||||
schedule_response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert schedule_response.status_code == 200
|
||||
await _create_pattern(client, child_id)
|
||||
|
||||
# Create: the override comes back with its id and the posted shape.
|
||||
created = await client.post(
|
||||
@@ -412,7 +824,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
|
||||
|
||||
# Database proof: the row exists — and the RESOLVED presence for the
|
||||
# affected date flips: the override beats the Tuesday-absent pattern.
|
||||
_schedule, overrides, engine = await _child_state(database, child_id)
|
||||
_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.
|
||||
@@ -430,7 +842,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
|
||||
await _dao_presence.PresenceOverridesDao(database).get(payload["id"])
|
||||
is None
|
||||
)
|
||||
_schedule, overrides, engine = await _child_state(database, child_id)
|
||||
_patterns, overrides, engine = await _child_state(database, child_id)
|
||||
assert overrides == []
|
||||
assert engine.is_present(child_id, TUESDAY) is False
|
||||
|
||||
@@ -438,19 +850,19 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
|
||||
# --- error mapping: 404 vs 422 ----------------------------------------------
|
||||
|
||||
|
||||
async def test_schedule_and_override_for_unknown_child_are_404(
|
||||
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
|
||||
|
||||
schedule = await client.put(
|
||||
"/api/v1/admin/children/999/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
pattern = await client.post(
|
||||
_patterns_url(999),
|
||||
json=_pattern_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert schedule.status_code == 404
|
||||
assert schedule.json()["detail"] == "Child not found"
|
||||
assert pattern.status_code == 404
|
||||
assert pattern.json()["detail"] == "Child not found"
|
||||
|
||||
override = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
@@ -462,42 +874,70 @@ async def test_schedule_and_override_for_unknown_child_are_404(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
("body", "field"),
|
||||
[
|
||||
_schedule_body(cycle_length_weeks=0), # below the 1..4 range
|
||||
_schedule_body(cycle_length_weeks=5), # above the documented cap
|
||||
(_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:
|
||||
_schedule_body(cycle_length_weeks=2, pattern={"0": [0]}),
|
||||
{
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": "2026-13-40",
|
||||
"pattern": {"0": [0]},
|
||||
},
|
||||
_schedule_body(pattern={"0": [7]}), # weekday out of 0..6
|
||||
_schedule_body(pattern={"nope": [0]}), # week index not an integer
|
||||
_schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list
|
||||
(_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_schedule_is_422(
|
||||
admin_presence: SimpleNamespace, body: dict[str, object]
|
||||
async def test_rejected_pattern_is_422(
|
||||
admin_presence: SimpleNamespace, body: dict[str, object], field: str
|
||||
) -> None:
|
||||
"""A rejected schedule is 422 and stores nothing."""
|
||||
"""A rejected pattern is 422 naming the field, and stores nothing."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
response = await client.post(
|
||||
_patterns_url(child_id),
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert response.status_code == 422, response.text
|
||||
assert _detail_names(response, field), response.json()
|
||||
assert (
|
||||
await _dao_presence.PresenceSchedulesDao(
|
||||
await _dao_presence.PresencePatternsDao(
|
||||
admin_presence.database
|
||||
).get_by_child(child_id)
|
||||
is None
|
||||
).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",
|
||||
[
|
||||
@@ -548,7 +988,7 @@ async def test_overlapping_override_is_422_and_first_survives(
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
_schedule, overrides, _engine = await _child_state(
|
||||
_patterns, overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert [record.id for record in overrides] == [created.json()["id"]]
|
||||
|
||||
Reference in New Issue
Block a user