chore: commit remaining workspace updates
This commit is contained in:
@@ -9,6 +9,10 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-unit-tests-32chars")
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -503,25 +507,34 @@ class TestAgentHandler:
|
||||
from ss_tools.agent.app import agent_handler
|
||||
from ss_tools.agent.context import get_user_jwt
|
||||
|
||||
# Create a test JWT using the same jose library the agent uses
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import jwt as jose_jwt
|
||||
import os
|
||||
secret = os.getenv("AUTH_SECRET_KEY", "test-secret-key-for-unit-tests")
|
||||
token = jose_jwt.encode(
|
||||
{"sub": "admin", "scopes": ["Admin"], "exp": datetime.now(timezone.utc) + timedelta(hours=1)},
|
||||
secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsInNjb3BlcyI6WyJBZG1pbiJdLCJleHAiOjE3ODQxNDkwMzh9.fake"
|
||||
mock_event_stream = [{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="ok")}}]
|
||||
agent = _make_agent_mock(mock_event_stream)
|
||||
|
||||
with patch("ss_tools.agent.app.create_agent", return_value=agent), patch("ss_tools.agent.app.get_all_tools", return_value=[]), patch("ss_tools.agent.app.save_conversation", AsyncMock()):
|
||||
# Check JWT is set DURING handler execution (reset_user_jwt runs in finally)
|
||||
jwt_during = None
|
||||
orig_event_stream = mock_event_stream
|
||||
|
||||
def _check_jwt(*args, **kwargs):
|
||||
nonlocal jwt_during
|
||||
jwt_during = get_user_jwt()
|
||||
return _make_async_iter(orig_event_stream)
|
||||
|
||||
agent.astream_events = MagicMock(side_effect=_check_jwt)
|
||||
|
||||
with (
|
||||
patch("ss_tools.agent.app.create_agent", return_value=agent),
|
||||
patch("ss_tools.agent.app.get_all_tools", return_value=[]),
|
||||
patch("ss_tools.agent.app.save_conversation", AsyncMock()),
|
||||
patch("ss_tools.agent.app.decode_token", return_value={"sub": "admin", "scopes": ["Admin"]}),
|
||||
):
|
||||
results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)]
|
||||
|
||||
filtered = _skip_pipeline(results)
|
||||
assert len(filtered) == 1
|
||||
assert get_user_jwt() == token
|
||||
assert jwt_during == token, f"JWT should be {token[:20]}... during handler, got {jwt_during!r}"
|
||||
# After handler exits, JWT is reset (by finally block) — this is by design
|
||||
assert get_user_jwt() == "", "JWT should be cleared after handler completes"
|
||||
|
||||
|
||||
# #endregion test_agent_handler
|
||||
@@ -563,26 +576,29 @@ class TestSaveConversation:
|
||||
async def test_save_success(self):
|
||||
from ss_tools.agent._persistence import save_conversation
|
||||
|
||||
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock()
|
||||
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
|
||||
await save_conversation("conv-1", "test message", "user-1")
|
||||
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
|
||||
mock_client.post.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_with_service_jwt(self):
|
||||
from ss_tools.agent._persistence import save_conversation
|
||||
|
||||
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client, patch("ss_tools.agent._persistence.os.getenv", return_value="service-token"):
|
||||
mock_client.return_value.__aenter__.return_value.post = AsyncMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock()
|
||||
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client), patch("ss_tools.agent._persistence.os.getenv", return_value="service-token"):
|
||||
await save_conversation("conv-1", "hello", "admin")
|
||||
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
|
||||
mock_client.post.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_failure_logged(self):
|
||||
from ss_tools.agent._persistence import save_conversation
|
||||
|
||||
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.post.side_effect = Exception("network err")
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(side_effect=Exception("network err"))
|
||||
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
|
||||
# Should not raise
|
||||
await save_conversation("conv-1", "msg", "u1")
|
||||
|
||||
@@ -590,11 +606,11 @@ class TestSaveConversation:
|
||||
async def test_save_empty_title(self):
|
||||
from ss_tools.agent._persistence import save_conversation
|
||||
|
||||
with patch("ss_tools.agent._persistence.httpx.AsyncClient") as mock_client:
|
||||
client_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = client_instance
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock()
|
||||
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
|
||||
await save_conversation("conv-1", " ", "user-1")
|
||||
call_kwargs = client_instance.post.call_args[1]
|
||||
call_kwargs = mock_client.post.call_args[1]
|
||||
# clean_title(" ") returns "Новый диалог" (Russian for "New conversation")
|
||||
assert call_kwargs["json"]["title"] == "Новый диалог"
|
||||
|
||||
@@ -862,7 +878,10 @@ class TestAppMainBlock:
|
||||
spec = importlib.util.spec_from_file_location("__main__", str(app_path))
|
||||
|
||||
mock_demo = MagicMock()
|
||||
with patch("gradio.ChatInterface") as mock_ci:
|
||||
with (
|
||||
patch("gradio.ChatInterface") as mock_ci,
|
||||
patch("ss_tools.agent.middleware.close_lifecycle_resources", AsyncMock()),
|
||||
):
|
||||
mock_ci.return_value = mock_demo
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
|
||||
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
|
||||
@@ -19,6 +20,24 @@ os.environ["SERVICE_JWT"] = "test-service-jwt"
|
||||
os.environ["OPENAI_API_KEY"] = "sk-test-key"
|
||||
|
||||
|
||||
def _mock_http_client(get_return=None, post_return=None, get_side_effect=None):
|
||||
"""Create a mock for get_shared_http_client that returns a mock client.
|
||||
|
||||
Returns (mock_client, patcher) tuple. Use as:
|
||||
mock_client, patcher = _mock_http_client(...)
|
||||
with patcher:
|
||||
...
|
||||
"""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
if get_side_effect is not None:
|
||||
mock_client.get = AsyncMock(side_effect=get_side_effect)
|
||||
elif get_return is not None:
|
||||
mock_client.get = AsyncMock(return_value=get_return)
|
||||
if post_return is not None:
|
||||
mock_client.post = AsyncMock(return_value=post_return)
|
||||
return mock_client, patch("ss_tools.agent.tools.get_shared_http_client", return_value=mock_client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
@@ -38,19 +57,15 @@ async def test_tool_dual_auth_headers():
|
||||
set_user_jwt("user-jwt-token")
|
||||
set_service_jwt("service-jwt-token")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value = Mock(
|
||||
status_code=200,
|
||||
text='{"dashboards": [], "total": 0}',
|
||||
)
|
||||
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
|
||||
mock_resp = Mock(status_code=200, text='{"dashboards": [], "total": 0}')
|
||||
mock_resp.json.return_value = {"dashboards": [], "total": 0}
|
||||
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
await search_dashboards.ainvoke({"query": "test"})
|
||||
|
||||
# Verify the HTTP request included dual-identity headers
|
||||
call_kwargs = mock_instance.get.call_args
|
||||
call_kwargs = mock_client.get.call_args
|
||||
assert call_kwargs is not None, "HTTP GET should have been called"
|
||||
_, kwargs = call_kwargs
|
||||
headers = kwargs.get("headers", {})
|
||||
@@ -78,19 +93,14 @@ async def test_tool_auth_fallback_to_env():
|
||||
set_service_jwt("")
|
||||
os.environ["SERVICE_JWT"] = "env-service-token"
|
||||
|
||||
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value = Mock(
|
||||
status_code=200,
|
||||
text='{"dashboards": [], "total": 0}',
|
||||
)
|
||||
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
|
||||
mock_resp = Mock(status_code=200, text='{"dashboards": [], "total": 0}')
|
||||
mock_resp.json.return_value = {"dashboards": [], "total": 0}
|
||||
|
||||
# Since tool uses os.getenv at call time, the env var will be read
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), patcher:
|
||||
await search_dashboards.ainvoke({"query": "test"})
|
||||
|
||||
call_kwargs = mock_instance.get.call_args
|
||||
call_kwargs = mock_client.get.call_args
|
||||
assert call_kwargs is not None
|
||||
_, kwargs = call_kwargs
|
||||
headers = kwargs.get("headers", {})
|
||||
@@ -114,11 +124,8 @@ async def test_tool_http_exception_handling():
|
||||
set_user_jwt("test-jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.side_effect = Exception("Connection refused")
|
||||
|
||||
_, patcher = _mock_http_client(get_side_effect=Exception("Connection refused"))
|
||||
with patcher:
|
||||
# Should propagate the exception (caller handles error)
|
||||
with pytest.raises((Exception,)):
|
||||
await search_dashboards.ainvoke({"query": "test"})
|
||||
@@ -209,18 +216,14 @@ async def test_search_dashboards_correct_url():
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value = Mock(
|
||||
status_code=200,
|
||||
text='{"dashboards": [], "total": 0}',
|
||||
)
|
||||
mock_instance.get.return_value.json.return_value = {"dashboards": [], "total": 0}
|
||||
mock_resp = Mock(status_code=200, text='{"dashboards": [], "total": 0}')
|
||||
mock_resp.json.return_value = {"dashboards": [], "total": 0}
|
||||
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
await search_dashboards.ainvoke({"query": "dashboard-name", "env_id": "prod"})
|
||||
|
||||
call_args = mock_instance.get.call_args
|
||||
call_args = mock_client.get.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
@@ -243,15 +246,13 @@ async def test_get_health_summary_calls_correct_url():
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.status_code = 200
|
||||
mock_instance.get.return_value.text = '{"status": "ok"}'
|
||||
mock_resp = Mock(status_code=200, text='{"status": "ok"}')
|
||||
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
await get_health_summary.ainvoke({"env_id": "ss-dev"})
|
||||
|
||||
call_args = mock_instance.get.call_args
|
||||
call_args = mock_client.get.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
@@ -275,15 +276,13 @@ async def test_list_environments_calls_correct_url():
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.status_code = 200
|
||||
mock_instance.get.return_value.text = '["prod", "dev"]'
|
||||
mock_resp = Mock(status_code=200, text='["prod", "dev"]')
|
||||
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
await list_environments.ainvoke({})
|
||||
|
||||
call_args = mock_instance.get.call_args
|
||||
call_args = mock_client.get.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
@@ -299,12 +298,13 @@ async def test_list_environments_redacts_sensitive_fields():
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.status_code = 200
|
||||
mock_instance.get.return_value.text = '[{"id":"prod","password":"secret-pass","api_key":"secret-key","nested":{"token":"secret-token"},"name":"ss-prod"}]'
|
||||
mock_resp = Mock(
|
||||
status_code=200,
|
||||
text='[{"id":"prod","password":"secret-pass","api_key":"secret-key","nested":{"token":"secret-token"},"name":"ss-prod"}]',
|
||||
)
|
||||
|
||||
_, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
result = await list_environments.ainvoke({})
|
||||
|
||||
assert "secret-pass" not in result
|
||||
@@ -329,15 +329,13 @@ async def test_get_task_status_calls_correct_url():
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.get.return_value.status_code = 200
|
||||
mock_instance.get.return_value.text = '{"status": "running"}'
|
||||
mock_resp = Mock(status_code=200, text='{"status": "running"}')
|
||||
|
||||
mock_client, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
await get_task_status.ainvoke({"task_id": "task-123"})
|
||||
|
||||
call_args = mock_instance.get.call_args
|
||||
call_args = mock_client.get.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
@@ -357,17 +355,17 @@ async def test_run_backup_posts_task_payload():
|
||||
set_service_jwt("svc-jwt")
|
||||
set_user_role("admin")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.post.return_value = Mock(status_code=201, text='{"id": "task-1"}')
|
||||
mock_resp = Mock(status_code=201, text='{"id": "task-1"}')
|
||||
|
||||
mock_client, patcher = _mock_http_client(post_return=mock_resp)
|
||||
with patcher:
|
||||
await run_backup.ainvoke({"environment_id": "prod", "dashboard_id": 10})
|
||||
|
||||
call_args = mock_instance.post.call_args
|
||||
call_args = mock_client.post.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
assert "api/tasks" in args[0]
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
assert "api/tasks" in url
|
||||
assert kwargs["json"] == {
|
||||
"plugin_id": "superset-backup",
|
||||
"params": {"environment_id": "prod", "dashboard_ids": [10]},
|
||||
@@ -384,17 +382,17 @@ async def test_deploy_dashboard_posts_git_endpoint():
|
||||
set_service_jwt("svc-jwt")
|
||||
set_user_role("admin")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
mock_instance.post.return_value = Mock(status_code=200, text='{"status": "success"}')
|
||||
mock_resp = Mock(status_code=200, text='{"status": "success"}')
|
||||
|
||||
mock_client, patcher = _mock_http_client(post_return=mock_resp)
|
||||
with patcher:
|
||||
await deploy_dashboard.ainvoke({"dashboard_ref": "42", "environment_id": "prod"})
|
||||
|
||||
call_args = mock_instance.post.call_args
|
||||
call_args = mock_client.post.call_args
|
||||
assert call_args is not None
|
||||
args, kwargs = call_args
|
||||
assert "api/git/repositories/42/deploy" in args[0]
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
assert "api/git/repositories/42/deploy" in url
|
||||
assert kwargs["json"] == {"environment_id": "prod"}
|
||||
|
||||
|
||||
|
||||
57
agent/tests/test_agent/test_package_install.py
Normal file
57
agent/tests/test_agent/test_package_install.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# #region Test.AgentChat.Packaging [C:3] [TYPE Module] [SEMANTICS test,agent,packaging,entrypoint,subprocess]
|
||||
# @BRIEF Verifies the installed agent package exposes the production module entry point.
|
||||
# @RELATION BINDS_TO -> [EXT:Python:ModuleEntrypoint]
|
||||
# @TEST_FIXTURE: installed_packages -> INLINE_JSON
|
||||
# @TEST_EDGE: missing_field -> Imports resolve without agent/src added to PYTHONPATH.
|
||||
# @TEST_EDGE: invalid_type -> Both shared and agent distributions are installed as packages.
|
||||
# @TEST_EDGE: external_fail -> pip installation failures surface through the subprocess result.
|
||||
# @RATIONALE Tests normally insert agent/src into sys.path, which can hide a broken editable or
|
||||
# wheel installation even though run.sh executes python -m ss_tools.agent.run.
|
||||
# @REJECTED Importing directly from agent/src was rejected because it cannot prove packaging works.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
# #region test_installed_packages_expose_agent_entrypoint [C:2] [TYPE Function] [SEMANTICS test,agent,packaging,entrypoint]
|
||||
# @BRIEF Install shared and agent distributions into an isolated target and import the entry point.
|
||||
def test_installed_packages_expose_agent_entrypoint(tmp_path: Path) -> None:
|
||||
"""run.sh's module entry point is available without source-tree path injection."""
|
||||
repository_root = Path(__file__).parents[3]
|
||||
package_target = tmp_path / "site-packages"
|
||||
install = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--no-deps",
|
||||
"--target",
|
||||
str(package_target),
|
||||
str(repository_root / "shared"),
|
||||
str(repository_root / "agent"),
|
||||
],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert install.returncode == 0, install.stderr
|
||||
|
||||
environment = os.environ.copy()
|
||||
environment["PYTHONPATH"] = str(package_target)
|
||||
imported = subprocess.run(
|
||||
[sys.executable, "-c", "import ss_tools.agent.run; import ss_tools.shared"],
|
||||
cwd=tmp_path,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert imported.returncode == 0, imported.stderr
|
||||
# #endregion test_installed_packages_expose_agent_entrypoint
|
||||
|
||||
|
||||
# #endregion Test.AgentChat.Packaging
|
||||
@@ -1,3 +1,10 @@
|
||||
# #region Alembic.TaskRecordsUserId [C:3] [TYPE Module] [SEMANTICS alembic,migration,task,postgres]
|
||||
# @BRIEF Adds nullable task ownership to persistent task records.
|
||||
# @RELATION DEPENDS_ON -> [EXT:SQLAlchemy:Alembic]
|
||||
# @POST Existing task_records rows retain data and gain a nullable user_id column.
|
||||
# @RATIONALE Task ownership must persist so task list queries can be scoped to a user after restart.
|
||||
# @REJECTED Runtime create_all() or manual ALTER TABLE was rejected because schema evolution must
|
||||
# remain reproducible through the Alembic migration chain.
|
||||
"""add user_id column to task_records table
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
@@ -7,10 +14,10 @@ Create Date: 2026-07-15 19:06:00.000000
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c3d4e5f6a7b8'
|
||||
@@ -38,3 +45,6 @@ def upgrade() -> None:
|
||||
def downgrade() -> None:
|
||||
"""Remove user_id column from task_records table."""
|
||||
op.drop_column("task_records", "user_id")
|
||||
|
||||
|
||||
# #endregion Alembic.TaskRecordsUserId
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# #region Alembic.AddDeploymentRecords [C:2] [TYPE Function] [SEMANTICS alembic,migration,deployment,versioning]
|
||||
# #region Alembic.AddDeploymentRecords [C:3] [TYPE Module] [SEMANTICS alembic,migration,deployment,versioning]
|
||||
# @BRIEF Add deployment_records table for version tracking (Phase 0).
|
||||
# @RELATION DEPENDS_ON -> [DeploymentModels]
|
||||
# @POST Creates deployment dependency tables before adding foreign-key-constrained records.
|
||||
# @RATIONALE DeploymentEnvironment and GitRepository were historically created by runtime metadata,
|
||||
# which left a fresh Alembic upgrade without the foreign-key targets required here.
|
||||
# @REJECTED Relying on Base.metadata.create_all() before Alembic was rejected because production
|
||||
# startup must be able to initialize its schema through migrations alone.
|
||||
"""add deployment_records table
|
||||
|
||||
Revision ID: e3a4b5c6d7e8
|
||||
@@ -8,21 +13,56 @@ Revises: f2b3c4d5e6f7
|
||||
Create Date: 2026-07-10 13:30:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e3a4b5c6d7e8"
|
||||
down_revision: Union[str, None] = "f2b3c4d5e6f7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
down_revision: str | None = "f2b3c4d5e6f7"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"git_server_configs",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("provider", sa.String(20), nullable=False),
|
||||
sa.Column("url", sa.String(255), nullable=False),
|
||||
sa.Column("pat", sa.String(255), nullable=False),
|
||||
sa.Column("default_repository", sa.String(255), nullable=True),
|
||||
sa.Column("default_branch", sa.String(255), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=True),
|
||||
sa.Column("last_validated", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table(
|
||||
"git_repositories",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("dashboard_id", sa.Integer(), nullable=False),
|
||||
sa.Column("config_id", sa.String(36), nullable=False),
|
||||
sa.Column("remote_url", sa.String(255), nullable=False),
|
||||
sa.Column("local_path", sa.String(255), nullable=False),
|
||||
sa.Column("current_branch", sa.String(255), nullable=True),
|
||||
sa.Column("sync_status", sa.String(20), nullable=True),
|
||||
sa.ForeignKeyConstraint(["config_id"], ["git_server_configs.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("dashboard_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"deployment_environments",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("superset_url", sa.String(255), nullable=False),
|
||||
sa.Column("superset_token", sa.String(255), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table(
|
||||
"deployment_records",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
@@ -55,3 +95,9 @@ def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_deployment_records_content_hash"), table_name="deployment_records")
|
||||
op.drop_index(op.f("ix_deployment_records_repository_env"), table_name="deployment_records")
|
||||
op.drop_table("deployment_records")
|
||||
op.drop_table("deployment_environments")
|
||||
op.drop_table("git_repositories")
|
||||
op.drop_table("git_server_configs")
|
||||
|
||||
|
||||
# #endregion Alembic.AddDeploymentRecords
|
||||
|
||||
@@ -434,7 +434,12 @@ class TestManagerLifecycleDelegates:
|
||||
mgr.lifecycle.resume_task_with_password = AsyncMock()
|
||||
mgr._add_log = AsyncMock()
|
||||
await mgr.resume_task_with_password("t1", {"db": "pass"})
|
||||
mgr.lifecycle.resume_task_with_password.assert_called_with("t1", {"db": "pass"}, add_log_callback=mgr._add_log)
|
||||
mgr.lifecycle.resume_task_with_password.assert_called_with(
|
||||
"t1", {"db": "pass"},
|
||||
add_log_callback=mgr._add_log,
|
||||
requester_user_id=None,
|
||||
allow_task_override=False,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -300,9 +300,9 @@ class TestWriteBackupManifest:
|
||||
zf.writestr("a.txt", "data")
|
||||
archive_path = Path(fname)
|
||||
manifest_path = write_backup_manifest(archive_path)
|
||||
# No .tmp file should remain
|
||||
tmp_files = list(archive_path.parent.glob("*.tmp"))
|
||||
assert len(tmp_files) == 0
|
||||
# Scope: only check for this specific manifest's .tmp file
|
||||
manifest_tmp = archive_path.with_suffix(".manifest.json.tmp")
|
||||
assert not manifest_tmp.exists(), f"Orphan .tmp file left: {manifest_tmp}"
|
||||
manifest_path.unlink()
|
||||
finally:
|
||||
os.unlink(fname)
|
||||
|
||||
72
backend/tests/integration/test_alembic_user_id_migration.py
Normal file
72
backend/tests/integration/test_alembic_user_id_migration.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# #region Test.Alembic.TaskRecordsUserId [C:3] [TYPE Module] [SEMANTICS test,alembic,migration,task,postgres]
|
||||
# @BRIEF Verifies the real PostgreSQL upgrade that adds task_records.user_id.
|
||||
# @RELATION BINDS_TO -> [EXT:Alembic:MigrationChain]
|
||||
# @TEST_FIXTURE: migration_revisions -> INLINE_JSON
|
||||
# @TEST_EDGE: missing_field -> Pre-user-id schema does not expose task_records.user_id.
|
||||
# @TEST_EDGE: invalid_type -> Upgrade is addressed by a concrete Alembic revision.
|
||||
# @TEST_EDGE: external_fail -> PostgreSQL DDL failures propagate instead of being masked.
|
||||
# @TEST_INVARIANT: task_records_user_id_upgrade -> VERIFIED_BY: [test_user_id_migration_adds_column]
|
||||
# @RATIONALE SQLite and metadata.create_all() cannot expose an unapplied PostgreSQL migration;
|
||||
# an isolated Testcontainers database exercises Alembic's actual DDL path.
|
||||
# @REJECTED Stamping the revision or applying raw ALTER TABLE was rejected because each can
|
||||
# conceal a migration chain failure while falsely marking the schema current.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
|
||||
# #region migration_database_url [C:1] [TYPE Function]
|
||||
@pytest.fixture
|
||||
def migration_database_url() -> str:
|
||||
"""Provide a pristine PostgreSQL database that receives only Alembic DDL."""
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
|
||||
with PostgresContainer(
|
||||
image="postgres:16-alpine",
|
||||
username="test",
|
||||
password="test",
|
||||
dbname="test_migrations",
|
||||
) as container:
|
||||
yield container.get_connection_url()
|
||||
# #endregion migration_database_url
|
||||
|
||||
|
||||
# #region test_user_id_migration_adds_column [C:2] [TYPE Function] [SEMANTICS test,alembic,migration,task,postgres]
|
||||
# @BRIEF Upgrades a pristine PostgreSQL schema through the task_records.user_id revision.
|
||||
def test_user_id_migration_adds_column(monkeypatch: pytest.MonkeyPatch, migration_database_url: str) -> None:
|
||||
"""Alembic adds user_id after the predecessor revision without runtime schema creation."""
|
||||
monkeypatch.setenv("DATABASE_URL", migration_database_url)
|
||||
_run_alembic_upgrade("b2a3c4d5e6f7")
|
||||
|
||||
engine = create_engine(migration_database_url)
|
||||
try:
|
||||
before_columns = {column["name"] for column in inspect(engine).get_columns("task_records")}
|
||||
assert "user_id" not in before_columns
|
||||
|
||||
_run_alembic_upgrade("c3d4e5f6a7b8")
|
||||
after_columns = {column["name"] for column in inspect(engine).get_columns("task_records")}
|
||||
assert "user_id" in after_columns
|
||||
finally:
|
||||
engine.dispose()
|
||||
# #endregion test_user_id_migration_adds_column
|
||||
|
||||
|
||||
# #region _run_alembic_upgrade [C:1] [TYPE Function]
|
||||
def _run_alembic_upgrade(revision: str) -> None:
|
||||
"""Run a named revision against the DATABASE_URL injected for this test."""
|
||||
from alembic.config import Config
|
||||
|
||||
from alembic import command
|
||||
|
||||
backend_dir = Path(__file__).parents[2]
|
||||
config = Config(str(backend_dir / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(backend_dir / "alembic"))
|
||||
config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])
|
||||
command.upgrade(config, revision)
|
||||
# #endregion _run_alembic_upgrade
|
||||
|
||||
|
||||
# #endregion Test.Alembic.TaskRecordsUserId
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region TestAlembicMigrations [C:3] [TYPE Module] [SEMANTICS tests, alembic, migration, schema, idempotent]
|
||||
# @BRIEF Verifies Alembic migration chain: fresh upgrade and legacy upgrade.
|
||||
# @RELATION BINDS_TO -> [ed310b33f02c]
|
||||
# #region Test.Alembic.Migrations [C:3] [TYPE Module] [SEMANTICS test,alembic,migration,schema,postgres]
|
||||
# @BRIEF Verifies fresh, legacy, and targeted PostgreSQL Alembic upgrades.
|
||||
# @RELATION BINDS_TO -> [Alembic.AddDeploymentRecords]
|
||||
# @INVARIANT Running `alembic upgrade head` twice is idempotent (no-op on second run) on PostgreSQL.
|
||||
# @INVARIANT Legacy database with existing tables can be upgraded via `alembic upgrade head`.
|
||||
# @PRE Alembic is installed, migration files exist in alembic/versions/.
|
||||
@@ -14,10 +14,9 @@
|
||||
# for legacy databases, ensuring all missing tables/columns are created.
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
# Ensure backend/src is importable for model metadata
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
@@ -38,9 +37,7 @@ _REQUIRES_PG = pytest.mark.skipif(
|
||||
@_REQUIRES_PG
|
||||
def test_fresh_upgrade_creates_tables() -> None:
|
||||
"""Fresh Alembic upgrade creates all expected tables (PostgreSQL only)."""
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy import text as sa_text
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
from sqlalchemy import create_engine, inspect, text as sa_text
|
||||
_run_alembic_upgrade()
|
||||
engine = create_engine(os.environ["DATABASE_URL"])
|
||||
inspector = inspect(engine)
|
||||
@@ -73,11 +70,9 @@ def test_fresh_upgrade_creates_tables() -> None:
|
||||
@_REQUIRES_PG
|
||||
def test_legacy_database_upgrade() -> None:
|
||||
"""Alembic upgrade head works on a database that already has tables but no alembic_version."""
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy import text as sa_text
|
||||
from src.models.mapping import Base
|
||||
from src.models import auth as _auth # noqa: F401
|
||||
from src.models import task as _task # noqa: F401
|
||||
from sqlalchemy import create_engine, inspect, text as sa_text
|
||||
|
||||
from src.models import auth as _auth, task as _task # noqa: F401
|
||||
|
||||
engine = create_engine(os.environ["DATABASE_URL"])
|
||||
# Drop alembic_version if exists to simulate legacy state
|
||||
@@ -106,14 +101,16 @@ def test_legacy_database_upgrade() -> None:
|
||||
|
||||
|
||||
# #region _run_alembic_upgrade [C:1] [TYPE Function]
|
||||
def _run_alembic_upgrade() -> None:
|
||||
"""Run alembic upgrade head programmatically."""
|
||||
def _run_alembic_upgrade(revision: str = "head") -> None:
|
||||
"""Run a named Alembic upgrade programmatically against DATABASE_URL."""
|
||||
from alembic.config import Config as AlembicConfig
|
||||
|
||||
from alembic import command as alembic_command
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
alembic_cfg = AlembicConfig(str(backend_dir / "alembic.ini"))
|
||||
alembic_cfg.set_main_option("script_location", str(backend_dir / "alembic"))
|
||||
alembic_command.upgrade(alembic_cfg, "head")
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])
|
||||
alembic_command.upgrade(alembic_cfg, revision)
|
||||
# #endregion _run_alembic_upgrade
|
||||
|
||||
# #endregion TestAlembicMigrations
|
||||
|
||||
21
run.sh
21
run.sh
@@ -73,6 +73,15 @@ validate_env() {
|
||||
|
||||
validate_env
|
||||
|
||||
# ── Load backend .env early so check_database sees DATABASE_URL ──
|
||||
if [ -f "backend/.env" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. "backend/.env"
|
||||
set +a
|
||||
echo "Loaded backend/.env for database config."
|
||||
fi
|
||||
|
||||
# Database connectivity preflight
|
||||
check_database() {
|
||||
# Keep resolution order aligned with backend/src/core/database.py defaults.
|
||||
@@ -250,8 +259,10 @@ setup_backend() {
|
||||
if [ -f "requirements.txt" ]; then
|
||||
echo "Installing backend dependencies..."
|
||||
pip install -r requirements.txt
|
||||
else
|
||||
echo "Warning: backend/requirements.txt not found."
|
||||
fi
|
||||
# Install shared package
|
||||
if ! python3 -c "import ss_tools.shared" 2>/dev/null; then
|
||||
pip install -e ../shared 2>/dev/null || true
|
||||
fi
|
||||
cd ..
|
||||
}
|
||||
@@ -381,6 +392,12 @@ start_agent() {
|
||||
elif [ -f "../backend/.venv/bin/activate" ]; then
|
||||
source "../backend/.venv/bin/activate"
|
||||
fi
|
||||
# Install both editable packages before using the module entry point. Tests may put
|
||||
# agent/src on sys.path, but production must import the installed package graph.
|
||||
if ! python3 -c "import ss_tools.agent, ss_tools.shared" 2>/dev/null; then
|
||||
pip install -e ../shared 2>/dev/null && echo -e "\033[0;35m[Agent]\033[0m Installed ss-tools-shared"
|
||||
pip install -e . 2>/dev/null && echo -e "\033[0;35m[Agent]\033[0m Installed ss-tools-agent"
|
||||
fi
|
||||
# Agent reads backend .env for LLM/DB config
|
||||
if [ -f "../backend/.env" ]; then
|
||||
local _saved_dev_mode="$DEV_MODE"
|
||||
|
||||
Reference in New Issue
Block a user