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:
Matt 2025-12-04 00:30:37 +01:00
parent dd8a53e657
commit f40c5fcc69
15 changed files with 686 additions and 6 deletions

View File

@ -13,6 +13,8 @@ from app.config import settings
from app.db import engine from app.db import engine
from app.routes import ( from app.routes import (
agents_router, agents_router,
env_router,
files_router,
health_router, health_router,
playbooks_router, playbooks_router,
tasks_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(tasks_router, prefix="/api/v1")
app.include_router(agents_router, prefix="/api/v1") app.include_router(agents_router, prefix="/api/v1")
app.include_router(playbooks_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 --- # --- Root endpoint ---

View File

@ -4,10 +4,13 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any 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.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship 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 from app.models.base import Base, UUIDMixin
if TYPE_CHECKING: if TYPE_CHECKING:
@ -46,7 +49,7 @@ class Event(UUIDMixin, Base):
index=True, index=True,
) )
payload: Mapped[dict[str, Any]] = mapped_column( payload: Mapped[dict[str, Any]] = mapped_column(
JSONB, JSONType,
nullable=False, nullable=False,
default=dict, default=dict,
) )

View File

@ -4,10 +4,13 @@ import uuid
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Any 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.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship 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 from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING: if TYPE_CHECKING:
@ -51,7 +54,7 @@ class Task(UUIDMixin, TimestampMixin, Base):
index=True, index=True,
) )
payload: Mapped[dict[str, Any]] = mapped_column( payload: Mapped[dict[str, Any]] = mapped_column(
JSONB, JSONType,
nullable=False, nullable=False,
default=dict, default=dict,
) )
@ -62,7 +65,7 @@ class Task(UUIDMixin, TimestampMixin, Base):
index=True, index=True,
) )
result: Mapped[dict[str, Any] | None] = mapped_column( result: Mapped[dict[str, Any] | None] = mapped_column(
JSONB, JSONType,
nullable=True, nullable=True,
) )

View File

@ -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.tenants import router as tenants_router
from app.routes.agents import router as agents_router from app.routes.agents import router as agents_router
from app.routes.playbooks import router as playbooks_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__ = [ __all__ = [
"health_router", "health_router",
@ -12,4 +14,6 @@ __all__ = [
"tasks_router", "tasks_router",
"agents_router", "agents_router",
"playbooks_router", "playbooks_router",
"env_router",
"files_router",
] ]

158
app/routes/env.py Normal file
View File

@ -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

94
app/routes/files.py Normal file
View File

@ -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

View File

@ -20,6 +20,11 @@ from app.schemas.tasks_extended import (
CompositeSubTask, CompositeSubTask,
CompositePayload, CompositePayload,
) )
from app.schemas.env import (
EnvInspectRequest,
EnvUpdateRequest,
)
from app.schemas.file import FileInspectRequest
__all__ = [ __all__ = [
# Common # Common
@ -42,4 +47,9 @@ __all__ = [
"AgentRegisterResponse", "AgentRegisterResponse",
"AgentHeartbeatResponse", "AgentHeartbeatResponse",
"AgentResponse", "AgentResponse",
# Env Management
"EnvInspectRequest",
"EnvUpdateRequest",
# File Management
"FileInspectRequest",
] ]

32
app/schemas/env.py Normal file
View File

@ -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"
)

20
app/schemas/file.py Normal file
View 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)",
)

View File

@ -31,6 +31,7 @@ services:
volumes: volumes:
- ./app:/app/app - ./app:/app/app
- ./alembic:/app/alembic - ./alembic:/app/alembic
- ./tests:/app/tests
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes: volumes:

View File

@ -13,3 +13,8 @@ pydantic-settings>=2.1.0
# Utilities # Utilities
python-dotenv>=1.0.0 python-dotenv>=1.0.0
# Testing
pytest>=8.0.0
pytest-asyncio>=0.23.0
aiosqlite>=0.19.0

View File

@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from app.models.base import Base from app.models.base import Base
from app.models.tenant import Tenant from app.models.tenant import Tenant
from app.models.agent import Agent
# Use in-memory SQLite for testing # Use in-memory SQLite for testing
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@ -55,9 +56,24 @@ async def test_tenant(db: AsyncSession) -> Tenant:
tenant = Tenant( tenant = Tenant(
id=uuid.uuid4(), id=uuid.uuid4(),
name="Test Tenant", name="Test Tenant",
slug="test-tenant",
) )
db.add(tenant) db.add(tenant)
await db.commit() await db.commit()
await db.refresh(tenant) await db.refresh(tenant)
return 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

1
tests/routes/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Tests for route modules."""

View File

@ -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

View File

@ -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