Add ENV and FILE management APIs with tests
Features:
- POST /api/v1/agents/{agent_id}/env/inspect - Create ENV_INSPECT tasks
- POST /api/v1/agents/{agent_id}/env/update - Create ENV_UPDATE tasks
- POST /api/v1/agents/{agent_id}/files/inspect - Create FILE_INSPECT tasks
Changes:
- Add EnvInspectRequest, EnvUpdateRequest, FileInspectRequest schemas
- Add env and files route modules
- Fix JSONB to use JSON variant for SQLite test compatibility
- Add pytest, pytest-asyncio, aiosqlite for testing
- Add tests for all new endpoints (17 tests passing)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
dd8a53e657
commit
f40c5fcc69
|
|
@ -13,6 +13,8 @@ from app.config import settings
|
|||
from app.db import engine
|
||||
from app.routes import (
|
||||
agents_router,
|
||||
env_router,
|
||||
files_router,
|
||||
health_router,
|
||||
playbooks_router,
|
||||
tasks_router,
|
||||
|
|
@ -83,6 +85,8 @@ app.include_router(tenants_router, prefix="/api/v1")
|
|||
app.include_router(tasks_router, prefix="/api/v1")
|
||||
app.include_router(agents_router, prefix="/api/v1")
|
||||
app.include_router(playbooks_router, prefix="/api/v1")
|
||||
app.include_router(env_router, prefix="/api/v1")
|
||||
app.include_router(files_router, prefix="/api/v1")
|
||||
|
||||
|
||||
# --- Root endpoint ---
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ import uuid
|
|||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy import DateTime, ForeignKey, JSON, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
# Use JSONB on PostgreSQL, JSON on other databases (SQLite for tests)
|
||||
JSONType = JSON().with_variant(JSONB, "postgresql")
|
||||
|
||||
from app.models.base import Base, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -46,7 +49,7 @@ class Event(UUIDMixin, Base):
|
|||
index=True,
|
||||
)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB,
|
||||
JSONType,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ import uuid
|
|||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy import ForeignKey, JSON, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
# Use JSONB on PostgreSQL, JSON on other databases (SQLite for tests)
|
||||
JSONType = JSON().with_variant(JSONB, "postgresql")
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -51,7 +54,7 @@ class Task(UUIDMixin, TimestampMixin, Base):
|
|||
index=True,
|
||||
)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB,
|
||||
JSONType,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
)
|
||||
|
|
@ -62,7 +65,7 @@ class Task(UUIDMixin, TimestampMixin, Base):
|
|||
index=True,
|
||||
)
|
||||
result: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSONB,
|
||||
JSONType,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from app.routes.tasks import router as tasks_router
|
|||
from app.routes.tenants import router as tenants_router
|
||||
from app.routes.agents import router as agents_router
|
||||
from app.routes.playbooks import router as playbooks_router
|
||||
from app.routes.env import router as env_router
|
||||
from app.routes.files import router as files_router
|
||||
|
||||
__all__ = [
|
||||
"health_router",
|
||||
|
|
@ -12,4 +14,6 @@ __all__ = [
|
|||
"tasks_router",
|
||||
"agents_router",
|
||||
"playbooks_router",
|
||||
"env_router",
|
||||
"files_router",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
"""Env management endpoints for creating ENV_INSPECT and ENV_UPDATE tasks."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AsyncSessionDep
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.env import EnvInspectRequest, EnvUpdateRequest
|
||||
from app.schemas.task import TaskResponse
|
||||
|
||||
router = APIRouter(prefix="/agents/{agent_id}/env", tags=["Env Management"])
|
||||
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
|
||||
async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None:
|
||||
"""Retrieve a tenant by ID."""
|
||||
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_agent_by_id(db: AsyncSessionDep, agent_id: uuid.UUID) -> Agent | None:
|
||||
"""Retrieve an agent by ID."""
|
||||
result = await db.execute(select(Agent).where(Agent.id == agent_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# --- Route handlers ---
|
||||
|
||||
|
||||
@router.post(
|
||||
"/inspect",
|
||||
response_model=TaskResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def inspect_env(
|
||||
agent_id: uuid.UUID,
|
||||
request: EnvInspectRequest,
|
||||
db: AsyncSessionDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Create an ENV_INSPECT task to read env file contents.
|
||||
|
||||
The SysAdmin Agent will execute this task and return the env file
|
||||
key-value pairs in the task result.
|
||||
|
||||
## Request Body
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **path**: Path to the env file (e.g., `/opt/letsbe/env/chatwoot.env`)
|
||||
- **keys**: Optional list of specific keys to inspect (returns all if omitted)
|
||||
|
||||
## Response
|
||||
Returns the created Task with type="ENV_INSPECT" and status="pending".
|
||||
"""
|
||||
# Validate tenant exists
|
||||
tenant = await get_tenant_by_id(db, request.tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {request.tenant_id} not found",
|
||||
)
|
||||
|
||||
# Validate agent exists
|
||||
agent = await get_agent_by_id(db, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Agent {agent_id} not found",
|
||||
)
|
||||
|
||||
# Build payload
|
||||
payload: dict = {"path": request.path}
|
||||
if request.keys is not None:
|
||||
payload["keys"] = request.keys
|
||||
|
||||
# Create the task
|
||||
task = Task(
|
||||
tenant_id=request.tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="ENV_INSPECT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update",
|
||||
response_model=TaskResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def update_env(
|
||||
agent_id: uuid.UUID,
|
||||
request: EnvUpdateRequest,
|
||||
db: AsyncSessionDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Create an ENV_UPDATE task to modify env file contents.
|
||||
|
||||
The SysAdmin Agent will execute this task to update or remove
|
||||
key-value pairs in the specified env file.
|
||||
|
||||
## Request Body
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **path**: Path to the env file (e.g., `/opt/letsbe/env/chatwoot.env`)
|
||||
- **updates**: Optional dict of key-value pairs to set or update
|
||||
- **remove_keys**: Optional list of keys to remove from the env file
|
||||
|
||||
## Response
|
||||
Returns the created Task with type="ENV_UPDATE" and status="pending".
|
||||
"""
|
||||
# Validate tenant exists
|
||||
tenant = await get_tenant_by_id(db, request.tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {request.tenant_id} not found",
|
||||
)
|
||||
|
||||
# Validate agent exists
|
||||
agent = await get_agent_by_id(db, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Agent {agent_id} not found",
|
||||
)
|
||||
|
||||
# Build payload
|
||||
payload: dict = {"path": request.path}
|
||||
if request.updates is not None:
|
||||
payload["updates"] = request.updates
|
||||
if request.remove_keys is not None:
|
||||
payload["remove_keys"] = request.remove_keys
|
||||
|
||||
# Create the task
|
||||
task = Task(
|
||||
tenant_id=request.tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="ENV_UPDATE",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"""File management endpoints for creating FILE_INSPECT tasks."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AsyncSessionDep
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.file import FileInspectRequest
|
||||
from app.schemas.task import TaskResponse
|
||||
|
||||
router = APIRouter(prefix="/agents/{agent_id}/files", tags=["File Management"])
|
||||
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
|
||||
async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None:
|
||||
"""Retrieve a tenant by ID."""
|
||||
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_agent_by_id(db: AsyncSessionDep, agent_id: uuid.UUID) -> Agent | None:
|
||||
"""Retrieve an agent by ID."""
|
||||
result = await db.execute(select(Agent).where(Agent.id == agent_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# --- Route handlers ---
|
||||
|
||||
|
||||
@router.post(
|
||||
"/inspect",
|
||||
response_model=TaskResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def inspect_file(
|
||||
agent_id: uuid.UUID,
|
||||
request: FileInspectRequest,
|
||||
db: AsyncSessionDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Create a FILE_INSPECT task to read file contents.
|
||||
|
||||
The SysAdmin Agent will execute this task and return the file
|
||||
contents (up to max_bytes) in the task result.
|
||||
|
||||
## Request Body
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **path**: Absolute path to the file to inspect
|
||||
- **max_bytes**: Optional max bytes to read (default 4096, max 1MB)
|
||||
|
||||
## Response
|
||||
Returns the created Task with type="FILE_INSPECT" and status="pending".
|
||||
"""
|
||||
# Validate tenant exists
|
||||
tenant = await get_tenant_by_id(db, request.tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {request.tenant_id} not found",
|
||||
)
|
||||
|
||||
# Validate agent exists
|
||||
agent = await get_agent_by_id(db, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Agent {agent_id} not found",
|
||||
)
|
||||
|
||||
# Build payload
|
||||
payload: dict = {"path": request.path}
|
||||
if request.max_bytes is not None:
|
||||
payload["max_bytes"] = request.max_bytes
|
||||
|
||||
# Create the task
|
||||
task = Task(
|
||||
tenant_id=request.tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="FILE_INSPECT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
|
@ -20,6 +20,11 @@ from app.schemas.tasks_extended import (
|
|||
CompositeSubTask,
|
||||
CompositePayload,
|
||||
)
|
||||
from app.schemas.env import (
|
||||
EnvInspectRequest,
|
||||
EnvUpdateRequest,
|
||||
)
|
||||
from app.schemas.file import FileInspectRequest
|
||||
|
||||
__all__ = [
|
||||
# Common
|
||||
|
|
@ -42,4 +47,9 @@ __all__ = [
|
|||
"AgentRegisterResponse",
|
||||
"AgentHeartbeatResponse",
|
||||
"AgentResponse",
|
||||
# Env Management
|
||||
"EnvInspectRequest",
|
||||
"EnvUpdateRequest",
|
||||
# File Management
|
||||
"FileInspectRequest",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
"""Pydantic schemas for env management endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EnvInspectRequest(BaseModel):
|
||||
"""Request body for env inspect endpoint."""
|
||||
|
||||
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
|
||||
path: str = Field(
|
||||
..., min_length=1, description="Path to .env file (e.g., /opt/letsbe/env/chatwoot.env)"
|
||||
)
|
||||
keys: list[str] | None = Field(
|
||||
None, description="Optional list of specific keys to inspect"
|
||||
)
|
||||
|
||||
|
||||
class EnvUpdateRequest(BaseModel):
|
||||
"""Request body for env update endpoint."""
|
||||
|
||||
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
|
||||
path: str = Field(
|
||||
..., min_length=1, description="Path to .env file (e.g., /opt/letsbe/env/chatwoot.env)"
|
||||
)
|
||||
updates: dict[str, str] | None = Field(
|
||||
None, description="Key-value pairs to set or update"
|
||||
)
|
||||
remove_keys: list[str] | None = Field(
|
||||
None, description="Keys to remove from the env file"
|
||||
)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
"""Pydantic schemas for file management endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FileInspectRequest(BaseModel):
|
||||
"""Request body for FILE_INSPECT tasks."""
|
||||
|
||||
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
|
||||
path: str = Field(
|
||||
..., min_length=1, description="Absolute path to the file to inspect"
|
||||
)
|
||||
max_bytes: int | None = Field(
|
||||
4096,
|
||||
ge=1,
|
||||
le=1048576,
|
||||
description="Max bytes to read from file (default 4096, max 1MB)",
|
||||
)
|
||||
|
|
@ -31,6 +31,7 @@ services:
|
|||
volumes:
|
||||
- ./app:/app/app
|
||||
- ./alembic:/app/alembic
|
||||
- ./tests:/app/tests
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -13,3 +13,8 @@ pydantic-settings>=2.1.0
|
|||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
aiosqlite>=0.19.0
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
|||
|
||||
from app.models.base import Base
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.agent import Agent
|
||||
|
||||
# Use in-memory SQLite for testing
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
|
@ -55,9 +56,24 @@ async def test_tenant(db: AsyncSession) -> Tenant:
|
|||
tenant = Tenant(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Tenant",
|
||||
slug="test-tenant",
|
||||
)
|
||||
db.add(tenant)
|
||||
await db.commit()
|
||||
await db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_agent(db: AsyncSession) -> Agent:
|
||||
"""Create a test agent."""
|
||||
agent = Agent(
|
||||
id=uuid.uuid4(),
|
||||
name="test-agent-host",
|
||||
version="1.0.0",
|
||||
status="online",
|
||||
token="test-token-12345",
|
||||
)
|
||||
db.add(agent)
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return agent
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
"""Tests for route modules."""
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
"""Tests for env management routes."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.env import inspect_env, update_env
|
||||
from app.schemas.env import EnvInspectRequest, EnvUpdateRequest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestInspectEnv:
|
||||
"""Tests for the inspect_env endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates an ENV_INSPECT task."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
keys=["FRONTEND_URL", "BACKEND_URL"],
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "ENV_INSPECT"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert task.payload["keys"] == ["FRONTEND_URL", "BACKEND_URL"]
|
||||
|
||||
async def test_without_keys(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request without keys omits keys from payload."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
keys=None,
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_INSPECT"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert "keys" not in task.payload
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_env(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = EnvInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
)
|
||||
|
||||
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "ENV_INSPECT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateEnv:
|
||||
"""Tests for the update_env endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates an ENV_UPDATE task."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"FRONTEND_URL": "https://new.domain.com"},
|
||||
remove_keys=["OLD_KEY"],
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||
assert task.payload["updates"] == {"FRONTEND_URL": "https://new.domain.com"}
|
||||
assert task.payload["remove_keys"] == ["OLD_KEY"]
|
||||
|
||||
async def test_updates_only(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with only updates omits remove_keys from payload."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
remove_keys=None,
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert task.payload["updates"] == {"KEY": "VALUE"}
|
||||
assert "remove_keys" not in task.payload
|
||||
|
||||
async def test_remove_keys_only(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with only remove_keys omits updates from payload."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates=None,
|
||||
remove_keys=["OLD_KEY"],
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "ENV_UPDATE"
|
||||
assert "updates" not in task.payload
|
||||
assert task.payload["remove_keys"] == ["OLD_KEY"]
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_env(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = EnvUpdateRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/env/chatwoot.env",
|
||||
updates={"KEY": "VALUE"},
|
||||
)
|
||||
|
||||
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "ENV_UPDATE"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"""Tests for file management routes."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.routes.files import inspect_file
|
||||
from app.schemas.file import FileInspectRequest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestInspectFile:
|
||||
"""Tests for the inspect_file endpoint."""
|
||||
|
||||
async def test_happy_path(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Valid request creates a FILE_INSPECT task."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
max_bytes=4096,
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.tenant_id == test_tenant.id
|
||||
assert task.agent_id == test_agent.id
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.status == "pending"
|
||||
assert task.payload["path"] == "/opt/letsbe/data/config.json"
|
||||
assert task.payload["max_bytes"] == 4096
|
||||
|
||||
async def test_with_default_max_bytes(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request uses default max_bytes of 4096."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.payload["path"] == "/opt/letsbe/data/config.json"
|
||||
assert task.payload["max_bytes"] == 4096
|
||||
|
||||
async def test_with_custom_max_bytes(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Request with custom max_bytes is respected."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/large_file.txt",
|
||||
max_bytes=1048576, # 1MB
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert task.type == "FILE_INSPECT"
|
||||
assert task.payload["max_bytes"] == 1048576
|
||||
|
||||
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||
"""Returns 404 when tenant doesn't exist."""
|
||||
fake_tenant_id = uuid.uuid4()
|
||||
request = FileInspectRequest(
|
||||
tenant_id=fake_tenant_id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||
"""Returns 404 when agent doesn't exist."""
|
||||
fake_agent_id = uuid.uuid4()
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await inspect_file(agent_id=fake_agent_id, request=request, db=db)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||
|
||||
async def test_task_persisted_to_database(
|
||||
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||
):
|
||||
"""Verify the task is actually persisted and can be retrieved."""
|
||||
request = FileInspectRequest(
|
||||
tenant_id=test_tenant.id,
|
||||
path="/opt/letsbe/data/config.json",
|
||||
)
|
||||
|
||||
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||
|
||||
# Query the task back from the database
|
||||
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||
retrieved_task = result.scalar_one_or_none()
|
||||
|
||||
assert retrieved_task is not None
|
||||
assert retrieved_task.type == "FILE_INSPECT"
|
||||
assert retrieved_task.tenant_id == test_tenant.id
|
||||
Loading…
Reference in New Issue