diff --git a/agent/tests/test_agent/test_app.py b/agent/tests/test_agent/test_app.py index e76341b7a..73c072d7d 100644 --- a/agent/tests/test_agent/test_app.py +++ b/agent/tests/test_agent/test_app.py @@ -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) diff --git a/agent/tests/test_agent/test_langchain_tools.py b/agent/tests/test_agent/test_langchain_tools.py index 2c1e44276..4baab3858 100644 --- a/agent/tests/test_agent/test_langchain_tools.py +++ b/agent/tests/test_agent/test_langchain_tools.py @@ -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"} diff --git a/agent/tests/test_agent/test_package_install.py b/agent/tests/test_agent/test_package_install.py new file mode 100644 index 000000000..38f128448 --- /dev/null +++ b/agent/tests/test_agent/test_package_install.py @@ -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 diff --git a/backend/alembic/versions/c3d4e5f6a7b8_add_user_id_to_task_records.py b/backend/alembic/versions/c3d4e5f6a7b8_add_user_id_to_task_records.py index 1bb4dbd62..9a2646988 100644 --- a/backend/alembic/versions/c3d4e5f6a7b8_add_user_id_to_task_records.py +++ b/backend/alembic/versions/c3d4e5f6a7b8_add_user_id_to_task_records.py @@ -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 diff --git a/backend/alembic/versions/e3a4b5c6d7e8_add_deployment_records.py b/backend/alembic/versions/e3a4b5c6d7e8_add_deployment_records.py index 343f66e7b..4c3b868d6 100644 --- a/backend/alembic/versions/e3a4b5c6d7e8_add_deployment_records.py +++ b/backend/alembic/versions/e3a4b5c6d7e8_add_deployment_records.py @@ -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 diff --git a/backend/tests/core/task_manager/test_manager.py b/backend/tests/core/task_manager/test_manager.py index fed844306..c4aa34983 100644 --- a/backend/tests/core/task_manager/test_manager.py +++ b/backend/tests/core/task_manager/test_manager.py @@ -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, + ) # ═══════════════════════════════════════════════════════════════ diff --git a/backend/tests/core/test_fileio_utils.py b/backend/tests/core/test_fileio_utils.py index 832f370db..371ff9479 100644 --- a/backend/tests/core/test_fileio_utils.py +++ b/backend/tests/core/test_fileio_utils.py @@ -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) diff --git a/backend/tests/integration/test_alembic_user_id_migration.py b/backend/tests/integration/test_alembic_user_id_migration.py new file mode 100644 index 000000000..3437f1040 --- /dev/null +++ b/backend/tests/integration/test_alembic_user_id_migration.py @@ -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 diff --git a/backend/tests/test_alembic_migrations.py b/backend/tests/test_alembic_migrations.py index 21dcc2144..bd50490c7 100644 --- a/backend/tests/test_alembic_migrations.py +++ b/backend/tests/test_alembic_migrations.py @@ -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 diff --git a/run.sh b/run.sh index 4b4df4d81..9e62916e1 100755 --- a/run.sh +++ b/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"