diff --git a/api/routes_admin.py b/api/routes_admin.py index 93f9037..1a94e60 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -368,6 +368,12 @@ class AdminQuestDefinitionResponse(BaseModel): windows: list[AdminDefinitionWindowResponse] +class AdminQuestDefinitionListResponse(BaseModel): + """The definitions list: every definition, active AND inactive, by id.""" + + definitions: list[AdminQuestDefinitionResponse] + + class AdminChildCreateRequest(BaseModel): """The body of ``POST /api/v1/admin/children``. @@ -1168,6 +1174,32 @@ async def admin_reorder_children( return {"status": "ok"} +@router.get( + "/quest-definitions", + summary="List quest definitions", + response_model=AdminQuestDefinitionListResponse, +) +async def admin_list_quest_definitions( + request: Request, +) -> AdminQuestDefinitionListResponse: + """Return every quest definition in rising id order. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: one + :func:`nestquest_core.quest_definitions.list_all_definitions` call, + active AND inactive definitions included (the admin screen manages + both), each serialized exactly as the create/edit responses are. + """ + state: DatabaseState = request.app.state.db + bundles = await core_quest_definitions.list_all_definitions( + state.database + ) + return AdminQuestDefinitionListResponse( + definitions=[_quest_definition_response(bundle) for bundle in bundles] + ) + + @router.post( "/quest-definitions", summary="Create a quest definition", diff --git a/custom_components/nestquest/core/dao_rules.py b/custom_components/nestquest/core/dao_rules.py index ec3f6d9..6770eb2 100644 --- a/custom_components/nestquest/core/dao_rules.py +++ b/custom_components/nestquest/core/dao_rules.py @@ -907,6 +907,13 @@ class QuestDefinitionsDao: ) return [_definition_from_row(row) for row in rows] + async def list_all(self) -> list[QuestDefinitionRecord]: + """Return every definition, active AND inactive, oldest first.""" + rows = await self._database.fetch_all( + f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions ORDER BY id" + ) + return [_definition_from_row(row) for row in rows] + async def _snapshots_for( self, definitions: list[QuestDefinitionRecord] ) -> list[QuestDefinitionSnapshot]: @@ -951,6 +958,18 @@ class QuestDefinitionsDao: definitions = await self.list_active() return await self._snapshots_for(definitions) + async def list_snapshots_all(self) -> list[QuestDefinitionSnapshot]: + """Return every definition (active AND inactive) with its rule, + assignees and windows, all read inside one locked transaction. + + Mirrors :meth:`list_snapshots_active` over :meth:`list_all` + (oldest first), with the same coherence guarantee. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definitions = await self.list_all() + return await self._snapshots_for(definitions) + async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]: """Return every active definition's snapshot WITHOUT locking. diff --git a/custom_components/nestquest/core/quest_definitions.py b/custom_components/nestquest/core/quest_definitions.py index d301aa4..643c511 100644 --- a/custom_components/nestquest/core/quest_definitions.py +++ b/custom_components/nestquest/core/quest_definitions.py @@ -558,6 +558,21 @@ async def list_active_definitions( return [_bundle_snapshot(snapshot) for snapshot in snapshots] +async def list_all_definitions( + database: NestQuestDatabase, +) -> list[CreatedQuestDefinition]: + """Return EVERY definition, active AND inactive (rich view). + + Mirrors :func:`list_active_definitions` without the activity + filter: ordered by rising definition id, each entry the same + :class:`CreatedQuestDefinition` snapshot, read by the DAO inside one + locked transaction. + """ + dao = QuestDefinitionsDao(database) + snapshots = await dao.list_snapshots_all() + return [_bundle_snapshot(snapshot) for snapshot in snapshots] + + async def list_definitions_for_child( database: NestQuestDatabase, child_id: int, diff --git a/tests/test_api_admin_questdef_list.py b/tests/test_api_admin_questdef_list.py new file mode 100644 index 0000000..69d022d --- /dev/null +++ b/tests/test_api_admin_questdef_list.py @@ -0,0 +1,250 @@ +"""Admin-plane quest-definition LIST route tests (task d4667573). + +``GET /api/v1/admin/quest-definitions`` 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. + +- An admin JWT lists EVERY definition — active AND inactive — in rising + id order, each in the same documented shape as the create/edit + responses (id, title, description, icon, is_active, rule, assignees, + windows). +- An empty household lists nothing: 200 with an empty list. +- Authentication runs through the router's ONE ``require_admin`` + dependency: an absent credential is 401, the panel service token is + refused outright (403, the credential-type detail), and a JWT without + the nestquest-admins group is 403. +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +#: The admin list route under test. +LIST_PATH = "/api/v1/admin/quest-definitions" + +#: 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()}"} + + +@pytest.fixture +async def admin_client(temp_db_path: str) -> SimpleNamespace: + """An admin-plane app client over a fresh (empty) household.""" + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield client + + +async def _seed_active_and_inactive(client: object) -> tuple[int, int, int]: + """Seed one child, an ACTIVE definition and an INACTIVE one. + + Returns ``(child_id, active_id, inactive_id)``. Seeded through the + admin routes so the rows are exactly what the admin plane writes. + """ + child = await client.post( + "/api/v1/admin/children", + json={"display_name": "Ada"}, + headers=_admin_headers(), + ) + assert child.status_code == 201 + child_id = child.json()["id"] + + active = await client.post( + LIST_PATH, + json={ + "title": "Tidy the den", + "rule": { + "rule_type": "weekly", + "interval": 1, + "weekday_set": [0, 2], + "start_date": "2026-03-02", + }, + "assignee_child_ids": [child_id], + "windows": ["morning", ["evening", "19:30"]], + "description": "Put everything back where it belongs.", + "icon": "toy-box", + }, + headers=_admin_headers(), + ) + assert active.status_code == 201 + + inactive = await client.post( + LIST_PATH, + json={ + "title": "Water the plants", + "rule": { + "rule_type": "daily", + "interval": 1, + "start_date": "2026-03-01", + }, + "assignee_child_ids": [child_id], + "windows": ["afternoon"], + }, + headers=_admin_headers(), + ) + assert inactive.status_code == 201 + inactive_id = inactive.json()["id"] + deactivated = await client.patch( + f"{LIST_PATH}/{inactive_id}/active", + json={"is_active": False}, + headers=_admin_headers(), + ) + assert deactivated.status_code == 200 + + return child_id, active.json()["id"], inactive_id + + +async def test_admin_lists_active_and_inactive_definitions_with_full_shape( + admin_client: object, +) -> None: + """Both definitions come back, by rising id, in the documented shape.""" + child_id, active_id, inactive_id = await _seed_active_and_inactive( + admin_client + ) + + response = await admin_client.get(LIST_PATH, headers=_admin_headers()) + + assert response.status_code == 200 + assert response.json() == { + "definitions": [ + { + "id": active_id, + "title": "Tidy the den", + "description": "Put everything back where it belongs.", + "icon": "toy-box", + "is_active": True, + "rule": { + "rule_type": "weekly", + "interval": 1, + "weekday_set": [0, 2], + "day_of_month": None, + "nth_weekday": None, + "nth_weekday_weekday": None, + "month": None, + "start_date": "2026-03-02", + "end_date": None, + }, + "assignees": [{"id": child_id, "display_name": "Ada"}], + "windows": [ + {"window": "morning", "due_time": None}, + {"window": "evening", "due_time": "19:30"}, + ], + }, + { + "id": inactive_id, + "title": "Water the plants", + "description": None, + "icon": None, + "is_active": False, + "rule": { + "rule_type": "daily", + "interval": 1, + "weekday_set": None, + "day_of_month": None, + "nth_weekday": None, + "nth_weekday_weekday": None, + "month": None, + "start_date": "2026-03-01", + "end_date": None, + }, + "assignees": [{"id": child_id, "display_name": "Ada"}], + "windows": [{"window": "afternoon", "due_time": None}], + }, + ] + } + + +async def test_list_items_match_the_create_response_shape( + admin_client: object, +) -> None: + """Each listed item is exactly what the create route returned.""" + child = await admin_client.post( + "/api/v1/admin/children", + json={"display_name": "Ben"}, + headers=_admin_headers(), + ) + created = await admin_client.post( + LIST_PATH, + json={ + "title": "Feed the cat", + "rule": { + "rule_type": "daily", + "interval": 1, + "start_date": "2026-03-01", + }, + "assignee_child_ids": [child.json()["id"]], + "windows": ["morning"], + }, + headers=_admin_headers(), + ) + assert created.status_code == 201 + + response = await admin_client.get(LIST_PATH, headers=_admin_headers()) + + assert response.status_code == 200 + assert response.json() == {"definitions": [created.json()]} + + +async def test_empty_household_lists_no_definitions( + admin_client: object, +) -> None: + """With no definitions stored the list is 200 and empty.""" + response = await admin_client.get(LIST_PATH, headers=_admin_headers()) + + assert response.status_code == 200 + assert response.json() == {"definitions": []} + + +async def test_list_without_credentials_is_401(admin_client: object) -> None: + """An absent credential is the uniform 401.""" + response = await admin_client.get(LIST_PATH) + + assert response.status_code == 401 + + +async def test_list_with_panel_service_token_is_403( + admin_client: object, +) -> None: + """The panel service token is refused outright, never a JWT.""" + response = await admin_client.get( + LIST_PATH, + headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL + + +async def test_list_with_non_admin_jwt_is_403(admin_client: object) -> None: + """A valid JWT without the nestquest-admins group is 403.""" + non_admin = make_token(groups=("some-other-group",)) + response = await admin_client.get( + LIST_PATH, headers={"Authorization": f"Bearer {non_admin}"} + ) + + assert response.status_code == 403 + assert response.json()["detail"] == FORBIDDEN_DETAIL