test: add comprehensive tests for tool workshop functionality

Backend tests:
- Unit tests for docker_build service (successful/failed builds, context, paths)
- Unit tests for readiness_probe service (success, timeout, retries, edge cases)
- Integration tests for config_folders API (CRUD + project overrides)
- Integration tests for tool_types API with new fields
- Integration tests for tool_configs API with new fields

Frontend tests:
- ToolWorkshopPage component tests (all 3 tabs, create/edit/delete)
- API client tests for tool_types and config_folders

Fixes:
- Add field_validator import to tool_configs.py
- Add JSON import to tool_config model
- Update frontend test button names to match UI (Create Tool Type, Add Config, Create Folder)

Quality gates: backend unit tests passing (23/23)
This commit is contained in:
Fusion
2026-05-22 19:45:10 +02:00
parent 8dd350286e
commit dacf105200
13 changed files with 2621 additions and 5 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import logging
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
+1 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text from sqlalchemy import ForeignKey, JSON, String, Text
from sqlalchemy import Uuid as UUID from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -0,0 +1,546 @@
import uuid
from datetime import UTC, datetime, timedelta
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
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.config_folder import ConfigFolder
from src.models.user import User
def _prepare_test_db() -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(text("TRUNCATE TABLE config_folders, users RESTART IDENTITY CASCADE"))
await engine.dispose()
asyncio.run(_run())
def _load_app():
import importlib
import src.database as database_module
import src.api.auth as auth_module
import src.api.config_folders as config_folders_module
import src.main as main_module
if hasattr(database_module, 'engine'):
import asyncio
asyncio.run(database_module.engine.dispose())
importlib.reload(database_module)
importlib.reload(auth_module)
importlib.reload(config_folders_module)
importlib.reload(main_module)
return main_module.app
def _mint_token(user_id: str) -> str:
settings = Settings()
return create_session_cookie(
settings=settings,
user_id=user_id,
)
def _insert_user(user_id: str, email: str = "test@headquarter.local") -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
user = User(
id=uuid.UUID(user_id),
email=email,
name="Test User",
authentik_id=f"authentik-{user_id}",
avatar_url=None,
)
await session.merge(user)
await session.commit()
await engine.dispose()
asyncio.run(_run())
def _insert_config_folder(
folder_id: str,
user_id: str,
name: str,
mount_path: str = "/home/user",
files: dict | None = None,
is_active: bool = True,
) -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
folder = ConfigFolder(
id=uuid.UUID(folder_id),
user_id=uuid.UUID(user_id),
name=name,
description="Test config folder",
mount_path=mount_path,
files=files or {"test.txt": "hello world"},
is_active=is_active,
)
await session.merge(folder)
await session.commit()
await engine.dispose()
asyncio.run(_run())
@pytest.mark.integration
def test_list_config_folders_requires_authentication() -> None:
_prepare_test_db()
app = _load_app()
client = TestClient(app)
response = client.get("/config-folders")
assert response.status_code == 401
@pytest.mark.integration
def test_list_config_folders_returns_user_folders() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
_insert_config_folder(
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
user_id,
"my-dotfiles",
files={".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\""},
)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
response = client.get("/config-folders")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "my-dotfiles"
assert data[0]["files"] == {".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\""}
assert data[0]["is_active"] == True
@pytest.mark.integration
def test_list_config_folders_only_returns_own_folders() -> None:
_prepare_test_db()
user1_id = "11111111-1111-1111-1111-111111111111"
user2_id = "22222222-2222-2222-2222-222222222222"
_insert_user(user1_id)
_insert_user(user2_id)
_insert_config_folder(
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
user1_id,
"user1-folder",
)
_insert_config_folder(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
user2_id,
"user2-folder",
)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user1_id))
response = client.get("/config-folders")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "user1-folder"
@pytest.mark.integration
def test_create_config_folder_successfully() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"name": "my-dotfiles",
"description": "My personal configuration files",
"mount_path": "/home/user",
"files": {
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"",
".gitconfig": "[user]\\nname = Test User",
},
}
response = client.post("/config-folders", json=payload)
assert response.status_code == 201
data = response.json()
assert data["name"] == "my-dotfiles"
assert data["description"] == "My personal configuration files"
assert data["mount_path"] == "/home/user"
assert data["files"] == {
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"",
".gitconfig": "[user]\\nname = Test User",
}
assert data["is_active"] == True
assert "id" in data
@pytest.mark.integration
def test_create_config_folder_duplicate_name() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
_insert_config_folder(
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
user_id,
"existing-folder",
)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"name": "existing-folder",
"mount_path": "/home/user",
"files": {},
}
response = client.post("/config-folders", json=payload)
assert response.status_code == 409
@pytest.mark.integration
def test_create_config_folder_exceeds_size_limit() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
# Create files that total > 10MB
large_content = "x" * (11 * 1024 * 1024) # 11MB
payload = {
"name": "too-large",
"mount_path": "/home/user",
"files": {
"large.txt": large_content,
},
}
response = client.post("/config-folders", json=payload)
assert response.status_code == 422
@pytest.mark.integration
def test_create_config_folder_path_traversal_attack() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"name": "attack",
"mount_path": "/home/user",
"files": {
"../../../etc/passwd": "root:x:0:0",
},
}
response = client.post("/config-folders", json=payload)
assert response.status_code == 422
@pytest.mark.integration
def test_get_config_folder_by_id() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_config_folder(
folder_id,
user_id,
"my-dotfiles",
files={".zshrc": "test content"},
)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
response = client.get(f"/config-folders/{folder_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == folder_id
assert data["name"] == "my-dotfiles"
assert data["files"] == {".zshrc": "test content"}
@pytest.mark.integration
def test_get_config_folder_not_found() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
response = client.get("/config-folders/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
assert response.status_code == 404
@pytest.mark.integration
def test_get_config_folder_forbidden() -> None:
_prepare_test_db()
user1_id = "11111111-1111-1111-1111-111111111111"
user2_id = "22222222-2222-2222-2222-222222222222"
folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user1_id)
_insert_user(user2_id)
_insert_config_folder(folder_id, user1_id, "private-folder")
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user2_id))
response = client.get(f"/config-folders/{folder_id}")
assert response.status_code == 403
@pytest.mark.integration
def test_update_config_folder_successfully() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_config_folder(
folder_id,
user_id,
"old-name",
mount_path="/old/path",
files={".zshrc": "old content"},
)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"name": "new-name",
"mount_path": "/new/path",
"files": {".zshrc": "new content"},
"is_active": False,
}
response = client.put(f"/config-folders/{folder_id}", json=payload)
assert response.status_code == 200
data = response.json()
assert data["name"] == "new-name"
assert data["mount_path"] == "/new/path"
assert data["files"] == {".zshrc": "new content"}
assert data["is_active"] == False
@pytest.mark.integration
def test_update_config_folder_not_found() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {"name": "new-name", "mount_path": "/new/path", "files": {}}
response = client.put("/config-folders/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", json=payload)
assert response.status_code == 404
@pytest.mark.integration
def test_delete_config_folder_successfully() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_config_folder(folder_id, user_id, "deletable-folder")
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
response = client.delete(f"/config-folders/{folder_id}")
assert response.status_code == 204
# Verify it's gone
get_response = client.get(f"/config-folders/{folder_id}")
assert get_response.status_code == 404
@pytest.mark.integration
def test_add_project_override_successfully() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
project_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
_insert_user(user_id)
_insert_config_folder(
folder_id,
user_id,
"my-dotfiles",
files={".zshrc": "global content"},
)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"project_id": project_id,
"mount_path": "/workspace",
"files": {".zshrc": "project-specific content"},
}
response = client.post(f"/config-folders/{folder_id}/overrides", json=payload)
assert response.status_code == 201
data = response.json()
assert data["project_overrides"][project_id]["mount_path"] == "/workspace"
assert data["project_overrides"][project_id]["files"] == {".zshrc": "project-specific content"}
@pytest.mark.integration
def test_add_project_override_folder_not_found() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"project_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"mount_path": "/workspace",
"files": {},
}
response = client.post("/config-folders/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/overrides", json=payload)
assert response.status_code == 404
@pytest.mark.integration
def test_update_project_override_successfully() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
project_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
_insert_user(user_id)
_insert_config_folder(
folder_id,
user_id,
"my-dotfiles",
files={".zshrc": "global"},
)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
# First add an override
client.post(
f"/config-folders/{folder_id}/overrides",
json={"project_id": project_id, "mount_path": "/old", "files": {".zshrc": "old"}},
)
# Then update it
payload = {
"mount_path": "/new",
"files": {".zshrc": "new"},
}
response = client.put(f"/config-folders/{folder_id}/overrides/{project_id}", json=payload)
assert response.status_code == 200
data = response.json()
assert data["project_overrides"][project_id]["mount_path"] == "/new"
assert data["project_overrides"][project_id]["files"] == {".zshrc": "new"}
@pytest.mark.integration
def test_delete_project_override_successfully() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
project_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
_insert_user(user_id)
_insert_config_folder(folder_id, user_id, "my-dotfiles")
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
# Add an override first
client.post(
f"/config-folders/{folder_id}/overrides",
json={"project_id": project_id, "mount_path": "/workspace", "files": {}},
)
# Delete it
response = client.delete(f"/config-folders/{folder_id}/overrides/{project_id}")
assert response.status_code == 204
# Verify it's gone
get_response = client.get(f"/config-folders/{folder_id}")
data = get_response.json()
assert project_id not in data.get("project_overrides", {})
@@ -6,7 +6,7 @@ import pytest
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.auth.jwt_service import mint_access_token from src.auth.session import mint_access_token
from src.config import Settings, build_database_url from src.config import Settings, build_database_url
from src.models import Base from src.models import Base
from src.models.project import Project from src.models.project import Project
@@ -0,0 +1,381 @@
import uuid
from datetime import UTC, datetime, timedelta
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
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_config import ToolConfig
from src.models.tool_type import ToolType
from src.models.user import User
def _prepare_test_db() -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(text("TRUNCATE TABLE tool_configs, tool_types, users RESTART IDENTITY CASCADE"))
await engine.dispose()
asyncio.run(_run())
def _load_app():
import importlib
import src.database as database_module
import src.api.auth as auth_module
import src.api.tool_configs as tool_configs_module
import src.main as main_module
if hasattr(database_module, 'engine'):
import asyncio
asyncio.run(database_module.engine.dispose())
importlib.reload(database_module)
importlib.reload(auth_module)
importlib.reload(tool_configs_module)
importlib.reload(main_module)
return main_module.app
def _mint_token(user_id: str) -> str:
settings = Settings()
return create_session_cookie(
settings=settings,
user_id=user_id,
)
def _insert_user(user_id: str, email: str = "test@headquarter.local") -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
user = User(
id=uuid.UUID(user_id),
email=email,
name="Test User",
authentik_id=f"authentik-{user_id}",
avatar_url=None,
)
await session.merge(user)
await session.commit()
await engine.dispose()
asyncio.run(_run())
def _insert_tool_type(
tool_type_id: str,
name: str,
display_name: str,
created_by_id: str | None = None,
) -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
tool_type = ToolType(
id=uuid.UUID(tool_type_id),
name=name,
display_name=display_name,
description="A test tool type",
compose_template="version: '3.8'\\nservices:\\n app:\\n image: test",
required_variables=["REPO_PATH"],
is_builtin=False,
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
)
await session.merge(tool_type)
await session.commit()
await engine.dispose()
asyncio.run(_run())
def _insert_tool_config(
config_id: str,
user_id: str,
tool_type_id: str,
key: str,
value: str,
config_type: str = "env",
**kwargs,
) -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
config = ToolConfig(
id=uuid.UUID(config_id),
user_id=uuid.UUID(user_id),
tool_type_id=uuid.UUID(tool_type_id),
key=key,
value=value,
config_type=config_type,
**kwargs,
)
await session.merge(config)
await session.commit()
await engine.dispose()
asyncio.run(_run())
@pytest.mark.integration
def test_create_tool_config_with_new_fields() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"tool_type_id": tool_type_id,
"key": "advanced-config",
"value": "test-value",
"config_type": "env",
"port_override": 9090,
"start_command": "python app.py --port 9090",
"working_directory": "/app/src",
"environment_variables": {"DEBUG": "true", "LOG_LEVEL": "debug"},
"volumes": [
{"source": "dotfiles", "target": "/home/user/.config", "type": "config_folder"},
],
}
response = client.post("/tool-configs", json=payload)
assert response.status_code == 201
data = response.json()
assert data["key"] == "advanced-config"
assert data["port_override"] == 9090
assert data["start_command"] == "python app.py --port 9090"
assert data["working_directory"] == "/app/src"
assert data["environment_variables"] == {"DEBUG": "true", "LOG_LEVEL": "debug"}
assert len(data["volumes"]) == 1
assert data["volumes"][0]["source"] == "dotfiles"
@pytest.mark.integration
def test_create_tool_config_invalid_port() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"tool_type_id": tool_type_id,
"key": "bad-config",
"value": "test",
"port_override": 99999, # Invalid port
}
response = client.post("/tool-configs", json=payload)
assert response.status_code == 422
@pytest.mark.integration
def test_create_tool_config_invalid_volume_structure() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"tool_type_id": tool_type_id,
"key": "bad-config",
"value": "test",
"volumes": [
{"invalid_key": "value"}, # Missing required fields
],
}
response = client.post("/tool-configs", json=payload)
assert response.status_code == 422
@pytest.mark.integration
def test_update_tool_config_with_new_fields() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
config_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
_insert_user(user_id)
_insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id)
_insert_tool_config(config_id, user_id, tool_type_id, "my-config", "old-value")
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"value": "new-value",
"port_override": 8080,
"start_command": "npm start",
"working_directory": "/app",
"environment_variables": {"NODE_ENV": "production"},
"volumes": [
{"source": "config", "target": "/app/config", "type": "bind"},
],
}
response = client.put(f"/tool-configs/{config_id}", json=payload)
assert response.status_code == 200
data = response.json()
assert data["value"] == "new-value"
assert data["port_override"] == 8080
assert data["start_command"] == "npm start"
assert data["working_directory"] == "/app"
assert data["environment_variables"] == {"NODE_ENV": "production"}
assert len(data["volumes"]) == 1
@pytest.mark.integration
def test_list_tool_configs_returns_new_fields() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
config_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
_insert_user(user_id)
_insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id)
_insert_tool_config(
config_id,
user_id,
tool_type_id,
"advanced-config",
"test-value",
port_override=9090,
start_command="python app.py",
working_directory="/app",
environment_variables={"DEBUG": "true"},
volumes=[{"source": "data", "target": "/data", "type": "bind"}],
)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
response = client.get("/tool-configs")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
config = data[0]
assert config["port_override"] == 9090
assert config["start_command"] == "python app.py"
assert config["working_directory"] == "/app"
assert config["environment_variables"] == {"DEBUG": "true"}
assert len(config["volumes"]) == 1
@pytest.mark.integration
def test_get_tool_config_defaults() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(
tool_type_id,
"test-tool",
"Test Tool",
created_by_id=user_id,
)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
response = client.get(f"/tool-configs/defaults/{tool_type_id}")
assert response.status_code == 200
data = response.json()
assert "tool_type_id" in data
assert data["tool_type_id"] == tool_type_id
assert "suggested_configs" in data
assert "port_override" in data
@pytest.mark.integration
def test_tool_config_backward_compatibility() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
# Create config without new fields (old API usage)
payload = {
"tool_type_id": tool_type_id,
"key": "simple-config",
"value": "simple-value",
"config_type": "env",
}
response = client.post("/tool-configs", json=payload)
assert response.status_code == 201
data = response.json()
assert data["key"] == "simple-config"
# New fields should have default values
assert data["port_override"] is None
assert data["start_command"] is None
assert data["working_directory"] is None
assert data["environment_variables"] == {}
assert data["volumes"] == []
@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.auth.jwt_service import mint_access_token from src.auth.session import mint_access_token
from src.config import Settings, build_database_url from src.config import Settings, build_database_url
from src.models import Base from src.models import Base
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
@@ -0,0 +1,415 @@
import uuid
from datetime import UTC, datetime, timedelta
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
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
def _prepare_test_db() -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(text("TRUNCATE TABLE tool_types, users RESTART IDENTITY CASCADE"))
await engine.dispose()
asyncio.run(_run())
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.main as main_module
if hasattr(database_module, 'engine'):
import asyncio
asyncio.run(database_module.engine.dispose())
importlib.reload(database_module)
importlib.reload(auth_module)
importlib.reload(tool_types_module)
importlib.reload(main_module)
return main_module.app
def _mint_token(user_id: str) -> str:
settings = Settings()
return create_session_cookie(
settings=settings,
user_id=user_id,
)
def _insert_user(user_id: str, email: str = "test@headquarter.local") -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
user = User(
id=uuid.UUID(user_id),
email=email,
name="Test User",
authentik_id=f"authentik-{user_id}",
avatar_url=None,
)
await session.merge(user)
await session.commit()
await engine.dispose()
asyncio.run(_run())
def _insert_tool_type(
tool_type_id: str,
name: str,
display_name: str,
compose_template: str | None = None,
dockerfile_template: str | None = None,
definition_type: str = "compose",
readiness_probe: dict | None = None,
is_builtin: bool = False,
created_by_id: str | None = None,
) -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
tool_type = ToolType(
id=uuid.UUID(tool_type_id),
name=name,
display_name=display_name,
description="A test tool type",
compose_template=compose_template,
dockerfile_template=dockerfile_template,
definition_type=definition_type,
readiness_probe=readiness_probe,
required_variables=["REPO_PATH"],
is_builtin=is_builtin,
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
)
await session.merge(tool_type)
await session.commit()
await engine.dispose()
asyncio.run(_run())
@pytest.mark.integration
def test_create_tool_type_with_dockerfile() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"name": "custom-docker-tool",
"display_name": "Custom Docker Tool",
"description": "A custom tool with Dockerfile",
"definition_type": "dockerfile",
"dockerfile_template": "FROM python:3.11\\nRUN pip install flask\\nCMD ['python', 'app.py']",
"default_port": 5000,
"required_variables": ["REPO_PATH"],
}
response = client.post("/tool-types", json=payload)
assert response.status_code == 201
data = response.json()
assert data["name"] == "custom-docker-tool"
assert data["definition_type"] == "dockerfile"
assert data["dockerfile_template"] == "FROM python:3.11\\nRUN pip install flask\\nCMD ['python', 'app.py']"
assert data["compose_template"] is None
@pytest.mark.integration
def test_create_tool_type_with_readiness_probe() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"name": "probed-tool",
"display_name": "Probed Tool",
"compose_template": "version: '3.8'\\nservices:\\n app:\\n image: nginx",
"default_port": 8080,
"required_variables": [],
"readiness_probe": {
"command": "curl -f http://localhost:8080/health",
"timeout": 60,
"interval": 3,
},
}
response = client.post("/tool-types", json=payload)
assert response.status_code == 201
data = response.json()
assert data["readiness_probe"] == {
"command": "curl -f http://localhost:8080/health",
"timeout": 60,
"interval": 3,
}
@pytest.mark.integration
def test_create_tool_type_invalid_definition_type() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"name": "bad-tool",
"display_name": "Bad Tool",
"definition_type": "invalid",
"compose_template": "version: '3.8'\\nservices:\\n app:\\n image: nginx",
"required_variables": [],
}
response = client.post("/tool-types", json=payload)
assert response.status_code == 422
@pytest.mark.integration
def test_create_tool_type_dockerfile_without_template() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"name": "bad-docker-tool",
"display_name": "Bad Docker Tool",
"definition_type": "dockerfile",
"dockerfile_template": "",
"required_variables": [],
}
response = client.post("/tool-types", json=payload)
assert response.status_code == 422
@pytest.mark.integration
def test_update_tool_type_with_new_fields() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(
tool_type_id,
"custom-tool",
"Custom Tool",
compose_template="version: '3.8'\\nservices:\\n app:\\n image: old",
created_by_id=user_id,
)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"definition_type": "dockerfile",
"dockerfile_template": "FROM python:3.11",
"readiness_probe": {
"command": "python --version",
"timeout": 30,
"interval": 2,
},
}
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
assert response.status_code == 200
data = response.json()
assert data["definition_type"] == "dockerfile"
assert data["dockerfile_template"] == "FROM python:3.11"
assert data["readiness_probe"]["command"] == "python --version"
@pytest.mark.integration
def test_validate_tool_type_compose() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"definition_type": "compose",
"compose_template": "version: '3.8'\\nservices:\\n app:\\n image: nginx",
}
response = client.post("/tool-types/validate", json=payload)
assert response.status_code == 200
data = response.json()
assert data["valid"] == True
assert "errors" not in data or len(data["errors"]) == 0
@pytest.mark.integration
def test_validate_tool_type_invalid_compose() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"definition_type": "compose",
"compose_template": "this is not: valid: yaml: [",
}
response = client.post("/tool-types/validate", json=payload)
assert response.status_code == 200
data = response.json()
assert data["valid"] == False
assert len(data["errors"]) > 0
@pytest.mark.integration
def test_validate_tool_type_dockerfile() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"definition_type": "dockerfile",
"dockerfile_template": "FROM python:3.11\\nRUN pip install flask",
}
response = client.post("/tool-types/validate", json=payload)
assert response.status_code == 200
data = response.json()
assert data["valid"] == True
@pytest.mark.integration
def test_validate_tool_type_empty_dockerfile() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
payload = {
"definition_type": "dockerfile",
"dockerfile_template": "",
}
response = client.post("/tool-types/validate", json=payload)
assert response.status_code == 200
data = response.json()
assert data["valid"] == False
@pytest.mark.integration
def test_get_tool_type_returns_new_fields() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(
tool_type_id,
"probed-tool",
"Probed Tool",
definition_type="dockerfile",
dockerfile_template="FROM python:3.11",
readiness_probe={"command": "python --version", "timeout": 30, "interval": 2},
created_by_id=user_id,
)
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
response = client.get(f"/tool-types/{tool_type_id}")
assert response.status_code == 200
data = response.json()
assert data["definition_type"] == "dockerfile"
assert data["dockerfile_template"] == "FROM python:3.11"
assert data["readiness_probe"]["command"] == "python --version"
assert data["readiness_probe"]["timeout"] == 30
@pytest.mark.integration
def test_builtin_tool_types_have_definition_type_compose() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
# Load app triggers startup event which seeds built-in types
app = _load_app()
client = TestClient(app)
client.cookies.set("session", _mint_token(user_id))
response = client.get("/tool-types")
assert response.status_code == 200
data = response.json()
builtin_types = [t for t in data if t["is_builtin"]]
assert len(builtin_types) > 0
for tool_type in builtin_types:
assert tool_type["definition_type"] == "compose"
assert tool_type["compose_template"] is not None
assert tool_type["dockerfile_template"] is None
+1 -1
View File
@@ -8,7 +8,7 @@ import pytest
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from src.auth.jwt_service import mint_access_token from src.auth.session import mint_access_token
from src.config import Settings, build_database_url from src.config import Settings, build_database_url
from src.models import Base from src.models import Base
from src.models.user import User from src.models.user import User
+151
View File
@@ -0,0 +1,151 @@
"""Unit tests for docker build service."""
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from src.services.docker_build import build_image
class TestBuildImage:
"""Tests for build_image function."""
@patch("subprocess.run")
def test_builds_image_successfully(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="Successfully built abc123",
stderr="",
)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test-image:latest")
assert result[0] == 0
assert "Successfully built" in result[1]
mock_run.assert_called_once()
call_args = mock_run.call_args
assert "test-image:latest" in call_args[0][0]
assert "build" in call_args[0][0]
@patch("subprocess.run")
def test_build_fails(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Error: failed to build",
)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM invalid:image", "test-image:latest")
assert result[0] == 1
assert "failed to build" in result[2]
@patch("subprocess.run")
def test_build_with_tag(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="",
stderr="",
)
with tempfile.TemporaryDirectory() as tmpdir:
build_image(tmpdir, "FROM python:3.11", "my-registry/tool:v1.0")
call_args = mock_run.call_args[0][0]
assert "my-registry/tool:v1.0" in call_args
@patch("subprocess.run")
def test_build_command_structure(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_image(tmpdir, "FROM python:3.11", "test:latest")
cmd = mock_run.call_args[0][0]
assert cmd[0] == "docker"
assert cmd[1] == "build"
assert "-t" in cmd
assert "test:latest" in cmd
assert tmpdir in cmd
@patch("subprocess.run")
def test_build_writes_dockerfile(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
dockerfile_content = "FROM python:3.11\\nRUN pip install flask"
build_image(tmpdir, dockerfile_content, "test:latest")
dockerfile_path = Path(tmpdir) / "Dockerfile"
assert dockerfile_path.exists()
assert dockerfile_path.read_text() == dockerfile_content
@patch("subprocess.run")
def test_build_writes_context_files(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"requirements.txt": "flask==2.0\\nnumpy==1.21",
"app.py": "from flask import Flask\\napp = Flask(__name__)",
}
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
req_path = Path(tmpdir) / "requirements.txt"
app_path = Path(tmpdir) / "app.py"
assert req_path.exists()
assert req_path.read_text() == "flask==2.0\\nnumpy==1.21"
assert app_path.exists()
assert app_path.read_text() == "from flask import Flask\\napp = Flask(__name__)"
@patch("subprocess.run")
def test_build_creates_nested_directories(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"src/app.py": "print('hello')",
}
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
app_path = Path(tmpdir) / "src" / "app.py"
assert app_path.exists()
@patch("subprocess.run")
def test_build_prevents_path_traversal(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"../../../etc/passwd": "root:x:0:0",
}
with pytest.raises(ValueError, match="escapes instance directory"):
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
mock_run.assert_not_called()
@patch("subprocess.run")
def test_build_timeout(self, mock_run) -> None:
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["docker", "build"], timeout=300)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
assert result[0] == 1
assert "timed out" in result[2].lower()
@patch("subprocess.run")
def test_build_exception(self, mock_run) -> None:
mock_run.side_effect = OSError("Docker not available")
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
assert result[0] == 1
assert "Docker not available" in result[2]
+217
View File
@@ -0,0 +1,217 @@
"""Unit tests for readiness probe service."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from src.services.readiness_probe import execute_probe
class TestExecuteProbe:
"""Tests for execute_probe function."""
@patch("subprocess.run")
async def test_probe_succeeds_first_attempt(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="healthy",
stderr="",
)
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080")
assert result is True
assert any("Success" in log for log in logs)
mock_run.assert_called_once_with(
["docker", "exec", "container-123", "sh", "-c", "curl -f http://localhost:8080"],
capture_output=True,
text=True,
timeout=2,
)
@patch("subprocess.run")
async def test_probe_fails_then_succeeds(self, mock_run) -> None:
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
MagicMock(returncode=0, stdout="healthy", stderr=""),
]
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=10, interval=0.1)
assert result is True
assert mock_run.call_count == 3
assert any("Attempt 1: Failed" in log for log in logs)
assert any("Attempt 3: Success" in log for log in logs)
@patch("subprocess.run")
async def test_probe_times_out(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Connection refused",
)
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=0.5, interval=0.2)
assert result is False
assert any("timed out" in log.lower() for log in logs)
@patch("subprocess.run")
async def test_probe_command_not_found(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=127,
stdout="",
stderr="command not found",
)
result, logs = await execute_probe("container-123", "nonexistent-command", timeout=1, interval=0.3)
assert result is False
assert any("exit code 127" in log for log in logs)
@patch("subprocess.run")
async def test_probe_exception(self, mock_run) -> None:
mock_run.side_effect = OSError("Docker not available")
result, logs = await execute_probe("container-123", "curl http://localhost", timeout=1, interval=0.3)
assert result is False
assert any("Error" in log for log in logs)
@patch("subprocess.run")
async def test_probe_with_special_characters(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="",
stderr="",
)
cmd = "bash -c 'echo \"hello world\" && exit 0'"
await execute_probe("container-123", cmd)
call_args = mock_run.call_args
assert cmd in call_args[0][0]
@patch("subprocess.run")
async def test_probe_captures_stdout(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="Server is ready\\nVersion: 1.0",
stderr="",
)
result, logs = await execute_probe("container-123", "cat /app/status")
assert result is True
assert any("Server is ready" in log for log in logs)
class TestIntegrationScenarios:
"""Integration-style tests with realistic scenarios."""
@patch("subprocess.run")
async def test_web_server_probe(self, mock_run) -> None:
"""Test typical web server health check."""
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=0, stdout="OK", stderr=""),
]
result, logs = await execute_probe(
"web-container",
"curl -f http://localhost:8080/health",
timeout=10,
interval=0.2,
)
assert result is True
assert mock_run.call_count == 4
@patch("subprocess.run")
async def test_command_probe(self, mock_run) -> None:
"""Test command availability check."""
mock_run.return_value = MagicMock(
returncode=0,
stdout="opencode 1.0.0",
stderr="",
)
result, logs = await execute_probe(
"tool-container",
"which opencode && opencode --version",
timeout=30,
interval=2,
)
assert result is True
assert any("opencode 1.0.0" in log for log in logs)
@patch("subprocess.run")
async def test_database_probe(self, mock_run) -> None:
"""Test database readiness check."""
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=0, stdout="/var/run/postgresql:5432 - accepting connections", stderr=""),
]
result, logs = await execute_probe(
"db-container",
"pg_isready -U postgres",
timeout=10,
interval=0.3,
)
assert result is True
assert mock_run.call_count == 3
@patch("subprocess.run")
async def test_file_probe(self, mock_run) -> None:
"""Test file existence check."""
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result, logs = await execute_probe(
"app-container",
"[ -f /app/ready ]",
timeout=10,
interval=1,
)
assert result is True
@patch("subprocess.run")
async def test_slow_starting_service(self, mock_run) -> None:
"""Test service that takes time to start."""
# Simulate 5 failures before success
side_effects = [MagicMock(returncode=1, stdout="", stderr="")] * 5
side_effects.append(MagicMock(returncode=0, stdout="Ready", stderr=""))
mock_run.side_effect = side_effects
result, logs = await execute_probe(
"slow-container",
"curl -f http://localhost:8080",
timeout=10,
interval=0.2,
)
assert result is True
assert mock_run.call_count == 6
assert any("Attempt 6: Success" in log for log in logs)
@patch("subprocess.run")
async def test_zero_timeout_immediate_return(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
result, logs = await execute_probe(
"container",
"test",
timeout=0,
interval=1,
)
assert result is False
assert any("timed out" in log.lower() for log in logs)
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from "vitest";
import axios from "axios";
import {
createConfigFolder,
deleteConfigFolder,
listConfigFolders,
updateConfigFolder,
} from "../api/config_folders";
vi.mock("axios");
const mockedAxios = vi.mocked(axios);
describe("config_folders API", () => {
describe("listConfigFolders", () => {
it("returns folders with files and overrides", async () => {
const mockResponse = {
data: [
{
id: "folder-1",
name: "my-dotfiles",
mount_path: "/home/user",
files: {
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"",
},
project_overrides: {
"proj-1": {
mount_path: "/workspace",
files: { ".zshrc": "different content" },
},
},
is_active: true,
},
],
};
mockedAxios.get.mockResolvedValue(mockResponse);
const result = await listConfigFolders();
expect(result).toHaveLength(1);
expect(result[0].name).toBe("my-dotfiles");
expect(result[0].files).toEqual({
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"",
});
expect(result[0].project_overrides).toEqual({
"proj-1": {
mount_path: "/workspace",
files: { ".zshrc": "different content" },
},
});
});
it("returns empty array when no folders", async () => {
mockedAxios.get.mockResolvedValue({ data: [] });
const result = await listConfigFolders();
expect(result).toEqual([]);
});
});
describe("createConfigFolder", () => {
it("creates folder with files", async () => {
const mockResponse = {
data: {
id: "new-folder",
name: "my-configs",
mount_path: "/home/user",
files: { "test.txt": "hello" },
is_active: true,
},
};
mockedAxios.post.mockResolvedValue(mockResponse);
const result = await createConfigFolder({
name: "my-configs",
mount_path: "/home/user",
files: { "test.txt": "hello" },
});
expect(result.name).toBe("my-configs");
expect(result.files).toEqual({ "test.txt": "hello" });
expect(mockedAxios.post).toHaveBeenCalledWith(
"/config-folders",
expect.objectContaining({
name: "my-configs",
mount_path: "/home/user",
files: { "test.txt": "hello" },
})
);
});
});
describe("updateConfigFolder", () => {
it("updates folder files", async () => {
const mockResponse = {
data: {
id: "folder-1",
name: "updated-name",
files: { "new.txt": "content" },
},
};
mockedAxios.put.mockResolvedValue(mockResponse);
const result = await updateConfigFolder("folder-1", {
name: "updated-name",
files: { "new.txt": "content" },
});
expect(result.name).toBe("updated-name");
expect(mockedAxios.put).toHaveBeenCalledWith(
"/config-folders/folder-1",
expect.objectContaining({
name: "updated-name",
files: { "new.txt": "content" },
})
);
});
it("updates folder activation status", async () => {
const mockResponse = {
data: {
id: "folder-1",
name: "my-configs",
is_active: false,
},
};
mockedAxios.put.mockResolvedValue(mockResponse);
const result = await updateConfigFolder("folder-1", {
is_active: false,
});
expect(result.is_active).toBe(false);
});
});
describe("deleteConfigFolder", () => {
it("deletes folder", async () => {
mockedAxios.delete.mockResolvedValue({ data: undefined });
await deleteConfigFolder("folder-1");
expect(mockedAxios.delete).toHaveBeenCalledWith("/config-folders/folder-1");
});
});
});
+201
View File
@@ -0,0 +1,201 @@
import { describe, expect, it, vi } from "vitest";
import axios from "axios";
import {
createToolType,
deleteToolType,
listToolTypes,
updateToolType,
validateToolType,
} from "../api/tool_types";
vi.mock("axios");
const mockedAxios = vi.mocked(axios);
describe("tool_types API", () => {
describe("listToolTypes", () => {
it("returns tool types with new fields", async () => {
const mockResponse = {
data: [
{
id: "type-1",
name: "custom-tool",
display_name: "Custom Tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
readiness_probe: {
command: "python --version",
timeout: 30,
interval: 2,
},
},
],
};
mockedAxios.get.mockResolvedValue(mockResponse);
const result = await listToolTypes();
expect(result[0].definition_type).toBe("dockerfile");
expect(result[0].dockerfile_template).toBe("FROM python:3.11");
expect(result[0].readiness_probe).toEqual({
command: "python --version",
timeout: 30,
interval: 2,
});
});
it("returns compose tool types", async () => {
const mockResponse = {
data: [
{
id: "type-1",
name: "code-server",
definition_type: "compose",
compose_template: "version: '3.8'",
dockerfile_template: null,
},
],
};
mockedAxios.get.mockResolvedValue(mockResponse);
const result = await listToolTypes();
expect(result[0].definition_type).toBe("compose");
expect(result[0].dockerfile_template).toBeNull();
});
});
describe("createToolType", () => {
it("creates tool type with dockerfile", async () => {
const mockResponse = {
data: {
id: "new-type",
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
},
};
mockedAxios.post.mockResolvedValue(mockResponse);
const result = await createToolType({
name: "docker-tool",
display_name: "Docker Tool",
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
default_port: 3000,
required_variables: [],
});
expect(result.definition_type).toBe("dockerfile");
expect(mockedAxios.post).toHaveBeenCalledWith(
"/tool-types",
expect.objectContaining({
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
})
);
});
it("creates tool type with readiness probe", async () => {
const mockResponse = {
data: {
id: "new-type",
name: "probed-tool",
readiness_probe: {
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
},
},
};
mockedAxios.post.mockResolvedValue(mockResponse);
const result = await createToolType({
name: "probed-tool",
display_name: "Probed Tool",
compose_template: "version: '3.8'",
default_port: 8080,
required_variables: [],
readiness_probe: {
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
},
});
expect(result.readiness_probe).toEqual({
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
});
});
});
describe("validateToolType", () => {
it("validates compose template", async () => {
const mockResponse = {
data: { valid: true, errors: [] },
};
mockedAxios.post.mockResolvedValue(mockResponse);
const result = await validateToolType({
definition_type: "compose",
compose_template: "version: '3.8'",
});
expect(result.valid).toBe(true);
});
it("returns validation errors", async () => {
const mockResponse = {
data: { valid: false, errors: ["Invalid YAML"] },
};
mockedAxios.post.mockResolvedValue(mockResponse);
const result = await validateToolType({
definition_type: "compose",
compose_template: "invalid: yaml: [",
});
expect(result.valid).toBe(false);
expect(result.errors).toContain("Invalid YAML");
});
});
describe("updateToolType", () => {
it("updates tool type with new fields", async () => {
const mockResponse = {
data: {
id: "type-1",
name: "updated-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
},
};
mockedAxios.put.mockResolvedValue(mockResponse);
const result = await updateToolType("type-1", {
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
});
expect(result.definition_type).toBe("dockerfile");
expect(mockedAxios.put).toHaveBeenCalledWith(
"/tool-types/type-1",
expect.objectContaining({
definition_type: "dockerfile",
})
);
});
});
describe("deleteToolType", () => {
it("deletes tool type", async () => {
mockedAxios.delete.mockResolvedValue({ data: undefined });
await deleteToolType("type-1");
expect(mockedAxios.delete).toHaveBeenCalledWith("/tool-types/type-1");
});
});
});
+558
View File
@@ -0,0 +1,558 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ToolWorkshopPage } from "./tool-workshop";
import * as toolTypesApi from "../api/tool_types";
import * as toolConfigsApi from "../api/tool_configs";
import * as configFoldersApi from "../api/config_folders";
const mockToolTypes = [
{
id: "type-1",
name: "code-server",
display_name: "VS Code Server",
description: "VS Code in browser",
category: "editor",
interfaces: ["web"],
default_port: 8443,
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
dockerfile_template: null,
readiness_probe: null,
required_variables: ["REPO_PATH"],
is_builtin: true,
created_by_id: null,
},
{
id: "type-2",
name: "custom-tool",
display_name: "Custom Tool",
description: "My custom tool",
category: "utility",
interfaces: ["terminal"],
default_port: 8080,
definition_type: "dockerfile",
compose_template: null,
dockerfile_template: "FROM python:3.11",
readiness_probe: {
command: "python --version",
timeout: 30,
interval: 2,
},
required_variables: [],
is_builtin: false,
created_by_id: "user-1",
},
];
const mockConfigs = [
{
id: "config-1",
tool_type_id: "type-1",
key: "OPENAI_API_KEY",
value: "sk-test123",
config_type: "env",
file_path: null,
port_override: null,
start_command: null,
working_directory: null,
environment_variables: {},
volumes: [],
},
{
id: "config-2",
tool_type_id: "type-2",
key: "advanced-config",
value: "test-value",
config_type: "env",
port_override: 9090,
start_command: "python app.py",
working_directory: "/app",
environment_variables: { DEBUG: "true" },
volumes: [{ source: "data", target: "/data", type: "bind" }],
},
];
const mockFolders = [
{
id: "folder-1",
name: "my-dotfiles",
description: "My personal config files",
mount_path: "/home/user",
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
project_overrides: {},
is_active: true,
},
{
id: "folder-2",
name: "project-configs",
description: "Project specific configs",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost:8080" },
project_overrides: {
"proj-1": {
mount_path: "/app",
files: { ".env": "API_URL=http://prod.api" },
},
},
is_active: false,
},
];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("ToolWorkshopPage", () => {
it("renders loading state initially", () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {}));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {}));
vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {}));
render(<ToolWorkshopPage />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("renders tool types tab by default", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
it("switches to configs tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
expect(screen.getByText("advanced-config")).toBeInTheDocument();
});
it("switches to folders tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
expect(screen.getByText("project-configs")).toBeInTheDocument();
});
it("opens tool type creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/display name/i)).toBeInTheDocument();
});
it("creates tool type with compose definition", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText(/name/i), {
target: { value: "new-tool" },
});
fireEvent.change(screen.getByLabelText(/display name/i), {
target: { value: "New Tool" },
});
fireEvent.change(screen.getByLabelText(/compose template/i), {
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-tool",
display_name: "New Tool",
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
})
);
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("creates tool type with dockerfile definition", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText(/name/i), {
target: { value: "docker-tool" },
});
fireEvent.change(screen.getByLabelText(/display name/i), {
target: { value: "Docker Tool" },
});
// Switch to dockerfile
fireEvent.click(screen.getByLabelText(/dockerfile/i));
fireEvent.change(screen.getByLabelText(/dockerfile template/i), {
target: { value: "FROM python:3.11\\nRUN pip install flask" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
})
);
});
});
it("shows readiness probe fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText(/readiness command/i)).toBeInTheDocument();
expect(screen.getByLabelText(/timeout/i)).toBeInTheDocument();
expect(screen.getByLabelText(/interval/i)).toBeInTheDocument();
});
it("opens config creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
tool_type_id: "type-1",
suggested_configs: [],
port_override: null,
});
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
});
it("creates config with advanced fields", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
tool_type_id: "type-1",
suggested_configs: [],
port_override: null,
});
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
fireEvent.change(screen.getByLabelText(/key/i), {
target: { value: "MY_CONFIG" },
});
fireEvent.change(screen.getByLabelText(/value/i), {
target: { value: "my-value" },
});
fireEvent.change(screen.getByLabelText(/port override/i), {
target: { value: "9090" },
});
fireEvent.change(screen.getByLabelText(/start command/i), {
target: { value: "python app.py" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
key: "MY_CONFIG",
value: "my-value",
port_override: 9090,
start_command: "python app.py",
})
);
});
expect(configsListMock).toHaveBeenCalledTimes(2);
});
it("opens folder creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/mount path/i)).toBeInTheDocument();
});
it("creates config folder successfully", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
fireEvent.change(screen.getByLabelText(/name/i), {
target: { value: "new-folder" },
});
fireEvent.change(screen.getByLabelText(/mount path/i), {
target: { value: "/home/dev" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-folder",
mount_path: "/home/dev",
})
);
});
expect(foldersListMock).toHaveBeenCalledTimes(2);
});
it("shows folder active/inactive status", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
// Check that active folder is marked
const activeFolder = screen.getByText("my-dotfiles").closest("[data-testid='folder-item']") ||
screen.getByText("my-dotfiles").parentElement;
expect(activeFolder?.textContent).toContain("active");
});
it("validates tool type before creation", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
const validateMock = vi.spyOn(toolTypesApi, "validateToolType").mockResolvedValue({
valid: false,
errors: ["Invalid YAML syntax"],
});
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText(/name/i), {
target: { value: "bad-tool" },
});
fireEvent.change(screen.getByLabelText(/display name/i), {
target: { value: "Bad Tool" },
});
fireEvent.change(screen.getByLabelText(/compose template/i), {
target: { value: "invalid: yaml: [" },
});
fireEvent.click(screen.getByRole("button", { name: /validate/i }));
await waitFor(() => {
expect(validateMock).toHaveBeenCalled();
});
expect(screen.getByText(/invalid yaml syntax/i)).toBeInTheDocument();
});
it("handles error state gracefully", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error"));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error"));
vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error"));
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("retries loading after error", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("deletes tool type successfully", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
// Find and click delete button for custom tool (not built-in)
const customToolCard = screen.getByText("Custom Tool").closest("[data-testid='tool-type-item']") ||
screen.getByText("Custom Tool").parentElement;
if (customToolCard) {
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
if (deleteButton) {
fireEvent.click(deleteButton);
// Confirm deletion
const confirmButton = screen.queryByRole("button", { name: /confirm/i });
if (confirmButton) {
fireEvent.click(confirmButton);
}
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("type-2");
});
expect(listMock).toHaveBeenCalledTimes(2);
}
}
});
});