"""Journal API route coverage — async httpx + dependency overrides. Drives /api/journals/me and /api/journals/me/* through a real DB session so the route's HTTP plumbing (validation, content-length gates, error mapping) is exercised end-to-end. """ from __future__ import annotations from typing import TYPE_CHECKING from uuid import UUID, uuid4 import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from roboco.api.deps import get_agent_context, get_db from roboco.api.routes.journals import router as journals_router from roboco.db.tables import AgentTable from roboco.models import AgentRole, AgentStatus, Team from roboco.models.permissions import AgentContext if TYPE_CHECKING: from collections.abc import AsyncIterator from sqlalchemy.ext.asyncio import AsyncSession @pytest_asyncio.fixture async def journal_client( db_session: AsyncSession, ) -> AsyncIterator[tuple[AsyncClient, AgentTable]]: agent = AgentTable( id=uuid4(), name="Dev", slug=f"be-dev-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) db_session.add(agent) await db_session.flush() app = FastAPI() app.include_router(journals_router, prefix="/api/journals") async def _override_db(): yield db_session async def _override_agent() -> AgentContext: return AgentContext( agent_id=agent.id, role=AgentRole.DEVELOPER, team=Team.BACKEND ) app.dependency_overrides[get_db] = _override_db app.dependency_overrides[get_agent_context] = _override_agent transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: yield client, agent app.dependency_overrides.clear() _HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "developer"} @pytest.mark.asyncio async def test_get_my_journal_creates_one( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.get("/api/journals/me", headers=_HDR) assert response.status_code == 200 body = response.json() assert "id" in body assert body["total_entries"] == 0 @pytest.mark.asyncio async def test_create_entry( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.post( "/api/journals/me/entries", json={ "type": "general", "title": "First entry", "content": "This is some genuinely long content for the entry " "that easily clears the minimum threshold.", }, headers=_HDR, ) assert response.status_code == 201 body = response.json() assert body["title"] == "First entry" @pytest.mark.asyncio async def test_create_entry_too_short_returns_400( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.post( "/api/journals/me/entries", json={"type": "general", "title": "x", "content": "short"}, headers=_HDR, ) assert response.status_code == 400 assert "CONTENT_TOO_SHORT" in response.json()["detail"] @pytest.mark.asyncio async def test_create_entry_invalid_type_returns_400( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.post( "/api/journals/me/entries", json={ "type": "bogus_type", "title": "x", "content": "x" * 200, }, headers=_HDR, ) assert response.status_code == 400 @pytest.mark.asyncio async def test_list_my_entries_empty( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client # Listing before journal exists triggers auto-create path → empty list. response = await client.get("/api/journals/me/entries", headers=_HDR) assert response.status_code == 200 assert isinstance(response.json(), list) @pytest.mark.asyncio async def test_list_my_entries_after_create( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client await client.post( "/api/journals/me/entries", json={ "type": "general", "title": "First", "content": "Long enough content to pass the minimum threshold check.", }, headers=_HDR, ) response = await client.get("/api/journals/me/entries", headers=_HDR) assert response.status_code == 200 entries = response.json() assert len(entries) >= 1 @pytest.mark.asyncio async def test_list_my_entries_invalid_type_filter_returns_400( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client # Create journal first so the filter-validation path is reached. await client.get("/api/journals/me", headers=_HDR) response = await client.get( "/api/journals/me/entries?entry_type=ghost", headers=_HDR, ) assert response.status_code == 400 @pytest.mark.asyncio async def test_get_my_stats( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client # Create the journal first. await client.get("/api/journals/me", headers=_HDR) response = await client.get("/api/journals/me/stats", headers=_HDR) assert response.status_code == 200 @pytest.mark.asyncio async def test_get_my_growth_metrics( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client await client.get("/api/journals/me", headers=_HDR) response = await client.get("/api/journals/me/growth", headers=_HDR) # Growth route returns 200 with metrics or 404 if no journal yet. assert response.status_code in (200, 404) # --------------------------------------------------------------------------- # Helper add endpoints — exercise the dataclass-conversion paths. # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def journal_setup_with_task( db_session: AsyncSession, ) -> AsyncIterator[tuple[AsyncClient, AgentTable, UUID]]: from roboco.db.tables import ProjectTable, TaskTable from roboco.models.base import TaskNature, TaskStatus, TaskType agent = AgentTable( id=uuid4(), name="Dev", slug=f"be-dev-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) db_session.add(agent) await db_session.flush() project = ProjectTable( id=uuid4(), name="JR-Proj", slug=f"jr-proj-{uuid4().hex[:8]}", git_url="https://example.com/r.git", assigned_cell=Team.BACKEND, created_by=agent.id, ) db_session.add(project) await db_session.flush() task = TaskTable( id=uuid4(), title="t", description="d", acceptance_criteria=["ac"], status=TaskStatus.PENDING, priority=2, task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, project_id=project.id, created_by=agent.id, team=Team.BACKEND, ) db_session.add(task) await db_session.flush() app = FastAPI() app.include_router(journals_router, prefix="/api/journals") async def _override_db(): yield db_session async def _override_agent() -> AgentContext: return AgentContext( agent_id=agent.id, role=AgentRole.DEVELOPER, team=Team.BACKEND ) app.dependency_overrides[get_db] = _override_db app.dependency_overrides[get_agent_context] = _override_agent transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: yield client, agent, task.id app.dependency_overrides.clear() @pytest.mark.asyncio async def test_add_task_reflection(journal_setup_with_task) -> None: client, _, task_id = journal_setup_with_task response = await client.post( "/api/journals/me/reflections", json={ "task_id": str(task_id), "title": "what I did", "what_done": "implemented X", "what_learned": "learned Y", "what_struggled": "struggled with Z", "next_steps": ["next thing"], }, headers=_HDR, ) assert response.status_code == 201 @pytest.mark.asyncio async def test_add_decision_log(journal_setup_with_task) -> None: client, _, task_id = journal_setup_with_task response = await client.post( "/api/journals/me/decisions", json={ "title": "Choose framework", "context": "Need to pick web framework", "options": [ {"name": "FastAPI", "rationale": "fast"}, {"name": "Flask", "rationale": "simple"}, ], "chosen": "FastAPI", "rationale": "best for our needs", "consequences": ["learn fastapi"], "task_id": str(task_id), }, headers=_HDR, ) assert response.status_code == 201 @pytest.mark.asyncio async def test_add_learning(journal_setup_with_task) -> None: client, _, task_id = journal_setup_with_task response = await client.post( "/api/journals/me/learnings", json={ "title": "TIL", "what_learned": "Pydantic field aliases work", "task_id": str(task_id), }, headers=_HDR, ) assert response.status_code == 201 @pytest.mark.asyncio async def test_add_struggle(journal_setup_with_task) -> None: client, _, task_id = journal_setup_with_task response = await client.post( "/api/journals/me/struggles", json={ "title": "Fighting tests", "what_struggled": "couldn't make pytest happy", "attempted_solutions": ["bumped versions", "renamed"], "task_id": str(task_id), }, headers=_HDR, ) assert response.status_code == 201 @pytest.mark.asyncio async def test_get_entry_not_found( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.get(f"/api/journals/entries/{uuid4()}", headers=_HDR) assert response.status_code == 404 @pytest.mark.asyncio async def test_delete_entry_not_found( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.delete(f"/api/journals/entries/{uuid4()}", headers=_HDR) assert response.status_code == 404 @pytest.mark.asyncio async def test_get_journal_by_agent_id( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, agent = journal_client # Need to create the journal first. await client.get("/api/journals/me", headers=_HDR) response = await client.get(f"/api/journals/{agent.id}", headers=_HDR) assert response.status_code in (200, 403, 404) @pytest.mark.asyncio async def test_get_journal_by_unknown_agent( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.get(f"/api/journals/{uuid4()}", headers=_HDR) assert response.status_code in (404, 403) @pytest.mark.asyncio async def test_list_agent_entries_unknown_agent( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.get(f"/api/journals/{uuid4()}/entries", headers=_HDR) assert response.status_code in (404, 403) @pytest.mark.asyncio async def test_list_agent_entries_for_self( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, agent = journal_client await client.get("/api/journals/me", headers=_HDR) response = await client.get(f"/api/journals/{agent.id}/entries", headers=_HDR) assert response.status_code in (200, 403) @pytest.mark.asyncio async def test_search_my_entries_returns_list( journal_setup_with_task, ) -> None: """Search route — may 200 with empty list or 500 if RAG isn't configured.""" client, _, _ = journal_setup_with_task response = await client.post( "/api/journals/me/search", json={"query": "test query", "top_k": 5}, headers=_HDR, ) # Accept any non-server-error response. assert response.status_code in (200, 500)