"""Tests for the tool manifest Pydantic models.""" from __future__ import annotations import pytest from pydantic import ValidationError from app.tools.models import ( ExecutableConfig, HealthCheckConfig, MountConfig, PortConfig, ResourceLimits, SecretRef, ToolManifest, TraefikConfig, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _minimal_manifest(**overrides: object) -> ToolManifest: defaults: dict[str, object] = { "id": "test-tool", "name": "Test Tool", "image": "test:latest", "ports": [PortConfig(container_port=8080, primary=True)], "traefik": TraefikConfig(enabled=False), } defaults.update(overrides) return ToolManifest.model_validate(defaults) # --------------------------------------------------------------------------- # Valid construction # --------------------------------------------------------------------------- def test_valid_runfusion_shape() -> None: manifest = ToolManifest( id="runfusion", name="RunFusion", description="Executable Node.js environment.", image="node:22-slim", runtime_working_dir="/workspace", ports=[PortConfig(container_port=8080, name="http", primary=True)], workspace_mounts=[ MountConfig(source_pattern="{project_repo}", target="/workspace") ], config_mounts=[ MountConfig( source_pattern="{user_config}/runfusion", target="/home/node/.config" ) ], env={"NODE_ENV": "development"}, health_check=HealthCheckConfig( type="http", path="/", port=8080, start_period_seconds=10 ), resource_limits=ResourceLimits(cpus=2.0, memory_mb=2048), executable=ExecutableConfig(node_version="22", package_manager="npm"), traefik=TraefikConfig( enabled=True, subdomain_prefix="runfusion", port=8080 ), ) assert manifest.id == "runfusion" assert manifest.ports[0].primary is True assert manifest.traefik is not None assert manifest.traefik.enabled is True def test_valid_code_server_shape() -> None: manifest = ToolManifest( id="code-server", name="code-server", description="VS Code in the browser.", image="codercom/code-server:latest", runtime_working_dir="/workspace", ports=[PortConfig(container_port=8080, name="http", primary=True)], workspace_mounts=[ MountConfig(source_pattern="{project_repo}", target="/workspace") ], config_mounts=[ MountConfig( source_pattern="{user_config}/code-server", target="/home/coder/.config/code-server", ) ], health_check=HealthCheckConfig(type="http", path="/healthz", port=8080), resource_limits=ResourceLimits(cpus=2.0, memory_mb=4096), traefik=TraefikConfig(enabled=True, subdomain_prefix="code", port=8080), secrets=[ SecretRef(name="code-server-password", env_var="PASSWORD", required=False) ], ) assert manifest.id == "code-server" assert manifest.secrets[0].env_var == "PASSWORD" # --------------------------------------------------------------------------- # Invalid id values # --------------------------------------------------------------------------- def test_invalid_id_uppercase() -> None: with pytest.raises(ValidationError) as exc_info: _minimal_manifest(id="RunFusion") assert "id" in str(exc_info.value) def test_invalid_id_spaces() -> None: with pytest.raises(ValidationError) as exc_info: _minimal_manifest(id="run fusion") assert "id" in str(exc_info.value) def test_invalid_id_empty_string() -> None: with pytest.raises(ValidationError) as exc_info: _minimal_manifest(id="") assert "id" in str(exc_info.value) # --------------------------------------------------------------------------- # Port validation # --------------------------------------------------------------------------- def test_invalid_container_port_zero() -> None: with pytest.raises(ValidationError) as exc_info: PortConfig(container_port=0) assert "container_port" in str(exc_info.value) def test_invalid_container_port_too_high() -> None: with pytest.raises(ValidationError) as exc_info: PortConfig(container_port=70000) assert "container_port" in str(exc_info.value) # --------------------------------------------------------------------------- # Mount target validation # --------------------------------------------------------------------------- def test_mount_target_not_absolute() -> None: with pytest.raises(ValidationError) as exc_info: MountConfig(source_pattern="{project_repo}", target="workspace") assert "absolute" in str(exc_info.value).lower() # --------------------------------------------------------------------------- # HealthCheck validation # --------------------------------------------------------------------------- def test_health_check_http_missing_path() -> None: with pytest.raises(ValidationError) as exc_info: HealthCheckConfig(type="http") assert "path" in str(exc_info.value) def test_health_check_command_missing_command() -> None: with pytest.raises(ValidationError) as exc_info: HealthCheckConfig(type="command") assert "command" in str(exc_info.value) def test_health_check_tcp_allows_missing_path() -> None: hc = HealthCheckConfig(type="tcp") assert hc.type == "tcp" # --------------------------------------------------------------------------- # Traefik + primary port validation # --------------------------------------------------------------------------- def test_missing_primary_port_when_traefik_enabled() -> None: with pytest.raises(ValidationError) as exc_info: ToolManifest( id="bad-tool", name="Bad Tool", image="test:latest", ports=[PortConfig(container_port=8080, primary=False)], traefik=TraefikConfig(enabled=True), ) assert "primary" in str(exc_info.value).lower() def test_traefik_disabled_allows_no_primary_port() -> None: manifest = ToolManifest( id="no-route", name="No Route", image="test:latest", ports=[PortConfig(container_port=8080, primary=False)], traefik=TraefikConfig(enabled=False), ) assert manifest.traefik is not None assert manifest.traefik.enabled is False def test_no_traefik_allows_no_primary_port() -> None: manifest = ToolManifest( id="no-route", name="No Route", image="test:latest", ports=[PortConfig(container_port=8080, primary=False)], ) assert manifest.traefik is None # --------------------------------------------------------------------------- # Resource limits validation # --------------------------------------------------------------------------- def test_resource_limits_cpus_too_low() -> None: with pytest.raises(ValidationError) as exc_info: ResourceLimits(cpus=0.001) assert "cpus" in str(exc_info.value) def test_resource_limits_memory_mb_too_low() -> None: with pytest.raises(ValidationError) as exc_info: ResourceLimits(memory_mb=8) assert "memory_mb" in str(exc_info.value) def test_resource_limits_memory_swap_negative_one_ok() -> None: rl = ResourceLimits(memory_swap_mb=-1) assert rl.memory_swap_mb == -1 # --------------------------------------------------------------------------- # Serialization round-trip # --------------------------------------------------------------------------- def test_serialization_roundtrip() -> None: original = _minimal_manifest( id="roundtrip", name="Roundtrip Tool", ports=[PortConfig(container_port=3000, name="http", primary=True)], traefik=TraefikConfig(enabled=True, subdomain_prefix="rt"), ) dumped = original.model_dump(mode="json") restored = ToolManifest.model_validate(dumped) assert restored.id == original.id assert restored.ports[0].container_port == original.ports[0].container_port assert restored.traefik is not None assert restored.traefik.subdomain_prefix == "rt"