"""API-level tests for `GET /api/tips/{workspace_id}` and its dismiss route. Guards two things a unit test on the service can't see: - the wire shape (`TipResponse`) actually carries `anchor_date`/ `anchor_range` and `message_key` + `data` โ€” not rendered prose; - the dismiss endpoint round-trips through the real DB and a second GET no longer returns the dismissed tip. """ from datetime import date, datetime from uuid import uuid4 import aiosqlite import pytest_asyncio from httpx import ASGITransport, AsyncClient from app.dependencies import get_connection, get_current_user from app.entities.child import Child from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.entities.personal_data import PersonalData from app.entities.user import User from app.entities.workspace import Workspace from app.main import app from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository def _make_fetch(personal_data_uuid, workspace_id, child_id, used: int = 20) -> ParentalLeaveFetch: now = datetime.now() tier = TierData( sjukpenning_total=390, sjukpenning_used=used, sjukpenning_left=390 - used, lagsta_niva_total=90, lagsta_niva_used=0, lagsta_niva_left=90, ) return ParentalLeaveFetch( id=uuid4(), personal_data_uuid=personal_data_uuid, workspace_id=workspace_id, child_id=child_id, source="manual", fetched_at=now, total_days=240, days_used=used, days_left=240 - used, reserved_days_total=90, reserved_days_left=90, transferable_days_left=50, dubbeldagar_left=60, tier_data=tier, created_at=now, updated_at=now, ) # Fixed identity for the authenticated caller in every test in this file โ€” # `workspace_with_expiring_days` creates its parent ("Alice") with this uuid # so `get_current_personal_data` resolves to the seeded workspace (see # CLAUDE.md T1 authz work / audit ยง4 blast radius). _CURRENT_USER_PD_UUID = uuid4() @pytest_asyncio.fixture async def client(connection: aiosqlite.Connection) -> AsyncClient: app.dependency_overrides[get_connection] = lambda: connection app.dependency_overrides[get_current_user] = lambda: User( id=uuid4(), personal_data_uuid=_CURRENT_USER_PD_UUID, email="test@example.com", hashed_password="not-a-real-hash", created_at=datetime.now(), ) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: yield ac app.dependency_overrides.pop(get_connection, None) app.dependency_overrides.pop(get_current_user, None) @pytest_asyncio.fixture async def workspace_with_expiring_days(connection: aiosqlite.Connection): now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) child_repo = SQLiteChildRepository(connection) parental_leave_repo = SQLiteParentalLeaveRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData(uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alice", created_at=now) ) child = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Barn", birth_date=date.today().replace(year=date.today().year - 1), created_at=now, ) ) await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id, used=20)) return ws, child class TestGetTipsOverTheWire: async def test_response_carries_anchor_date_and_no_rendered_prose( self, client: AsyncClient, workspace_with_expiring_days, ) -> None: ws, child = workspace_with_expiring_days response = await client.get(f"/api/tips/{ws.id}") assert response.status_code == 200 body = response.json() preserve = next(t for t in body if t["id"].startswith("preserve_for_age_4:")) assert preserve["id"] == f"preserve_for_age_4:{child.id}" assert preserve["message_key"] == "tips.preserve_for_age_4" assert preserve["anchor_date"] == ( child.birth_date.replace(year=child.birth_date.year + 4).isoformat() ) assert preserve["anchor_range"] is None assert preserve["data"]["child_id"] == str(child.id) assert preserve["data"]["child_name"] == "Barn" # Never rendered Swedish prose โ€” only message_key + data. for tip in body: assert isinstance(tip["message_key"], str) and tip["message_key"].startswith("tips.") for value in tip["data"].values(): assert not isinstance(value, dict) class TestDismissTipOverTheWire: async def test_dismiss_then_get_excludes_the_tip( self, client: AsyncClient, workspace_with_expiring_days, ) -> None: ws, _child = workspace_with_expiring_days before = await client.get(f"/api/tips/{ws.id}") tip = next(t for t in before.json() if t["id"].startswith("preserve_for_age_4:")) dismiss_response = await client.post( f"/api/tips/{ws.id}/dismissals", json={"tip_id": tip["id"]} ) assert dismiss_response.status_code == 204 after = await client.get(f"/api/tips/{ws.id}") assert tip["id"] not in {t["id"] for t in after.json()} async def test_dismiss_unknown_tip_returns_404( self, client: AsyncClient, workspace_with_expiring_days, ) -> None: ws, _child = workspace_with_expiring_days response = await client.post( f"/api/tips/{ws.id}/dismissals", json={"tip_id": "not_a_real_tip"} ) assert response.status_code == 404