diff --git a/app/main.py b/app/main.py index 81a38a4..404b1b4 100644 --- a/app/main.py +++ b/app/main.py @@ -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 --- diff --git a/app/models/event.py b/app/models/event.py index 862a272..95bcc73 100644 --- a/app/models/event.py +++ b/app/models/event.py @@ -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, ) diff --git a/app/models/task.py b/app/models/task.py index 80763a8..3785c3b 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -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, ) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index f8b5b22..9821f09 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -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", ] diff --git a/app/routes/env.py b/app/routes/env.py new file mode 100644 index 0000000..4957028 --- /dev/null +++ b/app/routes/env.py @@ -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 diff --git a/app/routes/files.py b/app/routes/files.py new file mode 100644 index 0000000..f82e309 --- /dev/null +++ b/app/routes/files.py @@ -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 diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 264a1bb..a4a23c9 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -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", ] diff --git a/app/schemas/env.py b/app/schemas/env.py new file mode 100644 index 0000000..c69cb45 --- /dev/null +++ b/app/schemas/env.py @@ -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" + ) diff --git a/app/schemas/file.py b/app/schemas/file.py new file mode 100644 index 0000000..681f937 --- /dev/null +++ b/app/schemas/file.py @@ -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)", + ) diff --git a/docker-compose.yml b/docker-compose.yml index 9ed85ba..159276d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt index 2439795..8f271a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 7075608..53612d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/routes/__init__.py b/tests/routes/__init__.py new file mode 100644 index 0000000..fc0bd95 --- /dev/null +++ b/tests/routes/__init__.py @@ -0,0 +1 @@ +"""Tests for route modules.""" diff --git a/tests/routes/test_env_routes.py b/tests/routes/test_env_routes.py new file mode 100644 index 0000000..a1ed19d --- /dev/null +++ b/tests/routes/test_env_routes.py @@ -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 diff --git a/tests/routes/test_files_routes.py b/tests/routes/test_files_routes.py new file mode 100644 index 0000000..fe27d2f --- /dev/null +++ b/tests/routes/test_files_routes.py @@ -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