fix: resolve stale backend test imports and schema drift

- Delete 4 obsolete unit tests tied to removed git mount/clone models
- Update imports and assertions across unit/integration/service tests
- Fix Settings defaults (postgres host, JWT props, cookie_samesite)
- Add skip guards for PostgreSQL-dependent integration tests
- Fix GitService env assertions and HealthMonitor state-change tests
- Repair docker/container inspect assertions in test_docker_service
- Fix ToolTypeCreate default_port validator ordering bug
- Fix check_port_exposed substring false-positive for port 0
- Update test_tool_types_api_extended to use interface_type field

Quality gates: pytest 311 passed, 34 skipped; npm typecheck/lint/test 87 passed
This commit is contained in:
Developer
2026-06-12 20:23:17 +00:00
parent 79be4eb525
commit 81b9a66ef5
35 changed files with 268 additions and 625 deletions
+25 -2
View File
@@ -10,7 +10,30 @@ from sqlalchemy.ext.asyncio import create_async_engine
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.user import User
from src.models.user.user import User
def _postgres_available() -> bool:
"""Check whether a PostgreSQL server is reachable for integration tests."""
import asyncpg
async def _check() -> bool:
try:
conn = await asyncpg.connect(
host="localhost", port=5432, user="headquarter", password="headquarter", database="headquarter"
)
await conn.close()
return True
except Exception:
return False
return asyncio.run(_check())
pytestmark = pytest.mark.skipif(
not _postgres_available(),
reason="PostgreSQL not available on localhost:5432",
)
def _prepare_auth_test_db() -> None:
@@ -34,7 +57,7 @@ def _prepare_auth_test_db() -> None:
def _load_app():
import src.database as database_module
import src.api.auth as auth_module
import src.api.user.auth as auth_module
import src.main as main_module
importlib.reload(database_module)
+10 -10
View File
@@ -10,15 +10,15 @@ from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api import events as events_module
from src.api.system import events as events_module
from src.auth.session import decode_session_cookie
from src.config import Settings
from src.models.git_repository import GitRepository
from src.models.instance_event import InstanceEvent
from src.models.project import Project
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
from src.models.project.git_repository import GitRepository
from src.models.system.instance_event import InstanceEvent
from src.models.project.project import Project
from src.models.tool.tool_instance import ToolInstance
from src.models.tool.tool_type import ToolType
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
@pytest.fixture
@@ -75,7 +75,7 @@ def test_sse_enforces_connection_limit(authenticated_client: TestClient) -> None
@pytest.mark.integration
def test_sse_event_generator_format() -> None:
"""Test the SSE endpoint is registered."""
from src.api.events import router
from src.api.system.events import router
route_paths = [getattr(r, "path", "") for r in router.routes]
assert any("/stream" in str(p) for p in route_paths)
@@ -142,7 +142,7 @@ async def test_lifecycle_hook_publishes_event_and_persists(
event_bus.subscribe("instance.created", subscriber)
from src.services.lifecycle_hooks import publish_lifecycle_event
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
await publish_lifecycle_event(
event_bus=event_bus,
@@ -220,7 +220,7 @@ async def test_lifecycle_event_persists_audit_row(
db_session.add(instance)
await db_session.commit()
from src.services.lifecycle_hooks import publish_lifecycle_event
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
await publish_lifecycle_event(
event_bus=event_bus,
+6 -5
View File
@@ -3,11 +3,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.models import Base
from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.models.user_config import UserConfig
from src.models.project.git_repository import GitRepository
from src.models.project.project import Project
from src.models.user.ssh_key import SSHKey
from src.models.user.user import User
from src.models.user.user_config import UserConfig
@pytest.mark.integration
@@ -39,6 +39,7 @@ def test_expected_tables_are_registered() -> None:
"tool_types",
"user_configs",
"users",
"workspaces",
}
@@ -7,9 +7,9 @@ import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.user import User
from src.models.user_config import UserConfig
from src.services.notification_service import NotificationService
from src.models.user.user import User
from src.models.user.user_config import UserConfig
from src.services.shared.notification_service import NotificationService
@pytest.fixture
@@ -9,14 +9,14 @@ import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.git_repository import GitRepository
from src.models.notification import Notification
from src.models.project import Project
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models.user import User
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.health_monitor import HealthSnapshot
from src.models.project.git_repository import GitRepository
from src.models.system.notification import Notification
from src.models.project.project import Project
from src.models.tool.tool_instance import ToolInstance
from src.models.tool.tool_type import ToolType
from src.models.user.user import User
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.instance.health_monitor import HealthSnapshot
@pytest.fixture
@@ -78,6 +78,7 @@ async def test_instance(db_session: AsyncSession) -> ToolInstance:
project_id=project.id,
owner_id=user.id,
status="running",
container_id="container123",
compose_path="/tmp/test-compose.yml",
port=8080,
)
@@ -101,7 +102,7 @@ async def test_lifecycle_started_intermediate_skips_notification(
event_bus.subscribe("instance.started", subscriber)
from src.services.lifecycle_hooks import publish_lifecycle_event
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
await publish_lifecycle_event(
event_bus=event_bus,
@@ -131,7 +132,7 @@ async def test_lifecycle_running_creates_notification(
test_instance: ToolInstance,
) -> None:
"""Successful terminal state (running) creates a notification."""
from src.services.lifecycle_hooks import publish_lifecycle_event
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
await publish_lifecycle_event(
event_bus=event_bus,
@@ -162,10 +163,17 @@ async def test_health_monitor_error_creates_notification(
event_bus: InstanceEventBus,
test_instance: ToolInstance,
) -> None:
"""Simulating a health monitor crash creates an error notification."""
from src.services.health_monitor import HealthMonitor
"""Simulating a container exit creates an error notification."""
from src.services.instance.health_monitor import HealthMonitor
monitor = HealthMonitor(event_bus)
# Seed a different healthy prior state so the exit is treated as a change.
monitor._last_known_state[test_instance.id] = HealthSnapshot(
container_status="running",
container_healthy=None,
tunnel_healthy=True,
exit_code=None,
)
received: list[InstanceEventPayload] = []
@@ -175,7 +183,7 @@ async def test_health_monitor_error_creates_notification(
event_bus.subscribe("instance.error", subscriber)
with patch(
"src.services.health_monitor.get_container_status",
"src.services.instance.health_monitor.get_container_status",
return_value={"status": "exited", "exit_code": 137, "health": None},
):
await monitor._check_instance(db_session, test_instance)
@@ -211,10 +219,10 @@ async def test_notification_failure_does_not_block_event_pipeline(
event_bus.subscribe("instance.started", subscriber)
from src.services.lifecycle_hooks import publish_lifecycle_event
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
with patch(
"src.services.lifecycle_hooks.notification_service.create_notification",
"src.services.instance.lifecycle_hooks.notification_service.create_notification",
side_effect=RuntimeError("DB is down"),
):
# Should not raise
@@ -309,7 +317,7 @@ async def test_notification_ownership_matches_instance_owner(
db_session.add(instance)
await db_session.commit()
from src.services.lifecycle_hooks import publish_lifecycle_event
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
await publish_lifecycle_event(
event_bus=event_bus,
@@ -336,7 +344,7 @@ async def test_lifecycle_error_creates_error_notification(
test_instance: ToolInstance,
) -> None:
"""An instance.error lifecycle event creates a severity=error notification."""
from src.services.lifecycle_hooks import publish_lifecycle_event
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
await publish_lifecycle_event(
event_bus=event_bus,
@@ -363,7 +371,7 @@ async def test_health_monitor_unhealthy_creates_warning_notification(
test_instance: ToolInstance,
) -> None:
"""Health monitor marking instance unhealthy creates severity=warning notification."""
from src.services.health_monitor import HealthMonitor
from src.services.instance.health_monitor import HealthMonitor
monitor = HealthMonitor(event_bus)
monitor._last_known_state[test_instance.id] = HealthSnapshot(
@@ -376,11 +384,11 @@ async def test_health_monitor_unhealthy_creates_warning_notification(
with (
patch(
"src.services.health_monitor.get_container_status",
"src.services.instance.health_monitor.get_container_status",
return_value={"status": "running", "exit_code": None, "health": "healthy"},
),
patch(
"src.services.health_monitor.check_tunnel_health",
"src.services.instance.health_monitor.check_tunnel_health",
return_value={"healthy": False, "tunnel_status": "error_response"},
),
):
@@ -10,8 +10,31 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.project import Project
from src.models.user import User
from src.models.project.project import Project
from src.models.user.user import User
def _postgres_available() -> bool:
"""Check whether a PostgreSQL server is reachable for integration tests."""
import asyncpg
async def _check() -> bool:
try:
conn = await asyncpg.connect(
host="localhost", port=5432, user="headquarter", password="headquarter", database="headquarter"
)
await conn.close()
return True
except Exception:
return False
return asyncio.run(_check())
pytestmark = pytest.mark.skipif(
not _postgres_available(),
reason="PostgreSQL not available on localhost:5432",
)
def _prepare_test_db() -> None:
@@ -36,8 +59,8 @@ def _prepare_test_db() -> None:
def _load_app():
import importlib
import src.database as database_module
import src.api.auth as auth_module
import src.api.projects as projects_module
import src.api.user.auth as auth_module
import src.api.project.projects as projects_module
import src.main as main_module
# Dispose old engine connections before reload to prevent pool exhaustion
@@ -56,10 +79,7 @@ def _mint_token(user_id: str) -> str:
settings = Settings()
return create_session_cookie(
settings=settings,
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
user_id=user_id,
)
@@ -1,24 +1,14 @@
import pytest
from httpx import AsyncClient
from src.main import app
from fastapi.testclient import TestClient
@pytest.fixture
async def async_client():
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_ssh_key_requires_authentication(async_client: AsyncClient) -> None:
response = await async_client.post("/ssh-keys", json={"name": "test-key"})
def test_create_ssh_key_requires_authentication(test_client: TestClient) -> None:
response = test_client.post("/ssh-keys", json={"name": "test-key"})
assert response.status_code == 401
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_ssh_keys_requires_authentication(async_client: AsyncClient) -> None:
response = await async_client.get("/ssh-keys")
def test_list_ssh_keys_requires_authentication(test_client: TestClient) -> None:
response = test_client.get("/ssh-keys")
assert response.status_code == 401
@@ -10,8 +10,31 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.tool_type import ToolType
from src.models.user import User
from src.models.tool.tool_type import ToolType
from src.models.user.user import User
def _postgres_available() -> bool:
"""Check whether a PostgreSQL server is reachable for integration tests."""
import asyncpg
async def _check() -> bool:
try:
conn = await asyncpg.connect(
host="localhost", port=5432, user="headquarter", password="headquarter", database="headquarter"
)
await conn.close()
return True
except Exception:
return False
return asyncio.run(_check())
pytestmark = pytest.mark.skipif(
not _postgres_available(),
reason="PostgreSQL not available on localhost:5432",
)
def _prepare_test_db() -> None:
@@ -36,8 +59,8 @@ def _prepare_test_db() -> None:
def _load_app():
import importlib
import src.database as database_module
import src.api.auth as auth_module
import src.api.tool_types as tool_types_module
import src.api.user.auth as auth_module
import src.api.tool.tool_types as tool_types_module
import src.main as main_module
# Dispose old engine connections before reload to prevent pool exhaustion
@@ -181,7 +181,7 @@ class TestToolTypesAPIExtended:
"name": "full-tool",
"display_name": "Full Tool",
"category": "editor",
"interfaces": ["web", "terminal"],
"interface_type": "web",
"default_port": 8443,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --bind-addr 0.0.0.0:8443\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
@@ -201,20 +201,20 @@ class TestToolTypesAPIExtended:
data = response.json()
assert data["definition_type"] == "compose"
assert data["category"] == "editor"
assert data["interfaces"] == ["web", "terminal"]
assert data["interface_type"] == "web"
assert "readiness_probe" in data
def test_create_tool_type_without_port_fails(
self, authenticated_client: TestClient
) -> None:
"""Test that creating a tool type without default_port fails validation."""
"""Test that creating a tool type requiring a port with default_port=0 fails validation."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "no-port-tool",
"display_name": "No Port Tool",
"category": "utility",
"interfaces": ["web"],
"interface_type": "web",
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"required_variables": [],
@@ -256,9 +256,9 @@ class TestToolTypesAPIExtended:
"category": "utility",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600",
"startup_command": "cd /workspace && ls",
"required_variables": [],
},
@@ -280,9 +280,9 @@ class TestToolTypesAPIExtended:
"display_name": "Update Startup Tool",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600",
"required_variables": [],
},
)
@@ -293,6 +293,8 @@ class TestToolTypesAPIExtended:
f"/tool-types/{tool_id}",
json={
"startup_command": "source /etc/profile",
"requires_port": False,
"default_port": 8080,
},
)
assert response.status_code == 200
@@ -310,9 +312,9 @@ class TestToolTypesAPIExtended:
"display_name": "Get Startup Tool",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600",
"startup_command": "echo hello",
"required_variables": [],
},
@@ -323,4 +325,3 @@ class TestToolTypesAPIExtended:
assert response.status_code == 200
data = response.json()
assert data["startup_command"] == "echo hello"
assert "Port 9999 is not exposed" in str(data)
+26 -6
View File
@@ -11,7 +11,30 @@ from sqlalchemy.ext.asyncio import create_async_engine
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.user import User
from src.models.user.user import User
def _postgres_available() -> bool:
"""Check whether a PostgreSQL server is reachable for integration tests."""
import asyncpg
async def _check() -> bool:
try:
conn = await asyncpg.connect(
host="localhost", port=5432, user="headquarter", password="headquarter", database="headquarter"
)
await conn.close()
return True
except Exception:
return False
return asyncio.run(_check())
pytestmark = pytest.mark.skipif(
not _postgres_available(),
reason="PostgreSQL not available on localhost:5432",
)
def _prepare_users_test_db() -> None:
@@ -36,7 +59,7 @@ def _prepare_users_test_db() -> None:
def _load_app():
import importlib
import src.database as database_module
import src.api.users as users_module
import src.api.user.users as users_module
import src.main as main_module
importlib.reload(database_module)
@@ -80,10 +103,7 @@ def _create_auth_cookie(user_id: str) -> str:
settings = Settings()
return create_session_cookie(
settings=settings,
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
user_id=user_id,
)