mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Thread the deployer's product name through the X reply + feature-spotlight prompts (B6 leftover; release/video paths shipped in #570); make the docs-site repo/URL config (ROBOCO_DOCS_SITE_*, defaults unchanged) instead of a roboco-website hardcode (B8); de-assert our repo from the Main PM prompt (B10); derive PR labels from the real target branch instead of literal to-master/to-slave; drop the stale headcount from base.md; and make the bash-guard's Makefile check require an actual quality/gate/lint/ test target before denying raw package-manager commands (no more false-remediation loop on Go/Rust Makefiles). Co-authored-by: Renn F <rennf93@users.noreply.github.com>
1072 lines
36 KiB
Python
1072 lines
36 KiB
Python
"""DocsService coverage — write/read/list/delete docs + RAG dedup."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from roboco.config import settings
|
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
|
from roboco.models import AgentRole, AgentStatus, Team
|
|
from roboco.models.base import (
|
|
TaskNature,
|
|
TaskStatus,
|
|
TaskType,
|
|
)
|
|
from roboco.models.task import DocRef
|
|
from roboco.services.base import (
|
|
NotFoundError,
|
|
UnauthorizedError,
|
|
ValidationError,
|
|
)
|
|
from roboco.services.docs import (
|
|
DocsService,
|
|
WriteDocInput,
|
|
_refused_doc_types,
|
|
get_docs_service,
|
|
)
|
|
from sqlalchemy import select
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def docs_setup(
|
|
db_session: AsyncSession,
|
|
) -> AsyncIterator[dict]:
|
|
"""Seed an agent + project + task."""
|
|
agent = AgentTable(
|
|
id=uuid4(),
|
|
name="Doc",
|
|
slug=f"be-doc-{uuid4().hex[:8]}",
|
|
role=AgentRole.DOCUMENTER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="doc",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(agent)
|
|
await db_session.flush()
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="Doc-Proj",
|
|
slug=f"doc-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()
|
|
yield {
|
|
"svc": DocsService(db_session),
|
|
"agent_id": agent.id,
|
|
"task_id": task.id,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_docs_service factory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_docs_service_factory(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
assert svc is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_factory_function(db_session: AsyncSession) -> None:
|
|
svc = get_docs_service(db_session)
|
|
assert isinstance(svc, DocsService)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Permissions / validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_unknown_agent_team(docs_setup: dict) -> None:
|
|
"""Agent with no team mapping → ValidationError."""
|
|
svc = docs_setup["svc"]
|
|
with pytest.raises(ValidationError, match="Unknown agent team"):
|
|
await svc.write_doc(
|
|
agent_id="ghost-agent",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="x.md",
|
|
doc_type="api",
|
|
title="Title",
|
|
content="Content",
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_role_unauthorized(docs_setup: dict) -> None:
|
|
"""Agent role not in WRITE_ROLES → UnauthorizedError."""
|
|
svc = docs_setup["svc"]
|
|
# be-dev-1 is a developer (not in WRITE_ROLES = {"documenter", "cell_pm"})
|
|
with pytest.raises(UnauthorizedError, match="cannot write"):
|
|
await svc.write_doc(
|
|
agent_id="be-dev-1",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="x.md",
|
|
doc_type="api",
|
|
title="Title",
|
|
content="Content",
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_invalid_doc_type(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
with pytest.raises(ValidationError, match="Unknown doc_type"):
|
|
await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="x.md",
|
|
doc_type="bogus",
|
|
title="Title",
|
|
content="Content",
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_user_facing_refused(docs_setup: dict) -> None:
|
|
"""doc_type='user_facing' is a recognized value (not a generic 'Unknown
|
|
doc_type') but is structurally refused: this store's buckets are all
|
|
excluded from the published site. The guidance names the roboco-website
|
|
project and the 3-edit pattern instead of silently landing an
|
|
unpublished write (docs-site-split Phase 2)."""
|
|
svc = docs_setup["svc"]
|
|
with pytest.raises(ValidationError, match="roboco-website") as exc_info:
|
|
await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="x.md",
|
|
doc_type="user_facing",
|
|
title="Title",
|
|
content="Content",
|
|
),
|
|
)
|
|
assert "Unknown doc_type" not in str(exc_info.value)
|
|
assert "docs.roboco.tech" in str(exc_info.value)
|
|
|
|
|
|
def test_refused_doc_types_uses_configured_docs_site(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A deployer's own docs-site slug/URL (ROBOCO_DOCS_SITE_*) reaches the
|
|
refusal message instead of RoboCo's own docs site."""
|
|
monkeypatch.setattr(settings, "docs_site_project_slug", "acme-docs")
|
|
monkeypatch.setattr(settings, "docs_site_public_url", "docs.acme.example")
|
|
message = _refused_doc_types()["user_facing"]
|
|
assert "acme-docs" in message
|
|
assert "docs.acme.example" in message
|
|
assert "roboco-website" not in message
|
|
assert "docs.roboco.tech" not in message
|
|
|
|
|
|
def test_refused_doc_types_falls_back_when_unset(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""An empty docs-site slug/URL degrades to a generic message, never a
|
|
bare empty string spliced into the refusal text."""
|
|
monkeypatch.setattr(settings, "docs_site_project_slug", "")
|
|
monkeypatch.setattr(settings, "docs_site_public_url", "")
|
|
message = _refused_doc_types()["user_facing"]
|
|
assert "your docs-site project" in message
|
|
assert "roboco-website" not in message
|
|
assert "docs.roboco.tech" not in message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_path_traversal_in_filename(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
with pytest.raises(ValidationError, match="path separators"):
|
|
await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="../evil.md",
|
|
doc_type="api",
|
|
title="Title",
|
|
content="Content",
|
|
),
|
|
)
|
|
with pytest.raises(ValidationError, match="path separators"):
|
|
await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="sub/evil.md",
|
|
doc_type="api",
|
|
title="Title",
|
|
content="Content",
|
|
),
|
|
)
|
|
with pytest.raises(ValidationError, match="path separators"):
|
|
await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename=r"sub\evil.md",
|
|
doc_type="api",
|
|
title="Title",
|
|
content="Content",
|
|
),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Write doc (creating new)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_creates_new(docs_setup: dict, tmp_path: Path) -> None:
|
|
"""Happy path — create new doc, RAG dedup returns None."""
|
|
svc = docs_setup["svc"]
|
|
with (
|
|
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
|
|
patch.object(svc, "_find_similar_doc", AsyncMock(return_value=None)),
|
|
patch.object(svc, "_index_doc_in_rag", AsyncMock(return_value=None)),
|
|
):
|
|
rel_path, doc_ref, is_update = await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="example.md",
|
|
doc_type="api",
|
|
title="Title",
|
|
content="# Hello",
|
|
),
|
|
)
|
|
assert is_update is False
|
|
assert rel_path.endswith("example.md")
|
|
assert doc_ref.title == "Title"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_creates_new_subfolder_empty(
|
|
docs_setup: dict, tmp_path: Path
|
|
) -> None:
|
|
"""doc_type=readme has empty subfolder → no subfolder in path."""
|
|
svc = docs_setup["svc"]
|
|
with (
|
|
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
|
|
patch.object(svc, "_find_similar_doc", AsyncMock(return_value=None)),
|
|
patch.object(svc, "_index_doc_in_rag", AsyncMock(return_value=None)),
|
|
):
|
|
rel_path, _, _ = await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="README.md",
|
|
doc_type="readme",
|
|
title="Readme",
|
|
content="# Project",
|
|
),
|
|
)
|
|
# Should be backend/README.md (no subfolder for readme).
|
|
assert rel_path == "backend/README.md"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_updates_existing(docs_setup: dict, tmp_path: Path) -> None:
|
|
"""When _find_similar_doc returns a path AND the filename matches, update."""
|
|
svc = docs_setup["svc"]
|
|
existing_path = "backend/api/existing.md"
|
|
with (
|
|
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
|
|
patch.object(svc, "_find_similar_doc", AsyncMock(return_value=existing_path)),
|
|
patch.object(svc, "_index_doc_in_rag", AsyncMock(return_value=None)),
|
|
):
|
|
rel_path, doc_ref, is_update = await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="existing.md",
|
|
doc_type="api",
|
|
title="New Title",
|
|
content="# Updated",
|
|
),
|
|
)
|
|
assert is_update is True
|
|
assert rel_path == existing_path
|
|
# On update, doc_ref preserves created_by and adds updated_by.
|
|
assert doc_ref.updated_by == "be-doc"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_update_preserves_existing_metadata(
|
|
docs_setup: dict, tmp_path: Path
|
|
) -> None:
|
|
"""If existing DocRef in task.documents, created_by/created_at preserved."""
|
|
svc = docs_setup["svc"]
|
|
existing_path = "backend/api/existing.md"
|
|
# Pre-seed task.documents with an existing entry.
|
|
result = await svc.session.execute(
|
|
select(TaskTable).where(TaskTable.id == docs_setup["task_id"])
|
|
)
|
|
task = result.scalar_one()
|
|
task.documents = [
|
|
{
|
|
"path": existing_path,
|
|
"title": "Old Title",
|
|
"doc_type": "api",
|
|
"version": "1",
|
|
"created_by": "be-pm",
|
|
"created_at": "2025-01-01T00:00:00Z",
|
|
}
|
|
]
|
|
await svc.session.flush()
|
|
|
|
with (
|
|
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
|
|
patch.object(svc, "_find_similar_doc", AsyncMock(return_value=existing_path)),
|
|
patch.object(svc, "_index_doc_in_rag", AsyncMock(return_value=None)),
|
|
):
|
|
_rel_path, doc_ref, is_update = await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="existing.md",
|
|
doc_type="api",
|
|
title="New Title",
|
|
content="# Updated",
|
|
),
|
|
)
|
|
assert is_update is True
|
|
# Original creator preserved, updater set to current agent.
|
|
assert doc_ref.created_by == "be-pm"
|
|
assert doc_ref.updated_by == "be-doc"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_no_collapse_on_different_filename(
|
|
docs_setup: dict, tmp_path: Path
|
|
) -> None:
|
|
"""#35: a similar doc with a DIFFERENT filename must not be overwritten —
|
|
the agent named a new file, so create it instead of collapsing onto the
|
|
similar doc's path."""
|
|
svc = docs_setup["svc"]
|
|
existing_path = "backend/api/existing.md"
|
|
with (
|
|
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
|
|
patch.object(svc, "_find_similar_doc", AsyncMock(return_value=existing_path)),
|
|
patch.object(svc, "_index_doc_in_rag", AsyncMock(return_value=None)),
|
|
):
|
|
rel_path, _doc_ref, is_update = await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="other.md",
|
|
doc_type="api",
|
|
title="New Title",
|
|
content="# New",
|
|
),
|
|
)
|
|
assert is_update is False
|
|
# A new file is created at the requested filename, NOT the similar doc path.
|
|
assert rel_path.endswith("other.md")
|
|
assert rel_path != existing_path
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_update_path_containment_checked(
|
|
docs_setup: dict, tmp_path: Path
|
|
) -> None:
|
|
"""#33: the RAG-returned update path is containment-checked — a ``source``
|
|
that escapes the docs dir (``../../etc/evil.md``) is refused, not written."""
|
|
svc = docs_setup["svc"]
|
|
escaping = "../../etc/evil.md"
|
|
with (
|
|
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
|
|
patch.object(svc, "_find_similar_doc", AsyncMock(return_value=escaping)),
|
|
patch.object(svc, "_index_doc_in_rag", AsyncMock(return_value=None)),
|
|
pytest.raises(ValidationError),
|
|
):
|
|
await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="evil.md",
|
|
doc_type="api",
|
|
title="Title",
|
|
content="# x",
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_doc_commit_status_skipped_when_no_branch(
|
|
docs_setup: dict, tmp_path: Path
|
|
) -> None:
|
|
"""#34: when there is no task branch to commit onto, the doc still saves to
|
|
/app/docs and the doc_ref carries ``commit_status='skipped'`` (not a silent
|
|
None) so the agent knows the repo commit did not happen."""
|
|
svc = docs_setup["svc"]
|
|
with (
|
|
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
|
|
patch.object(svc, "_find_similar_doc", AsyncMock(return_value=None)),
|
|
patch.object(svc, "_index_doc_in_rag", AsyncMock(return_value=None)),
|
|
):
|
|
_rel, doc_ref, _is_update = await svc.write_doc(
|
|
agent_id="be-doc",
|
|
req=WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="example.md",
|
|
doc_type="api",
|
|
title="Title",
|
|
content="# Hello",
|
|
),
|
|
)
|
|
# The fixture task has no branch_name → commit is skipped, not silent.
|
|
assert doc_ref.commit_status == "skipped"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_doc_to_repo_returns_failed_on_git_error(
|
|
docs_setup: dict,
|
|
) -> None:
|
|
"""#34: a git hiccup surfaces as ``failed`` (fail-loud), not a swallowed
|
|
None — the agent can tell the cell PM the doc did not reach the repo."""
|
|
svc = docs_setup["svc"]
|
|
# Give the task a branch + project so the commit path is entered.
|
|
result = await svc.session.execute(
|
|
select(TaskTable).where(TaskTable.id == docs_setup["task_id"])
|
|
)
|
|
task = result.scalar_one()
|
|
task.branch_name = "feature/docs"
|
|
await svc.session.flush()
|
|
|
|
fake_git = MagicMock()
|
|
fake_git.get_workspace = AsyncMock(side_effect=RuntimeError("git boom"))
|
|
with patch("roboco.services.git.get_git_service", return_value=fake_git):
|
|
status = await svc._commit_doc_to_repo(
|
|
"be-doc",
|
|
WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="example.md",
|
|
doc_type="api",
|
|
title="Title",
|
|
content="# Hello",
|
|
),
|
|
"api",
|
|
)
|
|
assert status == "failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_doc_to_repo_returns_committed_on_success(
|
|
docs_setup: dict, tmp_path: Path
|
|
) -> None:
|
|
"""#34: a successful repo commit reports ``committed``."""
|
|
svc = docs_setup["svc"]
|
|
result = await svc.session.execute(
|
|
select(TaskTable).where(TaskTable.id == docs_setup["task_id"])
|
|
)
|
|
task = result.scalar_one()
|
|
task.branch_name = "feature/docs"
|
|
await svc.session.flush()
|
|
|
|
fake_git = MagicMock()
|
|
fake_git.get_workspace = AsyncMock(return_value=tmp_path)
|
|
fake_git.commit = AsyncMock(return_value={"oid": "abc"})
|
|
with (
|
|
patch("roboco.services.git.get_git_service", return_value=fake_git),
|
|
patch.object(svc, "_write_file", AsyncMock(return_value=None)),
|
|
):
|
|
status = await svc._commit_doc_to_repo(
|
|
str(docs_setup["agent_id"]),
|
|
WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="example.md",
|
|
doc_type="api",
|
|
title="Title",
|
|
content="# Hello",
|
|
),
|
|
"api",
|
|
)
|
|
assert status == "committed"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _find_similar_doc
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_similar_doc_no_results(docs_setup: dict) -> None:
|
|
"""If no RAG results, returns None."""
|
|
svc = docs_setup["svc"]
|
|
mock_optimal = AsyncMock()
|
|
mock_optimal.search = AsyncMock(return_value=[])
|
|
with patch(
|
|
"roboco.services.optimal.get_optimal_service",
|
|
AsyncMock(return_value=mock_optimal),
|
|
):
|
|
result = await svc._find_similar_doc(title="t", content="c", team="backend")
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_similar_doc_high_score_match(docs_setup: dict) -> None:
|
|
"""High-score match in same team returns the source path."""
|
|
svc = docs_setup["svc"]
|
|
mock_result = MagicMock()
|
|
mock_result.score = 0.9
|
|
mock_result.source = "backend/api/something.md"
|
|
mock_optimal = AsyncMock()
|
|
mock_optimal.search = AsyncMock(return_value=[mock_result])
|
|
with patch(
|
|
"roboco.services.optimal.get_optimal_service",
|
|
AsyncMock(return_value=mock_optimal),
|
|
):
|
|
result = await svc._find_similar_doc(
|
|
title="t", content="c" * 1000, team="backend"
|
|
)
|
|
assert result == "backend/api/something.md"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_similar_doc_low_score_no_match(docs_setup: dict) -> None:
|
|
"""Low-score result is ignored."""
|
|
svc = docs_setup["svc"]
|
|
mock_result = MagicMock()
|
|
mock_result.score = 0.5
|
|
mock_result.source = "backend/api/something.md"
|
|
mock_optimal = AsyncMock()
|
|
mock_optimal.search = AsyncMock(return_value=[mock_result])
|
|
with patch(
|
|
"roboco.services.optimal.get_optimal_service",
|
|
AsyncMock(return_value=mock_optimal),
|
|
):
|
|
result = await svc._find_similar_doc(title="t", content="c", team="backend")
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_similar_doc_different_team_no_match(
|
|
docs_setup: dict,
|
|
) -> None:
|
|
"""High score but different team returns None."""
|
|
svc = docs_setup["svc"]
|
|
mock_result = MagicMock()
|
|
mock_result.score = 0.9
|
|
mock_result.source = "frontend/api/something.md"
|
|
mock_optimal = AsyncMock()
|
|
mock_optimal.search = AsyncMock(return_value=[mock_result])
|
|
with patch(
|
|
"roboco.services.optimal.get_optimal_service",
|
|
AsyncMock(return_value=mock_optimal),
|
|
):
|
|
result = await svc._find_similar_doc(title="t", content="c", team="backend")
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_similar_doc_swallows_exceptions(docs_setup: dict) -> None:
|
|
"""RAG search failure returns None, doesn't raise."""
|
|
svc = docs_setup["svc"]
|
|
with patch(
|
|
"roboco.services.optimal.get_optimal_service",
|
|
AsyncMock(side_effect=RuntimeError("network down")),
|
|
):
|
|
result = await svc._find_similar_doc(title="t", content="c", team="backend")
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# read_doc
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_doc_unauthorized(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
with pytest.raises(UnauthorizedError, match="cannot read"):
|
|
await svc.read_doc(agent_id="ghost-agent", path="x.md")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_doc_head_marketing_authorized(
|
|
docs_setup: dict, tmp_path: Path
|
|
) -> None:
|
|
"""Head of Marketing has read-only docs access (Board oversight)."""
|
|
svc = docs_setup["svc"]
|
|
target = tmp_path / "board" / "design" / "brand.md"
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text("# Brand", encoding="utf-8")
|
|
with patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path):
|
|
content, _ = await svc.read_doc(
|
|
agent_id="head-marketing", path="board/design/brand.md"
|
|
)
|
|
assert content == "# Brand"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_doc_path_traversal(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
with pytest.raises(ValidationError, match="cannot contain"):
|
|
await svc.read_doc(agent_id="be-doc", path="../etc/passwd")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_doc_success(docs_setup: dict, tmp_path: Path) -> None:
|
|
"""Read existing file."""
|
|
svc = docs_setup["svc"]
|
|
target = tmp_path / "backend" / "api" / "x.md"
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text("# Hello", encoding="utf-8")
|
|
with patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path):
|
|
content, size = await svc.read_doc(agent_id="be-doc", path="backend/api/x.md")
|
|
assert content == "# Hello"
|
|
assert size == len(b"# Hello")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_doc_not_found(docs_setup: dict, tmp_path: Path) -> None:
|
|
"""Read missing file → NotFoundError."""
|
|
svc = docs_setup["svc"]
|
|
with (
|
|
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
|
|
pytest.raises(NotFoundError),
|
|
):
|
|
await svc.read_doc(agent_id="be-doc", path="ghost.md")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_docs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_docs_unauthorized(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
with pytest.raises(UnauthorizedError):
|
|
await svc.list_docs(agent_id="ghost-agent")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_docs_head_marketing_authorized(
|
|
docs_setup: dict, tmp_path: Path
|
|
) -> None:
|
|
"""Head of Marketing can list docs (read-only Board oversight)."""
|
|
svc = docs_setup["svc"]
|
|
with patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path):
|
|
docs = await svc.list_docs(agent_id="head-marketing")
|
|
assert docs == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_docs_by_task_id(docs_setup: dict) -> None:
|
|
"""Pass task_id, list from task.documents."""
|
|
svc = docs_setup["svc"]
|
|
# Pre-seed task.documents.
|
|
result = await svc.session.execute(
|
|
select(TaskTable).where(TaskTable.id == docs_setup["task_id"])
|
|
)
|
|
task = result.scalar_one()
|
|
task.documents = [
|
|
{
|
|
"path": "backend/api/x.md",
|
|
"title": "X",
|
|
"doc_type": "api",
|
|
"version": "1",
|
|
}
|
|
]
|
|
await svc.session.flush()
|
|
docs = await svc.list_docs(agent_id="be-doc", task_id=docs_setup["task_id"])
|
|
assert len(docs) == 1
|
|
assert docs[0].path == "backend/api/x.md"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_docs_by_task_id_not_found(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
with pytest.raises(NotFoundError):
|
|
await svc.list_docs(agent_id="be-doc", task_id=uuid4())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_docs_unknown_agent_team_returns_empty(
|
|
docs_setup: dict,
|
|
) -> None:
|
|
"""Agent with no team but in READ_ROLES returns empty list."""
|
|
svc = docs_setup["svc"]
|
|
# Patch get_agent_team to return None while keeping role permissions.
|
|
with patch("roboco.services.docs.get_agent_team", return_value=None):
|
|
docs = await svc.list_docs(agent_id="be-doc")
|
|
assert docs == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_docs_filesystem_scan(docs_setup: dict, tmp_path: Path) -> None:
|
|
"""Scan filesystem for team docs."""
|
|
svc = docs_setup["svc"]
|
|
backend_dir = tmp_path / "backend" / "api"
|
|
backend_dir.mkdir(parents=True)
|
|
(backend_dir / "endpoint.md").write_text("# x", encoding="utf-8")
|
|
(tmp_path / "backend" / "README.md").write_text("# r", encoding="utf-8")
|
|
with patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path):
|
|
docs = await svc.list_docs(agent_id="be-doc")
|
|
paths = {d.path for d in docs}
|
|
assert "backend/api/endpoint.md" in paths
|
|
assert "backend/README.md" in paths
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_docs_filesystem_no_dir_returns_empty(
|
|
docs_setup: dict, tmp_path: Path
|
|
) -> None:
|
|
"""No team folder → empty list."""
|
|
svc = docs_setup["svc"]
|
|
with patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path):
|
|
docs = await svc.list_docs(agent_id="be-doc")
|
|
assert docs == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# delete_doc
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_doc_unauthorized(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
with pytest.raises(UnauthorizedError, match="cannot delete"):
|
|
await svc.delete_doc(agent_id="be-dev-1", path="backend/api/x.md")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_doc_path_traversal(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
with pytest.raises(ValidationError):
|
|
await svc.delete_doc(agent_id="be-doc", path="../etc/passwd")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_doc_not_found(docs_setup: dict, tmp_path: Path) -> None:
|
|
svc = docs_setup["svc"]
|
|
with (
|
|
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
|
|
pytest.raises(NotFoundError),
|
|
):
|
|
await svc.delete_doc(agent_id="be-doc", path="missing.md")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_doc_success(docs_setup: dict, tmp_path: Path) -> None:
|
|
svc = docs_setup["svc"]
|
|
target = tmp_path / "backend" / "api" / "x.md"
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text("# x", encoding="utf-8")
|
|
with patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path):
|
|
result = await svc.delete_doc(agent_id="be-doc", path="backend/api/x.md")
|
|
assert result is True
|
|
assert not target.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _infer_doc_type
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_infer_doc_type_with_subfolder(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
assert svc._infer_doc_type("backend/api/x.md") == "api"
|
|
|
|
|
|
def test_infer_doc_type_without_subfolder(docs_setup: dict) -> None:
|
|
"""Path with only team/file (2 parts) → readme inferred."""
|
|
svc = docs_setup["svc"]
|
|
# 2 parts = team/file → readme.
|
|
assert svc._infer_doc_type("backend/x.md") == "readme"
|
|
|
|
|
|
def test_infer_doc_type_too_short(docs_setup: dict) -> None:
|
|
"""Single part → readme default."""
|
|
svc = docs_setup["svc"]
|
|
assert svc._infer_doc_type("x.md") == "readme"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _add_doc_to_task — task missing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_doc_to_task_missing_task(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
doc_ref = DocRef(path="x.md", title="t", doc_type="readme")
|
|
with pytest.raises(NotFoundError):
|
|
await svc._add_doc_to_task(uuid4(), doc_ref)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_doc_to_task_existing_path_is_updated(
|
|
docs_setup: dict,
|
|
) -> None:
|
|
"""If path already exists in task.documents, it gets updated."""
|
|
svc = docs_setup["svc"]
|
|
result = await svc.session.execute(
|
|
select(TaskTable).where(TaskTable.id == docs_setup["task_id"])
|
|
)
|
|
task = result.scalar_one()
|
|
task.documents = [
|
|
{
|
|
"path": "backend/api/x.md",
|
|
"title": "Old",
|
|
"doc_type": "api",
|
|
"version": "1",
|
|
}
|
|
]
|
|
await svc.session.flush()
|
|
|
|
new_ref = DocRef(
|
|
path="backend/api/x.md",
|
|
title="New",
|
|
doc_type="api",
|
|
)
|
|
await svc._add_doc_to_task(docs_setup["task_id"], new_ref)
|
|
# Only one entry remains, with updated title.
|
|
result = await svc.session.execute(
|
|
select(TaskTable).where(TaskTable.id == docs_setup["task_id"])
|
|
)
|
|
refreshed = result.scalar_one()
|
|
assert len(refreshed.documents) == 1
|
|
assert refreshed.documents[0]["title"] == "New"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _index_doc_in_rag — failure swallowed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_index_doc_in_rag_swallows_exception(
|
|
docs_setup: dict, tmp_path: Path
|
|
) -> None:
|
|
svc = docs_setup["svc"]
|
|
with patch(
|
|
"roboco.services.optimal.get_optimal_service",
|
|
AsyncMock(side_effect=RuntimeError("rag down")),
|
|
):
|
|
# No exception raised.
|
|
await svc._index_doc_in_rag(tmp_path / "x.md")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_index_doc_in_rag_success(docs_setup: dict, tmp_path: Path) -> None:
|
|
"""Successful index call."""
|
|
svc = docs_setup["svc"]
|
|
mock_optimal = AsyncMock()
|
|
mock_optimal.index_documentation = AsyncMock(return_value=None)
|
|
with patch(
|
|
"roboco.services.optimal.get_optimal_service",
|
|
AsyncMock(return_value=mock_optimal),
|
|
):
|
|
await svc._index_doc_in_rag(tmp_path / "x.md")
|
|
mock_optimal.index_documentation.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_existing_doc_ref
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_existing_doc_ref_none_when_task_missing(
|
|
docs_setup: dict,
|
|
) -> None:
|
|
svc = docs_setup["svc"]
|
|
result = await svc._get_existing_doc_ref(uuid4(), "x.md")
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_existing_doc_ref_none_when_path_missing(
|
|
docs_setup: dict,
|
|
) -> None:
|
|
svc = docs_setup["svc"]
|
|
# No documents on task.
|
|
result = await svc._get_existing_doc_ref(docs_setup["task_id"], "x.md")
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_existing_doc_ref_returns_match(docs_setup: dict) -> None:
|
|
svc = docs_setup["svc"]
|
|
result = await svc.session.execute(
|
|
select(TaskTable).where(TaskTable.id == docs_setup["task_id"])
|
|
)
|
|
task = result.scalar_one()
|
|
task.documents = [
|
|
{
|
|
"path": "backend/api/x.md",
|
|
"title": "T",
|
|
"doc_type": "api",
|
|
"version": "1",
|
|
}
|
|
]
|
|
await svc.session.flush()
|
|
found = await svc._get_existing_doc_ref(docs_setup["task_id"], "backend/api/x.md")
|
|
assert found is not None
|
|
assert found.path == "backend/api/x.md"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_existing_doc_ref_no_match_among_documents(
|
|
docs_setup: dict,
|
|
) -> None:
|
|
"""Documents present but path not in any of them → None."""
|
|
svc = docs_setup["svc"]
|
|
result = await svc.session.execute(
|
|
select(TaskTable).where(TaskTable.id == docs_setup["task_id"])
|
|
)
|
|
task = result.scalar_one()
|
|
task.documents = [
|
|
{
|
|
"path": "backend/api/other.md",
|
|
"title": "T",
|
|
"doc_type": "api",
|
|
"version": "1",
|
|
}
|
|
]
|
|
await svc.session.flush()
|
|
found = await svc._get_existing_doc_ref(docs_setup["task_id"], "backend/api/x.md")
|
|
assert found is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _list_docs_for_team — filesystem scan with shallow paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_docs_for_team_shallow_path_infers_readme(
|
|
docs_setup: dict, tmp_path: Path
|
|
) -> None:
|
|
"""A .md file directly under the team folder (1 part after team) → readme.
|
|
|
|
The filesystem scan returns the relative path including the team prefix
|
|
(e.g., 'backend/x.md'). Splitting that gives 2 parts — meeting the
|
|
`>= _MIN_PATH_PARTS_FOR_SUBFOLDER` check but `not has_subfolder` (since
|
|
`len(parts) > _MIN_PATH_PARTS_FOR_SUBFOLDER` is False with exactly 2).
|
|
The else-branch on line 604 runs when len(parts) < 2, which only happens
|
|
for a path with no separators — `_list_docs_for_team` always produces at
|
|
least `team/file`, so we patch DOCS_BASE_PATH to be the team dir itself.
|
|
"""
|
|
svc = docs_setup["svc"]
|
|
# Place the docs root AT the team level so rel_path is just "x.md".
|
|
backend_root = tmp_path / "backend"
|
|
backend_root.mkdir()
|
|
(backend_root / "x.md").write_text("# x", encoding="utf-8")
|
|
# DOCS_BASE_PATH set to backend_root means rel_path = 'x.md' (1 part).
|
|
with (
|
|
patch("roboco.services.docs.DOCS_BASE_PATH", backend_root),
|
|
patch("roboco.services.docs.TEAM_PATHS", {"backend": ""}),
|
|
):
|
|
docs = await svc._list_docs_for_team("backend")
|
|
assert any(d.doc_type == "readme" for d in docs)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_doc_to_repo_writes_into_workspace_and_commits(
|
|
docs_setup: dict,
|
|
db_session: AsyncSession,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A doc write also commits the file into the project repo on the task branch."""
|
|
svc = docs_setup["svc"]
|
|
task_id = docs_setup["task_id"]
|
|
agent_uuid = docs_setup["agent_id"]
|
|
|
|
task = (
|
|
await db_session.execute(select(TaskTable).where(TaskTable.id == task_id))
|
|
).scalar_one()
|
|
task.branch_name = "feature/backend/ABC12345"
|
|
await db_session.flush()
|
|
|
|
mock_git = MagicMock()
|
|
mock_git.get_workspace = AsyncMock(return_value=tmp_path)
|
|
mock_git.commit = AsyncMock(return_value={"sha": "deadbeef"})
|
|
monkeypatch.setattr(
|
|
"roboco.services.git.get_git_service", lambda _session: mock_git
|
|
)
|
|
|
|
req = WriteDocInput(
|
|
task_id=task_id,
|
|
filename="guide.md",
|
|
doc_type="api",
|
|
title="API Guide",
|
|
content="# API Guide\n",
|
|
)
|
|
await svc._commit_doc_to_repo(str(agent_uuid), req, "api")
|
|
|
|
# The doc landed in the workspace repo under docs/...
|
|
assert (tmp_path / "docs" / "api" / "guide.md").read_text() == "# API Guide\n"
|
|
# ...and was committed onto the task branch.
|
|
mock_git.commit.assert_awaited_once()
|
|
kwargs = mock_git.commit.await_args.kwargs
|
|
assert kwargs["branch_name"] == "feature/backend/ABC12345"
|
|
assert kwargs["files"] == ["docs/api/guide.md"]
|
|
assert kwargs["task_id"] == task_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_doc_to_repo_skips_without_task_branch(
|
|
docs_setup: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""No task branch yet → best-effort no-op (no git commit, no raise)."""
|
|
svc = docs_setup["svc"]
|
|
mock_git = MagicMock()
|
|
mock_git.commit = AsyncMock()
|
|
monkeypatch.setattr(
|
|
"roboco.services.git.get_git_service", lambda _session: mock_git
|
|
)
|
|
req = WriteDocInput(
|
|
task_id=docs_setup["task_id"],
|
|
filename="x.md",
|
|
doc_type="api",
|
|
title="X",
|
|
content="x",
|
|
)
|
|
await svc._commit_doc_to_repo(str(docs_setup["agent_id"]), req, "api")
|
|
mock_git.commit.assert_not_awaited()
|