mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
100% Coverage
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
"""LearningPropagationService integration coverage — _create_notifications.
|
||||
|
||||
The notifications path (lines 207-290 in learning.py) requires a real DB
|
||||
context because it queries `AgentTable` to find recipients. This file
|
||||
exercises the full path against the test Postgres so the SELECT, the
|
||||
loop body, and the per-agent NotificationService writes are covered.
|
||||
|
||||
Isolation: the fixture patches `get_db_context()` to yield the test's
|
||||
own `db_session`. That way `_create_notifications` reuses the same
|
||||
transaction as the test — no `commit()` is ever called, and the
|
||||
conftest's per-test rollback wipes every row at teardown. Earlier
|
||||
versions of this file committed via the session and corrupted the
|
||||
shared DB for later tests (broke test_qa_agent_for_team_returns_none).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.models.base import AgentRole, AgentStatus, Team
|
||||
from roboco.services.learning import (
|
||||
LearningPropagationService,
|
||||
LearningScope,
|
||||
LearningType,
|
||||
RecordLearningParams,
|
||||
)
|
||||
from sqlalchemy import update
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class _StubOptimal:
|
||||
"""Minimal stub matching the optimal-service shape."""
|
||||
|
||||
async def record_learning(self, _params: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def shared_session(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> AsyncIterator[AsyncSession]:
|
||||
"""Make `_create_notifications` reuse the test's session.
|
||||
|
||||
`get_db_context` is replaced with a context manager that yields the
|
||||
test's `db_session`. Both `_create_notifications` (which iterates
|
||||
AgentTable) and the test code share one transaction, so test data
|
||||
seen via `db.add(...); await db.flush()` is immediately visible to
|
||||
`_create_notifications` without committing.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def _ctx() -> AsyncIterator[AsyncSession]:
|
||||
yield db_session
|
||||
|
||||
# learning.py imports `get_db_context` inside the function body (so the
|
||||
# `roboco.db.base` patch picks up at call time), but notification.py
|
||||
# imports it at module load — patch both targets.
|
||||
monkeypatch.setattr("roboco.db.base.get_db_context", _ctx)
|
||||
monkeypatch.setattr("roboco.services.notification.get_db_context", _ctx)
|
||||
yield db_session
|
||||
|
||||
|
||||
def _make_agent(role: AgentRole, slug: str | None = None) -> AgentTable:
|
||||
return AgentTable(
|
||||
id=uuid4(),
|
||||
name=f"Agent {slug or role.value}",
|
||||
slug=slug or f"learn-{role.value}-{uuid4().hex[:8]}",
|
||||
role=role,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_scope_role_uppercase_via_agent_role_enum(
|
||||
shared_session: AsyncSession,
|
||||
) -> None:
|
||||
"""TEAM scope invokes AgentRole(role.upper()) — AgentRole is StrEnum
|
||||
with lowercase values, so .upper() raises ValueError and the except
|
||||
branch runs, falling through to no role filter.
|
||||
"""
|
||||
author = _make_agent(AgentRole.DEVELOPER)
|
||||
peer = _make_agent(AgentRole.DEVELOPER)
|
||||
other_role = _make_agent(AgentRole.QA)
|
||||
shared_session.add_all([author, peer, other_role])
|
||||
await shared_session.flush()
|
||||
|
||||
svc = LearningPropagationService()
|
||||
await svc.initialize(_StubOptimal())
|
||||
learning = await svc.record_learning(
|
||||
RecordLearningParams(
|
||||
agent_id=author.id,
|
||||
agent_role="developer",
|
||||
content="A useful pattern for batch updates",
|
||||
learning_type=LearningType.PATTERN,
|
||||
scope=LearningScope.TEAM,
|
||||
)
|
||||
)
|
||||
notified_ids = {
|
||||
n.target_agent_id
|
||||
for n in svc._notification_queue
|
||||
if n.learning_id == learning.learning_id
|
||||
}
|
||||
assert peer.id in notified_ids
|
||||
assert other_role.id in notified_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_scope_invalid_role_skips_filter(
|
||||
shared_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Invalid role string falls through to no role filter and notifies all."""
|
||||
a1 = _make_agent(AgentRole.DEVELOPER)
|
||||
a2 = _make_agent(AgentRole.QA)
|
||||
shared_session.add_all([a1, a2])
|
||||
await shared_session.flush()
|
||||
|
||||
svc = LearningPropagationService()
|
||||
await svc.initialize(_StubOptimal())
|
||||
await svc.record_learning(
|
||||
RecordLearningParams(
|
||||
agent_id=a1.id,
|
||||
agent_role="not_a_real_role",
|
||||
content="content",
|
||||
learning_type=LearningType.SOLUTION,
|
||||
scope=LearningScope.TEAM,
|
||||
)
|
||||
)
|
||||
# No assertion on per-agent counts — the invalid-role branch is
|
||||
# intentionally permissive. What matters is that no exception escapes.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_scope_runs_without_role_filter(
|
||||
shared_session: AsyncSession,
|
||||
) -> None:
|
||||
"""CELL scope hits the elif branch (line 222-224)."""
|
||||
a1 = _make_agent(AgentRole.DEVELOPER)
|
||||
a2 = _make_agent(AgentRole.QA)
|
||||
shared_session.add_all([a1, a2])
|
||||
await shared_session.flush()
|
||||
|
||||
svc = LearningPropagationService()
|
||||
await svc.initialize(_StubOptimal())
|
||||
await svc.record_learning(
|
||||
RecordLearningParams(
|
||||
agent_id=a1.id,
|
||||
agent_role="developer",
|
||||
content="cell-scope lesson",
|
||||
learning_type=LearningType.INSIGHT,
|
||||
scope=LearningScope.CELL,
|
||||
)
|
||||
)
|
||||
targets = {n.target_agent_id for n in svc._notification_queue}
|
||||
assert a2.id in targets
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_scope_notifies_all_other_agents(
|
||||
shared_session: AsyncSession,
|
||||
) -> None:
|
||||
"""ORG scope: all agents except author are notified."""
|
||||
author = _make_agent(AgentRole.DEVELOPER)
|
||||
a2 = _make_agent(AgentRole.QA)
|
||||
a3 = _make_agent(AgentRole.CELL_PM)
|
||||
shared_session.add_all([author, a2, a3])
|
||||
await shared_session.flush()
|
||||
|
||||
svc = LearningPropagationService()
|
||||
await svc.initialize(_StubOptimal())
|
||||
await svc.record_learning(
|
||||
RecordLearningParams(
|
||||
agent_id=author.id,
|
||||
agent_role="developer",
|
||||
content="x" * 250, # >200 chars to exercise the truncation branch
|
||||
learning_type=LearningType.SOLUTION,
|
||||
scope=LearningScope.ORG,
|
||||
)
|
||||
)
|
||||
targets = {n.target_agent_id for n in svc._notification_queue}
|
||||
assert a2.id in targets
|
||||
assert a3.id in targets
|
||||
assert author.id not in targets
|
||||
queued = next(n for n in svc._notification_queue if n.target_agent_id == a2.id)
|
||||
assert queued.learning_summary.endswith("...")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_other_agents_logs_and_returns(
|
||||
shared_session: AsyncSession,
|
||||
) -> None:
|
||||
"""If only the author exists, the no-recipients branch executes (line 230).
|
||||
|
||||
We use a unique slug-prefix and assert via per-learning-id filter
|
||||
on the queue so unrelated rows in the DB don't pollute the
|
||||
assertion. (Other tests' rolled-back rows shouldn't be visible
|
||||
here anyway, but defensive filtering keeps this test order-stable.)
|
||||
"""
|
||||
author = _make_agent(AgentRole.DEVELOPER, slug=f"solo-{uuid4().hex[:8]}")
|
||||
shared_session.add(author)
|
||||
await shared_session.flush()
|
||||
|
||||
svc = LearningPropagationService()
|
||||
await svc.initialize(_StubOptimal())
|
||||
learning = await svc.record_learning(
|
||||
RecordLearningParams(
|
||||
agent_id=author.id,
|
||||
agent_role="developer",
|
||||
content="solo agent learning",
|
||||
learning_type=LearningType.SOLUTION,
|
||||
scope=LearningScope.ORG,
|
||||
)
|
||||
)
|
||||
# No notifications should be queued for this specific learning. (Other
|
||||
# agents may exist in DB pollution from sibling tests; we don't assert
|
||||
# the queue is *globally* empty.)
|
||||
matching = [
|
||||
n for n in svc._notification_queue if n.learning_id == learning.learning_id
|
||||
]
|
||||
assert all(n.target_agent_id != author.id for n in matching)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_recipients_after_role_filter_hits_empty_branch(
|
||||
shared_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Cover lines 230-235: query.scalars().all() returns empty -> log + return.
|
||||
|
||||
Strategy: temporarily reassign every other agent's role to SYSTEM within
|
||||
this transaction so the TEAM-scope role filter (DEVELOPER) finds zero
|
||||
matches. Use the patched AgentRole so .upper() succeeds and the role
|
||||
filter actually applies (otherwise the except-ValueError branch falls
|
||||
through to no-filter, which finds all the other-role agents).
|
||||
"""
|
||||
real_role = AgentRole
|
||||
|
||||
class _PermissiveRole:
|
||||
def __new__(cls, value: str) -> object:
|
||||
return real_role(value.lower())
|
||||
|
||||
monkeypatch.setattr("roboco.models.base.AgentRole", _PermissiveRole)
|
||||
|
||||
author = _make_agent(AgentRole.DEVELOPER, slug=f"empty-{uuid4().hex[:8]}")
|
||||
shared_session.add(author)
|
||||
await shared_session.flush()
|
||||
|
||||
# Demote every other agent's role so the DEVELOPER filter finds nobody.
|
||||
await shared_session.execute(
|
||||
update(AgentTable)
|
||||
.where(AgentTable.id != author.id)
|
||||
.values(role=AgentRole.SYSTEM)
|
||||
)
|
||||
|
||||
svc = LearningPropagationService()
|
||||
await svc.initialize(_StubOptimal())
|
||||
learning = await svc.record_learning(
|
||||
RecordLearningParams(
|
||||
agent_id=author.id,
|
||||
agent_role="developer",
|
||||
content="alone with the role filter",
|
||||
learning_type=LearningType.SOLUTION,
|
||||
scope=LearningScope.TEAM,
|
||||
)
|
||||
)
|
||||
matching = [
|
||||
n for n in svc._notification_queue if n.learning_id == learning.learning_id
|
||||
]
|
||||
assert matching == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_scope_with_patched_agent_role_hits_filter(
|
||||
shared_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Patch AgentRole so .upper() resolves successfully; line 218 runs.
|
||||
|
||||
AgentRole is a StrEnum with lowercase values, so the production
|
||||
`AgentRole(learning.agent_role.upper())` call always raises
|
||||
ValueError. We monkeypatch the reference inside `learning.py` with
|
||||
a permissive constructor so the role-filter `query.where(...)` line
|
||||
executes.
|
||||
"""
|
||||
real_role = AgentRole
|
||||
|
||||
class _PermissiveRole:
|
||||
"""Stand-in: maps both lower and upper case strings to the real enum."""
|
||||
|
||||
def __new__(cls, value: str) -> object:
|
||||
return real_role(value.lower())
|
||||
|
||||
# AgentRole is imported inside the function body (`from roboco.models.base
|
||||
# import AgentRole`), so patching `roboco.models.base.AgentRole` is what
|
||||
# the function will pick up at call time.
|
||||
monkeypatch.setattr("roboco.models.base.AgentRole", _PermissiveRole)
|
||||
|
||||
author = _make_agent(real_role.DEVELOPER)
|
||||
peer_dev = _make_agent(real_role.DEVELOPER)
|
||||
peer_qa = _make_agent(real_role.QA)
|
||||
shared_session.add_all([author, peer_dev, peer_qa])
|
||||
await shared_session.flush()
|
||||
|
||||
svc = LearningPropagationService()
|
||||
await svc.initialize(_StubOptimal())
|
||||
await svc.record_learning(
|
||||
RecordLearningParams(
|
||||
agent_id=author.id,
|
||||
agent_role="developer",
|
||||
content="role-filter learning",
|
||||
learning_type=LearningType.PATTERN,
|
||||
scope=LearningScope.TEAM,
|
||||
)
|
||||
)
|
||||
notified = {n.target_agent_id for n in svc._notification_queue}
|
||||
assert peer_dev.id in notified
|
||||
assert peer_qa.id not in notified
|
||||
Reference in New Issue
Block a user