2026-06-03 06:35:03 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from http import HTTPStatus
|
2026-06-14 13:43:46 +02:00
|
|
|
from typing import TYPE_CHECKING, cast
|
|
|
|
|
from uuid import UUID, uuid4
|
2026-06-03 06:35:03 +02:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
import pytest_asyncio
|
|
|
|
|
from fastapi import FastAPI
|
|
|
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
|
from roboco.api.deps import get_agent_context, get_db
|
|
|
|
|
from roboco.api.routes.product import router as product_router
|
|
|
|
|
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.permissions import AgentContext
|
|
|
|
|
|
2026-06-14 13:43:46 +02:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from collections.abc import AsyncIterator
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-06-03 06:35:03 +02:00
|
|
|
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
2026-06-14 13:43:46 +02:00
|
|
|
async def product_client(db_session: AsyncSession) -> AsyncIterator[dict]:
|
2026-06-03 06:35:03 +02:00
|
|
|
pm = AgentTable(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
name="PM",
|
|
|
|
|
slug=f"pm-{uuid4().hex[:8]}",
|
|
|
|
|
role=AgentRole.MAIN_PM,
|
|
|
|
|
team=None,
|
|
|
|
|
status=AgentStatus.ACTIVE,
|
|
|
|
|
model_config={},
|
|
|
|
|
system_prompt="pm",
|
|
|
|
|
capabilities=[],
|
|
|
|
|
permissions={},
|
|
|
|
|
metrics={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(pm)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
project = ProjectTable(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
name="BE",
|
|
|
|
|
slug=f"be-{uuid4().hex[:6]}",
|
|
|
|
|
git_url="https://example.com/r.git",
|
|
|
|
|
assigned_cell=Team.BACKEND,
|
|
|
|
|
created_by=pm.id,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(project)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(product_router, prefix="/api/products")
|
|
|
|
|
|
2026-06-14 13:43:46 +02:00
|
|
|
async def _override_db() -> AsyncIterator[AsyncSession]:
|
2026-06-03 06:35:03 +02:00
|
|
|
yield db_session
|
|
|
|
|
|
|
|
|
|
async def _override_agent() -> AgentContext:
|
2026-06-14 13:43:46 +02:00
|
|
|
return AgentContext(
|
|
|
|
|
agent_id=cast("UUID", pm.id), role=AgentRole.MAIN_PM, team=None
|
|
|
|
|
)
|
2026-06-03 06:35:03 +02:00
|
|
|
|
|
|
|
|
app.dependency_overrides[get_db] = _override_db
|
|
|
|
|
app.dependency_overrides[get_agent_context] = _override_agent
|
|
|
|
|
transport = ASGITransport(app=app)
|
|
|
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
|
|
|
yield {"client": client, "project": project, "db": db_session}
|
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_and_get_product_with_cells(product_client: dict) -> None:
|
|
|
|
|
slug = f"prod-{uuid4().hex[:6]}"
|
|
|
|
|
create = await product_client["client"].post(
|
|
|
|
|
"/api/products",
|
|
|
|
|
json={
|
|
|
|
|
"name": "RoboCo",
|
|
|
|
|
"slug": slug,
|
|
|
|
|
"cells": [
|
|
|
|
|
{"team": "backend", "project_id": str(product_client["project"].id)}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
headers=_HDR,
|
|
|
|
|
)
|
|
|
|
|
assert create.status_code == HTTPStatus.CREATED, create.text
|
|
|
|
|
pid = create.json()["id"]
|
|
|
|
|
|
|
|
|
|
got = await product_client["client"].get(f"/api/products/{pid}", headers=_HDR)
|
|
|
|
|
assert got.status_code == HTTPStatus.OK
|
|
|
|
|
body = got.json()
|
|
|
|
|
assert body["slug"] == slug
|
|
|
|
|
assert body["cells"] == [
|
|
|
|
|
{"team": "backend", "project_id": str(product_client["project"].id)}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_product_duplicate_slug_returns_409(
|
|
|
|
|
product_client: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
slug = f"prod-{uuid4().hex[:6]}"
|
|
|
|
|
first = await product_client["client"].post(
|
|
|
|
|
"/api/products",
|
|
|
|
|
json={"name": "RoboCo", "slug": slug, "cells": []},
|
|
|
|
|
headers=_HDR,
|
|
|
|
|
)
|
|
|
|
|
assert first.status_code == HTTPStatus.CREATED, first.text
|
|
|
|
|
|
|
|
|
|
second = await product_client["client"].post(
|
|
|
|
|
"/api/products",
|
|
|
|
|
json={"name": "Other", "slug": slug, "cells": []},
|
|
|
|
|
headers=_HDR,
|
|
|
|
|
)
|
|
|
|
|
assert second.status_code == HTTPStatus.CONFLICT, second.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_product_duplicate_team_cell_returns_409(
|
|
|
|
|
product_client: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
project_id = str(product_client["project"].id)
|
|
|
|
|
create = await product_client["client"].post(
|
|
|
|
|
"/api/products",
|
|
|
|
|
json={
|
|
|
|
|
"name": "RoboCo",
|
|
|
|
|
"slug": f"prod-{uuid4().hex[:6]}",
|
|
|
|
|
"cells": [
|
|
|
|
|
{"team": "backend", "project_id": project_id},
|
|
|
|
|
{"team": "backend", "project_id": project_id},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
headers=_HDR,
|
|
|
|
|
)
|
|
|
|
|
assert create.status_code == HTTPStatus.CONFLICT, create.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_product_unknown_project_cell_returns_422(
|
|
|
|
|
product_client: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
create = await product_client["client"].post(
|
|
|
|
|
"/api/products",
|
|
|
|
|
json={
|
|
|
|
|
"name": "RoboCo",
|
|
|
|
|
"slug": f"prod-{uuid4().hex[:6]}",
|
|
|
|
|
"cells": [{"team": "backend", "project_id": str(uuid4())}],
|
|
|
|
|
},
|
|
|
|
|
headers=_HDR,
|
|
|
|
|
)
|
|
|
|
|
assert create.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, create.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_update_product_duplicate_team_cell_returns_409(
|
|
|
|
|
product_client: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
slug = f"prod-{uuid4().hex[:6]}"
|
|
|
|
|
create = await product_client["client"].post(
|
|
|
|
|
"/api/products",
|
|
|
|
|
json={"name": "RoboCo", "slug": slug, "cells": []},
|
|
|
|
|
headers=_HDR,
|
|
|
|
|
)
|
|
|
|
|
assert create.status_code == HTTPStatus.CREATED, create.text
|
|
|
|
|
pid = create.json()["id"]
|
|
|
|
|
|
|
|
|
|
project_id = str(product_client["project"].id)
|
|
|
|
|
patch = await product_client["client"].patch(
|
|
|
|
|
f"/api/products/{pid}",
|
|
|
|
|
json={
|
|
|
|
|
"cells": [
|
|
|
|
|
{"team": "backend", "project_id": project_id},
|
|
|
|
|
{"team": "backend", "project_id": project_id},
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
headers=_HDR,
|
|
|
|
|
)
|
|
|
|
|
assert patch.status_code == HTTPStatus.CONFLICT, patch.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_update_product_unknown_project_cell_returns_422(
|
|
|
|
|
product_client: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
slug = f"prod-{uuid4().hex[:6]}"
|
|
|
|
|
create = await product_client["client"].post(
|
|
|
|
|
"/api/products",
|
|
|
|
|
json={"name": "RoboCo", "slug": slug, "cells": []},
|
|
|
|
|
headers=_HDR,
|
|
|
|
|
)
|
|
|
|
|
assert create.status_code == HTTPStatus.CREATED, create.text
|
|
|
|
|
pid = create.json()["id"]
|
|
|
|
|
|
|
|
|
|
patch = await product_client["client"].patch(
|
|
|
|
|
f"/api/products/{pid}",
|
|
|
|
|
json={"cells": [{"team": "backend", "project_id": str(uuid4())}]},
|
|
|
|
|
headers=_HDR,
|
|
|
|
|
)
|
|
|
|
|
assert patch.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, patch.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_delete_unreferenced_product_returns_204(
|
|
|
|
|
product_client: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
create = await product_client["client"].post(
|
|
|
|
|
"/api/products",
|
|
|
|
|
json={"name": "RoboCo", "slug": f"prod-{uuid4().hex[:6]}", "cells": []},
|
|
|
|
|
headers=_HDR,
|
|
|
|
|
)
|
|
|
|
|
assert create.status_code == HTTPStatus.CREATED, create.text
|
|
|
|
|
pid = create.json()["id"]
|
|
|
|
|
|
|
|
|
|
deleted = await product_client["client"].delete(
|
|
|
|
|
f"/api/products/{pid}", headers=_HDR
|
|
|
|
|
)
|
|
|
|
|
assert deleted.status_code == HTTPStatus.NO_CONTENT, deleted.text
|
|
|
|
|
|
|
|
|
|
got = await product_client["client"].get(f"/api/products/{pid}", headers=_HDR)
|
|
|
|
|
assert got.status_code == HTTPStatus.NOT_FOUND, got.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_delete_product_referenced_by_task_returns_409(
|
|
|
|
|
product_client: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
create = await product_client["client"].post(
|
|
|
|
|
"/api/products",
|
|
|
|
|
json={
|
|
|
|
|
"name": "RoboCo",
|
|
|
|
|
"slug": f"prod-{uuid4().hex[:6]}",
|
|
|
|
|
"cells": [
|
|
|
|
|
{"team": "backend", "project_id": str(product_client["project"].id)}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
headers=_HDR,
|
|
|
|
|
)
|
|
|
|
|
assert create.status_code == HTTPStatus.CREATED, create.text
|
|
|
|
|
pid = create.json()["id"]
|
|
|
|
|
|
|
|
|
|
task = TaskTable(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
title="Ref task",
|
|
|
|
|
description="d",
|
|
|
|
|
acceptance_criteria=["ac"],
|
|
|
|
|
status=TaskStatus.PENDING,
|
|
|
|
|
priority=2,
|
|
|
|
|
task_type=TaskType.CODE,
|
|
|
|
|
nature=TaskNature.TECHNICAL,
|
|
|
|
|
project_id=product_client["project"].id,
|
|
|
|
|
created_by=product_client["project"].created_by,
|
|
|
|
|
team=Team.BACKEND,
|
|
|
|
|
product_id=pid,
|
|
|
|
|
)
|
|
|
|
|
product_client["db"].add(task)
|
|
|
|
|
await product_client["db"].flush()
|
|
|
|
|
|
|
|
|
|
deleted = await product_client["client"].delete(
|
|
|
|
|
f"/api/products/{pid}", headers=_HDR
|
|
|
|
|
)
|
|
|
|
|
assert deleted.status_code == HTTPStatus.CONFLICT, deleted.text
|