"""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 http import HTTPStatus from typing import TYPE_CHECKING, cast from unittest.mock import patch 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, JournalEntryTable, JournalTable, ProjectTable, TaskTable, ) from roboco.models import AgentRole, AgentStatus, Team from roboco.models.base import JournalEntryType, TaskNature, TaskStatus, TaskType from roboco.models.permissions import AgentContext if TYPE_CHECKING: from collections.abc import AsyncGenerator, 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() -> AsyncGenerator[AsyncSession]: yield db_session async def _override_agent() -> AgentContext: return AgentContext( agent_id=cast("UUID", agent.id), role=AgentRole.DEVELOPER, team=Team.BACKEND, slug=agent.slug, ) 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 == HTTPStatus.OK 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 == HTTPStatus.CREATED 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 == HTTPStatus.BAD_REQUEST 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 == HTTPStatus.BAD_REQUEST @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 == HTTPStatus.OK 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 == HTTPStatus.OK 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 == HTTPStatus.BAD_REQUEST @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 == HTTPStatus.OK @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 (HTTPStatus.OK, HTTPStatus.NOT_FOUND) # --------------------------------------------------------------------------- # 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]]: 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() -> AsyncGenerator[AsyncSession]: yield db_session async def _override_agent() -> AgentContext: return AgentContext( agent_id=cast("UUID", agent.id), role=AgentRole.DEVELOPER, team=Team.BACKEND, slug=agent.slug, ) 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, cast("UUID", task.id) app.dependency_overrides.clear() @pytest.mark.asyncio async def test_add_task_reflection( journal_setup_with_task: tuple[AsyncClient, AgentTable, UUID], ) -> 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 == HTTPStatus.CREATED @pytest.mark.asyncio async def test_add_decision_log( journal_setup_with_task: tuple[AsyncClient, AgentTable, UUID], ) -> 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 == HTTPStatus.CREATED @pytest.mark.asyncio async def test_add_learning( journal_setup_with_task: tuple[AsyncClient, AgentTable, UUID], ) -> 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 == HTTPStatus.CREATED @pytest.mark.asyncio async def test_add_struggle( journal_setup_with_task: tuple[AsyncClient, AgentTable, UUID], ) -> 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 == HTTPStatus.CREATED @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 == HTTPStatus.NOT_FOUND @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 == HTTPStatus.NOT_FOUND @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 ( HTTPStatus.OK, HTTPStatus.FORBIDDEN, HTTPStatus.NOT_FOUND, ) @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 (HTTPStatus.NOT_FOUND, HTTPStatus.FORBIDDEN) @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 (HTTPStatus.NOT_FOUND, HTTPStatus.FORBIDDEN) @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 (HTTPStatus.OK, HTTPStatus.FORBIDDEN) @pytest.mark.asyncio async def test_search_my_entries_returns_list( journal_setup_with_task: tuple[AsyncClient, AgentTable, UUID], ) -> 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 (HTTPStatus.OK, HTTPStatus.INTERNAL_SERVER_ERROR) # --------------------------------------------------------------------------- # General entry helpers + entry CRUD # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_add_general_entry( journal_setup_with_task: tuple[AsyncClient, AgentTable, UUID], ) -> None: client, _, task_id = journal_setup_with_task response = await client.post( "/api/journals/me/notes", json={ "title": "Some general note", "content": "This is a general entry with sufficient length", "task_id": str(task_id), }, headers=_HDR, ) assert response.status_code == HTTPStatus.CREATED @pytest.mark.asyncio async def test_get_entry_after_create( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client create = await client.post( "/api/journals/me/entries", json={ "type": "general", "title": "Entry", "content": "Long enough content to satisfy the threshold.", }, headers=_HDR, ) eid = create.json()["id"] response = await client.get(f"/api/journals/entries/{eid}", headers=_HDR) assert response.status_code in (HTTPStatus.OK, HTTPStatus.FORBIDDEN) @pytest.mark.asyncio async def test_delete_entry_success( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client create = await client.post( "/api/journals/me/entries", json={ "type": "general", "title": "X", "content": "Long enough content for deletion test purposes.", }, headers=_HDR, ) eid = create.json()["id"] response = await client.delete(f"/api/journals/entries/{eid}", headers=_HDR) assert response.status_code == HTTPStatus.NO_CONTENT @pytest.mark.asyncio async def test_get_entry_with_invalid_filter_via_me_entries( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.get( "/api/journals/me/entries?entry_type=ghostly", headers=_HDR ) # Triggers the BAD_REQUEST path in list_my_entries assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.OK) @pytest.mark.asyncio async def test_create_decision_log_too_short( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.post( "/api/journals/me/entries", json={"type": "decision_log", "title": "x", "content": "tiny"}, headers=_HDR, ) assert response.status_code == HTTPStatus.BAD_REQUEST @pytest.mark.asyncio async def test_create_task_reflection_too_short( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.post( "/api/journals/me/entries", json={"type": "task_reflection", "title": "x", "content": "short"}, headers=_HDR, ) assert response.status_code == HTTPStatus.BAD_REQUEST @pytest.mark.asyncio async def test_create_struggle_too_short( journal_client: tuple[AsyncClient, AgentTable], ) -> None: client, _ = journal_client response = await client.post( "/api/journals/me/entries", json={"type": "struggle", "title": "x", "content": "short"}, headers=_HDR, ) assert response.status_code == HTTPStatus.BAD_REQUEST # --------------------------------------------------------------------------- # Tests targeting empty/no-journal default-response branches # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_my_stats_no_journal_yet( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """Stats endpoint without prior journal — empty default response.""" client, _ = journal_client response = await client.get("/api/journals/me/stats", headers=_HDR) assert response.status_code == HTTPStatus.OK body = response.json() assert body["total_entries"] == 0 assert body["entries_by_type"] == {} @pytest.mark.asyncio async def test_get_my_growth_metrics_no_journal_yet( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """Growth metrics without prior journal — returns empty defaults.""" client, _ = journal_client response = await client.get("/api/journals/me/growth", headers=_HDR) assert response.status_code == HTTPStatus.OK body = response.json() assert body["total_reflections"] == 0 assert body["sentiment_trend"] == "stable" # --------------------------------------------------------------------------- # Helper-endpoint paths without task_id (the optional ones) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_add_decision_log_no_task( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """Decision log without task_id — exercises the no-FK branch.""" client, _ = journal_client response = await client.post( "/api/journals/me/decisions", json={ "title": "Choose tool", "context": "Need to pick something", "options": [{"name": "A"}, {"name": "B"}], "chosen": "A", "rationale": "best fit", "consequences": ["c1"], }, headers=_HDR, ) assert response.status_code == HTTPStatus.CREATED @pytest.mark.asyncio async def test_add_learning_no_task( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """Learning without task_id — exercises the no-FK branch.""" client, _ = journal_client response = await client.post( "/api/journals/me/learnings", json={ "title": "TIL", "what_learned": "Pydantic field aliases work", }, headers=_HDR, ) assert response.status_code == HTTPStatus.CREATED @pytest.mark.asyncio async def test_add_struggle_no_task( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """Struggle without task_id — exercises the no-FK branch.""" client, _ = journal_client response = await client.post( "/api/journals/me/struggles", json={ "title": "Fighting tests", "what_struggled": "couldn't make pytest happy", "attempted_solutions": ["bumped versions"], }, headers=_HDR, ) assert response.status_code == HTTPStatus.CREATED @pytest.mark.asyncio async def test_add_general_entry_no_task( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """General entry without task_id — exercises the no-FK branch.""" client, _ = journal_client response = await client.post( "/api/journals/me/notes", json={ "title": "Some note", "content": "Content sufficient for the threshold check", }, headers=_HDR, ) assert response.status_code == HTTPStatus.CREATED # --------------------------------------------------------------------------- # Entry GET — full success path with explicit slug-bearing context # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_entry_full_success_with_slug(db_session: AsyncSession) -> None: """Create an entry, fetch it back via /entries/{id} — slug=owner.""" agent = AgentTable( id=uuid4(), name="Self", slug=f"be-dev-self-{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() -> AsyncGenerator[AsyncSession]: yield db_session async def _override_agent() -> AgentContext: return AgentContext( agent_id=cast("UUID", agent.id), role=AgentRole.DEVELOPER, team=Team.BACKEND, slug=agent.slug, ) 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: create = await client.post( "/api/journals/me/entries", json={ "type": "general", "title": "Findable", "content": "Long enough content to satisfy the minimum threshold", }, headers=_HDR, ) assert create.status_code == HTTPStatus.CREATED eid = create.json()["id"] response = await client.get(f"/api/journals/entries/{eid}", headers=_HDR) app.dependency_overrides.clear() # Self-access: slug equality short-circuits validate_journal_access. assert response.status_code == HTTPStatus.OK # --------------------------------------------------------------------------- # Delete-entry forbidden (other agent's entry) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_delete_entry_other_agent_forbidden( db_session: AsyncSession, ) -> None: """An agent attempting to delete another agent's entry — 403.""" owner = AgentTable( id=uuid4(), name="Owner", slug=f"be-dev-owner-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) intruder = AgentTable( id=uuid4(), name="Intruder", slug=f"be-dev-other-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) db_session.add_all([owner, intruder]) await db_session.flush() app = FastAPI() app.include_router(journals_router, prefix="/api/journals") async def _override_db() -> AsyncGenerator[AsyncSession]: yield db_session async def _override_agent_owner() -> AgentContext: return AgentContext( agent_id=cast("UUID", owner.id), role=AgentRole.DEVELOPER, team=Team.BACKEND, slug=owner.slug, ) app.dependency_overrides[get_db] = _override_db app.dependency_overrides[get_agent_context] = _override_agent_owner transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: create = await client.post( "/api/journals/me/entries", json={ "type": "general", "title": "Owner's entry", "content": "Content sufficient for the minimum threshold check", }, headers=_HDR, ) assert create.status_code == HTTPStatus.CREATED eid = create.json()["id"] # Switch to the intruder. async def _override_agent_intruder() -> AgentContext: return AgentContext( agent_id=cast("UUID", intruder.id), role=AgentRole.DEVELOPER, team=Team.BACKEND, slug=intruder.slug, ) app.dependency_overrides[get_agent_context] = _override_agent_intruder response = await client.delete(f"/api/journals/entries/{eid}", headers=_HDR) app.dependency_overrides.clear() assert response.status_code == HTTPStatus.FORBIDDEN # --------------------------------------------------------------------------- # /{agent_id} — happy-path response (own journal) and entries list # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_journal_by_agent_self_returns_200( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """Reading own journal by UUID — full 200 path including get_or_create.""" client, agent = journal_client response = await client.get(f"/api/journals/{agent.id}", headers=_HDR) # Self-access: validate_journal_access permits when reader == owner. # journal_perms.can_read_journal returns True early for reader==owner. assert response.status_code in (HTTPStatus.OK, HTTPStatus.FORBIDDEN) @pytest.mark.asyncio async def test_list_agent_entries_self_with_data( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """List entries for the same agent — exercises the /{agent_id}/entries body.""" client, agent = journal_client # Seed one entry. create = await client.post( "/api/journals/me/entries", json={ "type": "general", "title": "Listed", "content": "Sufficient content for listing through /{agent_id}/entries", }, headers=_HDR, ) assert create.status_code == HTTPStatus.CREATED response = await client.get(f"/api/journals/{agent.id}/entries", headers=_HDR) assert response.status_code in (HTTPStatus.OK, HTTPStatus.FORBIDDEN) @pytest.mark.asyncio async def test_list_agent_entries_self_invalid_filter_400( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """Invalid entry_type filter on /{agent_id}/entries — 400.""" client, agent = journal_client # Make sure journal exists so the type-filter path is reached. await client.get("/api/journals/me", headers=_HDR) response = await client.get( f"/api/journals/{agent.id}/entries?entry_type=ghost", headers=_HDR, ) # 400 on bad filter or 403 if access ever gets denied. assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.FORBIDDEN) @pytest.mark.asyncio async def test_list_my_entries_with_filter_after_create( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """Create an entry then list with a valid type filter — exercises filter branch.""" client, _ = journal_client await client.post( "/api/journals/me/entries", json={ "type": "general", "title": "X", "content": "Sufficient content for the threshold check", }, headers=_HDR, ) response = await client.get( "/api/journals/me/entries?entry_type=general", headers=_HDR ) assert response.status_code == HTTPStatus.OK @pytest.mark.asyncio async def test_get_journal_by_agent_unknown_via_slug( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """Slug that resolves to nothing — 404.""" client, _ = journal_client response = await client.get("/api/journals/non-existent-agent-12345", headers=_HDR) assert response.status_code == HTTPStatus.NOT_FOUND @pytest.mark.asyncio async def test_list_agent_entries_unknown_slug( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """Slug that resolves to nothing — 404.""" client, _ = journal_client response = await client.get( "/api/journals/non-existent-agent-12345/entries", headers=_HDR ) assert response.status_code == HTTPStatus.NOT_FOUND # --------------------------------------------------------------------------- # Cross-agent access denied (403) — exercises the JournalAccessDeniedError # branches in /entries/{id}, /{agent_id}, and /{agent_id}/entries. # --------------------------------------------------------------------------- async def _make_cross_agent_app( db_session: AsyncSession, reader: AgentTable ) -> FastAPI: """Build a journals app with the reader's slug-bearing context.""" app = FastAPI() app.include_router(journals_router, prefix="/api/journals") async def _override_db() -> AsyncGenerator[AsyncSession]: yield db_session async def _override_agent() -> AgentContext: return AgentContext( agent_id=cast("UUID", reader.id), role=reader.role, team=reader.team, slug=reader.slug, ) app.dependency_overrides[get_db] = _override_db app.dependency_overrides[get_agent_context] = _override_agent return app @pytest.mark.asyncio async def test_get_entry_cross_agent_denied(db_session: AsyncSession) -> None: """Reader on a different cell from owner — 403 on /entries/{id}.""" # Owner: backend developer. owner = AgentTable( id=uuid4(), name="Owner", slug=f"be-dev-own-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) # Reader: frontend developer (different cell, no global read). reader = AgentTable( id=uuid4(), name="Reader", slug=f"fe-dev-rdr-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.FRONTEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) db_session.add_all([owner, reader]) await db_session.flush() # Owner creates an entry first. owner_app = await _make_cross_agent_app(db_session, owner) transport = ASGITransport(app=owner_app) async with AsyncClient(transport=transport, base_url="http://test") as client: create = await client.post( "/api/journals/me/entries", json={ "type": "general", "title": "Owner's entry", "content": "Sufficient content to satisfy the threshold check", }, headers=_HDR, ) owner_app.dependency_overrides.clear() assert create.status_code == HTTPStatus.CREATED eid = create.json()["id"] # Reader (different cell) tries to fetch — 403. reader_app = await _make_cross_agent_app(db_session, reader) transport = ASGITransport(app=reader_app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get(f"/api/journals/entries/{eid}", headers=_HDR) reader_app.dependency_overrides.clear() assert response.status_code == HTTPStatus.FORBIDDEN @pytest.mark.asyncio async def test_get_journal_by_agent_cross_agent_denied( db_session: AsyncSession, ) -> None: """Reader on a different cell from owner — 403 on /{agent_id}.""" owner = AgentTable( id=uuid4(), name="Owner", slug=f"be-dev-own2-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) reader = AgentTable( id=uuid4(), name="Reader", slug=f"fe-dev-rdr2-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.FRONTEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) db_session.add_all([owner, reader]) await db_session.flush() reader_app = await _make_cross_agent_app(db_session, reader) transport = ASGITransport(app=reader_app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get(f"/api/journals/{owner.id}", headers=_HDR) reader_app.dependency_overrides.clear() assert response.status_code == HTTPStatus.FORBIDDEN @pytest.mark.asyncio async def test_list_agent_entries_cross_agent_denied( db_session: AsyncSession, ) -> None: """Reader on a different cell from owner — 403 on /{agent_id}/entries.""" owner = AgentTable( id=uuid4(), name="Owner", slug=f"be-dev-own3-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) reader = AgentTable( id=uuid4(), name="Reader", slug=f"fe-dev-rdr3-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.FRONTEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) db_session.add_all([owner, reader]) await db_session.flush() reader_app = await _make_cross_agent_app(db_session, reader) transport = ASGITransport(app=reader_app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get(f"/api/journals/{owner.id}/entries", headers=_HDR) reader_app.dependency_overrides.clear() assert response.status_code == HTTPStatus.FORBIDDEN @pytest.mark.asyncio async def test_list_agent_entries_self_no_journal_yet( db_session: AsyncSession, ) -> None: """Listing own entries before any journal is created — empty list.""" agent = AgentTable( id=uuid4(), name="Solo", slug=f"be-dev-solo-{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 = await _make_cross_agent_app(db_session, agent) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get(f"/api/journals/{agent.id}/entries", headers=_HDR) app.dependency_overrides.clear() assert response.status_code == HTTPStatus.OK # --------------------------------------------------------------------------- # 409 conflict branches when service returns None for missing entities # (lines 183, 339, 391, 441, 492, 542) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_create_entry_returns_409_when_service_returns_none( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """create_entry returning None → 409 (line 183).""" client, _ = journal_client with patch( "roboco.services.journal.JournalService.create_entry", return_value=None ): response = await client.post( "/api/journals/me/entries", json={ "type": "general", "title": "x", "content": "x" * 200, }, headers=_HDR, ) assert response.status_code == HTTPStatus.CONFLICT @pytest.mark.asyncio async def test_add_task_reflection_returns_409_when_service_returns_none( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """add_task_reflection returning None → 409 (line 339).""" client, _ = journal_client with patch( "roboco.services.journal.JournalService.add_task_reflection", return_value=None, ): response = await client.post( "/api/journals/me/reflections", json={ "task_id": str(uuid4()), "title": "x", "what_done": "y", "what_learned": "z", "what_struggled": "q", "next_steps": [], }, headers=_HDR, ) assert response.status_code == HTTPStatus.CONFLICT @pytest.mark.asyncio async def test_add_decision_log_returns_409_when_service_returns_none( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """add_decision_log returning None → 409 (line 391).""" client, _ = journal_client with patch( "roboco.services.journal.JournalService.add_decision_log", return_value=None, ): response = await client.post( "/api/journals/me/decisions", json={ "title": "x", "context": "y", "options": [{"name": "A"}, {"name": "B"}], "chosen": "A", "rationale": "fit", "consequences": ["c"], }, headers=_HDR, ) assert response.status_code == HTTPStatus.CONFLICT @pytest.mark.asyncio async def test_add_learning_returns_409_when_service_returns_none( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """add_learning returning None → 409 (line 441).""" client, _ = journal_client with patch( "roboco.services.journal.JournalService.add_learning", return_value=None ): response = await client.post( "/api/journals/me/learnings", json={"title": "x", "what_learned": "y"}, headers=_HDR, ) assert response.status_code == HTTPStatus.CONFLICT @pytest.mark.asyncio async def test_add_struggle_returns_409_when_service_returns_none( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """add_struggle returning None → 409 (line 492).""" client, _ = journal_client with patch( "roboco.services.journal.JournalService.add_struggle", return_value=None ): response = await client.post( "/api/journals/me/struggles", json={ "title": "x", "what_struggled": "y", "attempted_solutions": ["a"], }, headers=_HDR, ) assert response.status_code == HTTPStatus.CONFLICT @pytest.mark.asyncio async def test_add_general_entry_returns_409_when_service_returns_none( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """add_general_entry returning None → 409 (line 542).""" client, _ = journal_client with patch( "roboco.services.journal.JournalService.add_general_entry", return_value=None, ): response = await client.post( "/api/journals/me/notes", json={"title": "x", "content": "y"}, headers=_HDR, ) assert response.status_code == HTTPStatus.CONFLICT @pytest.mark.asyncio async def test_get_my_stats_returns_zero_defaults_when_no_stats( journal_client: tuple[AsyncClient, AgentTable], ) -> None: """When journal exists but get_journal_stats returns None → defaults (229).""" client, _ = journal_client # Force the journal-exists branch first by GET-ing /me, then stub stats=None. await client.get("/api/journals/me", headers=_HDR) with patch( "roboco.services.journal.JournalService.get_journal_stats", return_value=None, ): response = await client.get("/api/journals/me/stats", headers=_HDR) assert response.status_code == HTTPStatus.OK body = response.json() assert body["total_entries"] == 0 @pytest.mark.asyncio async def test_get_entry_journal_not_found_404( journal_client: tuple[AsyncClient, AgentTable], db_session: AsyncSession ) -> None: """When get_journal returns None → 404 (line 591).""" client, agent = journal_client journal = JournalTable( id=uuid4(), agent_id=agent.id, ) db_session.add(journal) await db_session.flush() entry = JournalEntryTable( id=uuid4(), journal_id=journal.id, type=JournalEntryType.GENERAL, title="t", content="c", ) db_session.add(entry) await db_session.flush() with patch("roboco.services.journal.JournalService.get_journal", return_value=None): response = await client.get(f"/api/journals/entries/{entry.id}", headers=_HDR) assert response.status_code == HTTPStatus.NOT_FOUND @pytest.mark.asyncio async def test_get_entry_owner_not_found_404( journal_client: tuple[AsyncClient, AgentTable], db_session: AsyncSession ) -> None: """When get_agent_slug returns None → 404 (line 599).""" client, agent = journal_client journal = JournalTable( id=uuid4(), agent_id=agent.id, ) db_session.add(journal) await db_session.flush() entry = JournalEntryTable( id=uuid4(), journal_id=journal.id, type=JournalEntryType.GENERAL, title="t", content="c", ) db_session.add(entry) await db_session.flush() with patch( "roboco.services.journal.JournalService.get_agent_slug", return_value=None, ): response = await client.get(f"/api/journals/entries/{entry.id}", headers=_HDR) assert response.status_code == HTTPStatus.NOT_FOUND