fix: resolve stale backend test imports and schema drift
- Delete 4 obsolete unit tests tied to removed git mount/clone models - Update imports and assertions across unit/integration/service tests - Fix Settings defaults (postgres host, JWT props, cookie_samesite) - Add skip guards for PostgreSQL-dependent integration tests - Fix GitService env assertions and HealthMonitor state-change tests - Repair docker/container inspect assertions in test_docker_service - Fix ToolTypeCreate default_port validator ordering bug - Fix check_port_exposed substring false-positive for port 0 - Update test_tool_types_api_extended to use interface_type field Quality gates: pytest 311 passed, 34 skipped; npm typecheck/lint/test 87 passed
This commit is contained in:
@@ -5,12 +5,12 @@ from src.database import build_database_url
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_settings_default_database_url_uses_asyncpg() -> None:
|
||||
def test_settings_default_database_url_uses_asyncpg(monkeypatch) -> None:
|
||||
"""Test that default database URL uses asyncpg driver and correct defaults."""
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
settings = Settings()
|
||||
# When DATABASE_URL env var is set (by conftest), it overrides the defaults
|
||||
# This test verifies the URL format when built from defaults
|
||||
expected = "postgresql+asyncpg://headquarter:headquarter@localhost:5432/headquarter"
|
||||
# When DATABASE_URL env var is not set, the URL is built from defaults.
|
||||
expected = "postgresql+asyncpg://headquarter:headquarter@postgres:5432/headquarter"
|
||||
assert settings.database_url == expected
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import uuid
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.services.config_profile_resolver import (
|
||||
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.services.config.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
ConfigProfileNotFoundError,
|
||||
ResolvedMount,
|
||||
@@ -77,7 +77,7 @@ class TestMergeFunctions:
|
||||
|
||||
def test_merge_mounts_file_override(self) -> None:
|
||||
"""Test mount file map merging with overrides."""
|
||||
from src.services.config_profile_resolver import ResolvedMount
|
||||
from src.services.config.config_profile_resolver import ResolvedMount
|
||||
|
||||
result = _merge_mounts(
|
||||
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
|
||||
@@ -89,7 +89,7 @@ class TestMergeFunctions:
|
||||
|
||||
def test_merge_mounts_mode_conflict(self) -> None:
|
||||
"""Test that mount mode conflicts are resolved (later wins)."""
|
||||
from src.services.config_profile_resolver import ResolvedMount
|
||||
from src.services.config.config_profile_resolver import ResolvedMount
|
||||
|
||||
overrides = {}
|
||||
result = _merge_mounts(
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.docker_build import build_image
|
||||
from src.services.build.docker_build import build_image
|
||||
|
||||
|
||||
class TestBuildImage:
|
||||
|
||||
@@ -4,11 +4,11 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import logging
|
||||
|
||||
from src.services.docker import (
|
||||
from src.services.docker.container import (
|
||||
get_container_id,
|
||||
get_container_name,
|
||||
sort_volumes_by_specificity,
|
||||
)
|
||||
from src.services.docker.compose import sort_volumes_by_specificity
|
||||
|
||||
|
||||
class TestGetContainerId:
|
||||
@@ -16,15 +16,15 @@ class TestGetContainerId:
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_lowercases_name_for_filter(self, mock_run) -> None:
|
||||
"""Docker ps name filter is case-sensitive; we must lowercase."""
|
||||
"""Docker inspect is case-sensitive; we must lowercase the name."""
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="abc123\n")
|
||||
|
||||
result = get_container_id("MyContainer-ABC")
|
||||
|
||||
assert result == "abc123"
|
||||
call_args = mock_run.call_args[0][0]
|
||||
# The filter must use lowercase
|
||||
assert "name=mycontainer-abc" in call_args
|
||||
# Exact inspect call uses lowercase
|
||||
assert call_args == ["docker", "inspect", "-f", "{{.Id}}", "mycontainer-abc"]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_returns_none_when_not_found(self, mock_run) -> None:
|
||||
@@ -40,14 +40,14 @@ class TestGetContainerName:
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_lowercases_name_for_filter(self, mock_run) -> None:
|
||||
"""Docker ps name filter is case-sensitive; we must lowercase."""
|
||||
"""Docker inspect is case-sensitive; we must lowercase the name."""
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="mycontainer-abc\n")
|
||||
|
||||
result = get_container_name("MyContainer-ABC")
|
||||
|
||||
assert result == "mycontainer-abc"
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "name=mycontainer-abc" in call_args
|
||||
assert call_args == ["docker", "inspect", "-f", "{{.Name}}", "mycontainer-abc"]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_returns_none_when_not_found(self, mock_run) -> None:
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Unit tests for git mount resolution in tool instances."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.tool_instances import (
|
||||
_checkout_branch,
|
||||
_expand_glob_source,
|
||||
_resolve_single_git_mount,
|
||||
)
|
||||
|
||||
|
||||
class TestExpandGlobSource:
|
||||
"""Unit tests for glob pattern expansion."""
|
||||
|
||||
def test_no_glob_single_file(self, tmp_path: Path) -> None:
|
||||
"""Test non-glob path returns single file."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("content")
|
||||
|
||||
result = _expand_glob_source(str(test_file), str(tmp_path))
|
||||
assert len(result) == 1
|
||||
assert result[0] == str(test_file)
|
||||
|
||||
def test_no_glob_missing_file(self, tmp_path: Path) -> None:
|
||||
"""Test non-glob missing file returns empty list."""
|
||||
missing_file = tmp_path / "missing.txt"
|
||||
|
||||
result = _expand_glob_source(str(missing_file), str(tmp_path))
|
||||
assert len(result) == 0
|
||||
|
||||
def test_glob_pattern(self, tmp_path: Path) -> None:
|
||||
"""Test glob pattern matches files."""
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.txt").write_text("content2")
|
||||
(tmp_path / "other.py").write_text("code")
|
||||
|
||||
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
|
||||
assert len(result) == 2
|
||||
assert all(f.endswith(".txt") for f in result)
|
||||
|
||||
def test_glob_recursive(self, tmp_path: Path) -> None:
|
||||
"""Test recursive glob pattern."""
|
||||
subdir = tmp_path / "subdir"
|
||||
subdir.mkdir()
|
||||
(subdir / "nested.txt").write_text("content")
|
||||
|
||||
result = _expand_glob_source(str(tmp_path / "**" / "*.txt"), str(tmp_path))
|
||||
assert len(result) == 1
|
||||
assert "nested.txt" in result[0]
|
||||
|
||||
def test_glob_limit_enforced(self, tmp_path: Path) -> None:
|
||||
"""Test that glob matches are limited to prevent abuse."""
|
||||
# Create more than 100 files
|
||||
for i in range(105):
|
||||
(tmp_path / f"file{i}.txt").write_text("content")
|
||||
|
||||
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
|
||||
assert len(result) == 100 # MAX_GLOB_MATCHES limit
|
||||
|
||||
def test_glob_escapes_repo(self, tmp_path: Path) -> None:
|
||||
"""Test that glob results outside repo are filtered."""
|
||||
other_dir = tmp_path.parent / "other"
|
||||
other_dir.mkdir(exist_ok=True)
|
||||
(other_dir / "outside.txt").write_text("content")
|
||||
|
||||
result = _expand_glob_source(str(tmp_path.parent / "*" / "*.txt"), str(tmp_path))
|
||||
# Should only include files within tmp_path, not other_dir
|
||||
assert all(r.startswith(str(tmp_path)) for r in result)
|
||||
|
||||
|
||||
class TestCheckoutBranch:
|
||||
"""Unit tests for branch checkout."""
|
||||
|
||||
def test_checkout_existing_branch(self, tmp_path: Path) -> None:
|
||||
"""Test checking out an existing branch."""
|
||||
# Initialize git repo
|
||||
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||
(tmp_path / "file.txt").write_text("content")
|
||||
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||
os.system(f"cd {tmp_path} && git branch feature")
|
||||
|
||||
_checkout_branch(str(tmp_path), "feature")
|
||||
|
||||
# Verify we're on feature branch
|
||||
result = os.popen(f"cd {tmp_path} && git branch --show-current").read().strip()
|
||||
assert result == "feature"
|
||||
|
||||
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
|
||||
"""Test checking out a non-existent branch returns False."""
|
||||
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||
(tmp_path / "file.txt").write_text("content")
|
||||
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||
|
||||
result = _checkout_branch(str(tmp_path), "nonexistent")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestResolveSingleGitMount:
|
||||
"""Unit tests for resolving a single git mount."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_missing_remote_url(self, db_session) -> None:
|
||||
"""Test that missing remote_url returns empty list."""
|
||||
git_mount = {
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
}
|
||||
|
||||
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_missing_target_path(self, db_session) -> None:
|
||||
"""Test that missing target path returns empty list."""
|
||||
git_mount = {
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": ".",
|
||||
}
|
||||
|
||||
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||
assert result == []
|
||||
@@ -1,219 +0,0 @@
|
||||
"""Unit tests for git mount resolution with multi-mapping support."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.tool_instances import (
|
||||
_clone_git_repo,
|
||||
_expand_glob_source,
|
||||
_normalize_git_mount,
|
||||
_resolve_git_mount_mappings,
|
||||
_resolve_single_git_mount,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeGitMount:
|
||||
"""Tests for _normalize_git_mount."""
|
||||
|
||||
def test_legacy_to_mappings(self) -> None:
|
||||
"""Legacy source_path + target_path becomes mappings array."""
|
||||
entry = {
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": "packages/api",
|
||||
"target_path": "/app/api",
|
||||
"branch": "main",
|
||||
}
|
||||
result = _normalize_git_mount(entry)
|
||||
assert "mappings" in result
|
||||
assert result["mappings"] == [
|
||||
{"source_path": "packages/api", "target_path": "/app/api"}
|
||||
]
|
||||
assert "source_path" not in result
|
||||
assert "target_path" not in result
|
||||
assert result["remote_url"] == "https://github.com/user/repo.git"
|
||||
assert result["branch"] == "main"
|
||||
|
||||
def test_already_mappings(self) -> None:
|
||||
"""Entry already with mappings is left unchanged."""
|
||||
entry = {
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"branch": "main",
|
||||
"mappings": [
|
||||
{"source_path": "a", "target_path": "/a"},
|
||||
{"source_path": "b", "target_path": "/b"},
|
||||
],
|
||||
}
|
||||
result = _normalize_git_mount(entry)
|
||||
assert result["mappings"] == [
|
||||
{"source_path": "a", "target_path": "/a"},
|
||||
{"source_path": "b", "target_path": "/b"},
|
||||
]
|
||||
assert "source_path" not in result
|
||||
assert "target_path" not in result
|
||||
|
||||
def test_missing_target_path_no_mappings(self) -> None:
|
||||
"""Entry with source_path but no target_path creates empty mappings."""
|
||||
entry = {
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": "src",
|
||||
}
|
||||
result = _normalize_git_mount(entry)
|
||||
assert "mappings" not in result
|
||||
|
||||
|
||||
class TestResolveGitMountMappings:
|
||||
"""Tests for _resolve_git_mount_mappings."""
|
||||
|
||||
def test_single_mapping(self) -> None:
|
||||
"""A single mapping produces one volume mount."""
|
||||
with tempfile.TemporaryDirectory() as repo_path:
|
||||
os.makedirs(os.path.join(repo_path, "packages", "api"))
|
||||
mappings = [
|
||||
{"source_path": "packages/api", "target_path": "/app/api"},
|
||||
]
|
||||
result = _resolve_git_mount_mappings(repo_path, mappings, None)
|
||||
assert len(result) == 1
|
||||
assert result[0]["source"] == os.path.join(repo_path, "packages", "api")
|
||||
assert result[0]["target"] == "/app/api"
|
||||
assert result[0]["type"] == "bind"
|
||||
|
||||
def test_multiple_mappings(self) -> None:
|
||||
"""Multiple mappings from same repo produce multiple mounts."""
|
||||
with tempfile.TemporaryDirectory() as repo_path:
|
||||
os.makedirs(os.path.join(repo_path, "packages", "api"))
|
||||
os.makedirs(os.path.join(repo_path, "packages", "web"))
|
||||
mappings = [
|
||||
{"source_path": "packages/api", "target_path": "/app/api"},
|
||||
{"source_path": "packages/web", "target_path": "/app/web"},
|
||||
]
|
||||
result = _resolve_git_mount_mappings(repo_path, mappings, None)
|
||||
assert len(result) == 2
|
||||
targets = {r["target"] for r in result}
|
||||
assert targets == {"/app/api", "/app/web"}
|
||||
|
||||
def test_relative_target_path(self) -> None:
|
||||
"""Relative target_path is resolved against working_directory."""
|
||||
with tempfile.TemporaryDirectory() as repo_path:
|
||||
os.makedirs(os.path.join(repo_path, "src"))
|
||||
mappings = [
|
||||
{"source_path": "src", "target_path": "code"},
|
||||
]
|
||||
result = _resolve_git_mount_mappings(repo_path, mappings, "/workspace")
|
||||
assert len(result) == 1
|
||||
assert result[0]["target"] == "/workspace/code"
|
||||
|
||||
def test_glob_expansion(self) -> None:
|
||||
"""Glob patterns in source_path are expanded."""
|
||||
with tempfile.TemporaryDirectory() as repo_path:
|
||||
os.makedirs(os.path.join(repo_path, "packages", "api"))
|
||||
os.makedirs(os.path.join(repo_path, "packages", "web"))
|
||||
mappings = [
|
||||
{"source_path": "packages/*", "target_path": "/app/packages"},
|
||||
]
|
||||
result = _resolve_git_mount_mappings(repo_path, mappings, None)
|
||||
assert len(result) == 2
|
||||
targets = {r["target"] for r in result}
|
||||
assert targets == {
|
||||
os.path.join("/app/packages", "packages", "api"),
|
||||
os.path.join("/app/packages", "packages", "web"),
|
||||
}
|
||||
|
||||
def test_missing_target_path_skipped(self) -> None:
|
||||
"""Mapping without target_path is skipped."""
|
||||
with tempfile.TemporaryDirectory() as repo_path:
|
||||
mappings = [
|
||||
{"source_path": "src"},
|
||||
]
|
||||
result = _resolve_git_mount_mappings(repo_path, mappings, None)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_no_working_directory_for_relative_target(self) -> None:
|
||||
"""Relative target without working_directory is skipped."""
|
||||
with tempfile.TemporaryDirectory() as repo_path:
|
||||
os.makedirs(os.path.join(repo_path, "src"))
|
||||
mappings = [
|
||||
{"source_path": "src", "target_path": "code"},
|
||||
]
|
||||
result = _resolve_git_mount_mappings(repo_path, mappings, None)
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestResolveSingleGitMount:
|
||||
"""Tests for _resolve_single_git_mount."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_remote_url(self) -> None:
|
||||
"""Git mount without remote_url returns empty list."""
|
||||
result = await _resolve_single_git_mount(
|
||||
MagicMock(),
|
||||
{"mappings": [{"source_path": ".", "target_path": "/app"}]},
|
||||
"/tmp",
|
||||
None,
|
||||
)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_instance_dir(self) -> None:
|
||||
"""Git mount without instance_dir returns empty list."""
|
||||
result = await _resolve_single_git_mount(
|
||||
MagicMock(),
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"mappings": [{"source_path": ".", "target_path": "/app"}],
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_format_normalized(self) -> None:
|
||||
"""Legacy format is normalized and resolved."""
|
||||
with tempfile.TemporaryDirectory() as instance_dir:
|
||||
with patch(
|
||||
"src.api.tool_instances._clone_git_repo",
|
||||
return_value=os.path.join(instance_dir, "repo-clone"),
|
||||
):
|
||||
os.makedirs(os.path.join(instance_dir, "repo-clone", "src"))
|
||||
result = await _resolve_single_git_mount(
|
||||
MagicMock(),
|
||||
{
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": "src",
|
||||
"target_path": "/app/src",
|
||||
},
|
||||
instance_dir,
|
||||
None,
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["target"] == "/app/src"
|
||||
|
||||
|
||||
class TestExpandGlobSource:
|
||||
"""Tests for _expand_glob_source."""
|
||||
|
||||
def test_no_glob(self) -> None:
|
||||
"""Non-glob path returns single item if exists."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "file.txt")
|
||||
open(path, "w").close()
|
||||
result = _expand_glob_source(path, tmp)
|
||||
assert result == [path]
|
||||
|
||||
def test_no_glob_missing(self) -> None:
|
||||
"""Non-glob path that doesn't exist returns empty list."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "missing.txt")
|
||||
result = _expand_glob_source(path, tmp)
|
||||
assert result == []
|
||||
|
||||
def test_glob_pattern(self) -> None:
|
||||
"""Glob pattern expands to matched paths."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
open(os.path.join(tmp, "a.txt"), "w").close()
|
||||
open(os.path.join(tmp, "b.txt"), "w").close()
|
||||
result = _expand_glob_source(os.path.join(tmp, "*.txt"), tmp)
|
||||
assert len(result) == 2
|
||||
@@ -1,28 +0,0 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.api.git_repositories import _build_provider_clone_url, _preflight_remote_repository
|
||||
|
||||
|
||||
def test_build_provider_clone_url_uses_fixed_host() -> None:
|
||||
assert _build_provider_clone_url("alice", "demo") == "git@git.commumedia.org:alice/demo.git"
|
||||
|
||||
|
||||
def test_preflight_remote_repository_allows_accessible_repo() -> None:
|
||||
completed = Mock(returncode=0)
|
||||
with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock:
|
||||
_preflight_remote_repository("git@git.commumedia.org:alice/demo.git")
|
||||
|
||||
run_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_preflight_remote_repository_rejects_missing_repo() -> None:
|
||||
completed = Mock(returncode=128)
|
||||
with patch("src.api.git_repositories.subprocess.run", return_value=completed):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_preflight_remote_repository("git@git.commumedia.org:alice/missing.git")
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "repository not found or inaccessible"
|
||||
@@ -1,64 +0,0 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.api.git_repositories import _clone_working_repository, _init_working_repository
|
||||
from src.utils.git_control import create_branch
|
||||
|
||||
|
||||
def test_clone_working_repository_uses_normal_clone() -> None:
|
||||
completed = Mock(returncode=0, stderr="")
|
||||
with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock:
|
||||
_clone_working_repository("git@git.commumedia.org:alice/demo.git", "/tmp/demo.git")
|
||||
|
||||
run_mock.assert_called_once()
|
||||
assert run_mock.call_args.args[0] == ["git", "clone", "git@git.commumedia.org:alice/demo.git", "/tmp/demo.git"]
|
||||
|
||||
|
||||
def test_clone_working_repository_raises_on_failure() -> None:
|
||||
completed = Mock(returncode=128, stderr="fatal: repository not found")
|
||||
with patch("src.api.git_repositories.subprocess.run", return_value=completed):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_clone_working_repository("git@git.commumedia.org:alice/missing.git", "/tmp/missing.git")
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "failed to clone repository" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_init_working_repository_prefers_init_b() -> None:
|
||||
init_b = Mock(returncode=0, stderr="")
|
||||
with patch("src.api.git_repositories.subprocess.run", return_value=init_b) as run_mock:
|
||||
_init_working_repository("/tmp/new-repo")
|
||||
|
||||
assert run_mock.call_args.args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"]
|
||||
|
||||
|
||||
def test_init_working_repository_falls_back_to_symbolic_ref() -> None:
|
||||
init_b = Mock(returncode=1, stderr="unknown switch `b'")
|
||||
init_ok = Mock(returncode=0, stderr="")
|
||||
symbolic_ref = Mock(returncode=0, stderr="")
|
||||
|
||||
with patch("src.api.git_repositories.subprocess.run", side_effect=[init_b, init_ok, symbolic_ref]) as run_mock:
|
||||
_init_working_repository("/tmp/new-repo")
|
||||
|
||||
assert run_mock.call_args_list[0].args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"]
|
||||
assert run_mock.call_args_list[1].args[0] == ["git", "init", "/tmp/new-repo"]
|
||||
assert run_mock.call_args_list[2].args[0] == ["git", "-C", "/tmp/new-repo", "symbolic-ref", "HEAD", "refs/heads/main"]
|
||||
|
||||
|
||||
def test_create_branch_uses_orphan_checkout_when_head_is_unborn() -> None:
|
||||
call_count = 0
|
||||
|
||||
def mock_run(repo_path: str, *args: str) -> str:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise RuntimeError("fatal: Needed a single revision")
|
||||
return ""
|
||||
|
||||
with patch("src.utils.git_control._run_git_command", side_effect=mock_run) as run_mock:
|
||||
create_branch("/tmp/new-repo", "feature/test")
|
||||
|
||||
assert run_mock.call_args_list[0].args[1:] == ("rev-parse", "--verify", "HEAD^{commit}")
|
||||
assert run_mock.call_args_list[1].args[1:] == ("checkout", "--orphan", "feature/test")
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.git_service import GitService
|
||||
from src.services.git.git_service import GitService
|
||||
|
||||
|
||||
class TestGitServiceClone:
|
||||
@@ -35,6 +35,7 @@ class TestGitServiceClone:
|
||||
"/tmp/ws",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -72,6 +73,7 @@ class TestGitServiceFetch:
|
||||
"origin",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -110,6 +112,7 @@ class TestGitServicePull:
|
||||
"feature-branch",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -130,6 +133,7 @@ class TestGitServiceBranchExistsRemotely:
|
||||
["git", "-C", "/tmp/repo", "ls-remote", "--heads", "origin", "main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=None,
|
||||
)
|
||||
|
||||
def test_branch_not_exists(self):
|
||||
|
||||
@@ -8,11 +8,11 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.models.health_check import HealthCheck
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.user import User
|
||||
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||
from src.services.health_monitor import HealthMonitor, HealthSnapshot
|
||||
from src.models.system.health_check import HealthCheck
|
||||
from src.models.tool.tool_instance import ToolInstance
|
||||
from src.models.user.user import User
|
||||
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
|
||||
from src.services.instance.health_monitor import HealthMonitor, HealthSnapshot
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -77,11 +77,11 @@ async def test_detects_container_crash(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.services.health_monitor.get_container_status",
|
||||
"src.services.instance.health_monitor.get_container_status",
|
||||
return_value={"status": "exited", "exit_code": 137, "health": None},
|
||||
),
|
||||
patch(
|
||||
"src.services.health_monitor.check_tunnel_health",
|
||||
"src.services.instance.health_monitor.check_tunnel_health",
|
||||
return_value={"healthy": False, "tunnel_status": "not_applicable"},
|
||||
),
|
||||
):
|
||||
@@ -124,11 +124,11 @@ async def test_detects_tunnel_failure(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.services.health_monitor.get_container_status",
|
||||
"src.services.instance.health_monitor.get_container_status",
|
||||
return_value={"status": "running", "exit_code": None, "health": "healthy"},
|
||||
),
|
||||
patch(
|
||||
"src.services.health_monitor.check_tunnel_health",
|
||||
"src.services.instance.health_monitor.check_tunnel_health",
|
||||
return_value={
|
||||
"healthy": False,
|
||||
"tunnel_status": "error_response",
|
||||
@@ -181,11 +181,11 @@ async def test_detects_recovery(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.services.health_monitor.get_container_status",
|
||||
"src.services.instance.health_monitor.get_container_status",
|
||||
return_value={"status": "running", "exit_code": None, "health": None},
|
||||
),
|
||||
patch(
|
||||
"src.services.health_monitor.check_tunnel_health",
|
||||
"src.services.instance.health_monitor.check_tunnel_health",
|
||||
return_value={
|
||||
"healthy": True,
|
||||
"tunnel_status": "healthy",
|
||||
@@ -218,13 +218,22 @@ async def test_skips_writes_when_no_state_change(
|
||||
"""Two identical polls should result in only one health_checks row."""
|
||||
instance = await _create_running_instance(db_session)
|
||||
|
||||
# Seed a different prior snapshot so the first poll writes a row, then the
|
||||
# second identical poll skips because the snapshot is unchanged.
|
||||
health_monitor._last_known_state[instance.id] = HealthSnapshot(
|
||||
container_status="running",
|
||||
container_healthy=None,
|
||||
tunnel_healthy=False,
|
||||
exit_code=None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.services.health_monitor.get_container_status",
|
||||
"src.services.instance.health_monitor.get_container_status",
|
||||
return_value={"status": "running", "exit_code": None, "health": None},
|
||||
),
|
||||
patch(
|
||||
"src.services.health_monitor.check_tunnel_health",
|
||||
"src.services.instance.health_monitor.check_tunnel_health",
|
||||
return_value={
|
||||
"healthy": True,
|
||||
"tunnel_status": "healthy",
|
||||
@@ -233,6 +242,7 @@ async def test_skips_writes_when_no_state_change(
|
||||
),
|
||||
):
|
||||
await health_monitor._check_instance(db_session, instance)
|
||||
# Second identical poll should skip writes because the snapshot is unchanged.
|
||||
await health_monitor._check_instance(db_session, instance)
|
||||
|
||||
result = await db_session.execute(
|
||||
@@ -259,7 +269,7 @@ async def test_docker_exception_resilience(
|
||||
event_bus.subscribe("instance.health_changed", capture_event)
|
||||
|
||||
with patch(
|
||||
"src.services.health_monitor.get_container_status",
|
||||
"src.services.instance.health_monitor.get_container_status",
|
||||
side_effect=RuntimeError("docker exploded"),
|
||||
):
|
||||
# Should not raise
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.tool_instances import _resolve_git_mount_mappings
|
||||
from src.services.config_profile_resolver import expand_container_path
|
||||
from src.services.manifest_compiler import get_manifest_home_dir
|
||||
from src.services.config.config_profile_resolver import expand_container_path
|
||||
from src.services.build.manifest_compiler import get_manifest_home_dir
|
||||
|
||||
|
||||
class TestExpandContainerPath:
|
||||
@@ -75,36 +74,3 @@ class TestGetManifestHomeDir:
|
||||
manifest = {"user": {"name": None, "uid": 1000, "gid": 1000}}
|
||||
assert get_manifest_home_dir(manifest) == "/root"
|
||||
|
||||
|
||||
class TestResolveGitMountMappingsExpansion:
|
||||
"""Tests that git mount mapping targets expand ~ and $HOME."""
|
||||
|
||||
def test_tilde_target_expansion(self, tmp_path) -> None:
|
||||
"""Mapping with ~/repo target expands to home dir."""
|
||||
(tmp_path / "src").mkdir()
|
||||
mappings = [{"source_path": "src", "target_path": "~/repo"}]
|
||||
result = _resolve_git_mount_mappings(
|
||||
str(tmp_path), mappings, None, "/home/user"
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["target"] == "/home/user/repo"
|
||||
|
||||
def test_dollar_home_target_expansion(self, tmp_path) -> None:
|
||||
"""Mapping with $HOME/repo target expands to home dir."""
|
||||
(tmp_path / "src").mkdir()
|
||||
mappings = [{"source_path": "src", "target_path": "$HOME/repo"}]
|
||||
result = _resolve_git_mount_mappings(
|
||||
str(tmp_path), mappings, None, "/home/user"
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["target"] == "/home/user/repo"
|
||||
|
||||
def test_absolute_target_unchanged(self, tmp_path) -> None:
|
||||
"""Absolute target paths are not modified."""
|
||||
(tmp_path / "src").mkdir()
|
||||
mappings = [{"source_path": "src", "target_path": "/app/src"}]
|
||||
result = _resolve_git_mount_mappings(
|
||||
str(tmp_path), mappings, None, "/home/user"
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["target"] == "/app/src"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.lifecycle_hooks import _derive_title, _should_notify
|
||||
from src.services.instance.lifecycle_hooks import _derive_title, _should_notify
|
||||
|
||||
|
||||
class TestDeriveTitle:
|
||||
|
||||
@@ -6,10 +6,10 @@ from datetime import datetime
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.models.health_check import HealthCheck
|
||||
from src.models.instance_event import InstanceEvent
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.user import User
|
||||
from src.models.system.health_check import HealthCheck
|
||||
from src.models.system.instance_event import InstanceEvent
|
||||
from src.models.tool.tool_instance import ToolInstance
|
||||
from src.models.user.user import User
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -7,9 +7,9 @@ import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.notification import Notification
|
||||
from src.models.user import User
|
||||
from src.services.notification_service import NotificationService
|
||||
from src.models.system.notification import Notification
|
||||
from src.models.user.user import User
|
||||
from src.services.shared.notification_service import NotificationService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.notifications import router as notifications_router
|
||||
from src.api.system.notifications import router as notifications_router
|
||||
|
||||
|
||||
def test_delete_notifications_route_order() -> None:
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.permission_fixer import (
|
||||
from src.services.shared.permission_fixer import (
|
||||
PermissionFixError,
|
||||
apply_mount_permissions,
|
||||
apply_ssh_permissions,
|
||||
@@ -16,7 +16,7 @@ from src.services.permission_fixer import (
|
||||
class TestApplyMountPermissions:
|
||||
"""Tests for apply_mount_permissions."""
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
@patch("src.services.shared.permission_fixer._run_in_container")
|
||||
def test_applies_chown_when_owner_declared(self, mock_run) -> None:
|
||||
mounts = [
|
||||
{"name": "workspace", "target": "/workspace", "owner": "user"},
|
||||
@@ -31,7 +31,7 @@ class TestApplyMountPermissions:
|
||||
assert args[0] == "abc123"
|
||||
assert args[1] == ["chown", "-R", "user:user", "/workspace"]
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
@patch("src.services.shared.permission_fixer._run_in_container")
|
||||
def test_applies_chmod_when_mode_declared(self, mock_run) -> None:
|
||||
mounts = [
|
||||
{"name": "ssh", "target": "/home/user/.ssh", "mode": "0700"},
|
||||
@@ -44,7 +44,7 @@ class TestApplyMountPermissions:
|
||||
chmod_call = mock_run.call_args_list[0]
|
||||
assert chmod_call[0][1] == ["chmod", "0700", "/home/user/.ssh"]
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
@patch("src.services.shared.permission_fixer._run_in_container")
|
||||
def test_applies_file_mode_when_declared(self, mock_run) -> None:
|
||||
mounts = [
|
||||
{
|
||||
@@ -64,7 +64,7 @@ class TestApplyMountPermissions:
|
||||
"find /home/user/.ssh -type f -exec chmod 0600" in file_mode_call[0][1][2]
|
||||
)
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
@patch("src.services.shared.permission_fixer._run_in_container")
|
||||
def test_skips_readonly_mount(self, mock_run) -> None:
|
||||
mounts = [
|
||||
{
|
||||
@@ -82,7 +82,7 @@ class TestApplyMountPermissions:
|
||||
assert results[0]["success"] is True
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
@patch("src.services.shared.permission_fixer._run_in_container")
|
||||
def test_skips_mount_with_no_policy(self, mock_run) -> None:
|
||||
mounts = [
|
||||
{"name": "workspace", "target": "/workspace", "writable": True},
|
||||
@@ -93,7 +93,7 @@ class TestApplyMountPermissions:
|
||||
assert results[0]["success"] is True
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
@patch("src.services.shared.permission_fixer._run_in_container")
|
||||
def test_reports_failure_on_command_error(self, mock_run) -> None:
|
||||
mock_run.side_effect = PermissionFixError("chown failed")
|
||||
|
||||
@@ -105,7 +105,7 @@ class TestApplyMountPermissions:
|
||||
assert results[0]["success"] is False
|
||||
assert "chown failed" in results[0]["error"]
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
@patch("src.services.shared.permission_fixer._run_in_container")
|
||||
def test_stops_on_first_failure(self, mock_run) -> None:
|
||||
"""If chown fails, chmod and file_mode should not run."""
|
||||
mock_run.side_effect = PermissionFixError("chown failed")
|
||||
@@ -227,11 +227,11 @@ class TestApplySshPermissions:
|
||||
class TestCheckRootUserAvailable:
|
||||
"""Tests for check_root_user_available."""
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
@patch("src.services.shared.permission_fixer._run_in_container")
|
||||
def test_returns_true_when_root_exists(self, mock_run) -> None:
|
||||
assert check_root_user_available("abc123") is True
|
||||
|
||||
@patch("src.services.permission_fixer._run_in_container")
|
||||
@patch("src.services.shared.permission_fixer._run_in_container")
|
||||
def test_returns_false_when_root_missing(self, mock_run) -> None:
|
||||
mock_run.side_effect = PermissionFixError("no such user")
|
||||
assert check_root_user_available("abc123") is False
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
from src.services.readiness_probe import execute_probe
|
||||
from src.services.shared.readiness_probe import execute_probe
|
||||
|
||||
|
||||
class TestExecuteProbe:
|
||||
|
||||
@@ -6,13 +6,13 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.ssh_keys import prepare_ssh_key_files
|
||||
from src.services.shared.ssh_keys import prepare_ssh_key_files
|
||||
|
||||
|
||||
class TestPrepareSshKeyFiles:
|
||||
"""Tests for prepare_ssh_key_files."""
|
||||
|
||||
@patch("src.services.ssh_keys._get_fernet")
|
||||
@patch("src.services.shared.ssh_keys._get_fernet")
|
||||
def test_creates_files_with_default_permissions(
|
||||
self, mock_fernet, tmp_path
|
||||
) -> None:
|
||||
@@ -29,7 +29,7 @@ class TestPrepareSshKeyFiles:
|
||||
assert (Path(ssh_dir) / "config").exists()
|
||||
assert oct(os.stat(Path(ssh_dir) / "id_ed25519").st_mode)[-3:] == "600"
|
||||
|
||||
@patch("src.services.ssh_keys._get_fernet")
|
||||
@patch("src.services.shared.ssh_keys._get_fernet")
|
||||
def test_sets_ownership_when_uid_gid_provided(self, mock_fernet, tmp_path) -> None:
|
||||
mock_fernet.return_value.decrypt.return_value = b"private-key-content"
|
||||
ssh_key = MagicMock()
|
||||
@@ -45,7 +45,7 @@ class TestPrepareSshKeyFiles:
|
||||
assert mock_chown.call_args_list[0][0][1] == 1001
|
||||
assert mock_chown.call_args_list[0][0][2] == 1001
|
||||
|
||||
@patch("src.services.ssh_keys._get_fernet")
|
||||
@patch("src.services.shared.ssh_keys._get_fernet")
|
||||
def test_gracefully_handles_permission_error_on_chown(
|
||||
self, mock_fernet, tmp_path
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user