mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Service-level tests now exercise provider, permissions, project, journal, messaging, work_session, metrics, kanban, extraction, learning, notification, dashboard, llm_routing, a2a, task, repository_base, audit, db_seed, branch_name, indexed_document, query_helpers, agent. API route tests cover provider, journal, project, sessions, dashboard, work_session, tasks, a2a, groups, notifications, agents, channels, messages, kanban, api_resources. Pure-function helpers covered: handlers, deps_helpers, middleware, middleware_docs, transcription, pr templates, agents_config, errors, logging, journal/notification/channel/a2a access, task_lifecycle, streaming, converters, crypto, schemas (common + websocket), events, permissions extras. pyproject ruff per-file-ignores extended for tests so PLR2004 (status code magic values), PLC0415 (lazy imports), PLR0913 (fixture params), ARG001 (unused fixture deps), SIM105, and E501 don't fight test idioms.
416 lines
12 KiB
Python
416 lines
12 KiB
Python
"""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)
|