chore: commit remaining workspace updates

This commit is contained in:
2026-07-16 07:53:41 +03:00
parent 20105f51c0
commit 45ce585aba
10 changed files with 343 additions and 122 deletions

View File

@@ -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)

View File

@@ -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"}

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)
# ═══════════════════════════════════════════════════════════════

View File

@@ -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)

View 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

View File

@@ -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
View File

@@ -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"