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:
@@ -4,7 +4,7 @@ import logging
|
||||
import uuid
|
||||
|
||||
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.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
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.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.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.models import Base
|
||||
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.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.models import Base
|
||||
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
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
from sqlalchemy import text
|
||||
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.models import Base
|
||||
from src.models.user import User
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user