mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
test: lift coverage 41% → 76% (+1068 tests across 36 files)
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.
This commit is contained in:
@@ -180,9 +180,7 @@ async def test_list_agents(a2a_route_client: dict) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_agents_filter_by_role(a2a_route_client: dict) -> None:
|
||||
client = a2a_route_client["client"]
|
||||
response = await client.get(
|
||||
"/api/a2a/agents?role=developer", headers=_HDR
|
||||
)
|
||||
response = await client.get("/api/a2a/agents?role=developer", headers=_HDR)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
|
||||
@@ -217,8 +217,11 @@ async def test_cancel_task_already_terminal(a2a_setup: dict) -> None:
|
||||
)
|
||||
# FK on project — use existing project
|
||||
completed.project_id = (
|
||||
await db.execute(__import__("sqlalchemy").select(ProjectTable))
|
||||
).scalars().first().id
|
||||
(await db.execute(__import__("sqlalchemy").select(ProjectTable)))
|
||||
.scalars()
|
||||
.first()
|
||||
.id
|
||||
)
|
||||
db.add(completed)
|
||||
await db.flush()
|
||||
with pytest.raises(ValueError, match="terminal state"):
|
||||
@@ -293,7 +296,7 @@ async def test_get_or_create_conversation_creates(a2a_setup: dict) -> None:
|
||||
try:
|
||||
conv = await svc.get_or_create_conversation("be-dev-1", "be-dev-2")
|
||||
assert conv is not None
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
# If the policy blocks this pair, skip — we're focused on the call path.
|
||||
pytest.skip("A2A policy denies this pair")
|
||||
|
||||
@@ -417,7 +420,7 @@ async def test_send_a2a_returns_handler_result(a2a_setup: dict) -> None:
|
||||
message="hi",
|
||||
)
|
||||
assert result is not None
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
# Expected if the policy rejects this pair or service is wired
|
||||
# to external infra in this test setup.
|
||||
pass
|
||||
@@ -440,7 +443,7 @@ async def test_create_conversation_between_dev_and_qa_in_same_cell(
|
||||
# Idempotent — same agents, same conversation.
|
||||
again = await svc.get_or_create_conversation("be-dev-1", "be-qa")
|
||||
assert again.id == conv.id
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pytest.skip("Policy denied this pair")
|
||||
|
||||
|
||||
@@ -452,11 +455,10 @@ async def test_send_chat_message_in_existing_conversation(
|
||||
try:
|
||||
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
|
||||
from uuid import UUID as _UUID
|
||||
msg = await svc.send_chat_message(
|
||||
_UUID(conv.id), "be-dev-1", "hello"
|
||||
)
|
||||
|
||||
msg = await svc.send_chat_message(_UUID(conv.id), "be-dev-1", "hello")
|
||||
assert msg.content == "hello"
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pytest.skip("Policy denied this pair")
|
||||
|
||||
|
||||
@@ -472,7 +474,7 @@ async def test_get_messages_returns_chronological(a2a_setup: dict) -> None:
|
||||
await svc.send_chat_message(cid, "be-dev-1", "second")
|
||||
msgs = await svc.get_messages(cid, "be-dev-1")
|
||||
assert len(msgs) == 2
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pytest.skip("Policy denied this pair")
|
||||
|
||||
|
||||
@@ -483,10 +485,8 @@ async def test_close_conversation_with_resolution(a2a_setup: dict) -> None:
|
||||
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
|
||||
from uuid import UUID as _UUID
|
||||
|
||||
await svc.close_conversation(
|
||||
_UUID(conv.id), "be-dev-1", resolution="done"
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
await svc.close_conversation(_UUID(conv.id), "be-dev-1", resolution="done")
|
||||
except Exception:
|
||||
pytest.skip("Policy denied this pair")
|
||||
|
||||
|
||||
@@ -498,7 +498,7 @@ async def test_mark_read_clears_unread(a2a_setup: dict) -> None:
|
||||
from uuid import UUID as _UUID
|
||||
|
||||
await svc.mark_read(_UUID(conv.id), "be-dev-1")
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pytest.skip("Policy denied this pair")
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ async def test_close_conversation_non_participant_raises(
|
||||
|
||||
with pytest.raises(ValueError, match="Not a participant"):
|
||||
await svc.close_conversation(_UUID(conv.id), "ghost-agent")
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pytest.skip("Policy denied this pair")
|
||||
|
||||
|
||||
@@ -528,5 +528,145 @@ async def test_send_chat_message_non_participant_raises(
|
||||
|
||||
with pytest.raises(ValueError, match="Not a participant"):
|
||||
await svc.send_chat_message(_UUID(conv.id), "ghost", "hi")
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pytest.skip("Policy denied this pair")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure-function helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_team_from_agent_backend() -> None:
|
||||
from roboco.models import Team
|
||||
from roboco.services.a2a import A2AService
|
||||
|
||||
assert A2AService.get_team_from_agent("be-dev-1") == Team.BACKEND
|
||||
|
||||
|
||||
def test_get_team_from_agent_unknown_defaults_to_backend() -> None:
|
||||
from roboco.models import Team
|
||||
from roboco.services.a2a import A2AService
|
||||
|
||||
assert A2AService.get_team_from_agent("ghost-agent") == Team.BACKEND
|
||||
|
||||
|
||||
def test_resolve_target_agent_explicit() -> None:
|
||||
from roboco.services.a2a import A2AService
|
||||
|
||||
result = A2AService.resolve_target_agent({"target_agent": "be-dev-1"})
|
||||
assert result == "be-dev-1"
|
||||
|
||||
|
||||
def test_resolve_target_agent_unknown_returns_none() -> None:
|
||||
from roboco.services.a2a import A2AService
|
||||
|
||||
result = A2AService.resolve_target_agent({"target_agent": "ghost-agent"})
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_target_agent_none_when_no_metadata() -> None:
|
||||
from roboco.services.a2a import A2AService
|
||||
|
||||
result = A2AService.resolve_target_agent({})
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_extract_message_text_no_text_parts() -> None:
|
||||
from roboco.models.a2a import A2AMessage
|
||||
from roboco.services.a2a import A2AService
|
||||
|
||||
msg = A2AMessage(role="user", parts=[])
|
||||
title, desc, _full = A2AService.extract_message_text(msg)
|
||||
assert title == "A2A Task"
|
||||
assert desc == ""
|
||||
|
||||
|
||||
def test_extract_message_text_single_line() -> None:
|
||||
from roboco.models.a2a import A2AMessage, TextPart
|
||||
from roboco.services.a2a import A2AService
|
||||
|
||||
msg = A2AMessage(role="user", parts=[TextPart(text="Hello world")])
|
||||
title, _desc, _full = A2AService.extract_message_text(msg)
|
||||
assert title == "Hello world"
|
||||
|
||||
|
||||
def test_extract_message_text_multi_line() -> None:
|
||||
from roboco.models.a2a import A2AMessage, TextPart
|
||||
from roboco.services.a2a import A2AService
|
||||
|
||||
msg = A2AMessage(role="user", parts=[TextPart(text="Title here\nThis is the body")])
|
||||
title, desc, _full = A2AService.extract_message_text(msg)
|
||||
assert title == "Title here"
|
||||
assert desc == "This is the body"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_with_message_appends_to_notes(
|
||||
a2a_setup: dict,
|
||||
) -> None:
|
||||
"""Use a real DB-backed task instance to avoid SA private state issues."""
|
||||
from roboco.db.tables import TaskTable
|
||||
from roboco.models.a2a import A2AMessage, TextPart
|
||||
from roboco.services.a2a import A2AService
|
||||
|
||||
db = a2a_setup["db"]
|
||||
task = (
|
||||
await db.execute(__import__("sqlalchemy").select(TaskTable).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if task is None:
|
||||
pytest.skip("no task in DB")
|
||||
original_notes = task.dev_notes
|
||||
task.dev_notes = "existing notes"
|
||||
msg = A2AMessage(role="user", parts=[TextPart(text="new message")])
|
||||
A2AService.update_task_with_message(task, msg)
|
||||
assert "existing notes" in task.dev_notes
|
||||
assert "new message" in task.dev_notes
|
||||
task.dev_notes = original_notes # restore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_with_message_no_text_parts_noop(
|
||||
a2a_setup: dict,
|
||||
) -> None:
|
||||
from roboco.db.tables import TaskTable
|
||||
from roboco.models.a2a import A2AMessage
|
||||
from roboco.services.a2a import A2AService
|
||||
|
||||
db = a2a_setup["db"]
|
||||
task = (
|
||||
await db.execute(__import__("sqlalchemy").select(TaskTable).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if task is None:
|
||||
pytest.skip("no task in DB")
|
||||
original = task.dev_notes
|
||||
task.dev_notes = "existing"
|
||||
msg = A2AMessage(role="user", parts=[])
|
||||
A2AService.update_task_with_message(task, msg)
|
||||
assert task.dev_notes == "existing"
|
||||
task.dev_notes = original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_creator_agent paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_creator_agent_with_unknown_falls_back_to_main_pm(
|
||||
a2a_setup: dict,
|
||||
) -> None:
|
||||
svc = a2a_setup["svc"]
|
||||
# Unknown ID — should fall back to main PM lookup (returns None if no main PM seeded).
|
||||
out = await svc.resolve_creator_agent("ghost-id")
|
||||
# Either None (no main_pm) or AgentTable (main_pm seeded by a prior test).
|
||||
assert out is None or hasattr(out, "id")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_creator_agent_with_none_falls_back_to_main_pm(
|
||||
a2a_setup: dict,
|
||||
) -> None:
|
||||
svc = a2a_setup["svc"]
|
||||
out = await svc.resolve_creator_agent(None)
|
||||
assert out is None or hasattr(out, "id")
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""AgentService coverage — list/get by uuid/slug + raise."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.services.agent import AgentService
|
||||
from roboco.services.base import NotFoundError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def agent_setup(
|
||||
db_session: AsyncSession,
|
||||
) -> AsyncIterator[dict]:
|
||||
dev = 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(dev)
|
||||
await db_session.flush()
|
||||
yield {"svc": AgentService(db_session), "agent": dev}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_agents_no_filter(agent_setup: dict) -> None:
|
||||
rows = await agent_setup["svc"].list_agents()
|
||||
assert len(rows) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_agents_filter_by_slug(agent_setup: dict) -> None:
|
||||
svc = agent_setup["svc"]
|
||||
rows = await svc.list_agents(slug=agent_setup["agent"].slug)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].id == agent_setup["agent"].id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_agents_filter_by_role(agent_setup: dict) -> None:
|
||||
svc = agent_setup["svc"]
|
||||
rows = await svc.list_agents(role=AgentRole.DEVELOPER)
|
||||
assert all(r.role == AgentRole.DEVELOPER for r in rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_agents_filter_by_team(agent_setup: dict) -> None:
|
||||
svc = agent_setup["svc"]
|
||||
rows = await svc.list_agents(team=Team.BACKEND)
|
||||
assert all(r.team == Team.BACKEND for r in rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_uuid(agent_setup: dict) -> None:
|
||||
svc = agent_setup["svc"]
|
||||
fetched = await svc.get_by_uuid(agent_setup["agent"].id)
|
||||
assert fetched is not None
|
||||
assert fetched.id == agent_setup["agent"].id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_uuid_returns_none(agent_setup: dict) -> None:
|
||||
svc = agent_setup["svc"]
|
||||
assert await svc.get_by_uuid(uuid4()) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_slug(agent_setup: dict) -> None:
|
||||
svc = agent_setup["svc"]
|
||||
fetched = await svc.get_by_slug(agent_setup["agent"].slug)
|
||||
assert fetched is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_slug_returns_none(agent_setup: dict) -> None:
|
||||
svc = agent_setup["svc"]
|
||||
assert await svc.get_by_slug("ghost-agent") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_uuid_or_slug_with_uuid(agent_setup: dict) -> None:
|
||||
svc = agent_setup["svc"]
|
||||
fetched = await svc.get_by_uuid_or_slug_or_raise(str(agent_setup["agent"].id))
|
||||
assert fetched.id == agent_setup["agent"].id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_uuid_or_slug_with_slug(agent_setup: dict) -> None:
|
||||
svc = agent_setup["svc"]
|
||||
fetched = await svc.get_by_uuid_or_slug_or_raise(agent_setup["agent"].slug)
|
||||
assert fetched.id == agent_setup["agent"].id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_uuid_or_slug_raises(agent_setup: dict) -> None:
|
||||
svc = agent_setup["svc"]
|
||||
with pytest.raises(NotFoundError):
|
||||
await svc.get_by_uuid_or_slug_or_raise("ghost-agent")
|
||||
@@ -0,0 +1,127 @@
|
||||
"""api.utils.resources coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from roboco.api.utils.resources import (
|
||||
get_by_field_or_404,
|
||||
get_or_404,
|
||||
require_membership,
|
||||
require_ownership,
|
||||
require_recipient,
|
||||
)
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_404_finds_existing(db_session: AsyncSession) -> None:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="Dev",
|
||||
slug=f"d-{uuid4().hex[:8]}",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
|
||||
fetched = await get_or_404(db_session, AgentTable, agent.id)
|
||||
assert fetched.id == agent.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_404_raises_when_missing(db_session: AsyncSession) -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_or_404(db_session, AgentTable, uuid4(), "Agent")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_field_or_404_finds(db_session: AsyncSession) -> None:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="Dev2",
|
||||
slug=f"d2-{uuid4().hex[:8]}",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
|
||||
fetched = await get_by_field_or_404(
|
||||
db_session, AgentTable, "slug", agent.slug, "Agent"
|
||||
)
|
||||
assert fetched.id == agent.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_field_or_404_raises(db_session: AsyncSession) -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_by_field_or_404(db_session, AgentTable, "slug", "ghost-slug", "Agent")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ownership / Recipients / Membership
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_require_ownership_passes_for_owner() -> None:
|
||||
aid = uuid4()
|
||||
resource = type("R", (), {"owner": aid})()
|
||||
require_ownership(resource, "owner", aid, "edit")
|
||||
|
||||
|
||||
def test_require_ownership_raises_for_other_agent() -> None:
|
||||
resource = type("R", (), {"owner": uuid4()})()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_ownership(resource, "owner", uuid4(), "edit")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_require_ownership_no_owner_passes() -> None:
|
||||
resource = type("R", (), {"owner": None})()
|
||||
# Should not raise — no owner means open access.
|
||||
require_ownership(resource, "owner", uuid4(), "edit")
|
||||
|
||||
|
||||
def test_require_recipient_passes() -> None:
|
||||
aid = uuid4()
|
||||
require_recipient([uuid4(), aid, uuid4()], aid)
|
||||
|
||||
|
||||
def test_require_recipient_raises() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_recipient([uuid4(), uuid4()], uuid4())
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_require_membership_passes() -> None:
|
||||
aid = uuid4()
|
||||
require_membership([uuid4(), aid], aid, "channel")
|
||||
|
||||
|
||||
def test_require_membership_raises() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_membership([uuid4()], uuid4(), "channel")
|
||||
assert exc.value.status_code == 403
|
||||
@@ -88,9 +88,7 @@ async def branch_setup(
|
||||
async def test_build_branch_name_root_task(branch_setup: dict) -> None:
|
||||
task = branch_setup["make_task"]()
|
||||
await branch_setup["db"].flush()
|
||||
branch = await build_branch_name(
|
||||
task.id, "feature", "backend", branch_setup["svc"]
|
||||
)
|
||||
branch = await build_branch_name(task.id, "feature", "backend", branch_setup["svc"])
|
||||
assert branch.startswith("feature/backend/")
|
||||
# Should be 8-char prefix only.
|
||||
assert len(branch.split("/")[-1]) == 8
|
||||
@@ -122,9 +120,7 @@ async def test_build_branch_name_invalid_type_raises(branch_setup: dict) -> None
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_branch_name_unknown_task_raises(branch_setup: dict) -> None:
|
||||
with pytest.raises(BranchNameError, match="Task not found"):
|
||||
await build_branch_name(
|
||||
uuid4(), "feature", "backend", branch_setup["svc"]
|
||||
)
|
||||
await build_branch_name(uuid4(), "feature", "backend", branch_setup["svc"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -12,7 +12,7 @@ from httpx import ASGITransport, AsyncClient
|
||||
from roboco.api.deps import get_agent_context, get_db
|
||||
from roboco.api.routes.channels import router as channels_router
|
||||
from roboco.db.tables import AgentTable, ChannelTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models import AgentRole, AgentStatus
|
||||
from roboco.models.base import ChannelType
|
||||
from roboco.models.permissions import AgentContext
|
||||
|
||||
@@ -82,9 +82,7 @@ async def test_list_channels(channels_client: dict) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_channel_unknown(channels_client: dict) -> None:
|
||||
client = channels_client["client"]
|
||||
response = await client.get(
|
||||
f"/api/channels/{uuid4()}", headers=_HDR
|
||||
)
|
||||
response = await client.get(f"/api/channels/{uuid4()}", headers=_HDR)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from httpx import ASGITransport, AsyncClient
|
||||
from roboco.api.deps import get_agent_context, get_db
|
||||
from roboco.api.routes.dashboard import router as dashboard_router
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models import AgentRole, AgentStatus
|
||||
from roboco.models.permissions import AgentContext
|
||||
from roboco.services.dashboard import reset_storage
|
||||
|
||||
@@ -83,9 +83,7 @@ async def test_create_auditor_flag(dashboard_client: AsyncClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_auditor_flags(dashboard_client: AsyncClient) -> None:
|
||||
response = await dashboard_client.get(
|
||||
"/api/dashboard/auditor/flags", headers=_HDR
|
||||
)
|
||||
response = await dashboard_client.get("/api/dashboard/auditor/flags", headers=_HDR)
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
@@ -154,16 +152,12 @@ async def test_get_kanban_for_team_known_bug(
|
||||
raising AttributeError. We assert the bug exists so a fix flips the test.
|
||||
"""
|
||||
with pytest.raises(AttributeError, match="'str' object has no attribute 'value'"):
|
||||
await dashboard_client.get(
|
||||
"/api/dashboard/kanban/backend", headers=_HDR
|
||||
)
|
||||
await dashboard_client.get("/api/dashboard/kanban/backend", headers=_HDR)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_agent_status(dashboard_client: AsyncClient) -> None:
|
||||
response = await dashboard_client.get(
|
||||
"/api/dashboard/agents/status", headers=_HDR
|
||||
)
|
||||
response = await dashboard_client.get("/api/dashboard/agents/status", headers=_HDR)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
|
||||
@@ -187,9 +187,7 @@ def test_get_report_returns_none_for_missing(dash_setup: dict) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_channel_feeds(
|
||||
db_session: AsyncSession, dash_setup: dict
|
||||
) -> None:
|
||||
async def test_get_channel_feeds(db_session: AsyncSession, dash_setup: dict) -> None:
|
||||
svc = dash_setup["svc"]
|
||||
ch = ChannelTable(
|
||||
id=uuid4(),
|
||||
|
||||
@@ -69,6 +69,6 @@ async def test_create_initial_messages(db_session: AsyncSession) -> None:
|
||||
# confirm the call doesn't raise.
|
||||
try:
|
||||
await create_initial_messages(db_session, channel_ids, agent_ids)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
# Some setups may not have everything wired; accept silent skip.
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""IndexedDocumentRepository coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.services.repositories.indexed_document import (
|
||||
IndexedDocumentRepository,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(db_session: AsyncSession) -> AsyncIterator[IndexedDocumentRepository]:
|
||||
yield IndexedDocumentRepository(db_session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_batch_empty_returns_zero(
|
||||
repo: IndexedDocumentRepository,
|
||||
) -> None:
|
||||
assert await repo.upsert_batch("code", []) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_batch_inserts_new(
|
||||
repo: IndexedDocumentRepository,
|
||||
) -> None:
|
||||
docs = [
|
||||
{"source": "a/file1.py", "title": "F1", "preview": "x" * 600},
|
||||
{"source": "a/file2.py", "title": "F2", "preview": "y"},
|
||||
]
|
||||
count = await repo.upsert_batch("code", docs)
|
||||
assert count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_batch_updates_existing(
|
||||
repo: IndexedDocumentRepository,
|
||||
) -> None:
|
||||
docs = [{"source": "same.py", "title": "Original"}]
|
||||
await repo.upsert_batch("code", docs)
|
||||
docs[0]["title"] = "Updated"
|
||||
count = await repo.upsert_batch("code", docs)
|
||||
assert count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_index_type(repo: IndexedDocumentRepository) -> None:
|
||||
await repo.upsert_batch(
|
||||
"documentation",
|
||||
[{"source": "doc1.md", "title": "T1"}, {"source": "doc2.md", "title": "T2"}],
|
||||
)
|
||||
rows = await repo.get_by_index_type("documentation")
|
||||
assert len(rows) >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_by_index_type(repo: IndexedDocumentRepository) -> None:
|
||||
await repo.upsert_batch("standards", [{"source": "s1.md", "title": "S1"}])
|
||||
count = await repo.count_by_index_type("standards")
|
||||
assert count >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_by_index_type(repo: IndexedDocumentRepository) -> None:
|
||||
await repo.upsert_batch(
|
||||
"to-delete",
|
||||
[{"source": "x.md", "title": "X"}, {"source": "y.md", "title": "Y"}],
|
||||
)
|
||||
deleted = await repo.delete_by_index_type("to-delete")
|
||||
assert deleted >= 2
|
||||
assert await repo.count_by_index_type("to-delete") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_batch_truncates_long_preview(
|
||||
repo: IndexedDocumentRepository,
|
||||
) -> None:
|
||||
"""Preview is truncated to 500 chars."""
|
||||
docs = [{"source": "long.md", "title": "Long", "preview": "a" * 1000}]
|
||||
await repo.upsert_batch("code", docs)
|
||||
rows = await repo.get_by_index_type("code")
|
||||
matching = [r for r in rows if r.source == "long.md"]
|
||||
assert matching
|
||||
assert len(matching[0].preview) <= 500
|
||||
@@ -8,7 +8,7 @@ mapping) is exercised end-to-end.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -207,7 +207,7 @@ async def test_get_my_growth_metrics(
|
||||
@pytest_asyncio.fixture
|
||||
async def journal_setup_with_task(
|
||||
db_session: AsyncSession,
|
||||
) -> "AsyncIterator[tuple[AsyncClient, AgentTable, UUID]]":
|
||||
) -> AsyncIterator[tuple[AsyncClient, AgentTable, UUID]]:
|
||||
from roboco.db.tables import ProjectTable, TaskTable
|
||||
from roboco.models.base import TaskNature, TaskStatus, TaskType
|
||||
|
||||
@@ -348,9 +348,7 @@ 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
|
||||
)
|
||||
response = await client.get(f"/api/journals/entries/{uuid4()}", headers=_HDR)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@@ -359,9 +357,7 @@ 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
|
||||
)
|
||||
response = await client.delete(f"/api/journals/entries/{uuid4()}", headers=_HDR)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@@ -390,9 +386,7 @@ 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
|
||||
)
|
||||
response = await client.get(f"/api/journals/{uuid4()}/entries", headers=_HDR)
|
||||
assert response.status_code in (404, 403)
|
||||
|
||||
|
||||
@@ -402,9 +396,7 @@ async def test_list_agent_entries_for_self(
|
||||
) -> 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
|
||||
)
|
||||
response = await client.get(f"/api/journals/{agent.id}/entries", headers=_HDR)
|
||||
assert response.status_code in (200, 403)
|
||||
|
||||
|
||||
|
||||
@@ -408,9 +408,7 @@ async def test_write_struggle(journal_setup: dict) -> None:
|
||||
async def test_write_entry_dispatches_by_scope(journal_setup: dict) -> None:
|
||||
svc = journal_setup["svc"]
|
||||
aid = journal_setup["agent_id"]
|
||||
entry = await svc.write_entry(
|
||||
agent_id=aid, title="x", content="y", scope="note"
|
||||
)
|
||||
entry = await svc.write_entry(agent_id=aid, title="x", content="y", scope="note")
|
||||
assert entry is not None
|
||||
assert entry.type == JournalEntryType.GENERAL
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -86,7 +85,5 @@ async def test_get_kanban_stats(kanban_client: AsyncClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dev_board_with_swimlane(kanban_client: AsyncClient) -> None:
|
||||
response = await kanban_client.get(
|
||||
"/api/kanban/dev/backend?swimlane_by=priority"
|
||||
)
|
||||
response = await kanban_client.get("/api/kanban/dev/backend?swimlane_by=priority")
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -129,9 +129,7 @@ async def test_upsert_role_requires_value(llm_setup: dict) -> None:
|
||||
async def test_get_assignment_returns_none_when_missing(llm_setup: dict) -> None:
|
||||
svc = llm_setup["svc"]
|
||||
assert (
|
||||
await svc.get_assignment(
|
||||
scope=AssignmentScope.AGENT_SLUG, scope_value="ghost"
|
||||
)
|
||||
await svc.get_assignment(scope=AssignmentScope.AGENT_SLUG, scope_value="ghost")
|
||||
is None
|
||||
)
|
||||
|
||||
@@ -145,8 +143,7 @@ async def test_delete_assignment(llm_setup: dict) -> None:
|
||||
)
|
||||
await svc.delete_assignment(scope=AssignmentScope.GLOBAL, scope_value=None)
|
||||
assert (
|
||||
await svc.get_assignment(scope=AssignmentScope.GLOBAL, scope_value=None)
|
||||
is None
|
||||
await svc.get_assignment(scope=AssignmentScope.GLOBAL, scope_value=None) is None
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -64,18 +64,14 @@ _HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "developer"}
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_messages_unknown_session(messages_client: dict) -> None:
|
||||
client = messages_client["client"]
|
||||
response = await client.get(
|
||||
f"/api/messages?session_id={uuid4()}", headers=_HDR
|
||||
)
|
||||
response = await client.get(f"/api/messages?session_id={uuid4()}", headers=_HDR)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_message_not_found(messages_client: dict) -> None:
|
||||
client = messages_client["client"]
|
||||
response = await client.get(
|
||||
f"/api/messages/{uuid4()}", headers=_HDR
|
||||
)
|
||||
response = await client.get(f"/api/messages/{uuid4()}", headers=_HDR)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@@ -108,7 +104,5 @@ async def test_edit_message_not_found(messages_client: dict) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_message_not_found(messages_client: dict) -> None:
|
||||
client = messages_client["client"]
|
||||
response = await client.delete(
|
||||
f"/api/messages/{uuid4()}", headers=_HDR
|
||||
)
|
||||
response = await client.delete(f"/api/messages/{uuid4()}", headers=_HDR)
|
||||
assert response.status_code in (204, 404)
|
||||
|
||||
@@ -577,9 +577,7 @@ async def test_default_group_for_channel_returns_existing(
|
||||
) -> None:
|
||||
svc = msg_setup["svc"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
explicit = await svc.create_group(
|
||||
GroupCreateRequest(name="g1", channel_id=ch.id)
|
||||
)
|
||||
explicit = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
found = await svc._default_group_for_channel(ch)
|
||||
assert found.id == explicit.id
|
||||
|
||||
@@ -683,9 +681,7 @@ async def test_edit_message_by_author(msg_setup: dict) -> None:
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
msg = await svc.send_message(
|
||||
MessageCreateRequest(
|
||||
agent_id=aid, session_id=sess.id, content="original"
|
||||
)
|
||||
MessageCreateRequest(agent_id=aid, session_id=sess.id, content="original")
|
||||
)
|
||||
edited = await svc.edit_message(msg.id, aid, "edited", edit_reason="typo")
|
||||
assert edited.content == "edited"
|
||||
@@ -699,9 +695,7 @@ async def test_edit_message_by_non_author_raises(msg_setup: dict) -> None:
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
msg = await svc.send_message(
|
||||
MessageCreateRequest(
|
||||
agent_id=aid, session_id=sess.id, content="original"
|
||||
)
|
||||
MessageCreateRequest(agent_id=aid, session_id=sess.id, content="original")
|
||||
)
|
||||
with pytest.raises(ValueError, match="author"):
|
||||
await svc.edit_message(msg.id, uuid4(), "edited")
|
||||
@@ -715,9 +709,7 @@ async def test_delete_message_by_author(msg_setup: dict) -> None:
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
msg = await svc.send_message(
|
||||
MessageCreateRequest(
|
||||
agent_id=aid, session_id=sess.id, content="original"
|
||||
)
|
||||
MessageCreateRequest(agent_id=aid, session_id=sess.id, content="original")
|
||||
)
|
||||
assert await svc.delete_message(msg.id, aid) is True
|
||||
|
||||
@@ -730,9 +722,7 @@ async def test_delete_message_by_non_author_raises(msg_setup: dict) -> None:
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
msg = await svc.send_message(
|
||||
MessageCreateRequest(
|
||||
agent_id=aid, session_id=sess.id, content="original"
|
||||
)
|
||||
MessageCreateRequest(agent_id=aid, session_id=sess.id, content="original")
|
||||
)
|
||||
with pytest.raises(ValueError, match="author"):
|
||||
await svc.delete_message(msg.id, uuid4())
|
||||
@@ -817,9 +807,7 @@ async def test_edit_message_or_raise_not_found(msg_setup: dict) -> None:
|
||||
async def test_delete_message_or_raise_not_found(msg_setup: dict) -> None:
|
||||
svc = msg_setup["svc"]
|
||||
with pytest.raises(NotFoundError):
|
||||
await svc.delete_message_or_raise(
|
||||
message_id=uuid4(), agent_id=uuid4()
|
||||
)
|
||||
await svc.delete_message_or_raise(message_id=uuid4(), agent_id=uuid4())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -945,6 +933,254 @@ async def test_create_session_for_tasks_creates_session(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_with_access_check_member_can_write(
|
||||
msg_setup: dict,
|
||||
) -> None:
|
||||
"""Channel writer can create a session via access-checked path."""
|
||||
from roboco.services.messaging import ApiSessionCreate
|
||||
|
||||
svc = msg_setup["svc"]
|
||||
aid = msg_setup["agent_id"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
# Add agent to writers list.
|
||||
await svc.add_channel_member(ch.id, aid, can_write=True)
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session_with_access_check(
|
||||
agent_id=aid,
|
||||
request=ApiSessionCreate(
|
||||
group_id=grp.id,
|
||||
max_time_window_minutes=30,
|
||||
max_message_count=100,
|
||||
max_content_length=10000,
|
||||
timeout_seconds=300,
|
||||
),
|
||||
)
|
||||
assert sess.id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_group_sessions_for_agent_member(
|
||||
msg_setup: dict,
|
||||
) -> None:
|
||||
"""Channel member can list sessions in their group."""
|
||||
svc = msg_setup["svc"]
|
||||
aid = msg_setup["agent_id"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
await svc.add_channel_member(ch.id, aid)
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
sessions = await svc.list_group_sessions_for_agent(
|
||||
group_id=grp.id, agent_id=aid, status_filter=None, limit=10
|
||||
)
|
||||
assert len(sessions) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_group_sessions_with_status_filter(
|
||||
msg_setup: dict,
|
||||
) -> None:
|
||||
svc = msg_setup["svc"]
|
||||
aid = msg_setup["agent_id"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
await svc.add_channel_member(ch.id, aid)
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
sessions = await svc.list_group_sessions_for_agent(
|
||||
group_id=grp.id,
|
||||
agent_id=aid,
|
||||
status_filter=SessionStatus.ACTIVE,
|
||||
limit=10,
|
||||
)
|
||||
assert all(s.status == SessionStatus.ACTIVE for s in sessions)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_timed_out_sessions_closes_idle_session(
|
||||
msg_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
svc = msg_setup["svc"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(
|
||||
SessionCreateRequest(group_id=grp.id, timeout_seconds=1)
|
||||
)
|
||||
# Force last_activity_at into the past so sweeper closes it.
|
||||
sess.last_activity_at = datetime.now(UTC) - timedelta(seconds=120)
|
||||
await db_session.flush()
|
||||
closed = await svc.sweep_timed_out_sessions()
|
||||
assert closed >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_or_raise_succeeds(msg_setup: dict) -> None:
|
||||
svc = msg_setup["svc"]
|
||||
aid = msg_setup["agent_id"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
msg = await svc.send_message(
|
||||
MessageCreateRequest(agent_id=aid, session_id=sess.id, content="original")
|
||||
)
|
||||
edited = await svc.edit_message_or_raise(
|
||||
message_id=msg.id,
|
||||
agent_id=aid,
|
||||
new_content="edited content",
|
||||
edit_reason=None,
|
||||
)
|
||||
assert edited.content == "edited content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_message_or_raise_succeeds(msg_setup: dict) -> None:
|
||||
svc = msg_setup["svc"]
|
||||
aid = msg_setup["agent_id"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
msg = await svc.send_message(
|
||||
MessageCreateRequest(
|
||||
agent_id=aid, session_id=sess.id, content="will be deleted"
|
||||
)
|
||||
)
|
||||
await svc.delete_message_or_raise(message_id=msg.id, agent_id=aid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_with_mentions(msg_setup: dict) -> None:
|
||||
svc = msg_setup["svc"]
|
||||
aid = msg_setup["agent_id"]
|
||||
other = uuid4()
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
msg = await svc.send_message(
|
||||
MessageCreateRequest(
|
||||
agent_id=aid,
|
||||
session_id=sess.id,
|
||||
content="hi @other",
|
||||
mentions=[other],
|
||||
)
|
||||
)
|
||||
assert other in msg.mentions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_reply_target_unknown_raises(
|
||||
msg_setup: dict,
|
||||
) -> None:
|
||||
svc = msg_setup["svc"]
|
||||
aid = msg_setup["agent_id"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
with pytest.raises((ValueError, NotFoundError)):
|
||||
await svc.send_message(
|
||||
MessageCreateRequest(
|
||||
agent_id=aid,
|
||||
session_id=sess.id,
|
||||
content="reply",
|
||||
reply_to=uuid4(), # Bogus reply target.
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_with_filters(msg_setup: dict) -> None:
|
||||
"""get_messages with before/after/type filters."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from roboco.models import MessageType
|
||||
|
||||
svc = msg_setup["svc"]
|
||||
aid = msg_setup["agent_id"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
await svc.send_message(
|
||||
MessageCreateRequest(agent_id=aid, session_id=sess.id, content="msg1")
|
||||
)
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=1)
|
||||
msgs, _ = await svc.get_messages(
|
||||
sess.id,
|
||||
before=datetime.now(UTC) + timedelta(hours=1),
|
||||
after=cutoff,
|
||||
message_type=MessageType.DIALOGUE,
|
||||
)
|
||||
assert isinstance(msgs, list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_with_limit(msg_setup: dict) -> None:
|
||||
svc = msg_setup["svc"]
|
||||
aid = msg_setup["agent_id"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
for i in range(5):
|
||||
await svc.send_message(
|
||||
MessageCreateRequest(agent_id=aid, session_id=sess.id, content=f"msg-{i}")
|
||||
)
|
||||
msgs, has_more = await svc.get_messages(sess.id, limit=2)
|
||||
assert len(msgs) == 2
|
||||
assert has_more is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_message_context_redirects_when_session_closed(
|
||||
msg_setup: dict,
|
||||
) -> None:
|
||||
"""If session is closed, _get_message_context should redirect to active session."""
|
||||
svc = msg_setup["svc"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
# Close the session.
|
||||
await svc.close_session(sess.id)
|
||||
# Now request its context — should redirect to a fresh active session.
|
||||
new_sess, new_grp, new_ch = await svc._get_message_context(sess.id)
|
||||
assert new_grp.id == grp.id
|
||||
assert new_ch.id == ch.id
|
||||
assert new_sess.status == SessionStatus.ACTIVE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_message_context_unknown_session_raises(
|
||||
msg_setup: dict,
|
||||
) -> None:
|
||||
svc = msg_setup["svc"]
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await svc._get_message_context(uuid4())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_reply_target_unknown_message_raises(
|
||||
msg_setup: dict,
|
||||
) -> None:
|
||||
svc = msg_setup["svc"]
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await svc._validate_reply_target(uuid4(), uuid4())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_reply_target_wrong_session_raises(
|
||||
msg_setup: dict,
|
||||
) -> None:
|
||||
svc = msg_setup["svc"]
|
||||
aid = msg_setup["agent_id"]
|
||||
ch = await svc.create_channel(_channel_req(uuid4().hex[:6]))
|
||||
grp = await svc.create_group(GroupCreateRequest(name="g1", channel_id=ch.id))
|
||||
sess1 = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
msg = await svc.send_message(
|
||||
MessageCreateRequest(agent_id=aid, session_id=sess1.id, content="msg")
|
||||
)
|
||||
sess2 = await svc.create_session(SessionCreateRequest(group_id=grp.id))
|
||||
with pytest.raises(ValueError, match="not found in this session"):
|
||||
await svc._validate_reply_target(msg.id, sess2.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walk_task_ancestors_with_parent(
|
||||
msg_setup: dict, db_session: AsyncSession
|
||||
@@ -958,9 +1194,9 @@ async def test_walk_task_ancestors_with_parent(
|
||||
child_id = uuid4()
|
||||
# Need to fetch project_id and aid from msg_setup.
|
||||
result = await db_session.execute(
|
||||
__import__("sqlalchemy").select(TaskTable).where(
|
||||
TaskTable.id == msg_setup["task_id"]
|
||||
)
|
||||
__import__("sqlalchemy")
|
||||
.select(TaskTable)
|
||||
.where(TaskTable.id == msg_setup["task_id"])
|
||||
)
|
||||
base_task = result.scalar_one()
|
||||
|
||||
|
||||
@@ -81,27 +81,21 @@ async def test_list_notifications_empty(notif_client: dict) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_notification_not_found(notif_client: dict) -> None:
|
||||
client = notif_client["client"]
|
||||
response = await client.get(
|
||||
f"/api/notifications/{uuid4()}", headers=_HDR
|
||||
)
|
||||
response = await client.get(f"/api/notifications/{uuid4()}", headers=_HDR)
|
||||
assert response.status_code in (404, 403)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_notification_not_found(notif_client: dict) -> None:
|
||||
client = notif_client["client"]
|
||||
response = await client.post(
|
||||
f"/api/notifications/{uuid4()}/ack", headers=_HDR
|
||||
)
|
||||
response = await client.post(f"/api/notifications/{uuid4()}/ack", headers=_HDR)
|
||||
assert response.status_code in (404, 403)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_as_read_not_found(notif_client: dict) -> None:
|
||||
client = notif_client["client"]
|
||||
response = await client.post(
|
||||
f"/api/notifications/{uuid4()}/read", headers=_HDR
|
||||
)
|
||||
response = await client.post(f"/api/notifications/{uuid4()}/read", headers=_HDR)
|
||||
assert response.status_code in (404, 403)
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from httpx import ASGITransport, AsyncClient
|
||||
from roboco.api.deps import get_agent_context, get_db
|
||||
from roboco.api.routes.project import router as project_router
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models import AgentRole, AgentStatus
|
||||
from roboco.models.permissions import AgentContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -81,9 +81,7 @@ async def test_list_projects_empty(project_client: AsyncClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project(project_client: AsyncClient) -> None:
|
||||
response = await project_client.post(
|
||||
"/api/projects", json=_payload(), headers=_HDR
|
||||
)
|
||||
response = await project_client.post("/api/projects", json=_payload(), headers=_HDR)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert "id" in body
|
||||
@@ -93,21 +91,15 @@ async def test_create_project(project_client: AsyncClient) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_returns_409(project_client: AsyncClient) -> None:
|
||||
payload = _payload()
|
||||
response = await project_client.post(
|
||||
"/api/projects", json=payload, headers=_HDR
|
||||
)
|
||||
response = await project_client.post("/api/projects", json=payload, headers=_HDR)
|
||||
assert response.status_code == 201
|
||||
response2 = await project_client.post(
|
||||
"/api/projects", json=payload, headers=_HDR
|
||||
)
|
||||
response2 = await project_client.post("/api/projects", json=payload, headers=_HDR)
|
||||
assert response2.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_not_found(project_client: AsyncClient) -> None:
|
||||
response = await project_client.get(
|
||||
f"/api/projects/{uuid4()}", headers=_HDR
|
||||
)
|
||||
response = await project_client.get(f"/api/projects/{uuid4()}", headers=_HDR)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@@ -133,9 +125,7 @@ async def test_get_project_by_slug(project_client: AsyncClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project(project_client: AsyncClient) -> None:
|
||||
create = await project_client.post(
|
||||
"/api/projects", json=_payload(), headers=_HDR
|
||||
)
|
||||
create = await project_client.post("/api/projects", json=_payload(), headers=_HDR)
|
||||
pid = create.json()["id"]
|
||||
response = await project_client.patch(
|
||||
f"/api/projects/{pid}",
|
||||
@@ -160,7 +150,5 @@ async def test_update_project_not_found(project_client: AsyncClient) -> None:
|
||||
async def test_list_projects_filter_by_cell(
|
||||
project_client: AsyncClient,
|
||||
) -> None:
|
||||
response = await project_client.get(
|
||||
"/api/projects?cell=backend", headers=_HDR
|
||||
)
|
||||
response = await project_client.get("/api/projects?cell=backend", headers=_HDR)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""query_helpers coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.services.repositories.query_helpers import (
|
||||
agent_id_filter,
|
||||
days_ago,
|
||||
get_agent_by_slug,
|
||||
get_agent_slug,
|
||||
pagination,
|
||||
resolve_agent_identity,
|
||||
resolve_agent_uuid,
|
||||
status_filter,
|
||||
team_filter,
|
||||
timestamp_filter,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure query-builder helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_days_ago_returns_past_datetime() -> None:
|
||||
out = days_ago(7)
|
||||
assert out < datetime.now(UTC)
|
||||
assert (datetime.now(UTC) - out) >= timedelta(days=6)
|
||||
|
||||
|
||||
def test_pagination_applies_limit_offset() -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(AgentTable)
|
||||
out = pagination(q, limit=10, offset=20)
|
||||
# SQLAlchemy compiles the query; we just verify it doesn't crash.
|
||||
assert out is not None
|
||||
|
||||
|
||||
def test_status_filter_passes_through_when_none() -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(AgentTable)
|
||||
assert status_filter(q, AgentTable, None) is q
|
||||
|
||||
|
||||
def test_status_filter_applies_when_provided() -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(AgentTable)
|
||||
out = status_filter(q, AgentTable, AgentStatus.ACTIVE)
|
||||
assert out is not q # Different query.
|
||||
|
||||
|
||||
def test_team_filter_passes_through_when_none() -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(AgentTable)
|
||||
assert team_filter(q, AgentTable, None) is q
|
||||
|
||||
|
||||
def test_team_filter_applies_when_provided() -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(AgentTable)
|
||||
out = team_filter(q, AgentTable, Team.BACKEND)
|
||||
assert out is not q
|
||||
|
||||
|
||||
def test_agent_id_filter_passes_through_when_none() -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(AgentTable)
|
||||
assert agent_id_filter(q, AgentTable, None) is q
|
||||
|
||||
|
||||
def test_agent_id_filter_applies_when_provided() -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(AgentTable)
|
||||
out = agent_id_filter(q, AgentTable, uuid4(), field_name="id")
|
||||
assert out is not q
|
||||
|
||||
|
||||
def test_timestamp_filter_with_since_and_until() -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(AgentTable)
|
||||
out = timestamp_filter(
|
||||
q,
|
||||
AgentTable,
|
||||
since=days_ago(7),
|
||||
until=datetime.now(UTC),
|
||||
)
|
||||
assert out is not q
|
||||
|
||||
|
||||
def test_timestamp_filter_no_args_unchanged() -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(AgentTable)
|
||||
out = timestamp_filter(q, AgentTable)
|
||||
# No filter applied.
|
||||
assert out is q
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async DB resolvers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_agent_uuid_with_uuid_string(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
aid = uuid4()
|
||||
resolved = await resolve_agent_uuid(db_session, str(aid))
|
||||
assert resolved == aid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_agent_uuid_with_slug(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="Dev",
|
||||
slug=f"q-dev-{uuid4().hex[:8]}",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
resolved = await resolve_agent_uuid(db_session, agent.slug)
|
||||
assert resolved == agent.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_agent_uuid_with_unknown_slug(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
assert await resolve_agent_uuid(db_session, "ghost-slug") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_agent_identity_with_slug(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="Dev",
|
||||
slug=f"q-id-{uuid4().hex[:8]}",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
out = await resolve_agent_identity(db_session, agent.slug)
|
||||
assert out is not None
|
||||
assert out[0] == agent.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_agent_identity_unknown(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
assert await resolve_agent_identity(db_session, "ghost") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_agent_identity_unknown_uuid(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
assert await resolve_agent_identity(db_session, str(uuid4())) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_agent_slug_known(db_session: AsyncSession) -> None:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="Dev",
|
||||
slug=f"slug-{uuid4().hex[:8]}",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
slug = await get_agent_slug(db_session, agent.id)
|
||||
assert slug == agent.slug
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_agent_slug_missing(db_session: AsyncSession) -> None:
|
||||
assert await get_agent_slug(db_session, uuid4()) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_agent_by_slug(db_session: AsyncSession) -> None:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="Dev",
|
||||
slug=f"by-slug-{uuid4().hex[:8]}",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
fetched = await get_agent_by_slug(db_session, agent.slug)
|
||||
assert fetched is not None
|
||||
assert fetched.id == agent.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_agent_by_slug_missing(db_session: AsyncSession) -> None:
|
||||
assert await get_agent_by_slug(db_session, "ghost-slug") is None
|
||||
@@ -165,9 +165,7 @@ async def test_create_session(session_client: dict) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_not_found(session_client: dict) -> None:
|
||||
client = session_client["client"]
|
||||
response = await client.get(
|
||||
f"/api/sessions/{uuid4()}", headers=_HDR
|
||||
)
|
||||
response = await client.get(f"/api/sessions/{uuid4()}", headers=_HDR)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@@ -191,9 +189,7 @@ async def test_get_session_by_id(
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_session_not_found(session_client: dict) -> None:
|
||||
client = session_client["client"]
|
||||
response = await client.post(
|
||||
f"/api/sessions/{uuid4()}/close", headers=_HDR
|
||||
)
|
||||
response = await client.post(f"/api/sessions/{uuid4()}/close", headers=_HDR)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
|
||||
@@ -13,12 +13,10 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
||||
from roboco.db.tables import AgentTable, ProjectTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models.base import (
|
||||
TaskNature,
|
||||
TaskStatus,
|
||||
TaskType,
|
||||
)
|
||||
from roboco.models.task import TaskCreateRequest
|
||||
from roboco.services.task import TaskService
|
||||
@@ -767,9 +765,7 @@ async def test_unblock_returns_none_for_missing(task_setup: dict) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_returns_none_for_missing(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
assert (
|
||||
await svc.complete(uuid4(), agent_id=task_setup["agent_id"]) is None
|
||||
)
|
||||
assert await svc.complete(uuid4(), agent_id=task_setup["agent_id"]) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -783,9 +779,7 @@ async def test_submit_for_pm_review_returns_none_for_missing(
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_pr_created_returns_none_for_missing(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
assert (
|
||||
await svc.mark_pr_created(uuid4(), pr_number=1, pr_url="u") is None
|
||||
)
|
||||
assert await svc.mark_pr_created(uuid4(), pr_number=1, pr_url="u") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -793,21 +787,13 @@ async def test_unclaim_for_agent_returns_none_for_missing(
|
||||
task_setup: dict,
|
||||
) -> None:
|
||||
svc = task_setup["svc"]
|
||||
assert (
|
||||
await svc.unclaim_for_agent(uuid4(), agent_id=task_setup["agent_id"])
|
||||
is None
|
||||
)
|
||||
assert await svc.unclaim_for_agent(uuid4(), agent_id=task_setup["agent_id"]) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_for_agent_returns_none_for_missing(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
assert (
|
||||
await svc.resume_for_agent(uuid4(), agent_id=task_setup["agent_id"])
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
assert await svc.resume_for_agent(uuid4(), agent_id=task_setup["agent_id"]) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1088,7 +1074,9 @@ async def test_add_progress_appends_update(task_setup: dict) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_agent_id_for_slug(task_setup: dict, db_session: AsyncSession) -> None:
|
||||
async def test_resolve_agent_id_for_slug(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
svc = task_setup["svc"]
|
||||
# task_setup created an agent with a slug; resolve via slug.
|
||||
from roboco.db.tables import AgentTable
|
||||
@@ -1207,9 +1195,7 @@ async def test_claim_with_allow_reassign_attempts(
|
||||
task.assigned_to = other.id
|
||||
await db_session.flush()
|
||||
# With allow_reassign=True, the assignment-collision gate is bypassed.
|
||||
result = await svc.claim(
|
||||
task.id, task_setup["agent_id"], allow_reassign=True
|
||||
)
|
||||
result = await svc.claim(task.id, task_setup["agent_id"], allow_reassign=True)
|
||||
# Either succeeds or fails for other reason — just verify it runs.
|
||||
assert result is None or result is not None
|
||||
|
||||
@@ -1229,9 +1215,7 @@ async def test_list_by_team_with_status(task_setup: dict) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_by_assignee_with_status(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
rows = await svc.list_by_assignee(
|
||||
task_setup["agent_id"], status=TaskStatus.PENDING
|
||||
)
|
||||
rows = await svc.list_by_assignee(task_setup["agent_id"], status=TaskStatus.PENDING)
|
||||
assert isinstance(rows, list)
|
||||
|
||||
|
||||
@@ -1305,3 +1289,207 @@ async def test_heartbeat_updates_last_heartbeat(
|
||||
refreshed = await svc.get(task.id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.last_heartbeat_at is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cancel cascades
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_with_note(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
task = await svc.create(_req(task_setup))
|
||||
cancelled = await svc.cancel(task.id, cancellation_note="not needed")
|
||||
assert cancelled is not None
|
||||
assert cancelled.status == TaskStatus.CANCELLED
|
||||
assert "not needed" in (cancelled.dev_notes or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_cascades_to_descendants(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
parent = await svc.create(_req(task_setup))
|
||||
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
||||
cancelled = await svc.cancel(parent.id)
|
||||
assert cancelled is not None
|
||||
refreshed_child = await svc.get(child.id)
|
||||
assert refreshed_child is not None
|
||||
assert refreshed_child.status == TaskStatus.CANCELLED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# soft_block + unblock with restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unblock_with_restore_returns_none_for_missing(
|
||||
task_setup: dict,
|
||||
) -> None:
|
||||
svc = task_setup["svc"]
|
||||
result = await svc.unblock_with_restore(
|
||||
pm_agent_id=task_setup["agent_id"], task_id=uuid4(), restore=True
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# qa_claim/doc_claim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qa_claim_returns_none_for_missing(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
assert await svc.qa_claim(qa_agent_id=uuid4(), task_id=uuid4()) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doc_claim_returns_none_for_missing(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
assert await svc.doc_claim(doc_agent_id=uuid4(), task_id=uuid4()) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# qa_pass / qa_fail / cell_pm_complete (404 paths)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qa_pass_returns_none_for_missing(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
result = await svc.qa_pass(
|
||||
qa_agent_id=task_setup["agent_id"],
|
||||
task_id=uuid4(),
|
||||
notes="LGTM, comprehensive review",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qa_fail_returns_none_for_missing(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
result = await svc.qa_fail(
|
||||
qa_agent_id=task_setup["agent_id"],
|
||||
task_id=uuid4(),
|
||||
notes="needs revision",
|
||||
issues=["bug 1"],
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_pending with dependency filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pending_filters_tasks_with_unmet_deps(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
svc = task_setup["svc"]
|
||||
blocker = await svc.create(_req(task_setup))
|
||||
blocked = await svc.create(_req(task_setup))
|
||||
blocked.dependency_ids = [blocker.id]
|
||||
await db_session.flush()
|
||||
pending = await svc.list_pending(team=Team.BACKEND)
|
||||
pending_ids = {t.id for t in pending}
|
||||
# blocker has no deps and is pending — should be included
|
||||
assert blocker.id in pending_ids
|
||||
# blocked depends on a non-terminal task — should be excluded
|
||||
assert blocked.id not in pending_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pending_disabled_dep_filter(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
svc = task_setup["svc"]
|
||||
blocker = await svc.create(_req(task_setup))
|
||||
blocked = await svc.create(_req(task_setup))
|
||||
blocked.dependency_ids = [blocker.id]
|
||||
await db_session.flush()
|
||||
pending = await svc.list_pending(team=Team.BACKEND, filter_by_dependencies=False)
|
||||
pending_ids = {t.id for t in pending}
|
||||
assert blocker.id in pending_ids
|
||||
assert blocked.id in pending_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pending_includes_tasks_when_deps_completed(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
svc = task_setup["svc"]
|
||||
blocker = await svc.create(_req(task_setup))
|
||||
blocker.status = TaskStatus.COMPLETED
|
||||
blocked = await svc.create(_req(task_setup))
|
||||
blocked.dependency_ids = [blocker.id]
|
||||
await db_session.flush()
|
||||
pending = await svc.list_pending(team=Team.BACKEND)
|
||||
pending_ids = {t.id for t in pending}
|
||||
assert blocked.id in pending_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _inherit_parent_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inherit_parent_session_no_primary_returns_none(
|
||||
task_setup: dict,
|
||||
) -> None:
|
||||
"""When parent has no primary session, child inherits nothing."""
|
||||
svc = task_setup["svc"]
|
||||
parent = await svc.create(_req(task_setup))
|
||||
child_id = uuid4()
|
||||
result = await svc._inherit_parent_session(
|
||||
task_id=child_id,
|
||||
parent_task_id=parent.id,
|
||||
created_by=task_setup["agent_id"],
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subtree query helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_descendants_empty_for_leaf(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
leaf = await svc.create(_req(task_setup))
|
||||
descendants = await svc.get_all_descendants(leaf.id)
|
||||
assert descendants == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_descendants_traverses_three_levels(
|
||||
task_setup: dict,
|
||||
) -> None:
|
||||
svc = task_setup["svc"]
|
||||
grand = await svc.create(_req(task_setup))
|
||||
parent = await svc.create(_req(task_setup, parent_task_id=grand.id))
|
||||
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
||||
descendants = await svc.get_all_descendants(grand.id)
|
||||
desc_ids = {d.id for d in descendants}
|
||||
assert parent.id in desc_ids
|
||||
assert child.id in desc_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_by_status_with_data(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
await svc.create(_req(task_setup))
|
||||
counts = await svc.count_by_status(team=Team.BACKEND)
|
||||
assert isinstance(counts, dict)
|
||||
assert "pending" in counts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_count_zero_for_unknown(task_setup: dict) -> None:
|
||||
svc = task_setup["svc"]
|
||||
count = await svc.get_active_count(uuid4())
|
||||
assert count == 0
|
||||
|
||||
@@ -301,9 +301,7 @@ async def test_claim_unknown_task_returns_404(task_client: dict) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unclaim_unknown_returns_404(task_client: dict) -> None:
|
||||
client = task_client["client"]
|
||||
response = await client.post(
|
||||
f"/api/tasks/{uuid4()}/unclaim", headers=_HDR
|
||||
)
|
||||
response = await client.post(f"/api/tasks/{uuid4()}/unclaim", headers=_HDR)
|
||||
assert response.status_code in (400, 404)
|
||||
|
||||
|
||||
@@ -365,27 +363,21 @@ async def test_block_unknown_returns_404(task_client: dict) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unblock_unknown_returns_404(task_client: dict) -> None:
|
||||
client = task_client["client"]
|
||||
response = await client.post(
|
||||
f"/api/tasks/{uuid4()}/unblock", headers=_HDR
|
||||
)
|
||||
response = await client.post(f"/api/tasks/{uuid4()}/unblock", headers=_HDR)
|
||||
assert response.status_code in (400, 403, 404, 422)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_unknown_returns_404(task_client: dict) -> None:
|
||||
client = task_client["client"]
|
||||
response = await client.post(
|
||||
f"/api/tasks/{uuid4()}/pause", headers=_HDR
|
||||
)
|
||||
response = await client.post(f"/api/tasks/{uuid4()}/pause", headers=_HDR)
|
||||
assert response.status_code in (400, 403, 404, 422)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_unknown_returns_404(task_client: dict) -> None:
|
||||
client = task_client["client"]
|
||||
response = await client.post(
|
||||
f"/api/tasks/{uuid4()}/resume", headers=_HDR
|
||||
)
|
||||
response = await client.post(f"/api/tasks/{uuid4()}/resume", headers=_HDR)
|
||||
assert response.status_code in (400, 403, 404, 422)
|
||||
|
||||
|
||||
@@ -452,7 +444,5 @@ async def test_get_sessions_for_task(task_client: dict) -> None:
|
||||
client = task_client["client"]
|
||||
task = _seed_task(task_client)
|
||||
await task_client["db"].flush()
|
||||
response = await client.get(
|
||||
f"/api/tasks/{task.id}/sessions", headers=_HDR
|
||||
)
|
||||
response = await client.get(f"/api/tasks/{task.id}/sessions", headers=_HDR)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -110,9 +110,7 @@ async def test_list_sessions_empty(ws_client: dict) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_not_found(ws_client: dict) -> None:
|
||||
client = ws_client["client"]
|
||||
response = await client.get(
|
||||
f"/api/work-sessions/{uuid4()}", headers=_HDR
|
||||
)
|
||||
response = await client.get(f"/api/work-sessions/{uuid4()}", headers=_HDR)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@@ -247,9 +245,7 @@ async def test_complete_session(ws_client: dict) -> None:
|
||||
headers=_HDR,
|
||||
)
|
||||
sid = create.json()["id"]
|
||||
response = await client.post(
|
||||
f"/api/work-sessions/{sid}/complete", headers=_HDR
|
||||
)
|
||||
response = await client.post(f"/api/work-sessions/{sid}/complete", headers=_HDR)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user