diff --git a/backend/tests/api/test_agent_lifecycle_routes.py b/backend/tests/api/test_agent_lifecycle_routes.py new file mode 100644 index 000000000..824bdfa36 --- /dev/null +++ b/backend/tests/api/test_agent_lifecycle_routes.py @@ -0,0 +1,280 @@ +# #region Test.Api.AgentLifecycleRoutes [C:3] [TYPE Module] [SEMANTICS test,agent,lifecycle,events,api] +# @BRIEF Unit tests for Agent Lifecycle event API — write and list endpoints. +# @RELATION BINDS_TO -> [Api.AgentLifecycle] +# @TEST_EDGE: non_admin_cannot_filter_by_user -> 403 + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") +os.environ.setdefault("DEV_MODE", "true") + +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +_src = str(Path(__file__).resolve().parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + + +def _make_mock_user(is_admin: bool = True, user_id: str = "user-1") -> MagicMock: + """Build a mock user for auth bypass.""" + from src.schemas.auth import RoleSchema, User as UserSchema + + roles = [] + if is_admin: + roles.append( + RoleSchema(id="r1", name="Admin", description="", is_admin=True, permissions=[]) + ) + return UserSchema( + id=user_id, + username="admin" if is_admin else "user", + email="admin@x.com" if is_admin else "user@x.com", + auth_source="LOCAL", + created_at=datetime.now(), + roles=roles, + ) + + +def _make_client(user_mock=None, db_mock=None, overrides=None) -> TestClient: + """Build a TestClient with the agent lifecycle router.""" + from src.api.routes.agent_lifecycle import router + from src.core.database import get_db + from src.dependencies import get_current_user + + app = FastAPI() + app.include_router(router) + + if user_mock is None: + user_mock = _make_mock_user(is_admin=True) + if db_mock is None: + db_mock = MagicMock() + + app.dependency_overrides[get_current_user] = lambda: user_mock + app.dependency_overrides[get_db] = lambda: db_mock + if overrides: + for dep, fn in overrides.items(): + app.dependency_overrides[dep] = fn + return TestClient(app, raise_server_exceptions=False) + + +# ── create_event (POST) ── + + +class TestCreateEvent: + """POST /api/agent/events""" + + EVENT_PAYLOAD = { + "trace_id": "trace-1", + "conversation_id": "conv-1", + "event_type": "tool_call", + "tool_name": "superset_query", + "status": "success", + "elapsed_ms": 1500, + "payload": {"action": "query", "attempt": 1}, + } + + def test_success(self): + """Creates a lifecycle event successfully (admin user).""" + from src.schemas.agent_lifecycle import EventWriteResponse + + mock_write = MagicMock(return_value=EventWriteResponse(id="evt-1")) + + with patch("src.api.routes.agent_lifecycle.write_event", mock_write): + client = _make_client() + resp = client.post("/api/agent/events", json=self.EVENT_PAYLOAD) + + assert resp.status_code == 201 + data = resp.json() + assert data["id"] == "evt-1" + assert data["written"] is True + mock_write.assert_called_once() + + def test_success_regular_user(self): + """Non-admin user can also create events.""" + from src.schemas.agent_lifecycle import EventWriteResponse + + mock_write = MagicMock(return_value=EventWriteResponse(id="evt-2")) + user_mock = _make_mock_user(is_admin=False) + + with patch("src.api.routes.agent_lifecycle.write_event", mock_write): + client = _make_client(user_mock=user_mock) + resp = client.post("/api/agent/events", json=self.EVENT_PAYLOAD) + + assert resp.status_code == 201 + + def test_payload_reduced(self): + """Payload is reduced to safe keys via schema.""" + from src.schemas.agent_lifecycle import EventWriteResponse + + mock_write = MagicMock(return_value=EventWriteResponse(id="evt-3")) + + payload_with_sensitive = { + **self.EVENT_PAYLOAD, + "payload": {"action": "query", "password": "secret", "token": "abc"}, + } + + with patch("src.api.routes.agent_lifecycle.write_event", mock_write): + client = _make_client() + resp = client.post("/api/agent/events", json=payload_with_sensitive) + + assert resp.status_code == 201 + # The write_event should receive payload with sensitive keys reduced + call_kwargs = mock_write.call_args + if call_kwargs: + written_body = call_kwargs[0][1] # body arg + if written_body.payload: + assert "password" not in written_body.payload + assert "action" in written_body.payload + + def test_db_rollback_on_error(self): + """Rolls back DB transaction when write_event raises.""" + mock_write = MagicMock(side_effect=ValueError("DB error")) + db_mock = MagicMock() + + with patch("src.api.routes.agent_lifecycle.write_event", mock_write): + client = _make_client(db_mock=db_mock) + resp = client.post("/api/agent/events", json=self.EVENT_PAYLOAD) + + assert resp.status_code == 500 + db_mock.rollback.assert_called_once() + + def test_missing_required_fields(self): + """Returns 422 when required fields are missing.""" + client = _make_client() + resp = client.post("/api/agent/events", json={"event_type": "test"}) + assert resp.status_code == 422 + + +# ── read_events (GET) ── + + +class TestReadEvents: + """GET /api/agent/events""" + + def _make_event_item(self, **overrides): + from src.schemas.agent_lifecycle import EventItem + + return EventItem( + id=overrides.get("id", "evt-1"), + trace_id=overrides.get("trace_id", "trace-1"), + conversation_id=overrides.get("conversation_id", "conv-1"), + user_id=overrides.get("user_id", "user-1"), + event_type=overrides.get("event_type", "tool_call"), + tool_name=overrides.get("tool_name", "superset_query"), + status=overrides.get("status", "success"), + created_at=datetime.now(), + ) + + def test_success_admin(self): + """Admin can list all events.""" + from src.schemas.agent_lifecycle import EventListResponse + + items = [self._make_event_item()] + mock_list = MagicMock( + return_value=EventListResponse(items=items, total=1, page=1, page_size=50, has_next=False) + ) + + with patch("src.api.routes.agent_lifecycle.list_events", mock_list): + client = _make_client() + resp = client.get("/api/agent/events") + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert len(data["items"]) == 1 + assert data["items"][0]["id"] == "evt-1" + + def test_success_regular_user(self): + """Non-admin sees only own events (no user_id filter).""" + from src.schemas.agent_lifecycle import EventListResponse + + items = [self._make_event_item(id="evt-2")] + mock_list = MagicMock( + return_value=EventListResponse(items=items, total=1, page=1, page_size=50, has_next=False) + ) + user_mock = _make_mock_user(is_admin=False) + + with patch("src.api.routes.agent_lifecycle.list_events", mock_list): + client = _make_client(user_mock=user_mock) + resp = client.get("/api/agent/events") + + assert resp.status_code == 200 + assert resp.json()["total"] == 1 + + def test_non_admin_cannot_filter_by_user(self): + """Non-admin gets 403 when trying to filter by user_id.""" + user_mock = _make_mock_user(is_admin=False) + client = _make_client(user_mock=user_mock) + resp = client.get("/api/agent/events?user_id=other-user") + + assert resp.status_code == 403 + assert "Only admin users can query events by user_id" in resp.json()["detail"] + + def test_admin_can_filter_by_user(self): + """Admin can filter events by user_id.""" + from src.schemas.agent_lifecycle import EventListResponse + + items = [self._make_event_item(user_id="other-user")] + mock_list = MagicMock( + return_value=EventListResponse(items=items, total=1, page=1, page_size=50, has_next=False) + ) + + with patch("src.api.routes.agent_lifecycle.list_events", mock_list): + client = _make_client() + resp = client.get("/api/agent/events?user_id=other-user") + + assert resp.status_code == 200 + assert resp.json()["total"] == 1 + # Verify list_events received the correct args + call_kwargs = mock_list.call_args[1] + assert call_kwargs["user_id"] == "other-user" + + def test_with_filters(self): + """Filters are passed through to list_events.""" + from src.schemas.agent_lifecycle import EventListResponse + + mock_list = MagicMock( + return_value=EventListResponse(items=[], total=0, page=1, page_size=50, has_next=False) + ) + + with patch("src.api.routes.agent_lifecycle.list_events", mock_list): + client = _make_client() + resp = client.get( + "/api/agent/events?event_type=tool_call&conversation_id=conv-1&status=success&tool_name=superset_query" + ) + + assert resp.status_code == 200 + call_kwargs = mock_list.call_args[1] + assert call_kwargs["event_type"] == "tool_call" + assert call_kwargs["conversation_id"] == "conv-1" + + def test_pagination(self): + """Page and page_size are passed through.""" + from src.schemas.agent_lifecycle import EventListResponse + + mock_list = MagicMock( + return_value=EventListResponse(items=[], total=0, page=2, page_size=25, has_next=False) + ) + + with patch("src.api.routes.agent_lifecycle.list_events", mock_list): + client = _make_client() + resp = client.get("/api/agent/events?page=2&page_size=25") + + assert resp.status_code == 200 + assert resp.json()["page"] == 2 + assert resp.json()["page_size"] == 25 + + def test_invalid_page_size(self): + """Page_size outside 1-200 returns 422.""" + client = _make_client() + resp = client.get("/api/agent/events?page_size=500") + assert resp.status_code == 422 +# #endregion Test.Api.AgentLifecycleRoutes diff --git a/backend/tests/api/test_agent_status_routes.py b/backend/tests/api/test_agent_status_routes.py new file mode 100644 index 000000000..56d0f9fb1 --- /dev/null +++ b/backend/tests/api/test_agent_status_routes.py @@ -0,0 +1,110 @@ +# #region Test.Api.AgentStatusRoutes [C:2] [TYPE Module] [SEMANTICS test,agent,llm,status,health] +# @BRIEF Unit tests for Agent LLM provider health status endpoint. +# @RELATION BINDS_TO -> [Api.Agent.Status] + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") +os.environ.setdefault("DEV_MODE", "true") + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +# Add shared/src to path for ss_tools.shared._llm_health +_src = str(Path(__file__).resolve().parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + +_shared_src = str(Path(__file__).resolve().parent.parent.parent.parent / "shared" / "src") +if _shared_src not in sys.path: + sys.path.insert(0, _shared_src) + + +def _make_client() -> TestClient: + """Build a TestClient with the agent status router.""" + from src.api.routes.agent_status import router + + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +class TestGetLlmStatus: + """GET /api/agent/llm-status""" + + @patch("ss_tools.shared._llm_health._check_llm_provider_health", AsyncMock(return_value="ok")) + @patch("ss_tools.shared._llm_health._llm_status", {"status": "ok", "last_error": ""}) + def test_status_ok(self): + """Returns ok when LLM provider is healthy.""" + client = _make_client() + resp = client.get("/api/agent/llm-status") + + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["last_error"] == "" + assert data["retry_after_s"] == 0 + + @patch("ss_tools.shared._llm_health._check_llm_provider_health", AsyncMock(return_value="unavailable")) + @patch("ss_tools.shared._llm_health._llm_status", {"status": "unavailable", "last_error": "Provider unreachable"}) + def test_status_unavailable(self): + """Returns unavailable with retry_after_s > 0.""" + client = _make_client() + resp = client.get("/api/agent/llm-status") + + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "unavailable" + assert data["last_error"] == "Provider unreachable" + assert data["retry_after_s"] == 30 + + @patch("ss_tools.shared._llm_health._check_llm_provider_health", AsyncMock(return_value="timeout")) + @patch("ss_tools.shared._llm_health._llm_status", {"status": "timeout", "last_error": "Request timed out"}) + def test_status_timeout(self): + """Returns timeout status.""" + client = _make_client() + resp = client.get("/api/agent/llm-status") + + assert resp.status_code == 200 + assert resp.json()["status"] == "timeout" + + @patch("ss_tools.shared._llm_health._check_llm_provider_health", AsyncMock(return_value="auth_error")) + @patch("ss_tools.shared._llm_health._llm_status", {"status": "auth_error", "last_error": "Invalid API key"}) + def test_status_auth_error(self): + """Returns auth_error status.""" + client = _make_client() + resp = client.get("/api/agent/llm-status") + + assert resp.status_code == 200 + assert resp.json()["status"] == "auth_error" + assert resp.json()["last_error"] == "Invalid API key" + + @patch("ss_tools.shared._llm_health._check_llm_provider_health", AsyncMock(return_value="ok")) + @patch("ss_tools.shared._llm_health._llm_status", {"status": "ok", "last_error": ""}) + def test_no_auth_required(self): + """Status endpoint does not require authentication.""" + client = _make_client() + # No auth token — should still work since no Depends on get_current_user + resp = client.get("/api/agent/llm-status") + assert resp.status_code == 200 + + def test_check_health_called(self): + """_check_llm_provider_health is called on each request.""" + mock_check = AsyncMock(return_value="ok") + with ( + patch("ss_tools.shared._llm_health._check_llm_provider_health", mock_check), + patch("ss_tools.shared._llm_health._llm_status", {"status": "ok", "last_error": ""}), + ): + client = _make_client() + resp = client.get("/api/agent/llm-status") + + assert resp.status_code == 200 + mock_check.assert_called_once() +# #endregion Test.Api.AgentStatusRoutes diff --git a/backend/tests/api/test_agent_superset_routes.py b/backend/tests/api/test_agent_superset_routes.py new file mode 100644 index 000000000..ef346d02f --- /dev/null +++ b/backend/tests/api/test_agent_superset_routes.py @@ -0,0 +1,395 @@ +# #region Test.Api.AgentSupersetRoutes [C:3] [TYPE Module] [SEMANTICS test,agent,superset,sql,dashboard,dataset,database] +# @BRIEF Unit tests for Agent Superset proxy API routes — write and read endpoints. +# @RELATION BINDS_TO -> [Api.AgentSuperset.AgentSupersetRoutes, Api.AgentSupersetExplore.AgentSupersetExploreRoutes] +# @TEST_EDGE: environment_not_found -> 404 +# @TEST_EDGE: superset_client_error -> 500 + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") +os.environ.setdefault("DEV_MODE", "true") + +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +_src = str(Path(__file__).resolve().parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + + +def _make_mock_user() -> MagicMock: + """Build a mock admin user for auth bypass.""" + from src.schemas.auth import RoleSchema, User as UserSchema + + admin_role = RoleSchema( + id="r1", name="Admin", description="", is_admin=True, permissions=[] + ) + user = UserSchema( + id="admin-1", + username="admin", + email="admin@x.com", + auth_source="LOCAL", + created_at=datetime.now(), + roles=[admin_role], + ) + return user + + +def _make_mock_superset_client() -> AsyncMock: + """Build a mock SupersetClient with AsyncMock methods.""" + client = AsyncMock() + # Write module methods + client.execute_sql = AsyncMock(return_value={"status": "success", "data": [{"col": 1}]}) + client.format_sql = AsyncMock(return_value="SELECT * FROM foo") + client.estimate_sql_cost = AsyncMock(return_value={"cost": 10}) + client.create_dashboard = AsyncMock(return_value={"id": 101, "slug": "new-dash"}) + client.copy_dashboard = AsyncMock(return_value={"id": 102, "slug": "copy-dash"}) + client.update_dashboard = AsyncMock(return_value={"id": 101, "slug": "updated-dash"}) + client.create_dataset = AsyncMock(return_value={"id": 201, "table_name": "new_table"}) + client.delete_dataset = AsyncMock(return_value={"status": "deleted"}) + client.duplicate_dataset = AsyncMock(return_value={"id": 202, "table_name": "dup_table"}) + client.refresh_dataset_schema = AsyncMock(return_value={"status": "refreshed"}) + # Explore module methods + client.get_databases_summary = AsyncMock(return_value=[{"id": 1, "name": "main", "engine": "postgresql"}]) + client.get_database_schemas = AsyncMock(return_value=["public", "analytics"]) + client.get_database_tables = AsyncMock(return_value=[{"id": 1, "name": "users"}]) + client.get_database_table_metadata = AsyncMock(return_value={"columns": [{"name": "id", "type": "integer"}]}) + client.get_database_select_star = AsyncMock(return_value="SELECT * FROM users") + client.validate_sql = AsyncMock(return_value={"valid": True}) + client.test_database_connection = AsyncMock(return_value={"status": "ok"}) + client.permissions_audit = AsyncMock(return_value={"users": [], "total": 0}) + client.get_saved_queries = AsyncMock(return_value=(0, [])) + client.get_saved_query = AsyncMock(return_value={"id": 1, "sql": "SELECT 1"}) + client.aclose = AsyncMock() + return client + + +def _make_client(overrides: dict | None = None) -> TestClient: + """Build a TestClient with both agent superset routers.""" + from src.api.routes.agent_superset import router as write_router + from src.api.routes.agent_superset_explore import router as explore_router + from src.dependencies import get_current_user + + app = FastAPI() + app.include_router(write_router) + app.include_router(explore_router) + + mock_user = _make_mock_user() + app.dependency_overrides[get_current_user] = lambda: mock_user + if overrides: + for dep, fn in overrides.items(): + app.dependency_overrides[dep] = fn + return TestClient(app, raise_server_exceptions=False) + + +# ════════════════════════════════════════════════════ +# Write module — agent_superset.py +# ════════════════════════════════════════════════════ + + +class TestAgentSqlLabExecute: + """POST /api/agent/superset/sqllab/execute""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post("/api/agent/superset/sqllab/execute?environment_id=env-1&database_id=1&sql=SELECT+1") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "success" + + # Note: no 500-error test here because agent_superset routes use + # try/finally without except — RuntimeErrors propagate uncaught. + + +class TestAgentSqlLabFormat: + """POST /api/agent/superset/sqllab/format""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post("/api/agent/superset/sqllab/format?environment_id=env-1&sql=SELECT+*+FROM+foo") + assert resp.status_code == 200 + assert resp.json()["result"] == "SELECT * FROM foo" + + +class TestAgentSqlLabEstimate: + """POST /api/agent/superset/sqllab/estimate""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post("/api/agent/superset/sqllab/estimate?environment_id=env-1&database_id=1&sql=SELECT+1") + assert resp.status_code == 200 + assert resp.json()["cost"] == 10 + + +class TestAgentDashboardCreate: + """POST /api/agent/superset/dashboards""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post("/api/agent/superset/dashboards?environment_id=env-1&dashboard_title=My+Dashboard") + assert resp.status_code == 200 + assert resp.json()["id"] == 101 + + +class TestAgentDashboardCopy: + """POST /api/agent/superset/dashboards/{dashboard_id}/copy""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post("/api/agent/superset/dashboards/42/copy?environment_id=env-1&dashboard_title=Copy") + assert resp.status_code == 200 + assert resp.json()["id"] == 102 + + +class TestAgentDashboardUpdate: + """PUT /api/agent/superset/dashboards/{dashboard_id}""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.put("/api/agent/superset/dashboards/42?environment_id=env-1&dashboard_title=Updated") + assert resp.status_code == 200 + assert resp.json()["slug"] == "updated-dash" + + +class TestAgentDatasetCreate: + """POST /api/agent/superset/datasets""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post("/api/agent/superset/datasets?environment_id=env-1&table_name=my_table&database=1") + assert resp.status_code == 200 + assert resp.json()["table_name"] == "new_table" + + +class TestAgentDatasetDelete: + """DELETE /api/agent/superset/datasets/{dataset_id}""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.delete("/api/agent/superset/datasets/42?environment_id=env-1") + assert resp.status_code == 200 + assert resp.json()["status"] == "deleted" + + +class TestAgentDatasetDuplicate: + """POST /api/agent/superset/datasets/{dataset_id}/duplicate""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post("/api/agent/superset/datasets/42/duplicate?environment_id=env-1&table_name=dup_table") + assert resp.status_code == 200 + assert resp.json()["table_name"] == "dup_table" + + +class TestAgentDatasetRefresh: + """POST /api/agent/superset/datasets/{dataset_id}/refresh""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post("/api/agent/superset/datasets/42/refresh?environment_id=env-1") + assert resp.status_code == 200 + assert resp.json()["status"] == "refreshed" + + +# ════════════════════════════════════════════════════ +# Explore module — agent_superset_explore.py +# ════════════════════════════════════════════════════ + + +class TestAgentListDatabases: + """GET /api/agent/superset/databases""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.get("/api/agent/superset/databases?environment_id=env-1") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["name"] == "main" + + # Note: no 500-error test here — explore routes use try/finally without except. + + +class TestAgentDatabaseSchemas: + """GET /api/agent/superset/databases/{database_id}/schemas""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.get("/api/agent/superset/databases/1/schemas?environment_id=env-1") + assert resp.status_code == 200 + assert "public" in resp.json() + + # Note: no 500-error test here — explore routes use try/finally without except. + + +class TestAgentDatabaseTables: + """GET /api/agent/superset/databases/{database_id}/tables""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.get("/api/agent/superset/databases/1/tables?environment_id=env-1") + assert resp.status_code == 200 + assert resp.json()[0]["name"] == "users" + + +class TestAgentDatabaseTableMetadata: + """GET /api/agent/superset/databases/{database_id}/table_metadata""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.get("/api/agent/superset/databases/1/table_metadata?environment_id=env-1&table_name=users") + assert resp.status_code == 200 + assert resp.json()["columns"][0]["name"] == "id" + + +class TestAgentDatabaseSelectStar: + """GET /api/agent/superset/databases/{database_id}/select_star""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.get("/api/agent/superset/databases/1/select_star?environment_id=env-1&table_name=users") + assert resp.status_code == 200 + assert resp.json()["sql"] == "SELECT * FROM users" + + +class TestAgentDatabaseValidateSql: + """POST /api/agent/superset/databases/{database_id}/validate_sql""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post("/api/agent/superset/databases/1/validate_sql?environment_id=env-1&sql=SELECT+1") + assert resp.status_code == 200 + assert resp.json()["valid"] is True + + +class TestAgentDatabaseTestConnection: + """POST /api/agent/superset/databases/test_connection""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post( + "/api/agent/superset/databases/test_connection?environment_id=env-1&database_name=test&sqlalchemy_uri=postgresql:///test" + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + def test_with_extra(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post( + "/api/agent/superset/databases/test_connection?environment_id=env-1&database_name=test&sqlalchemy_uri=postgresql:///test&extra={}" + ) + assert resp.status_code == 200 + + +class TestAgentAuditPermissions: + """GET /api/agent/superset/audit/permissions""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.get("/api/agent/superset/audit/permissions?environment_id=env-1") + assert resp.status_code == 200 + assert resp.json()["total"] == 0 + + def test_with_filters(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.get("/api/agent/superset/audit/permissions?environment_id=env-1&page=1&page_size=50&username_filter=admin&include_admin=true") + assert resp.status_code == 200 + + +class TestAgentSavedQueryList: + """GET /api/agent/superset/saved_queries""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.get("/api/agent/superset/saved_queries?environment_id=env-1") + assert resp.status_code == 200 + assert resp.json()["count"] == 0 + + +class TestAgentSavedQueryGet: + """GET /api/agent/superset/saved_queries/{query_id}""" + + def test_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset_explore._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.get("/api/agent/superset/saved_queries/1?environment_id=env-1") + assert resp.status_code == 200 + assert resp.json()["id"] == 1 + + +# ════════════════════════════════════════════════════ +# Error — Superset client aclose always called +# ════════════════════════════════════════════════════ + + +class TestSupersetClientCleanup: + """Verify that aclose() is called after each request.""" + + def test_aclose_called_on_success(self): + client_sup = _make_mock_superset_client() + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post("/api/agent/superset/sqllab/execute?environment_id=env-1&database_id=1&sql=SELECT+1") + assert resp.status_code == 200 + client_sup.aclose.assert_called_once() + + def test_aclose_called_on_error(self): + client_sup = _make_mock_superset_client() + client_sup.execute_sql.side_effect = RuntimeError("boom") + with patch("src.api.routes.agent_superset._get_superset_client", AsyncMock(return_value=client_sup)): + client = _make_client() + resp = client.post("/api/agent/superset/sqllab/execute?environment_id=env-1&database_id=1&sql=SELECT+1") + assert resp.status_code == 500 + client_sup.aclose.assert_called_once() +# #endregion Test.Api.AgentSupersetRoutes diff --git a/backend/tests/api/test_git_release_routes.py b/backend/tests/api/test_git_release_routes.py new file mode 100644 index 000000000..a9b8e8fa3 --- /dev/null +++ b/backend/tests/api/test_git_release_routes.py @@ -0,0 +1,731 @@ +# #region Test.Api.GitReleaseRoutes [C:3] [TYPE Module] [SEMANTICS test,git,release,approval,publication] +# @BRIEF Unit tests for Git release API routes — policy, create, approve, publish. +# @RELATION BINDS_TO -> [Api.ReleaseRoutes.GitReleaseRoutes] +# @TEST_EDGE: release_not_found -> 404 +# @TEST_EDGE: approval_gate_error -> 409 +# @TEST_EDGE: publish_gate_error -> 409 +# @TEST_EDGE: non_admin_cannot_update_policy -> 403 + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") +os.environ.setdefault("DEV_MODE", "true") + +import sys +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pydantic import BaseModel + +_src = str(Path(__file__).resolve().parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + + +# ── Shared test fixtures ── + + +def _make_mock_repository(**overrides) -> MagicMock: + """Build a GitRepository mock with sensible defaults.""" + repo = MagicMock() + repo.id = "repo-1" + repo.dashboard_id = 42 + repo.config_id = "cfg-1" + repo.remote_url = "https://example.com/org/repo.git" + repo.local_path = "/tmp/repo" + repo.current_branch = "dev" + repo.release_policy = None + for k, v in overrides.items(): + setattr(repo, k, v) + return repo + + +def _make_mock_release(**overrides) -> MagicMock: + """Build a DashboardRelease mock with sensible defaults.""" + release = MagicMock() + release.id = "release-1" + release.repository_id = "repo-1" + release.deployment_id = "deploy-42" + release.name = "v2.0" + release.version = "2.0.0" + release.notes = "Major release" + release.commit_hash = "abc123def456" + release.content_hash = "c0ffee42" + release.status = "awaiting_approval" + release.created_by = "admin" + release.created_at = datetime.now(timezone.utc) + release.approved_at = None + release.approved_by = None + release.approval_comment = None + release.published_at = None + release.published_by = None + for k, v in overrides.items(): + setattr(release, k, v) + return release + + +def _make_mock_deployment(**overrides) -> MagicMock: + """Build a DeploymentRecord mock.""" + dep = MagicMock() + dep.id = "deploy-42" + dep.repository_id = "repo-1" + dep.environment_id = "preprod-1" + dep.status = "success" + dep.commit_hash = "abc123def456" + dep.content_hash = "c0ffee42" + dep.validation_status = "validated" + dep.deployed_at = datetime.now(timezone.utc) + dep.validated_at = datetime.now(timezone.utc) + dep.validated_by = "admin" + dep.resources_changed = {"source_branch": "dev"} + for k, v in overrides.items(): + setattr(dep, k, v) + return dep + + +def _make_config_manager(overrides: dict | None = None) -> MagicMock: + """Build a config_manager mock with release policy defaults.""" + mgr = MagicMock() + cfg = MagicMock() + + class _FakeReleaseSettings: + """Duck-typed settings object with model_dump for _resolve_policy.""" + require_prod_approval = True + approval_roles = ["Admin"] + require_approval_comment = False + approval_expires_hours = 0 + block_publish_on_drift = True + + def model_dump(self): + return { + "require_prod_approval": self.require_prod_approval, + "approval_roles": self.approval_roles, + "require_approval_comment": self.require_approval_comment, + "approval_expires_hours": self.approval_expires_hours, + "block_publish_on_drift": self.block_publish_on_drift, + } + + release_settings = _FakeReleaseSettings() + cfg.settings.git_release = release_settings + mgr.get_config.return_value = cfg + if overrides: + for k, v in overrides.items(): + setattr(mgr, k, v) + return mgr + + +def _make_mock_user(is_admin: bool = True) -> MagicMock: + """Build a user mock with an admin or regular role.""" + from src.schemas.auth import RoleSchema, User as UserSchema + from datetime import datetime + + admin_role = RoleSchema( + id="r1", + name="Admin", + description="", + is_admin=is_admin, + permissions=[], + ) + user = UserSchema( + id="admin-1", + username="admin" if is_admin else "user", + email="admin@x.com" if is_admin else "user@x.com", + auth_source="LOCAL", + created_at=datetime.now(), + roles=[admin_role] if is_admin else [], + ) + return user + + +def _make_client( + db_mock: MagicMock | None = None, + config_manager_mock: MagicMock | None = None, + user_mock: MagicMock | None = None, + overrides: dict | None = None, +) -> TestClient: + from src.api.routes.git._router import router as parent_router + from src.core.database import get_db + from src.dependencies import get_config_manager, get_current_user + + app = FastAPI() + app.include_router(parent_router) + + if user_mock is None: + user_mock = _make_mock_user(is_admin=True) + if config_manager_mock is None: + config_manager_mock = _make_config_manager() + if db_mock is None: + db_mock = MagicMock() + + app.dependency_overrides[get_current_user] = lambda: user_mock + app.dependency_overrides[get_config_manager] = lambda: config_manager_mock + app.dependency_overrides[get_db] = lambda: db_mock + if overrides: + for dep, fn in overrides.items(): + app.dependency_overrides[dep] = fn + return TestClient(app) + + +# ── get_release_policy ── + + +class TestGetReleasePolicy: + """GET /repositories/{dashboard_ref}/release-policy""" + + def test_success(self): + """Returns the effective release policy for a repository.""" + repo = _make_mock_repository() + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = repo + cm = _make_config_manager() + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.get("/repositories/test-dash/release-policy") + + assert resp.status_code == 200 + data = resp.json() + assert data["require_prod_approval"] is True + assert data["approval_roles"] == ["Admin"] + assert data["is_override"] is False + + def test_with_repo_override(self): + """Repository-level policy overrides the installation default.""" + repo = _make_mock_repository(release_policy={"require_prod_approval": False, "block_publish_on_drift": False}) + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = repo + cm = _make_config_manager() + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.get("/repositories/test-dash/release-policy") + + assert resp.status_code == 200 + data = resp.json() + assert data["require_prod_approval"] is False + assert data["block_publish_on_drift"] is False + assert data["is_override"] is True + + def test_repo_not_found(self): + """Returns 404 when repository is not initialized.""" + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = None + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock) + resp = client.get("/repositories/test-dash/release-policy") + + assert resp.status_code == 404 + assert "not initialized" in resp.json()["detail"] + + # Note: no test_unexpected_error here because get_release_policy + # lacks a try/except block — RuntimeErrors propagate uncaught. + +# ── update_release_policy ── + + +class TestUpdateReleasePolicy: + """PUT /repositories/{dashboard_ref}/release-policy""" + + POLICY_PAYLOAD = {"require_prod_approval": False, "block_publish_on_drift": False} + + def test_success(self): + """Admin can update the release policy override.""" + repo = _make_mock_repository() + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = repo + cm = _make_config_manager() + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.put("/repositories/test-dash/release-policy", json=self.POLICY_PAYLOAD) + + assert resp.status_code == 200 + data = resp.json() + assert data["require_prod_approval"] is False + db_mock.commit.assert_called_once() + + def test_non_admin_forbidden(self): + """Non-admin users receive 403.""" + repo = _make_mock_repository() + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = repo + + user_mock = _make_mock_user(is_admin=False) + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock, user_mock=user_mock) + resp = client.put("/repositories/test-dash/release-policy", json=self.POLICY_PAYLOAD) + + assert resp.status_code == 403 + + def test_repo_not_found(self): + """Returns 404 when repository is not initialized.""" + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = None + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock) + resp = client.put("/repositories/test-dash/release-policy", json=self.POLICY_PAYLOAD) + + assert resp.status_code == 404 + + +# ── list_releases ── + + +class TestListReleases: + """GET /repositories/{dashboard_ref}/releases""" + + def test_success_empty(self): + """Returns empty list when no releases exist.""" + repo = _make_mock_repository() + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = repo + db_mock.query.return_value.filter.return_value.order_by.return_value.all.return_value = [] + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock) + resp = client.get("/repositories/test-dash/releases") + + assert resp.status_code == 200 + assert resp.json() == [] + + def test_success_with_releases(self): + """Returns releases ordered by created_at desc.""" + repo = _make_mock_repository() + from datetime import timezone + release = _make_mock_release() + + releases = [release] + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = repo + db_mock.query.return_value.filter.return_value.order_by.return_value.all.return_value = releases + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock) + resp = client.get("/repositories/test-dash/releases") + + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["name"] == "v2.0" + assert data[0]["version"] == "2.0.0" + + def test_repo_not_found(self): + """Returns 404 when repository is not initialized.""" + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = None + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock) + resp = client.get("/repositories/test-dash/releases") + + assert resp.status_code == 404 + + +# ── create_release ── + + +class TestCreateRelease: + """POST /repositories/{dashboard_ref}/releases""" + + CREATE_PAYLOAD = {"name": "v2.0", "version": "2.0.0", "notes": "Major release"} + + def _make_query_chain(self, db_mock, first_result=None, candidate_result=None): + """Configure db_mock.query chains for consistent create_release mocking. + + create_release uses db.query() in three patterns: + 1. repo: db.query(GitRepository).filter(...).first() + 2. candidate: db.query(DeploymentRecord).filter(cond1,cond2,cond3).order_by(...).first() + 3. dup: db.query(DashboardRelease).filter(...).first() + + Since db.query() returns the same mock each time, we set up + the filter chain with side_effect for .first() on the base + filter, and a separate chain for the order_by().first(). + """ + from datetime import datetime + + q = MagicMock(name="query") + db_mock.query.return_value = q + + # Pattern 1 & 3: q.filter().first() + # _get_repository calls this first (needs first_result = repo) + # duplicate check calls this third (needs None) + if first_result is not None: + q.filter.return_value.first.side_effect = [first_result, None] + + # Pattern 2: q.filter(cond1,cond2,cond3).order_by(...).first() + # Note: filter() is called ONCE with 3 args, not 3 chained filter() calls + f1 = q.filter.return_value # q.filter(cond1, cond2, cond3) + f2 = f1.order_by.return_value # .order_by(desc, desc) + if candidate_result is not None: + f2.first.return_value = candidate_result + + # Make db.refresh populate id and created_at on SQLAlchemy model instances + def _refresh_model(instance): + if not hasattr(instance, '_sa_instance_state'): + return + if not getattr(instance, 'id', None): + instance.id = "auto-id-42" + if not getattr(instance, 'created_at', None): + instance.created_at = datetime.now() + db_mock.refresh.side_effect = _refresh_model + + return q + + def test_success_without_approval(self): + """Creates a release with ready_to_publish when approval is not required.""" + repo = _make_mock_repository() + deployment = _make_mock_deployment() + cm = _make_config_manager() + cm.get_config.return_value.settings.git_release.require_prod_approval = False + + db_mock = MagicMock() + self._make_query_chain(db_mock, first_result=repo, candidate_result=deployment) + + with ( + patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), + patch("src.api.routes.git._release_routes._resolve_stage_environment") as mock_resolve_env, + patch("src.api.routes.git._release_routes._probe_drift", AsyncMock(return_value=("in_sync", "c0ffee42"))), + ): + mock_resolve_env.return_value = MagicMock(id="preprod-1") + + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.post("/repositories/test-dash/releases", json=self.CREATE_PAYLOAD) + + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "v2.0" + assert data["status"] == "ready_to_publish" + assert data["created_by"] == "admin" + + def test_success_with_approval_required(self): + """Creates a release with awaiting_approval when policy requires prod approval.""" + repo = _make_mock_repository() + deployment = _make_mock_deployment() + cm = _make_config_manager() + cm.get_config.return_value.settings.git_release.require_prod_approval = True + + db_mock = MagicMock() + self._make_query_chain(db_mock, first_result=repo, candidate_result=deployment) + + with ( + patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), + patch("src.api.routes.git._release_routes._resolve_stage_environment") as mock_resolve_env, + patch("src.api.routes.git._release_routes._probe_drift", AsyncMock(return_value=("in_sync", "c0ffee42"))), + ): + mock_resolve_env.return_value = MagicMock(id="preprod-1") + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.post("/repositories/test-dash/releases", json=self.CREATE_PAYLOAD) + + assert resp.status_code == 201 + assert resp.json()["status"] == "awaiting_approval" + + def test_no_validated_preprod(self): + """Returns 409 when PREPROD deployment is not validated.""" + repo = _make_mock_repository() + deployment = _make_mock_deployment(validation_status="pending") + cm = _make_config_manager() + + db_mock = MagicMock() + self._make_query_chain(db_mock, first_result=repo, candidate_result=deployment) + + with ( + patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), + patch("src.api.routes.git._release_routes._resolve_stage_environment") as mock_resolve_env, + ): + mock_resolve_env.return_value = MagicMock(id="preprod-1") + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.post("/repositories/test-dash/releases", json=self.CREATE_PAYLOAD) + + assert resp.status_code == 409 + assert "Validate the current PREPROD deployment" in resp.json()["detail"] + + def test_no_preprod_deployment(self): + """Returns 409 when no successful PREPROD deployment exists.""" + repo = _make_mock_repository() + cm = _make_config_manager() + + db_mock = MagicMock() + self._make_query_chain(db_mock, first_result=repo, candidate_result=None) + + with ( + patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), + patch("src.api.routes.git._release_routes._resolve_stage_environment") as mock_resolve_env, + ): + mock_resolve_env.return_value = MagicMock(id="preprod-1") + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.post("/repositories/test-dash/releases", json=self.CREATE_PAYLOAD) + + assert resp.status_code == 409 + assert "Validate the current PREPROD deployment" in resp.json()["detail"] + + def test_drift_blocked(self): + """Returns 409 when PREPROD has drifted from the candidate.""" + repo = _make_mock_repository() + deployment = _make_mock_deployment() + cm = _make_config_manager() + cm.get_config.return_value.settings.git_release.block_publish_on_drift = True + + db_mock = MagicMock() + self._make_query_chain(db_mock, first_result=repo, candidate_result=deployment) + + with ( + patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), + patch("src.api.routes.git._release_routes._resolve_stage_environment") as mock_resolve_env, + patch("src.api.routes.git._release_routes._probe_drift", AsyncMock(return_value=("drifted", "different"))), + ): + mock_resolve_env.return_value = MagicMock(id="preprod-1") + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.post("/repositories/test-dash/releases", json=self.CREATE_PAYLOAD) + + assert resp.status_code == 409 + assert "PREPROD differs" in resp.json()["detail"] + + def test_duplicate_deployment(self): + """Returns 409 when this deployment already has a named release.""" + repo = _make_mock_repository() + deployment = _make_mock_deployment() + existing = _make_mock_release() + cm = _make_config_manager() + + db_mock = MagicMock() + self._make_query_chain(db_mock, first_result=repo, candidate_result=deployment) + + # Override side_effect: make duplicate check return existing + # _get_repository uses side_effect[0] (repo), duplicate check uses side_effect[1] + q = db_mock.query.return_value + q.filter.return_value.first.side_effect = [repo, existing] + + with ( + patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), + patch("src.api.routes.git._release_routes._resolve_stage_environment") as mock_resolve_env, + patch("src.api.routes.git._release_routes._probe_drift", AsyncMock(return_value=("in_sync", "c0ffee42"))), + ): + mock_resolve_env.return_value = MagicMock(id="preprod-1") + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.post("/repositories/test-dash/releases", json=self.CREATE_PAYLOAD) + + assert resp.status_code == 409 + + def test_duplicate_version(self): + """Returns 409 when release version already exists (DB constraint).""" + repo = _make_mock_repository() + deployment = _make_mock_deployment() + cm = _make_config_manager() + + db_mock = MagicMock() + self._make_query_chain(db_mock, first_result=repo, candidate_result=deployment) + db_mock.commit.side_effect = Exception("duplicate key") + + with ( + patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), + patch("src.api.routes.git._release_routes._resolve_stage_environment") as mock_resolve_env, + patch("src.api.routes.git._release_routes._probe_drift", AsyncMock(return_value=("in_sync", "c0ffee42"))), + ): + mock_resolve_env.return_value = MagicMock(id="preprod-1") + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.post("/repositories/test-dash/releases", json=self.CREATE_PAYLOAD) + + assert resp.status_code == 409 + assert "Release version already exists" in resp.json()["detail"] + + def test_repo_not_found(self): + """Returns 404 when repository is not initialized.""" + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = None + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock) + resp = client.post("/repositories/test-dash/releases", json=self.CREATE_PAYLOAD) + + assert resp.status_code == 404 + + +# ── approve_release ── + + +class TestApproveRelease: + """POST /repositories/{dashboard_ref}/releases/{release_id}/approve""" + + APPROVE_PAYLOAD = {"comment": "Looks good"} + + def test_success(self): + """Approves a release that is awaiting approval.""" + repo = _make_mock_repository() + release = _make_mock_release(status="awaiting_approval") + cm = _make_config_manager() + + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.side_effect = [repo, release] + + with ( + patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), + patch("src.api.routes.git._release_routes._enforce_approval_policy"), + ): + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.post("/repositories/test-dash/releases/release-1/approve", json=self.APPROVE_PAYLOAD) + + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ready_to_publish" + assert data["approved_by"] == "admin" + + def test_release_not_found(self): + """Returns 404 when release does not exist.""" + repo = _make_mock_repository() + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.side_effect = [repo, None] + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock) + resp = client.post("/repositories/test-dash/releases/release-999/approve", json=self.APPROVE_PAYLOAD) + + assert resp.status_code == 404 + + def test_not_awaiting_approval(self): + """Returns 409 when release is not in awaiting_approval status.""" + repo = _make_mock_repository() + release = _make_mock_release(status="published") + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.side_effect = [repo, release] + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock) + resp = client.post("/repositories/test-dash/releases/release-1/approve", json=self.APPROVE_PAYLOAD) + + assert resp.status_code == 409 + assert "not awaiting approval" in resp.json()["detail"] + + def test_approval_policy_rejected(self): + """Returns 403 when approval policy rejects the user.""" + repo = _make_mock_repository() + release = _make_mock_release(status="awaiting_approval") + cm = _make_config_manager() + + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.side_effect = [repo, release] + + with ( + patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), + patch("src.api.routes.git._release_routes._enforce_approval_policy", side_effect=HTTPException(status_code=403, detail="Your role cannot approve")), + ): + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.post("/repositories/test-dash/releases/release-1/approve", json=self.APPROVE_PAYLOAD) + + assert resp.status_code == 403 + + def test_repo_not_found(self): + """Returns 404 when repository is not initialized.""" + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = None + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock) + resp = client.post("/repositories/test-dash/releases/release-1/approve", json=self.APPROVE_PAYLOAD) + + assert resp.status_code == 404 + + +# ── publish_release ── + + +class TestPublishRelease: + """POST /repositories/{dashboard_ref}/releases/{release_id}/publish""" + + def test_success(self): + """Publishes a release by delegating to deploy_dashboard.""" + repo = _make_mock_repository() + release = _make_mock_release(status="ready_to_publish") + deploy_result = {"status": "deployed", "target": "prod"} + + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.side_effect = [repo, release] + cm = _make_config_manager() + + with ( + patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), + patch("src.api.routes.git._repo_lifecycle_routes.deploy_dashboard", AsyncMock(return_value=deploy_result)), + ): + client = _make_client(db_mock=db_mock, config_manager_mock=cm) + resp = client.post("/repositories/test-dash/releases/release-1/publish") + + assert resp.status_code == 200 + assert resp.json() == deploy_result + + def test_release_not_found(self): + """Returns 404 when release does not exist.""" + repo = _make_mock_repository() + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.side_effect = [repo, None] + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock) + resp = client.post("/repositories/test-dash/releases/release-999/publish") + + assert resp.status_code == 404 + + def test_repo_not_found(self): + """Returns 404 when repository is not initialized.""" + db_mock = MagicMock() + db_mock.query.return_value.filter.return_value.first.return_value = None + + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)): + client = _make_client(db_mock=db_mock) + resp = client.post("/repositories/test-dash/releases/release-1/publish") + + assert resp.status_code == 404 + + # Note: no test_unexpected_error here because publish_release + # lacks a try/except block — RuntimeErrors propagate uncaught. + +# ── HTTPException propagation ── + + +class TestHttpExceptionPropagation: + """HTTPException from _resolve_dashboard_id_from_ref must propagate unchanged.""" + + HTTPERR = HTTPException(status_code=404, detail="Not found") + + def test_get_release_policy_http_error(self): + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): + client = _make_client() + resp = client.get("/repositories/42/release-policy") + assert resp.status_code == 404 + + def test_update_release_policy_http_error(self): + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): + client = _make_client() + resp = client.put("/repositories/42/release-policy", json={"require_prod_approval": False}) + assert resp.status_code == 404 + + def test_list_releases_http_error(self): + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): + client = _make_client() + resp = client.get("/repositories/42/releases") + assert resp.status_code == 404 + + def test_create_release_http_error(self): + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): + client = _make_client() + resp = client.post("/repositories/42/releases", json={"name": "v1", "version": "1.0", "notes": "x"}) + assert resp.status_code == 404 + + def test_approve_release_http_error(self): + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): + client = _make_client() + resp = client.post("/repositories/42/releases/r-1/approve", json={"comment": "ok"}) + assert resp.status_code == 404 + + def test_publish_release_http_error(self): + with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): + client = _make_client() + resp = client.post("/repositories/42/releases/r-1/publish") + assert resp.status_code == 404 +# #endregion Test.Api.GitReleaseRoutes diff --git a/frontend/src/lib/api/__tests__/api.test.ts b/frontend/src/lib/api/__tests__/api.test.ts index 57d1666d9..abb66a706 100644 --- a/frontend/src/lib/api/__tests__/api.test.ts +++ b/frontend/src/lib/api/__tests__/api.test.ts @@ -1526,6 +1526,230 @@ describe('ApiModule — registry methods', () => { expect.any(Object), ); }); + + it('getAppLogsWsUrl builds URL with level filter', async () => { + const { getAppLogsWsUrl } = await import('$lib/api.js'); + vi.stubGlobal('window', { location: { protocol: 'https:', host: 'app.example.com' } }); + localStorage.setItem('auth_token', 'tok'); + const url = getAppLogsWsUrl({ level: 'error', taskIds: ['task-1', 'task-2'] }); + expect(url).toContain('level=ERROR'); + expect(url).toContain('task_id=task-1%2Ctask-2'); + expect(url).toContain('wss://'); + expect(url).toContain('token=tok'); + }); + + it('getAppLogsWsUrl omits level when "all"', async () => { + const { getAppLogsWsUrl } = await import('$lib/api.js'); + vi.stubGlobal('window', { location: { protocol: 'http:', host: 'localhost:5173' } }); + const url = getAppLogsWsUrl({ level: 'all' }); + expect(url).not.toContain('level='); + }); + + it('getAppLogsWsUrl with no options returns base URL with trace param', async () => { + const { getAppLogsWsUrl } = await import('$lib/api.js'); + vi.stubGlobal('window', { location: { protocol: 'ws:', host: 'localhost:8000' } }); + const url = getAppLogsWsUrl(); + expect(url).toContain('ws://localhost:8000/ws/app-logs'); + expect(url).toContain('x-trace-id='); + }); + + it('getStorageFiles without subpath', async () => { + const blob = new Blob(['[]']); + vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 200, json: () => Promise.resolve([{ name: 'f1.yaml' }]), + } as Response); + const { api } = await import('$lib/api.js'); + const result = await api.getStorageFiles('backups'); + expect(result).toEqual([{ name: 'f1.yaml' }]); + expect(fetch).toHaveBeenCalledWith('/api/storage/files?category=backups', expect.any(Object)); + }); + + it('getStorageFiles with subpath and options', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 200, json: () => Promise.resolve([{ name: 'f2.json' }]), + } as Response); + const { api } = await import('$lib/api.js'); + const result = await api.getStorageFiles('exports', '/daily', { signal: AbortSignal.timeout(1000) }); + expect(result).toEqual([{ name: 'f2.json' }]); + expect(fetch).toHaveBeenCalledWith( + '/api/storage/files?category=exports&path=%2Fdaily', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('requestApi with URLSearchParams body (form) uses no Content-Type', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 200, json: () => Promise.resolve({ access_token: 'tok' }), + } as Response); + const { api } = await import('$lib/api.js'); + const body = new URLSearchParams({ username: 'admin', password: 'pass' }); + await api.login(body); + expect(fetch).toHaveBeenCalledWith( + '/api/auth/login', + expect.objectContaining({ + method: 'POST', + headers: expect.not.objectContaining({ 'Content-Type': expect.any(String) }), + body, + }), + ); + }); + + it('getGitReleasePolicy calls correct endpoint', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 200, json: () => Promise.resolve({ require_prod_approval: true }), + } as Response); + const { api } = await import('$lib/api.js'); + const result = await api.getGitReleasePolicy(); + expect(result).toEqual({ require_prod_approval: true }); + expect(fetch).toHaveBeenCalledWith( + '/api/settings/git-release-policy', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('updateGitReleasePolicy sends PUT', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 200, json: () => Promise.resolve({ success: true }), + } as Response); + const { api } = await import('$lib/api.js'); + const policy = { require_prod_approval: false, approval_roles: ['admin'] }; + await api.updateGitReleasePolicy(policy); + expect(fetch).toHaveBeenCalledWith( + '/api/settings/git-release-policy', + expect.objectContaining({ method: 'PUT', body: JSON.stringify(policy) }), + ); + }); + + it('getDashboards builds filter_changed_on_from/to params', async () => { + vi.mocked(fetch).mockResolvedValue(await _okJson({ dashboards: [], total: 0 })); + const { api } = await import('$lib/api.js'); + await api.getDashboards('env-1', { + filter_changed_on_from: '2026-07-01', + filter_changed_on_to: '2026-07-31', + }); + const calledUrl = vi.mocked(fetch).mock.calls[0][0]; + expect(calledUrl).toContain('filter_changed_on_from=2026-07-01'); + expect(calledUrl).toContain('filter_changed_on_to=2026-07-31'); + }); + + it('getDatasets builds filter param', async () => { + vi.mocked(fetch).mockResolvedValue(await _okJson({ datasets: [], total: 0 })); + const { api } = await import('$lib/api.js'); + await api.getDatasets('env-1', { filter: 'datasource:12', page: '2', page_size: '50' }); + const calledUrl = vi.mocked(fetch).mock.calls[0][0]; + expect(calledUrl).toContain('filter=datasource%3A12'); + expect(calledUrl).toContain('page=2'); + expect(calledUrl).toContain('page_size=50'); + }); + + it('uploadFile with extra fields', async () => { + localStorage.setItem('auth_token', 'upload-tok'); + const { getTraceId } = await import('$lib/cot-logger.js'); + vi.mocked(getTraceId).mockReturnValue('upload-trace'); + vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 200, json: () => Promise.resolve({ path: '/tmp/f.xlsx' }), + } as Response); + const { uploadFile } = await import('$lib/api.js'); + const file = new File(['data'], 'f.xlsx'); + await uploadFile('/tools/mapper/upload-xlsx', file, { dashboard_id: '42', mode: 'replace' }); + const callArgs = vi.mocked(fetch).mock.calls[0]; + const formData = callArgs[1].body as FormData; + expect(formData.get('dashboard_id')).toBe('42'); + expect(formData.get('mode')).toBe('replace'); + }); + + it('deleteApi with 204 response returns null', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, status: 204, + } as Response); + const { api } = await import('$lib/api.js'); + const result = await api.deleteApi('/no-content-delete'); + expect(result).toBeNull(); + }); + + it('deleteApi with 401 triggers session handler', async () => { + vi.useFakeTimers(); + const { setSessionExpiredHandler } = await import('$lib/api.js'); + const handler = vi.fn(); + setSessionExpiredHandler(handler); + vi.mocked(fetch).mockResolvedValue({ + ok: false, status: 401, + json: () => Promise.resolve({ detail: 'Unauthorized' }), + } as Response); + const { api, _resetSessionExpiryGuardForTests } = await import('$lib/api.js'); + _resetSessionExpiryGuardForTests(); + await api.deleteApi('/secure-resource').catch(() => {}); + expect(handler).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('postApi with 401 triggers session handler', async () => { + vi.useFakeTimers(); + const { setSessionExpiredHandler } = await import('$lib/api.js'); + const handler = vi.fn(); + setSessionExpiredHandler(handler); + vi.mocked(fetch).mockResolvedValue({ + ok: false, status: 401, + json: () => Promise.resolve({ detail: 'Unauthorized' }), + } as Response); + const { api, _resetSessionExpiryGuardForTests } = await import('$lib/api.js'); + _resetSessionExpiryGuardForTests(); + await api.postApi('/secure-post', {}).catch(() => {}); + expect(handler).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('requestApi with 401 triggers session handler', async () => { + vi.useFakeTimers(); + const { setSessionExpiredHandler } = await import('$lib/api.js'); + const handler = vi.fn(); + setSessionExpiredHandler(handler); + vi.mocked(fetch).mockResolvedValue({ + ok: false, status: 401, + json: () => Promise.resolve({ detail: 'Unauthorized' }), + } as Response); + const { api, _resetSessionExpiryGuardForTests } = await import('$lib/api.js'); + _resetSessionExpiryGuardForTests(); + await api.requestApi('/secure-request', 'PATCH', {}).catch(() => {}); + expect(handler).toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); + +describe('notifyApiError — 401 suppression during session expiry', () => { + beforeEach(() => { + vi.resetModules(); + vi.stubGlobal('window', { location: { protocol: 'http:', host: 'localhost:5173' } }); + vi.stubGlobal('fetch', vi.fn()); + localStorage.clear(); + }); + + afterEach(() => { vi.unstubAllGlobals(); }); + + it('suppresses 401 toast during ongoing session expiry flow', async () => { + vi.useFakeTimers(); + const handler = vi.fn(); + const { setSessionExpiredHandler, fetchApi } = await import('$lib/api.js'); + setSessionExpiredHandler(handler); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, status: 401, + json: async () => ({ detail: 'Unauthorized' }), + }); + + // Trigger session expiry flow: 401 fires handler and sets _sessionExpiryInProgress + await fetchApi('/any-endpoint').catch(() => {}); + expect(handler).toHaveBeenCalledTimes(1); + + // While session expiry is in progress, fire another 401 — toast should be suppressed + const { notifications } = await import('$lib/toasts.svelte.js'); + notifications.error.mockClear(); + await fetchApi('/another-endpoint').catch(() => {}); + // notifyApiError should suppress the second 401 toast + expect(notifications.error).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); }); // #region Test.Api.SessionExpiredHandler [C:3] [TYPE Block] [SEMANTICS test,api,session,expiry,handler,401] diff --git a/frontend/src/lib/auth/__tests__/sessionTimeout.test.ts b/frontend/src/lib/auth/__tests__/sessionTimeout.test.ts index e02360207..694095c22 100644 --- a/frontend/src/lib/auth/__tests__/sessionTimeout.test.ts +++ b/frontend/src/lib/auth/__tests__/sessionTimeout.test.ts @@ -19,11 +19,13 @@ describe('sessionTimeout', () => { beforeEach(async () => { vi.resetModules(); vi.useFakeTimers(); - vi.stubGlobal('BroadcastChannel', vi.fn(() => ({ - onmessage: null, - postMessage: vi.fn(), - close: vi.fn(), - }))); + vi.stubGlobal('BroadcastChannel', function () { + return { + onmessage: null, + postMessage: vi.fn(), + close: vi.fn(), + }; + }); sessionTimeout = await import('../sessionTimeout.svelte.js'); }); @@ -241,6 +243,381 @@ describe('sessionTimeout', () => { expect(onExpired).toHaveBeenCalled(); }); + // ── FetchApi failure branch (catch block) ───────────────────── + + it('handles fetchApi failure gracefully', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockRejectedValue(new Error('Network error')); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 5, absoluteTimeoutMinutes: 10, warningMinutes: 1 }, + onExpired, + onExtend, + ); + + // Should still start running even if fetchApi fails + expect(sessionTimeout.sessionWarning.isRunning).toBe(true); + }); + + // ── FetchApi with absolute_expires_at ───────────────────────── + + it('derives session start from absolute_expires_at', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + const absExpiresMs = Date.now() + 10 * 60 * 1000; // 10 min from now + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: new Date(absExpiresMs).toISOString(), + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 0, absoluteTimeoutMinutes: 10, warningMinutes: 5 }, + onExpired, + onExtend, + ); + + expect(sessionTimeout.sessionWarning.isRunning).toBe(true); + }); + + // ── FetchApi with idle_expires_at ───────────────────────────── + + it('derives last activity from idle_expires_at', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + const idleExpiresMs = Date.now() + 5 * 60 * 1000; // 5 min from now + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: new Date(idleExpiresMs).toISOString(), + absolute_expires_at: null, + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 5, absoluteTimeoutMinutes: 0, warningMinutes: 3 }, + onExpired, + onExtend, + ); + + expect(sessionTimeout.sessionWarning.isRunning).toBe(true); + }); + + // ── _tick early return when not running ─────────────────────── + + it('tick does nothing when session is not running', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: null, + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 0.1, absoluteTimeoutMinutes: 0, warningMinutes: 0.05 }, + onExpired, + onExtend, + ); + + sessionTimeout.stopSessionTracking(); + + // Advance far past deadline, should NOT trigger onExpired + vi.advanceTimersByTime(60 * 1000); + await vi.runAllTimersAsync(); + + expect(onExpired).not.toHaveBeenCalled(); + }); + + // ── Both deadlines Infinity → tick returns early ────────────── + + it('does not fire warning when both timeouts are 0 (infinity deadlines)', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: null, + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 0, absoluteTimeoutMinutes: 0, warningMinutes: 5 }, + onExpired, + onExtend, + ); + + // Advance a lot — should not trigger any warning since no timeout configured + vi.advanceTimersByTime(60 * 1000); + + expect(sessionTimeout.sessionWarning.visible).toBe(false); + expect(onExpired).not.toHaveBeenCalled(); + }); + + // ── Absolute warning type ───────────────────────────────────── + + it('shows absolute warning type when absolute deadline is nearest', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: null, + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 60, absoluteTimeoutMinutes: 0.1, warningMinutes: 0.05 }, + onExpired, + onExtend, + ); + + // Advance past absolute warning threshold (absolute = 6s, warning = 3s) + vi.advanceTimersByTime(4000); + + expect(sessionTimeout.sessionWarning.visible).toBe(true); + expect(sessionTimeout.sessionWarning.warningType).toBe('absolute'); + }); + + // ── recordActivity dismisses warning and broadcasts ─────────── + + it('recordActivity dismisses warning and broadcasts', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: null, + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 0.1, absoluteTimeoutMinutes: 0, warningMinutes: 0.05 }, + onExpired, + onExtend, + ); + + // Advance to trigger warning (idle = 6s, warning = 3s, advance 4s) + vi.advanceTimersByTime(4000); + expect(sessionTimeout.sessionWarning.visible).toBe(true); + + // Record activity — should dismiss warning and reset idle timer + sessionTimeout.recordActivity(); + expect(sessionTimeout.sessionWarning.visible).toBe(false); + + // Advance only 2s — still within warning-free zone (remaining 4s > 3s threshold) + vi.advanceTimersByTime(2000); + expect(sessionTimeout.sessionWarning.visible).toBe(false); + }); + + // ── extendSession early return ──────────────────────────────── + + it('extendSession returns early when session is not running', async () => { + const onExtend = vi.fn().mockResolvedValue(undefined); + + // Don't start tracking — _isRunning = false + await sessionTimeout.extendSession(); + + expect(onExtend).not.toHaveBeenCalled(); + }); + + it('extendSession returns early when onExtend is null', async () => { + const onExpired = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: null, + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 5, absoluteTimeoutMinutes: 0, warningMinutes: 1 }, + onExpired, + // Explicitly pass null — not a function + ); + + // Reset modules to clear cached state and provide no onExtend + }); + + // ── extendSession with onExtend = null via resetModules ─────── + + it('extendSession returns early when onExtend is null (no-op)', async () => { + vi.resetModules(); + const mod = await import('../sessionTimeout.svelte.js'); + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: null, + }); + + // We cannot pass null onExtend because the TS type requires () => Promise + // But the module-level _onExtend starts as null until startSessionTracking is called. + // If we extend without calling startSessionTracking, _onExtend is null. + // extendSession checks _onExtend, so this should be a no-op. + // But extendSession also checks _isRunning. + // We need both _isRunning=false and _onExtend=null for the early return. + // After stopSessionTracking, both are null/false. + mod.stopSessionTracking(); + + await mod.extendSession(); + + // No-op — no error thrown + expect(mod.sessionWarning.isExtending).toBe(false); + }); + + // ── dismissSession cleanup with active tickInterval ─────────── + + it('dismissSession cleans up tick interval', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: null, + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 30, absoluteTimeoutMinutes: 0, warningMinutes: 5 }, + onExpired, + onExtend, + ); + + sessionTimeout.dismissSession(); + + expect(onExpired).toHaveBeenCalledTimes(1); + expect(sessionTimeout.sessionWarning.isRunning).toBe(false); + }); + + // ── _tick expiry path with tickInterval cleanup ─────────────── + + it('clears tick interval on expiry', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: null, + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 0.016, absoluteTimeoutMinutes: 0, warningMinutes: 0.016 }, + onExpired, + onExtend, + ); + + // Warning threshold equals idle timeout, so it hits warning first + // Then advance past the deadline + vi.advanceTimersByTime(3000); + await vi.runAllTimersAsync(); + + // Should have called onExpired + expect(onExpired).toHaveBeenCalled(); + + // isRunning should be false after expiry + expect(sessionTimeout.sessionWarning.isRunning).toBe(false); + }); + + // ── BroadcastChannel onmessage dismisses warning ────────────── + + it('BroadcastChannel activity message dismisses warning', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + // Expose the most recently created BroadcastChannel instance globally + // so the test can invoke the onmessage handler + const BCtor = (globalThis as any).BroadcastChannel; + (globalThis as any).BroadcastChannel = function (name: string) { + const inst = BCtor(name); + (globalThis as any).__lastBCInstance = inst; + return inst; + }; + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: null, + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 0.1, absoluteTimeoutMinutes: 0, warningMinutes: 0.05 }, + onExpired, + onExtend, + ); + + // Advance to trigger warning + vi.advanceTimersByTime(4000); + expect(sessionTimeout.sessionWarning.visible).toBe(true); + + // Simulate receiving activity from another tab via BroadcastChannel + const bcInst = (globalThis as any).__lastBCInstance; + expect(bcInst).toBeTruthy(); + if (bcInst && bcInst.onmessage) { + bcInst.onmessage({ data: 'activity' } as MessageEvent); + expect(sessionTimeout.sessionWarning.visible).toBe(false); + } + }); + + // ── DOM event triggers recordActivity ───────────────────────── + + it('DOM pointerdown event triggers recordActivity and dismisses warning', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: null, + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 0.1, absoluteTimeoutMinutes: 0, warningMinutes: 0.05 }, + onExpired, + onExtend, + ); + + // Advance to trigger warning + vi.advanceTimersByTime(4000); + expect(sessionTimeout.sessionWarning.visible).toBe(true); + + // Simulate user activity via DOM event — this triggers the activityHandler closure + window.dispatchEvent(new Event('pointerdown')); + + // Warning should be dismissed after activity + expect(sessionTimeout.sessionWarning.visible).toBe(false); + }); + + it('DOM keydown event also resets idle timer', async () => { + const onExpired = vi.fn(); + const onExtend = vi.fn(); + + const { fetchApi } = await import('$lib/api'); + vi.mocked(fetchApi).mockResolvedValue({ + idle_expires_at: null, + absolute_expires_at: null, + }); + + await sessionTimeout.startSessionTracking( + { idleTimeoutMinutes: 0.1, absoluteTimeoutMinutes: 0, warningMinutes: 0.05 }, + onExpired, + onExtend, + ); + + // Advance to trigger warning + vi.advanceTimersByTime(4000); + expect(sessionTimeout.sessionWarning.visible).toBe(true); + + // Simulate keydown + window.dispatchEvent(new Event('keydown')); + expect(sessionTimeout.sessionWarning.visible).toBe(false); + }); + // ── BroadcastChannel is handled gracefully ───────────────────── it('handles missing BroadcastChannel gracefully', async () => { diff --git a/frontend/src/lib/logs/__tests__/parseCot.test.ts b/frontend/src/lib/logs/__tests__/parseCot.test.ts new file mode 100644 index 000000000..072a7c358 --- /dev/null +++ b/frontend/src/lib/logs/__tests__/parseCot.test.ts @@ -0,0 +1,308 @@ +// #region Test.Logs.ParseCot [C:3] [TYPE Module] [SEMANTICS test,logs,cot,parse] +// @BRIEF Unit tests for parseCot.ts — parseCotMessage and taskLogToViewEntry. +// @RELATION BINDS_TO -> [Logs.ParseCot] +// @TEST_EDGE: valid_reason -> Parses REASON marker with all optional fields. +// @TEST_EDGE: valid_reflect -> Parses REFLECT marker properly. +// @TEST_EDGE: valid_explore -> Parses EXPLORE marker properly. +// @TEST_EDGE: missing_marker -> No marker field returns null. +// @TEST_EDGE: invalid_marker -> Invalid marker value returns null. +// @TEST_EDGE: empty_intent -> Empty intent string returns null. +// @TEST_EDGE: non_string_intent -> Non-string intent returns null. +// @TEST_EDGE: non_json_input -> Input not starting with { returns null. +// @TEST_EDGE: malformed_json -> Invalid JSON returns null. +// @TEST_EDGE: empty_input -> Empty string returns null. +// @TEST_EDGE: optional_fields -> All optional fields extracted correctly. +// @TEST_EDGE: task_id_from_payload -> task_id extracted from payload when not at top level. +// @TEST_EDGE: payload_object -> payload is object and passed through. +// @TEST_EDGE: task_log_to_view_entry_cot -> taskLogToViewEntry with embedded CoT message. +// @TEST_EDGE: task_log_to_view_entry_no_cot -> taskLogToViewEntry with non-CoT message. +// @TEST_EDGE: task_log_to_view_entry_metadata -> taskLogToViewEntry with metadata fallback. + +import { describe, it, expect } from 'vitest'; +import { parseCotMessage, taskLogToViewEntry } from '../parseCot'; + +describe('parseCotMessage', () => { + // ── happy paths ────────────────────────────────────────── + + it('parses valid REASON marker with all fields', () => { + const result = parseCotMessage(JSON.stringify({ + marker: 'REASON', + intent: 'Starting dashboard migration', + src: 'MigrationService', + trace_id: 'trace-abc', + span_id: 'span-123', + task_id: 'task-456', + level: 'INFO', + payload: { dashboard_id: 'dash-1', target_env: 'prod' }, + })); + expect(result).not.toBeNull(); + expect(result!.marker).toBe('REASON'); + expect(result!.intent).toBe('Starting dashboard migration'); + expect(result!.src).toBe('MigrationService'); + expect(result!.trace_id).toBe('trace-abc'); + expect(result!.span_id).toBe('span-123'); + expect(result!.task_id).toBe('task-456'); + expect(result!.level).toBe('INFO'); + expect(result!.payload).toEqual({ dashboard_id: 'dash-1', target_env: 'prod' }); + }); + + it('parses valid REFLECT marker', () => { + const result = parseCotMessage(JSON.stringify({ + marker: 'REFLECT', + intent: 'Migration completed successfully', + })); + expect(result).not.toBeNull(); + expect(result!.marker).toBe('REFLECT'); + expect(result!.intent).toBe('Migration completed successfully'); + }); + + it('parses valid EXPLORE marker', () => { + const result = parseCotMessage(JSON.stringify({ + marker: 'EXPLORE', + intent: 'Retrying after timeout', + })); + expect(result).not.toBeNull(); + expect(result!.marker).toBe('EXPLORE'); + expect(result!.intent).toBe('Retrying after timeout'); + }); + + // ── invalid / edge cases ───────────────────────────────── + + it('returns null for input not starting with {', () => { + expect(parseCotMessage('not-json')).toBeNull(); + expect(parseCotMessage('[1,2,3]')).toBeNull(); + expect(parseCotMessage('plain text')).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + expect(parseCotMessage('{"marker": "REASON", intent:}')).toBeNull(); + }); + + it('returns null for empty input', () => { + expect(parseCotMessage('')).toBeNull(); + expect(parseCotMessage(' ')).toBeNull(); + }); + + it('returns null for null or undefined input', () => { + expect(parseCotMessage(null)).toBeNull(); + expect(parseCotMessage(undefined)).toBeNull(); + }); + + it('returns null when marker field is missing', () => { + const result = parseCotMessage(JSON.stringify({ + intent: 'some intent', + })); + expect(result).toBeNull(); + }); + + it('returns null for invalid marker value', () => { + const result = parseCotMessage(JSON.stringify({ + marker: 'DEBUG', + intent: 'debug log', + })); + expect(result).toBeNull(); + }); + + it('returns null for empty intent string', () => { + const result = parseCotMessage(JSON.stringify({ + marker: 'REASON', + intent: '', + })); + expect(result).toBeNull(); + }); + + it('returns null for non-string intent', () => { + // intent is a number + const result1 = parseCotMessage(JSON.stringify({ + marker: 'REASON', + intent: 42, + })); + expect(result1).toBeNull(); + + // intent is null + const result2 = parseCotMessage(JSON.stringify({ + marker: 'REASON', + intent: null, + })); + expect(result2).toBeNull(); + + // intent is an object + const result3 = parseCotMessage(JSON.stringify({ + marker: 'REASON', + intent: { key: 'value' }, + })); + expect(result3).toBeNull(); + }); + + it('handles undefined optional fields gracefully', () => { + const result = parseCotMessage(JSON.stringify({ + marker: 'REASON', + intent: 'test', + src: 123, // not a string → undefined + trace_id: null, // not a string → undefined + span_id: true, // not a string → undefined + level: 456, // not a string → undefined + error: null, // not a string → undefined + })); + expect(result).not.toBeNull(); + expect(result!.src).toBeUndefined(); + expect(result!.trace_id).toBeUndefined(); + expect(result!.span_id).toBeUndefined(); + expect(result!.level).toBeUndefined(); + expect(result!.error).toBeUndefined(); + }); + + it('extracts task_id from payload when not at top level', () => { + const result = parseCotMessage(JSON.stringify({ + marker: 'REASON', + intent: 'test', + payload: { task_id: 'task-from-payload' }, + })); + expect(result).not.toBeNull(); + expect(result!.task_id).toBe('task-from-payload'); + }); + + it('prefers top-level task_id over payload task_id', () => { + const result = parseCotMessage(JSON.stringify({ + marker: 'REASON', + intent: 'test', + task_id: 'top-level-task', + payload: { task_id: 'payload-task' }, + })); + expect(result).not.toBeNull(); + expect(result!.task_id).toBe('top-level-task'); + }); + + it('includes error field when present', () => { + const result = parseCotMessage(JSON.stringify({ + marker: 'EXPLORE', + intent: 'Something failed', + error: 'Connection timeout', + })); + expect(result).not.toBeNull(); + expect(result!.error).toBe('Connection timeout'); + }); + + it('passes through raw parsed object', () => { + const input = { marker: 'REASON', intent: 'test', extra: 'field' }; + const result = parseCotMessage(JSON.stringify(input)); + expect(result).not.toBeNull(); + expect(result!.raw).toEqual(input); + }); +}); + +describe('taskLogToViewEntry', () => { + it('maps entry with embedded CoT message', () => { + const entry = { + id: 'log-1', + timestamp: '2024-01-15T10:00:00Z', + level: 'INFO', + task_id: 'task-789', + source: 'Worker-1', + message: JSON.stringify({ + marker: 'REASON', + intent: 'Processing batch', + trace_id: 'trace-xyz', + span_id: 'span-789', + payload: { batch_size: 100 }, + }), + }; + const result = taskLogToViewEntry(entry, 0); + expect(result.id).toBe('log-1'); + expect(result.ts).toBe('2024-01-15T10:00:00Z'); + expect(result.level).toBe('INFO'); + expect(result.domain).toBe('task'); + expect(result.task_id).toBe('task-789'); + expect(result.source).toBe('Worker-1'); + expect(result.marker).toBe('REASON'); + expect(result.intent).toBe('Processing batch'); + expect(result.trace_id).toBe('trace-xyz'); + expect(result.span_id).toBe('span-789'); + expect(result.payload).toEqual({ batch_size: 100 }); + expect(result.rawMessage).toBe(entry.message); + }); + + it('maps entry without CoT message (plain string)', () => { + const entry = { + id: 'log-2', + timestamp: '2024-01-15T10:01:00Z', + level: 'WARN', + message: 'Plain log line without JSON', + }; + const result = taskLogToViewEntry(entry, 1); + expect(result.id).toBe('log-2'); + expect(result.ts).toBe('2024-01-15T10:01:00Z'); + expect(result.level).toBe('WARN'); + expect(result.marker).toBeUndefined(); + expect(result.intent).toBeUndefined(); + expect(result.payload).toBeUndefined(); + }); + + it('falls back to entry.metadata when cot has no payload', () => { + const entry = { + id: 'log-3', + timestamp: '2024-01-15T10:02:00Z', + message: 'some text', + metadata: { request_id: 'req-123', duration_ms: 450 }, + }; + const result = taskLogToViewEntry(entry, 2); + expect(result.payload).toEqual({ request_id: 'req-123', duration_ms: 450 }); + }); + + it('generates fallback id when entry.id is missing', () => { + const entry = { + timestamp: '2024-01-15T10:03:00Z', + level: 'ERROR', + message: 'critical error', + }; + const result = taskLogToViewEntry(entry, 3); + // id fallback: `${ts}-${index}-${level}` + expect(result.id).toBe('2024-01-15T10:03:00Z-3-ERROR'); + }); + + it('uses ts field when timestamp is missing', () => { + const entry = { + id: 'log-4', + ts: '2024-01-15T10:04:00Z', + message: 'has ts not timestamp', + }; + const result = taskLogToViewEntry(entry, 4); + expect(result.ts).toBe('2024-01-15T10:04:00Z'); + }); + + it('extracts cot error field into entry', () => { + const entry = { + id: 'log-5', + message: JSON.stringify({ + marker: 'EXPLORE', + intent: 'error path', + error: 'timeout after 30s', + }), + }; + const result = taskLogToViewEntry(entry, 5); + expect(result.error).toBe('timeout after 30s'); + }); + + it('handles missing message field gracefully', () => { + const entry = { + id: 'log-6', + timestamp: '2024-01-15T10:05:00Z', + }; + const result = taskLogToViewEntry(entry, 6); + expect(result.marker).toBeUndefined(); + expect(result.intent).toBeUndefined(); + expect(result.rawMessage).toBe(''); + }); + + it('handles undefined entry.task_id and entry.source', () => { + const entry = { + id: 'log-7', + timestamp: '2024-01-15T10:06:00Z', + message: 'No task_id or source', + }; + const result = taskLogToViewEntry(entry, 7); + expect(result.task_id).toBeUndefined(); + expect(result.source).toBeUndefined(); + }); +}); +// #endregion Test.Logs.ParseCot diff --git a/frontend/src/lib/models/__tests__/GitManagerModel.test.ts b/frontend/src/lib/models/__tests__/GitManagerModel.test.ts index 039fbb0fb..b16d67306 100644 --- a/frontend/src/lib/models/__tests__/GitManagerModel.test.ts +++ b/frontend/src/lib/models/__tests__/GitManagerModel.test.ts @@ -18,7 +18,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // ── Mocks ──────────────────────────────────────────────────────── vi.mock("../../../services/gitService.js", () => ({ - gitService: { getConfigs: vi.fn(), getBranches: vi.fn(), getStatus: vi.fn(), getDiff: vi.fn(), commit: vi.fn(), push: vi.fn(), pull: vi.fn(), promote: vi.fn(), sync: vi.fn(), checkoutBranch: vi.fn(), getMergeStatus: vi.fn(), getMergeConflicts: vi.fn(), resolveMergeConflicts: vi.fn(), abortMerge: vi.fn(), continueMerge: vi.fn(), getRepositoryBinding: vi.fn(), initRepository: vi.fn(), createRemoteRepository: vi.fn(), validatePreprodDeployment: vi.fn() }, + gitService: { getConfigs: vi.fn(), getBranches: vi.fn(), getStatus: vi.fn(), getDiff: vi.fn(), commit: vi.fn(), push: vi.fn(), pull: vi.fn(), promote: vi.fn(), sync: vi.fn(), checkoutBranch: vi.fn(), getMergeStatus: vi.fn(), getMergeConflicts: vi.fn(), resolveMergeConflicts: vi.fn(), abortMerge: vi.fn(), continueMerge: vi.fn(), getRepositoryBinding: vi.fn(), initRepository: vi.fn(), createRemoteRepository: vi.fn(), validatePreprodDeployment: vi.fn(), getCommitDiff: vi.fn(), getReleases: vi.fn(), getReleasePolicy: vi.fn(), updateReleasePolicy: vi.fn(), createRelease: vi.fn(), approveRelease: vi.fn(), publishRelease: vi.fn(), getDeploymentStatus: vi.fn(), getBranchCommits: vi.fn() }, })); vi.mock("$lib/api.js", () => ({ api: { getEnvironmentsList: vi.fn(), postApi: vi.fn() } })); vi.mock('$lib/toasts.svelte.js', () => ({ addToast: vi.fn(), notifications: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), show: vi.fn() } })); @@ -289,6 +289,36 @@ describe("GitManagerModel — L1 invariants (no render)", () => { expect(api.postApi).not.toHaveBeenCalled(); expect(model.workspaceSummaryState).toBe("idle"); }); + + it("stores error when LLM returns empty summary", async () => { + model.workspaceDiff = "diff --git a/x.yaml b/x.yaml\n--- a\n+++ b\n"; + vi.mocked(api.postApi).mockResolvedValue({ summary: "" }); + await model.handleGenerateWorkspaceSummary(true); + expect(model.workspaceSummaryState).toBe("error"); + expect(model.workspaceSummaryError).toContain("empty"); + }); + + it("discards stale response when workspaceDiff changes during request", async () => { + let resolveApi!: (v: any) => void; + vi.mocked(api.postApi).mockReturnValue(new Promise(r => { resolveApi = r; })); + model.workspaceDiff = "diff --git a/x.yaml b/x.yaml\n--- a\n+++ b\n"; + const promise = model.handleGenerateWorkspaceSummary(true); + model.workspaceDiff = "diff --git b/z.yaml b/z.yaml\n--- b\n+++ c\n"; + resolveApi({ summary: "stale response" }); + await promise; + expect(model.workspaceSummary).toBe(""); + }); + + it("discards stale error when workspaceDiff changes during failed request", async () => { + let rejectApi!: (e: Error) => void; + vi.mocked(api.postApi).mockReturnValue(new Promise((_, r) => { rejectApi = r; })); + model.workspaceDiff = "diff --git a/x.yaml b/x.yaml\n--- a\n+++ b\n"; + const promise = model.handleGenerateWorkspaceSummary(true); + model.workspaceDiff = "diff --git b/z.yaml b/z.yaml\n--- b\n+++ c\n"; + rejectApi(new Error("stale failure")); + await promise; + expect(model.workspaceSummaryState).toBe("loading"); + }); }); describe("checkStatus — guard paths", () => { @@ -676,6 +706,23 @@ describe("GitManagerModel — L1 invariants (no render)", () => { expect(model.selectedConfigId).toBe("cfg-2"); }); + it("confirmCreateRemoteRepo returns early when no config resolves", async () => { + model.pendingRepoName = "my-repo"; + model.showCreateRepoDialog = true; + await model.confirmCreateRemoteRepo(); + expect(gitService.createRemoteRepository).not.toHaveBeenCalled(); + }); + + it("confirmCreateRemoteRepo handles no URL in response", async () => { + model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1"; + model.pendingRepoName = "my-repo"; + model.showCreateRepoDialog = true; + vi.mocked(gitService.createRemoteRepository).mockResolvedValue({}); + await model.confirmCreateRemoteRepo(); + expect(model.gitError).not.toBeNull(); + expect(model.creatingRemoteRepo).toBe(false); + }); + it("handlePromote MR mode without url does not call window.open", async () => { model.promoteFromBranch = "dev"; model.promoteToBranch = "main"; model.promoteMode = "mr"; const openSpy = vi.spyOn(window, "open").mockReturnValue(null); @@ -947,6 +994,349 @@ describe("GitManagerModel — L1 invariants (no render)", () => { expect(m.envId).toBe("env-1"); expect(m.dashboardTitle).toBe("Test"); }); + + it("selectVersion sets secondary when isSecondary=true", () => { + model.selectedVersionA = "hash-a"; + model.selectVersion("hash-b", true); + expect(model.selectedVersionB).toBe("hash-b"); + expect(model.selectedVersionA).toBe("hash-a"); + }); + + it("selectVersion clears secondary when primary changes", () => { + model.selectedVersionA = "hash-old"; + model.selectedVersionB = "hash-b"; + model.selectVersion("hash-new", false); + expect(model.selectedVersionA).toBe("hash-new"); + expect(model.selectedVersionB).toBeNull(); + }); + + it("getSelectedVersionsDiff returns null when no versionA", async () => { + model.selectedVersionA = null; + const result = await model.getSelectedVersionsDiff(); + expect(result).toBeNull(); + }); + + it("getSelectedVersionsDiff returns diff from gitService", async () => { + model.selectedVersionA = "hash-a"; + model.selectedVersionB = "hash-b"; + const mockDiff = { from: "hash-a", to: "hash-b", diff: "--- a/file\n+++ b/file\n" }; + vi.mocked(gitService.getCommitDiff).mockResolvedValue(mockDiff); + const result = await model.getSelectedVersionsDiff(); + expect(result).toEqual(mockDiff); + expect(gitService.getCommitDiff).toHaveBeenCalledWith("test-dashboard", "hash-a", "hash-b", "env-123"); + }); + + it("applyPromotionDefaultsForCurrentBranch does nothing when branch has no stage", () => { + model.currentBranch = "feature/unknown"; + model.promoteFromBranch = "dev"; + model.promoteToBranch = "preprod"; + model.applyPromotionDefaultsForCurrentBranch(); + // Defaults preserved unchanged since no branch stage resolved + expect(model.promoteFromBranch).toBe("dev"); + expect(model.promoteToBranch).toBe("preprod"); + }); + + it("handleGenerateWorkspaceSummary force=true re-generates when diff exists", async () => { + model.workspaceDiff = "diff --git a/x.yaml b/x.yaml\n--- a\n+++ b\n"; + model.workspaceSummaryState = "ready"; + model._workspaceSummaryDiff = model.workspaceDiff; + vi.mocked(api.postApi).mockResolvedValue({ summary: "Forced summary" }); + await model.handleGenerateWorkspaceSummary(true); + expect(api.postApi).toHaveBeenCalled(); + expect(model.workspaceSummary).toBe("Forced summary"); + }); + + it("handleGenerateWorkspaceSummary handles empty diff with force=true", async () => { + model.workspaceDiff = ""; + await model.handleGenerateWorkspaceSummary(true); + expect(api.postApi).not.toHaveBeenCalled(); + expect(model.workspaceSummaryState).toBe("idle"); + }); + + it("openDeployModal blocks PROD when canDeployToProd is false", () => { + model.currentEnvStage = "PROD"; + model.dashboardId = "test-dashboard"; + model.deploymentStatus = { current_content_hash: "c1", environments: [ + { stage: "preprod", commit_hash: "a".repeat(40), content_hash: "c1", deployed_at: null, status: "deployed", is_behind: false, validation_status: "pending", validated_at: null }, + ] }; + model.openDeployModal("PROD", null); + // PROD deploy blocked because preprod is not validated + expect(model.showDeployModal).toBe(false); + expect(model.showDeployConfirm).toBe(false); + expect(notifications.warning).toHaveBeenCalledWith(expect.stringContaining("PROD")); + }); + + it("openDeployModal with non-PROD target opens modal directly", () => { + model.openDeployModal("PREPROD", null); + expect(model.showDeployModal).toBe(true); + expect(model.preferredDeployTargetStage).toBe("PREPROD"); + }); + + it("loadWorkspace skips load when not initialized", async () => { + model.initialized = false; + await model.loadWorkspace(); + expect(gitService.getStatus).not.toHaveBeenCalled(); + }); + + it("clearVersionSelection resets both A and B", () => { + model.selectedVersionA = "hash-a"; + model.selectedVersionB = "hash-b"; + model.clearVersionSelection(); + expect(model.selectedVersionA).toBeNull(); + expect(model.selectedVersionB).toBeNull(); + }); + + it("handleGenerateWorkspaceSummary returns early when already loading same diff", async () => { + model.workspaceDiff = "same diff content"; + model._workspaceSummaryDiff = "same diff content"; + model.workspaceSummaryState = "loading"; + vi.mocked(api.postApi).mockResolvedValue({ summary: "Should not be called" }); + await model.handleGenerateWorkspaceSummary(false); + expect(api.postApi).not.toHaveBeenCalled(); + }); + + it("loadReleases loads when dashboardId and initialized", async () => { + model.dashboardId = "test-dashboard"; model.initialized = true; + vi.mocked(gitService.getReleases).mockResolvedValue([{ id: "r1", name: "v1", version: "1.0", notes: "", commit_hash: "", content_hash: "", status: "published", created_at: "", created_by: "" }]); + await model.loadReleases(); + expect(model.releases).toHaveLength(1); + expect(model.releasesLoading).toBe(false); + }); + + it("loadReleases returns early when not initialized", async () => { + model.initialized = false; + await model.loadReleases(); + expect(gitService.getReleases).not.toHaveBeenCalled(); + }); + + it("loadReleases handles error", async () => { + model.dashboardId = "test-dashboard"; model.initialized = true; + vi.mocked(gitService.getReleases).mockRejectedValue(new Error("Failed")); + await model.loadReleases(); + expect(model.releases).toEqual([]); + expect(model.releasesLoading).toBe(false); + }); + + it("loadReleasePolicy loads when dashboardId and initialized", async () => { + model.dashboardId = "test-dashboard"; model.initialized = true; + const policy = { require_prod_approval: true, approval_roles: ["admin"], require_approval_comment: false, approval_expires_hours: 48, block_publish_on_drift: true, is_override: false }; + vi.mocked(gitService.getReleasePolicy).mockResolvedValue(policy); + await model.loadReleasePolicy(); + expect(model.releasePolicy).toEqual(policy); + }); + + it("loadReleasePolicy returns early when not initialized", async () => { + model.initialized = false; + await model.loadReleasePolicy(); + expect(gitService.getReleasePolicy).not.toHaveBeenCalled(); + }); + + it("loadReleasePolicy handles error", async () => { + model.dashboardId = "test-dashboard"; model.initialized = true; + vi.mocked(gitService.getReleasePolicy).mockRejectedValue(new Error("Failed")); + await model.loadReleasePolicy(); + expect(model.releasePolicy).toBeNull(); + }); + + it("loadDeploymentStatus loads when dashboardId set", async () => { + model.dashboardId = "test-dashboard"; + const status = { current_content_hash: "c1", environments: [{ stage: "preprod", commit_hash: "a".repeat(40), content_hash: "c1", deployed_at: null, status: "deployed", is_behind: false, validation_status: "pending", validated_at: null }] }; + vi.mocked(gitService.getDeploymentStatus).mockResolvedValue(status); + await model.loadDeploymentStatus(); + expect(model.deploymentStatus).toEqual(status); + }); + + it("loadDeploymentStatus returns early without dashboardId", async () => { + model.dashboardId = ""; + await model.loadDeploymentStatus(); + expect(gitService.getDeploymentStatus).not.toHaveBeenCalled(); + }); + + it("loadDeploymentStatus sets null on error", async () => { + model.dashboardId = "test-dashboard"; + model.deploymentStatus = { current_content_hash: "old", environments: [] }; + vi.mocked(gitService.getDeploymentStatus).mockRejectedValue(new Error("No status")); + await model.loadDeploymentStatus(); + expect(model.deploymentStatus).toBeNull(); + }); + + it("createRelease returns early when fields empty", async () => { + model.releaseName = ""; model.releaseVersion = ""; model.releaseNotes = ""; + await model.createRelease(); + expect(notifications.warning).toHaveBeenCalled(); + expect(gitService.createRelease).not.toHaveBeenCalled(); + }); + + it("createRelease succeeds with valid input", async () => { + model.dashboardId = "test-dashboard"; model.initialized = true; + model.releaseName = "v1"; model.releaseVersion = "1.0"; model.releaseNotes = "Initial release"; + vi.mocked(gitService.createRelease).mockResolvedValue({}); + vi.mocked(gitService.getReleases).mockResolvedValue([]); + await model.createRelease(); + expect(gitService.createRelease).toHaveBeenCalled(); + expect(model.releaseName).toBe(""); + expect(model.releaseVersion).toBe(""); + expect(model.releaseNotes).toBe(""); + expect(notifications.success).toHaveBeenCalledWith("Релиз создан"); + }); + + it("createRelease handles error", async () => { + model.releaseName = "v1"; model.releaseVersion = "1.0"; model.releaseNotes = "Test"; + vi.mocked(gitService.createRelease).mockRejectedValue(new Error("Create fail")); + await model.createRelease(); + expect(model.gitError).not.toBeNull(); + expect(model.releaseActionLoading).toBe(false); + }); + + it("approveRelease returns early without active release", async () => { + model.activeRelease = null; + await model.approveRelease(); + expect(gitService.approveRelease).not.toHaveBeenCalled(); + }); + + it("approveRelease succeeds", async () => { + model.dashboardId = "test-dashboard"; + model.releases = [{ id: "r1", name: "v1", version: "1.0", notes: "", commit_hash: "", content_hash: "", status: "awaiting_approval", created_at: "", created_by: "" }]; + vi.mocked(gitService.approveRelease).mockResolvedValue({}); + vi.mocked(gitService.getReleases).mockResolvedValue([]); + await model.approveRelease(); + expect(gitService.approveRelease).toHaveBeenCalled(); + expect(notifications.success).toHaveBeenCalledWith("Релиз согласован"); + }); + + it("approveRelease handles error", async () => { + model.releases = [{ id: "r1", name: "v1", version: "1.0", notes: "", commit_hash: "", content_hash: "", status: "awaiting_approval", created_at: "", created_by: "" }]; + vi.mocked(gitService.approveRelease).mockRejectedValue(new Error("Approve fail")); + await model.approveRelease(); + expect(model.gitError).not.toBeNull(); + }); + + it("publishRelease returns early without active release", async () => { + model.activeRelease = null; + await model.publishRelease(); + expect(gitService.publishRelease).not.toHaveBeenCalled(); + }); + + it("publishRelease succeeds", async () => { + model.dashboardId = "test-dashboard"; + model.releases = [{ id: "r2", name: "v2", version: "2.0", notes: "", commit_hash: "", content_hash: "", status: "ready_to_publish", created_at: "", created_by: "" }]; + vi.mocked(gitService.publishRelease).mockResolvedValue({}); + vi.mocked(gitService.getReleases).mockResolvedValue([]); + vi.mocked(gitService.getDeploymentStatus).mockResolvedValue({ current_content_hash: "c2", environments: [] }); + await model.publishRelease(); + expect(gitService.publishRelease).toHaveBeenCalled(); + expect(notifications.success).toHaveBeenCalledWith("Релиз опубликован в PROD"); + }); + + it("publishRelease handles error", async () => { + model.releases = [{ id: "r2", name: "v2", version: "2.0", notes: "", commit_hash: "", content_hash: "", status: "ready_to_publish", created_at: "", created_by: "" }]; + vi.mocked(gitService.publishRelease).mockRejectedValue(new Error("Publish fail")); + await model.publishRelease(); + expect(model.gitError).not.toBeNull(); + }); + + it("saveReleasePolicy returns early when no policy", async () => { + model.releasePolicy = null; + await model.saveReleasePolicy(); + expect(gitService.updateReleasePolicy).not.toHaveBeenCalled(); + }); + + it("saveReleasePolicy succeeds", async () => { + model.dashboardId = "test-dashboard"; + model.releasePolicy = { require_prod_approval: true, approval_roles: ["admin"], require_approval_comment: false, approval_expires_hours: 48, block_publish_on_drift: true, is_override: false }; + vi.mocked(gitService.updateReleasePolicy).mockResolvedValue(model.releasePolicy); + await model.saveReleasePolicy(); + expect(gitService.updateReleasePolicy).toHaveBeenCalled(); + expect(notifications.success).toHaveBeenCalledWith("Политика релизов сохранена"); + }); + + it("saveReleasePolicy handles error", async () => { + model.releasePolicy = { require_prod_approval: true, approval_roles: [], require_approval_comment: false, approval_expires_hours: 48, block_publish_on_drift: true, is_override: false }; + vi.mocked(gitService.updateReleasePolicy).mockRejectedValue(new Error("Save fail")); + await model.saveReleasePolicy(); + expect(model.gitError).not.toBeNull(); + }); + + it("openFeatureDraft returns early with empty branch", async () => { + await model.openFeatureDraft(""); + expect(gitService.checkoutBranch).not.toHaveBeenCalled(); + }); + + it("openFeatureDraft handles error", async () => { + vi.mocked(gitService.checkoutBranch).mockRejectedValue(new Error("Checkout fail")); + await model.openFeatureDraft("feature/test"); + expect(model.gitError).not.toBeNull(); + expect(model.workspaceLoading).toBe(false); + }); + + it("validatePreprodDeployment warns when no preprod content_hash", async () => { + model.deploymentStatus = { current_content_hash: "c1", environments: [] }; + await model.validatePreprodDeployment(); + expect(notifications.warning).toHaveBeenCalled(); + expect(gitService.validatePreprodDeployment).not.toHaveBeenCalled(); + }); + + it("validatePreprodDeployment handles error", async () => { + model.dashboardId = "test-dashboard"; + model.deploymentStatus = { current_content_hash: "c1", environments: [ + { stage: "preprod", commit_hash: "a".repeat(40), content_hash: "c1", deployed_at: null, status: "deployed", is_behind: false, validation_status: "pending", validated_at: null }, + ] }; + vi.mocked(gitService.validatePreprodDeployment).mockRejectedValue(new Error("Validation fail")); + await model.validatePreprodDeployment(); + expect(model.gitError).not.toBeNull(); + expect(model.validatingPreprod).toBe(false); + }); + + it("loadEnvironmentHistories returns early without dashboardId", async () => { + const m = new GitManagerModel(); // dashboardId defaults to "" + await m.loadEnvironmentHistories(); + expect(gitService.getBranchCommits).not.toHaveBeenCalled(); + }); + + it("loadEnvironmentHistories handles branch fetch failure", async () => { + model.dashboardId = "test-dashboard"; + vi.mocked(gitService.getBranchCommits).mockRejectedValue(new Error("Branch fail")); + await model.loadEnvironmentHistories(["dev"]); + expect(model.environmentHistories["dev"]).toEqual([]); + expect(model.environmentHistoriesLoading).toBe(false); + }); + + it("refreshStatus loads workspace when initialized", async () => { + model.initialized = true; + vi.mocked(gitService.getBranches).mockResolvedValue(["main"]); + vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); + vi.mocked(gitService.getDiff).mockResolvedValue(""); + vi.mocked(gitService.getDiff).mockResolvedValue(""); + await model.refreshStatus(); + expect(model.initialized).toBe(true); + expect(gitService.getStatus).toHaveBeenCalled(); + }); + + it("getSelectedVersionsDiff handles error", async () => { + model.selectedVersionA = "hash-a"; + model.selectedVersionB = "hash-b"; + vi.mocked(gitService.getCommitDiff).mockRejectedValue(new Error("Diff fail")); + const result = await model.getSelectedVersionsDiff(); + expect(result).toBeNull(); + expect(model.gitError).not.toBeNull(); + }); + + it("applyPromotionDefaultsForCurrentBranch calls applyPromotionDefaultsForStage when branch stage found", () => { + model.currentBranch = "dev"; + model.applyPromotionDefaultsForCurrentBranch(); + // Stage for "dev" branch should be DEV, which sets defaults + expect(model.promoteFromBranch).toBeTruthy(); + expect(model.promoteToBranch).toBeTruthy(); + }); + + it("loadCurrentEnvironmentStage applies defaults when no branch stage", async () => { + model.currentBranch = "feature/x"; + model.currentEnvStage = ""; + vi.mocked(api.getEnvironmentsList).mockResolvedValue([{ id: "env-123", name: "Dev" }]); + await model.loadCurrentEnvironmentStage(); + // branch "feature/x" has no gitflow stage → applyPromotionDefaultsForStage should be called + expect(model.currentEnvStage).toBe("DEV"); + }); }); }); // #endregion Models.GitManagerModel.GitManagerModelTests diff --git a/frontend/src/lib/models/__tests__/MappingsModel.test.ts b/frontend/src/lib/models/__tests__/MappingsModel.test.ts index 7bf38fd55..00b5c797a 100644 --- a/frontend/src/lib/models/__tests__/MappingsModel.test.ts +++ b/frontend/src/lib/models/__tests__/MappingsModel.test.ts @@ -455,6 +455,201 @@ describe("MappingsModel — L1 invariants (no render)", () => { expect(model.error).toBe("Failed to load"); expect(model.loading).toBe(false); }); + + it("handles non-Error rejection", async () => { + vi.mocked(api.getEnvironmentsList).mockRejectedValue("string error"); + await model.loadEnvironments(); + expect(model.error).toBe("Failed to load environments"); + expect(model.loading).toBe(false); + }); + + it("does NOT set sourceEnvId when active env is not in the environment list", async () => { + vi.mocked(api.getEnvironmentsList).mockResolvedValueOnce([ + { id: "env-3", name: "Staging" }, + ]); + model.sourceEnvId = "existing-id"; + await model.loadEnvironments(); + expect(model.sourceEnvId).toBe("existing-id"); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // saveAllSuggestions — completely uncovered + // ═══════════════════════════════════════════════════════════════ + describe("saveAllSuggestions", () => { + const srcDbs = [ + { uuid: "src-1", database_name: "SalesDB" }, + { uuid: "src-2", database_name: "AnalyticsDB" }, + ]; + const tgtDbs = [ + { uuid: "tgt-1", database_name: "SalesDW" }, + { uuid: "tgt-2", database_name: "AnalyticsDW" }, + ]; + + beforeEach(() => { + model.sourceDatabases = srcDbs; + model.targetDatabases = tgtDbs; + }); + + it("returns early when there are no suggestions", async () => { + model.suggestions = []; + await model.saveAllSuggestions(); + expect(api.postApi).not.toHaveBeenCalled(); + }); + + it("returns early when all suggestions are already mapped", async () => { + model.suggestions = [ + { source_db_uuid: "src-1", target_db_uuid: "tgt-1" }, + ]; + model.mappings = [ + { source_db_uuid: "src-1", target_db_uuid: "tgt-1" }, + ]; + await model.saveAllSuggestions(); + expect(api.postApi).not.toHaveBeenCalled(); + }); + + it("skips suggestions missing source or target db uuid", async () => { + model.suggestions = [ + { source_db_uuid: "src-1", target_db_uuid: "tgt-1" }, + { source_db_uuid: "src-2" }, + { target_db_uuid: "tgt-3" }, + {}, + ]; + const saved1 = { + id: "m-1", source_db_uuid: "src-1", target_db_uuid: "tgt-1", + source_db_name: "SalesDB", target_db_name: "SalesDW", + }; + vi.mocked(api.postApi).mockResolvedValueOnce(saved1); + vi.mocked(api.requestApi).mockResolvedValueOnce({ + environments: [], pairs: [], + totals: { saved: 1, unsaved_suggestions: 0, unmapped: 0, stale: 0 }, + }); + await model.saveAllSuggestions(); + expect(api.postApi).toHaveBeenCalledTimes(1); + }); + + it("saves all pending suggestions and batch updates mappings", async () => { + model.suggestions = [ + { source_db_uuid: "src-1", target_db_uuid: "tgt-1", confidence: 0.9 }, + { source_db_uuid: "src-2", target_db_uuid: "tgt-2", confidence: 0.8 }, + ]; + const saved1 = { + id: "m-1", source_db_uuid: "src-1", target_db_uuid: "tgt-1", + source_db_name: "SalesDB", target_db_name: "SalesDW", + }; + const saved2 = { + id: "m-2", source_db_uuid: "src-2", target_db_uuid: "tgt-2", + source_db_name: "AnalyticsDB", target_db_name: "AnalyticsDW", + }; + vi.mocked(api.postApi) + .mockResolvedValueOnce(saved1) + .mockResolvedValueOnce(saved2); + vi.mocked(api.requestApi).mockResolvedValueOnce({ + environments: [], pairs: [], + totals: { saved: 2, unsaved_suggestions: 0, unmapped: 0, stale: 0 }, + }); + + await model.saveAllSuggestions(); + + expect(api.postApi).toHaveBeenCalledTimes(2); + expect(model.mappings).toHaveLength(2); + expect(model.success).toBe("2 mappings saved"); + expect(api.requestApi).toHaveBeenCalledWith("/mappings/analysis"); + }); + + it("preserves pre-existing mappings that do not overlap with batch update", async () => { + model.suggestions = [ + { source_db_uuid: "src-1", target_db_uuid: "tgt-1" }, + ]; + model.mappings = [ + { id: "existing", source_db_uuid: "src-2", target_db_uuid: "tgt-2" }, + ]; + const savedMapping = { + id: "new-m", source_db_uuid: "src-1", target_db_uuid: "tgt-1", + source_db_name: "SalesDB", target_db_name: "SalesDW", + }; + vi.mocked(api.postApi).mockResolvedValueOnce(savedMapping); + vi.mocked(api.requestApi).mockResolvedValueOnce({ + environments: [], pairs: [], + totals: { saved: 1, unsaved_suggestions: 0, unmapped: 0, stale: 0 }, + }); + + await model.saveAllSuggestions(); + + expect(model.mappings).toHaveLength(2); // old mapping + new + expect(model.mappings.some((m: any) => m.source_db_uuid === "src-1")).toBe(true); + expect(model.mappings.some((m: any) => m.source_db_uuid === "src-2")).toBe(true); + }); + + it("handles non-Error string rejection in _persistMapping", async () => { + model.suggestions = [ + { source_db_uuid: "src-1", target_db_uuid: "tgt-1" }, + ]; + vi.mocked(api.postApi).mockRejectedValue("string error"); + await model.saveAllSuggestions(); + expect(model.error).toBe("Failed to save all mappings"); + expect(model.success).toBe(""); + }); + + it("handles API error mid-loop and returns early", async () => { + model.suggestions = [ + { source_db_uuid: "src-1", target_db_uuid: "tgt-1" }, + { source_db_uuid: "src-2", target_db_uuid: "tgt-2" }, + ]; + const saved1 = { + id: "m-1", source_db_uuid: "src-1", target_db_uuid: "tgt-1", + source_db_name: "SalesDB", target_db_name: "SalesDW", + }; + vi.mocked(api.postApi) + .mockResolvedValueOnce(saved1) + .mockRejectedValueOnce(new Error("Save failed")); + + await model.saveAllSuggestions(); + + expect(api.postApi).toHaveBeenCalledTimes(2); + expect(model.error).toBe("Save failed"); + expect(model.success).toBe(""); + expect(model.mappings).toHaveLength(0); + }); + + it("handles non-Error rejection", async () => { + model.suggestions = [ + { source_db_uuid: "src-1", target_db_uuid: "tgt-1" }, + ]; + vi.mocked(api.postApi).mockRejectedValue("string error"); + + await model.saveAllSuggestions(); + expect(model.error).toBe("Failed to save all mappings"); + expect(model.success).toBe(""); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Additional edge cases + // ═══════════════════════════════════════════════════════════════ + describe("edge cases", () => { + it("saveMapping handles non-Error rejection", async () => { + model.sourceDatabases = [{ uuid: "src-1", database_name: "SalesDB" }]; + model.targetDatabases = [{ uuid: "tgt-1", database_name: "SalesDW" }]; + vi.mocked(api.postApi).mockRejectedValue("string error"); + await model.saveMapping("src-1", "tgt-1"); + expect(model.error).toBe("Failed to save mapping"); + }); + + it("fetchDatabases handles non-Error rejection", async () => { + model.sourceEnvId = "env-1"; + model.targetEnvId = "env-2"; + vi.mocked(api.requestApi).mockRejectedValue("string error"); + await model.fetchDatabases(); + expect(model.error).toBe("Failed to fetch databases"); + }); + + it("loadMappingAnalysis handles non-Error rejection", async () => { + vi.mocked(api.requestApi).mockRejectedValue("string error"); + await model.loadMappingAnalysis(); + expect(model.analysisError).toBe("Failed to load mapping coverage"); + expect(model.analysisLoading).toBe(false); + }); }); }); // #endregion Models.MappingsModel.MappingsModelTests diff --git a/frontend/src/lib/models/__tests__/Migration.ExecutorModel.test.ts b/frontend/src/lib/models/__tests__/Migration.ExecutorModel.test.ts new file mode 100644 index 000000000..83bb59825 --- /dev/null +++ b/frontend/src/lib/models/__tests__/Migration.ExecutorModel.test.ts @@ -0,0 +1,525 @@ +// frontend/src/lib/models/__tests__/Migration.ExecutorModel.test.ts +// #region Models.Migration.ExecutorModelTests [C:3] [TYPE Module] [SEMANTICS test,model,migration,execution] +// @BRIEF L1 unit tests for Migration.ExecutorModel @INVARIANT guarantees — no DOM render. +// @RELATION BINDS_TO -> [Migration.ExecutorModel] +// @TEST_INVARIANT: password-prompt-only-for-database-password -> VERIFIED_BY: [test_checkPasswordPrompt_shows_for_database_password, test_checkPasswordPrompt_ignores_non_database_input, test_checkPasswordPrompt_returns_without_active_task] +// @TEST_INVARIANT: dryrun-sets-wizard-step -> VERIFIED_BY: [test_calculateDryRun_success] +// @TEST_EDGE: missing_field -> empty parent preconditions trigger early return +// @TEST_EDGE: invalid_type -> API returns non-DryRunResult shape +// @TEST_EDGE: external_fail -> API throws on postApi/getTask +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("$lib/api.js", () => ({ + api: { + postApi: vi.fn(), + getTask: vi.fn(), + }, +})); + +vi.mock("../../../services/taskService.js", () => ({ + resumeTask: vi.fn(), +})); + +vi.mock("$lib/i18n/index.svelte.js", () => ({ + t: { + migration: { + select_both_envs: "Select both environments", + different_envs: "Must be different", + select_dashboards: "Select dashboards", + resume_failed: "Resume failed", + }, + }, +})); + +import { MigrationExecutor } from "../Migration.ExecutorModel.svelte.ts"; +import { api } from "$lib/api.js"; +import { resumeTask } from "../../../services/taskService.js"; +import { t } from "$lib/i18n/index.svelte.js"; +import type { MigrationDryRunResult } from "../../types/dashboard"; + +// ── Hardcoded fixtures (no logic mirrors) ────────────────────── + +function validDryRunResult(): MigrationDryRunResult { + return { + generated_at: "2026-07-23T12:00:00Z", + selection: { + selected_ids: [1, 2], + source_env_id: "env-1", + target_env_id: "env-2", + replace_db_config: false, + fix_cross_filters: true, + }, + selected_dashboard_titles: ["Dash 1", "Dash 2"], + diff: { + dashboards: { create: [{ uuid: "d1", title: "Dash 1" }], update: [], delete: [] }, + charts: { create: [], update: [{ uuid: "c1", title: "Chart 1" }], delete: [] }, + datasets: { create: [], update: [], delete: [{ uuid: "ds1", title: "DS 1" }] }, + }, + summary: { + dashboards: { create: 1, update: 0, delete: 0 }, + charts: { create: 0, update: 1, delete: 0 }, + datasets: { create: 0, update: 0, delete: 1 }, + selected_dashboards: 2, + }, + risk: { + score: 5, + level: "medium", + blockers: 0, + warnings: 1, + confirmations: 0, + items: [ + { + code: "CHART_MODIFIED", + severity: "medium", + category: "warning", + object_type: "chart", + object_uuid: "c1", + message: "Chart will be updated", + }, + ], + }, + }; +} + +function createParent(overrides: Record = {}) { + return { + _validatePreconditions: vi.fn().mockReturnValue(true), + _buildSelection: vi.fn().mockReturnValue({ + selected_ids: [1], + source_env_id: "env-1", + target_env_id: "env-2", + replace_db_config: false, + fix_cross_filters: true, + }), + error: "", + wizard: { currentStep: 2 } as { currentStep: number }, + selectedTaskStore: { + current: null as unknown, + set: vi.fn(), + }, + ...overrides, + }; +} + +// ── Model invariant tests ───────────────────────────────────── + +describe("Migration.ExecutorModel — L1 invariants (no render)", () => { + let model: MigrationExecutor; + let parent: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + parent = createParent(); + model = new MigrationExecutor(parent as never); + }); + + // #region Test.ExecutorModel.InitialState [C:2] [TYPE Function] + // @BRIEF Verify all $state atoms start at declared defaults. + describe("initial state", () => { + it("all atoms have default values", () => { + expect(model.selectedDashboardIds).toEqual([]); + expect(model.dryRunResult).toBeNull(); + expect(model.dryRunLoading).toBe(false); + expect(model.showPasswordPrompt).toBe(false); + expect(model.passwordPromptDatabases).toEqual([]); + expect(model.passwordPromptErrorMessage).toBe(""); + }); + + it("derived isCalculating reflects dryRunLoading", () => { + expect(model.isCalculating).toBe(false); + model.dryRunLoading = true; + expect(model.isCalculating).toBe(true); + }); + }); + // #endregion Test.ExecutorModel.InitialState + + // #region Test.ExecutorModel.CalculateDryRun [C:2] [TYPE Function] + // @BRIEF Dry-run action — precondition gating, API call, validation, state transitions. + describe("calculateDryRun", () => { + it("returns early when parent preconditions fail", async () => { + parent._validatePreconditions.mockReturnValue(false); + parent.error = "Precondition failed"; + await model.calculateDryRun(); + expect(api.postApi).not.toHaveBeenCalled(); + expect(model.dryRunLoading).toBe(false); + expect(model.dryRunResult).toBeNull(); + }); + + it("sets loading true before API call, false after", async () => { + let resolvePromise!: (v: unknown) => void; + api.postApi.mockReturnValue(new Promise((r) => { resolvePromise = r; })); + const promise = model.calculateDryRun(); + expect(model.dryRunLoading).toBe(true); + resolvePromise(validDryRunResult()); + await promise; + expect(model.dryRunLoading).toBe(false); + }); + + it("clears parent error before API call", async () => { + parent.error = "stale error"; + api.postApi.mockResolvedValue(validDryRunResult()); + await model.calculateDryRun(); + expect(parent.error).toBe(""); + }); + + it("happy path: stores result and advances wizard step", async () => { + const result = validDryRunResult(); + api.postApi.mockResolvedValue(result); + await model.calculateDryRun(); + expect(api.postApi).toHaveBeenCalledWith("/migration/dry-run", parent._buildSelection()); + expect(model.dryRunResult).toEqual(result); + expect(parent.wizard.currentStep).toBe(3); + }); + + it("handles invalid API response shape", async () => { + api.postApi.mockResolvedValue({ not_a_valid_dry_run: true }); + await model.calculateDryRun(); + expect(model.dryRunResult).toBeNull(); + expect(parent.error).toBe("Migration dry-run response has an invalid shape"); + }); + + it("handles Error instance rejection", async () => { + api.postApi.mockRejectedValue(new Error("Network timeout")); + await model.calculateDryRun(); + expect(model.dryRunResult).toBeNull(); + expect(parent.error).toBe("Network timeout"); + }); + + it("handles non-Error rejection", async () => { + api.postApi.mockRejectedValue("string rejection"); + await model.calculateDryRun(); + expect(model.dryRunResult).toBeNull(); + expect(parent.error).toBe("Dry-run failed"); + }); + + it("sets dryRunResult null on API error", async () => { + model.dryRunResult = validDryRunResult(); + api.postApi.mockRejectedValue(new Error("fail")); + await model.calculateDryRun(); + expect(model.dryRunResult).toBeNull(); + }); + + // ── Type guard exercise via calculateDryRun ── + // These test branches inside isDryRunResult by feeding varied payloads. + + it("rejects null payload from API", async () => { + api.postApi.mockResolvedValue(null); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects non-object payload from API", async () => { + api.postApi.mockResolvedValue("string-body"); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload missing generated_at", async () => { + api.postApi.mockResolvedValue({ selection: {}, selected_dashboard_titles: [], summary: {}, diff: {}, risk: { score: 0, level: "low", blockers: 0, warnings: 0, confirmations: 0, items: [] } }); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload where selection is not an object", async () => { + api.postApi.mockResolvedValue({ generated_at: "2026-01-01T00:00:00Z", selection: null, selected_dashboard_titles: [] }); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload where selected_dashboard_titles is not an array", async () => { + api.postApi.mockResolvedValue({ generated_at: "2026-01-01T00:00:00Z", selection: {}, selected_dashboard_titles: "not-array" }); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload with missing summary", async () => { + api.postApi.mockResolvedValue({ generated_at: "2026-01-01T00:00:00Z", selection: {}, selected_dashboard_titles: [], diff: {}, risk: { score: 0, level: "low", blockers: 0, warnings: 0, confirmations: 0, items: [] } }); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload with invalid summary bucket", async () => { + const partial = validDryRunResult(); + (partial.summary as Record).dashboards = { create: "not-a-number", update: 0, delete: 0 }; + api.postApi.mockResolvedValue(partial); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload with missing diff bucket key", async () => { + const partial = validDryRunResult(); + (partial.diff as Record).dashboards = { create: [{ uuid: "d1" }], update: [] }; + api.postApi.mockResolvedValue(partial); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload with diff item missing uuid", async () => { + const partial = validDryRunResult(); + (partial.diff.dashboards.create as Array>) = [{ title: "no-uuid" }]; + api.postApi.mockResolvedValue(partial); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload with invalid risk item", async () => { + const partial = validDryRunResult(); + (partial.risk.items[0] as Record).code = 42; + api.postApi.mockResolvedValue(partial); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload with missing risk score", async () => { + const partial = validDryRunResult(); + delete (partial.risk as Record).score; + api.postApi.mockResolvedValue(partial); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload where risk.items is not an array", async () => { + const partial = validDryRunResult(); + (partial.risk as Record).items = "not-array"; + api.postApi.mockResolvedValue(partial); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload with empty/null diff category bucket create (non-array)", async () => { + const partial = validDryRunResult(); + (partial.diff.charts as Record).create = null; + api.postApi.mockResolvedValue(partial); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + // ── Type guard null-value guards (cover first-line if (!value || typeof ...)) ── + + it("rejects payload where summary.dashboards is null (isCountBucket guard)", async () => { + const partial = validDryRunResult(); + (partial.summary as Record).dashboards = null; + api.postApi.mockResolvedValue(partial); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload where diff.dashboards is null (isDiffBucket guard)", async () => { + const partial = validDryRunResult(); + (partial.diff as Record).dashboards = null; + api.postApi.mockResolvedValue(partial); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload where a diff item is null (isDiffObject guard)", async () => { + const partial = validDryRunResult(); + (partial.diff.dashboards.create as unknown[]) = [null]; + api.postApi.mockResolvedValue(partial); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + + it("rejects payload where a risk item is null (isRiskItem guard)", async () => { + const partial = validDryRunResult(); + (partial.risk.items as unknown[]) = [null]; + api.postApi.mockResolvedValue(partial); + await model.calculateDryRun(); + expect(parent.error).toContain("invalid shape"); + }); + }); + // #endregion Test.ExecutorModel.CalculateDryRun + + // #region Test.ExecutorModel.ExecuteMigration [C:2] [TYPE Function] + // @BRIEF Migration execution action — precondition gating, post, getTask fallback, error handling. + describe("executeMigration", () => { + it("returns early when parent preconditions fail", async () => { + parent._validatePreconditions.mockReturnValue(false); + parent.error = "Blocked"; + await model.executeMigration(); + expect(api.postApi).not.toHaveBeenCalled(); + }); + + it("clears parent error before execution", async () => { + parent.error = "stale error"; + api.postApi.mockResolvedValue({ task_id: "t1" }); + api.getTask.mockResolvedValue({ id: "t1", status: "RUNNING" }); + await model.executeMigration(); + expect(parent.error).toBe(""); + }); + + it("resets dryRunResult before execution", async () => { + model.dryRunResult = validDryRunResult(); + api.postApi.mockResolvedValue({ task_id: "t1" }); + api.getTask.mockResolvedValue({ id: "t1", status: "RUNNING" }); + await model.executeMigration(); + expect(model.dryRunResult).toBeNull(); + }); + + it("happy path: sets task from getTask response", async () => { + const task = { id: "task-1", status: "RUNNING", logs: [] }; + api.postApi.mockResolvedValue({ task_id: "task-1" }); + api.getTask.mockResolvedValue(task); + await model.executeMigration(); + expect(api.postApi).toHaveBeenCalledWith("/migration/execute", parent._buildSelection()); + expect(api.getTask).toHaveBeenCalledWith("task-1"); + expect(parent.selectedTaskStore.set).toHaveBeenCalledWith(task); + }); + + it("uses custom endpoint when provided", async () => { + api.postApi.mockResolvedValue({ task_id: "t1" }); + api.getTask.mockResolvedValue({ id: "t1", status: "RUNNING" }); + await model.executeMigration("/custom/endpoint"); + expect(api.postApi).toHaveBeenCalledWith("/custom/endpoint", expect.anything()); + }); + + it("falls back to inline task when getTask throws", async () => { + api.postApi.mockResolvedValue({ task_id: "task-1" }); + api.getTask.mockRejectedValue(new Error("fetch failed")); + await model.executeMigration(); + expect(parent.selectedTaskStore.set).toHaveBeenCalledWith({ + id: "task-1", + plugin_id: "superset-migration", + status: "RUNNING", + logs: [], + params: {}, + }); + }); + + it("handles postApi rejection with Error", async () => { + api.postApi.mockRejectedValue(new Error("Execution failed")); + await model.executeMigration(); + expect(parent.error).toBe("Execution failed"); + }); + + it("handles postApi rejection with non-Error", async () => { + api.postApi.mockRejectedValue("string rejection"); + await model.executeMigration(); + expect(parent.error).toBe("Migration execution failed"); + }); + }); + // #endregion Test.ExecutorModel.ExecuteMigration + + // #region Test.ExecutorModel.ResumeMigration [C:2] [TYPE Function] + // @BRIEF Resume paused migration — task gating, API call, prompt dismissal, error surface. + describe("resumeMigration", () => { + it("returns early when no active task", async () => { + parent.selectedTaskStore.current = null; + await model.resumeMigration({}); + expect(resumeTask).not.toHaveBeenCalled(); + }); + + it("happy path: resumes task and hides password prompt", async () => { + parent.selectedTaskStore.current = { id: "task-1", status: "AWAITING_INPUT" }; + resumeTask.mockResolvedValue({ ok: true }); + model.showPasswordPrompt = true; + await model.resumeMigration({ db1: "pass1" }); + expect(resumeTask).toHaveBeenCalledWith("task-1", { db1: "pass1" }); + expect(model.showPasswordPrompt).toBe(false); + expect(model.passwordPromptErrorMessage).toBe(""); + }); + + it("stores error message on Error rejection", async () => { + parent.selectedTaskStore.current = { id: "task-1" }; + resumeTask.mockRejectedValue(new Error("Wrong password")); + await model.resumeMigration({ db1: "bad" }); + expect(model.passwordPromptErrorMessage).toBe("Wrong password"); + expect(model.showPasswordPrompt).toBe(false); // unchanged + }); + + it("stores fallback message on non-Error rejection", async () => { + parent.selectedTaskStore.current = { id: "task-1" }; + resumeTask.mockRejectedValue("unknown"); + await model.resumeMigration({}); + expect(model.passwordPromptErrorMessage).toBe("Resume failed"); + }); + + it("stores default fallback when t.migration.resume_failed is falsy", async () => { + const originalResumeFailed = t.migration?.resume_failed; + try { + if (t.migration) t.migration.resume_failed = ""; + parent.selectedTaskStore.current = { id: "task-1" }; + resumeTask.mockRejectedValue("unknown"); + await model.resumeMigration({}); + expect(model.passwordPromptErrorMessage).toBe("Resume failed"); + } finally { + if (t.migration) t.migration.resume_failed = originalResumeFailed; + } + }); + }); + // #endregion Test.ExecutorModel.ResumeMigration + + // #region Test.ExecutorModel.CheckPasswordPrompt [C:2] [TYPE Function] + // @BRIEF Polling for AWAITING_INPUT + database_password — guards, type check, state mutations. + describe("checkPasswordPrompt", () => { + it("does nothing when no active task", () => { + parent.selectedTaskStore.current = null; + model.checkPasswordPrompt(); + expect(model.showPasswordPrompt).toBe(false); + }); + + it("does nothing when status is not AWAITING_INPUT", () => { + parent.selectedTaskStore.current = { + id: "t1", + status: "RUNNING", + input_request: { type: "database_password", databases: ["db1"] }, + }; + model.checkPasswordPrompt(); + expect(model.showPasswordPrompt).toBe(false); + }); + + it("does nothing when input_request is missing", () => { + parent.selectedTaskStore.current = { + id: "t1", + status: "AWAITING_INPUT", + input_request: null, + }; + model.checkPasswordPrompt(); + expect(model.showPasswordPrompt).toBe(false); + }); + + it("shows prompt for database_password type", () => { + parent.selectedTaskStore.current = { + id: "t1", + status: "AWAITING_INPUT", + input_request: { + type: "database_password", + databases: ["db1", "db2"], + error_message: "Previous attempt invalid", + }, + }; + model.checkPasswordPrompt(); + expect(model.showPasswordPrompt).toBe(true); + expect(model.passwordPromptDatabases).toEqual(["db1", "db2"]); + expect(model.passwordPromptErrorMessage).toBe("Previous attempt invalid"); + }); + + it("ignores non-database-password input types", () => { + parent.selectedTaskStore.current = { + id: "t1", + status: "AWAITING_INPUT", + input_request: { + type: "manual_approval", + databases: ["db1"], + error_message: "", + }, + }; + model.checkPasswordPrompt(); + expect(model.showPasswordPrompt).toBe(false); + }); + + it("handles missing databases and error_message fields gracefully", () => { + parent.selectedTaskStore.current = { + id: "t1", + status: "AWAITING_INPUT", + input_request: { type: "database_password" }, + }; + model.checkPasswordPrompt(); + expect(model.showPasswordPrompt).toBe(true); + expect(model.passwordPromptDatabases).toEqual([]); + expect(model.passwordPromptErrorMessage).toBe(""); + }); + }); + // #endregion Test.ExecutorModel.CheckPasswordPrompt +}); +// #endregion Models.Migration.ExecutorModelTests diff --git a/frontend/src/lib/models/__tests__/ReportsLogModel.test.ts b/frontend/src/lib/models/__tests__/ReportsLogModel.test.ts new file mode 100644 index 000000000..52cab96d8 --- /dev/null +++ b/frontend/src/lib/models/__tests__/ReportsLogModel.test.ts @@ -0,0 +1,625 @@ +// #region Models.ReportsLogModel.ReportsLogModelTests [C:2] [TYPE Module] [SEMANTICS test,model,reports,logs] +// @BRIEF L1 unit tests for ReportsLogModel — no DOM render. Covers state transitions, WS lifecycle, clipboard, download. +// @RELATION BINDS_TO -> [Reports.LogModel] +// @TEST_EDGE: missing_field -> Empty responses, null items handled gracefully +// @TEST_EDGE: invalid_type -> Malformed WS message and API failures don't crash +// @TEST_EDGE: external_fail -> Clipboard unavailable, fetch/WS errors handled +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mocks +vi.mock("$lib/api.js", () => ({ + api: { fetchApi: vi.fn() }, + getAppLogsWsUrl: vi.fn(() => "ws://test/app-logs"), +})); +vi.mock("$lib/cot-logger", () => ({ log: vi.fn() })); +vi.mock("$lib/toasts.svelte.js", () => ({ + addToast: vi.fn(), + notifications: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + show: vi.fn(), + }, +})); +vi.mock("$lib/i18n/index.svelte.js", () => ({ + getT: () => ({ + reports: { + logs_toast_nothing_copy: "Nothing to copy", + logs_toast_copied_all: "Copied {count} lines", + logs_toast_copied_selected: "Copied {count} lines", + logs_toast_clipboard_unavailable: "Clipboard not available", + logs_toast_copy_failed: "Copy failed", + logs_toast_nothing_download: "Nothing to download", + logs_toast_downloaded: "Downloaded", + }, + }), +})); + +import { ReportsLogModel } from "../ReportsLogModel.svelte.ts"; +import { api, getAppLogsWsUrl } from "$lib/api.js"; +import { notifications } from "$lib/toasts.svelte.js"; +import type { AppLogLine } from "../ReportsLogModel.svelte.ts"; + +// Fixtures +const makeLine = (seq: number, level = "INFO", raw = "test line", overrides: Partial = {}): AppLogLine => ({ + seq, + level, + raw, + timestamp: "2026-07-23T10:00:00Z", + logger: "test", + ...overrides, +}); + +/** + * Create a fake WebSocket class that shares a single handlers object across all instances. + * This allows tests to simulate WS events (onopen, onmessage, onclose, onerror) after + * any instance is created (including reconnects). + */ +interface WsHandlers { + onopen: (() => void) | null; + onmessage: ((ev: MessageEvent) => void) | null; + onclose: ((ev: CloseEvent) => void) | null; + onerror: (() => void) | null; +} + +function createWsMock(): { FakeWS: new (...args: any[]) => any; handlers: WsHandlers } { + const handlers: WsHandlers = { onopen: null, onmessage: null, onclose: null, onerror: null }; + + class FakeWS { + close = vi.fn(); + get onopen() { return handlers.onopen; } + set onopen(fn) { handlers.onopen = fn; } + get onmessage() { return handlers.onmessage; } + set onmessage(fn) { handlers.onmessage = fn; } + get onclose() { return handlers.onclose; } + set onclose(fn) { handlers.onclose = fn; } + get onerror() { return handlers.onerror; } + set onerror(fn) { handlers.onerror = fn; } + } + + return { FakeWS, handlers }; +} + +// #endregion Models.ReportsLogModel.ReportsLogModelTests +// #region ReportsLogModel.InvariantTests [C:2] [TYPE Function] +// @BRIEF L1 invariants: initial state, derived state, action transitions. +describe("ReportsLogModel — L1 invariants", () => { + let model: ReportsLogModel; + let ws: WsHandlers; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.mocked(api.fetchApi).mockResolvedValue({ items: [], current_seq: 0 }); + const wsMock = createWsMock(); + ws = wsMock.handlers; + global.WebSocket = wsMock.FakeWS as any; + model = new ReportsLogModel(); + }); + + afterEach(() => { + model.destroy(); + vi.useRealTimers(); + }); + + // #region ReportsLogModel.InitialState [C:2] [TYPE Test] + it("starts in idle state with empty data", () => { + expect(model.screenState).toBe("idle"); + expect(model.lines).toEqual([]); + expect(model.error).toBeNull(); + expect(model.levelFilter).toBe("INFO"); + expect(model.filterByTasks).toBe(false); + expect(model.filterTaskIds).toEqual([]); + expect(model.wsConnected).toBe(false); + expect(model.paused).toBe(false); + expect(model.selectedSeqs).toEqual([]); + }); + // #endregion + + // #region ReportsLogModel.DerivedState [C:2] [TYPE Test] + it("derives displayLines, displayText, and filterActive correctly", () => { + expect(model.displayLines).toEqual([]); + expect(model.displayText).toBe(""); + expect(model.filterActive).toBe(false); + + model.filterByTasks = true; + expect(model.filterActive).toBe(false); // no task ids yet + + model.filterTaskIds = ["t1"]; + expect(model.filterActive).toBe(true); + + model.lines = [makeLine(1, "INFO", "hello"), makeLine(2, "WARN", "world")]; + expect(model.displayText).toBe("hello\nworld"); + }); + // #endregion + + // #region ReportsLogModel.StartStreamSuccess [C:2] [TYPE Test] + it("startStream fetches snapshot and opens WS on success", async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ + items: [makeLine(1, "INFO", "init log")], + current_seq: 1, + }); + + const streamPromise = model.startStream(); + + // Should transition to connecting immediately + expect(model.screenState).toBe("connecting"); + expect(api.fetchApi).toHaveBeenCalledWith(expect.stringContaining("/logs/recent")); + + await streamPromise; + + expect(model.lines).toHaveLength(1); + expect(model.lines[0].seq).toBe(1); + expect(model.screenState).toBe("live"); + + // Simulate WS open + ws.onopen?.(); + expect(model.wsConnected).toBe(true); + }); + // #endregion + + // #region ReportsLogModel.StartStreamWithLevelFilter [C:2] [TYPE Test] + it("startStream passes level filter to fetchApi", async () => { + model.levelFilter = "WARN"; + await model.startStream(); + const url = vi.mocked(api.fetchApi).mock.calls[0][0] as string; + expect(url).toContain("level=WARN"); + }); + // #endregion + + // #region ReportsLogModel.StartStreamWithAllLevel [C:2] [TYPE Test] + it("startStream omits level param when level is 'all'", async () => { + model.levelFilter = "all"; + await model.startStream(); + const url = vi.mocked(api.fetchApi).mock.calls[0][0] as string; + expect(url).not.toContain("level"); + }); + // #endregion + + // #region ReportsLogModel.StartStreamWithTaskFilter [C:2] [TYPE Test] + it("startStream includes task_id param when filterActive", async () => { + model.filterByTasks = true; + model.filterTaskIds = ["t1", "t2"]; + await model.startStream(); + const url = vi.mocked(api.fetchApi).mock.calls[0][0] as string; + expect(url).toContain("task_id=t1%2Ct2"); + }); + // #endregion + + // #region ReportsLogModel.StartStreamApiError [C:2] [TYPE Test] + it("startStream transitions to error on API failure", async () => { + vi.mocked(api.fetchApi).mockRejectedValue(new Error("Network failure")); + await model.startStream(); + expect(model.screenState).toBe("error"); + expect(model.error).toBe("Network failure"); + }); + // #endregion + + // #region ReportsLogModel.StartStreamApiGenericError [C:2] [TYPE Test] + it("startStream handles non-Error throw as generic message", async () => { + vi.mocked(api.fetchApi).mockRejectedValue("string error"); + await model.startStream(); + expect(model.screenState).toBe("error"); + expect(model.error).toBe("Failed to load recent logs"); + }); + // #endregion + + // #region ReportsLogModel.StartStreamNullItems [C:2] [TYPE Test] + it("startStream handles null items in response", async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ current_seq: 0 } as any); + await model.startStream(); + expect(model.lines).toEqual([]); + expect(model.screenState).toBe("live"); + }); + // #endregion + + // #region ReportsLogModel.StartStreamAfterDestroy [C:2] [TYPE Test] + it("startStream returns early when destroyed", async () => { + model.destroy(); + await model.startStream(); + expect(api.fetchApi).not.toHaveBeenCalled(); + }); + // #endregion + + // #region ReportsLogModel.StopStream [C:2] [TYPE Test] + it("stopStream disconnects WS and sets idle", () => { + model.stopStream(); + expect(model.screenState).toBe("idle"); + expect(model.wsConnected).toBe(false); + }); + // #endregion + + // #region ReportsLogModel.Destroy [C:2] [TYPE Test] + it("destroy sets flag and disconnects WS", () => { + model.destroy(); + expect(model["_destroyed"]).toBe(true); + expect(model.wsConnected).toBe(false); + }); + + it("destroy can be called multiple times without error", () => { + model.destroy(); + model.destroy(); + expect(model["_destroyed"]).toBe(true); + }); + // #endregion + + // #region ReportsLogModel.SetLevel [C:2] [TYPE Test] + it("setLevel changes filter and restarts stream", async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ items: [], current_seq: 0 }); + model.setLevel("ERROR"); + expect(model.levelFilter).toBe("ERROR"); + await vi.waitFor(() => expect(api.fetchApi).toHaveBeenCalled()); + }); + // #endregion + + // #region ReportsLogModel.SetFilterByTasks [C:2] [TYPE Test] + it("setFilterByTasks enables filter and restarts stream", async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ items: [], current_seq: 0 }); + model.setFilterByTasks(true); + expect(model.filterByTasks).toBe(true); + await vi.waitFor(() => expect(api.fetchApi).toHaveBeenCalled()); + }); + // #endregion + + // #region ReportsLogModel.SetFilterTaskIds [C:2] [TYPE Test] + it("setFilterTaskIds stores deduplicated filtered ids", () => { + model.filterByTasks = false; + model.setFilterTaskIds(["a", "b", "", "a", "c"]); + expect(model.filterTaskIds).toEqual(["a", "b", "c"]); + }); + + it("setFilterTaskIds restarts stream when filterByTasks is active", async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ items: [], current_seq: 0 }); + model.filterByTasks = true; + model.setFilterTaskIds(["t1"]); + expect(model.filterTaskIds).toEqual(["t1"]); + await vi.waitFor(() => expect(api.fetchApi).toHaveBeenCalled()); + }); + + it("setFilterTaskIds caps at 20 ids", () => { + const ids = Array.from({ length: 30 }, (_, i) => `id-${i}`); + model.setFilterTaskIds(ids); + expect(model.filterTaskIds.length).toBeLessThanOrEqual(20); + }); + // #endregion + + // #region ReportsLogModel.ToggleFilterTask [C:2] [TYPE Test] + it("toggleFilterTask adds and removes task ids", () => { + model.filterByTasks = false; + model.toggleFilterTask("t1"); + expect(model.filterTaskIds).toContain("t1"); + model.toggleFilterTask("t1"); + expect(model.filterTaskIds).not.toContain("t1"); + }); + + it("toggleFilterTask restarts stream when filterByTasks is active", async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ items: [], current_seq: 0 }); + model.filterByTasks = true; + model.toggleFilterTask("t1"); + await vi.waitFor(() => expect(api.fetchApi).toHaveBeenCalled()); + }); + // #endregion + + // #region ReportsLogModel.PinTask [C:2] [TYPE Test] + it("pinTask adds task id without enabling filter by default", () => { + model.pinTask("t1"); + expect(model.filterTaskIds).toContain("t1"); + expect(model.filterByTasks).toBe(false); + }); + + it("pinTask adds task id and enables filter when requested", async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ items: [], current_seq: 0 }); + model.pinTask("t1", true); + expect(model.filterTaskIds).toContain("t1"); + expect(model.filterByTasks).toBe(true); + await vi.waitFor(() => expect(api.fetchApi).toHaveBeenCalled()); + }); + + it("pinTask does not duplicate existing id", () => { + model.filterTaskIds = ["t1"]; + model.pinTask("t1"); + expect(model.filterTaskIds).toEqual(["t1"]); + }); + // #endregion + + // #region ReportsLogModel.ClearTaskFilter [C:2] [TYPE Test] + it("clearTaskFilter clears filter and restarts stream", async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ items: [], current_seq: 0 }); + model.filterByTasks = true; + model.filterTaskIds = ["t1"]; + model.clearTaskFilter(); + expect(model.filterTaskIds).toEqual([]); + expect(model.filterByTasks).toBe(false); + await vi.waitFor(() => expect(api.fetchApi).toHaveBeenCalled()); + }); + // #endregion + + // #region ReportsLogModel.TogglePause [C:2] [TYPE Test] + it("togglePause toggles paused state", () => { + expect(model.paused).toBe(false); + model.togglePause(); + expect(model.paused).toBe(true); + model.togglePause(); + expect(model.paused).toBe(false); + }); + + it("togglePause flushes paused buffer when unpausing", () => { + model.lines = [makeLine(1)]; + model.togglePause(); // now paused + // Simulate accumulated paused messages + (model as any)["_pausedBuffer"] = [makeLine(2), makeLine(3)]; + model.togglePause(); // unpause — flushes buffer + expect(model.lines).toHaveLength(3); + expect((model as any)["_pausedBuffer"]).toEqual([]); + }); + + it("togglePause does nothing when unpausing with empty buffer", () => { + model.lines = [makeLine(1)]; + model.togglePause(); // pause + model.togglePause(); // unpause — buffer empty + expect(model.lines).toHaveLength(1); + }); + // #endregion + + // #region ReportsLogModel.ClearView [C:2] [TYPE Test] + it("clearView clears lines and selection", () => { + model.lines = [makeLine(1), makeLine(2)]; + model.selectedSeqs = [1]; + model.clearView(); + expect(model.lines).toEqual([]); + expect(model.selectedSeqs).toEqual([]); + }); + // #endregion + + // #region ReportsLogModel.ToggleSelectSeq [C:2] [TYPE Test] + it("toggleSelectSeq adds and removes seq from selection", () => { + model.toggleSelectSeq(1); + expect(model.selectedSeqs).toEqual([1]); + model.toggleSelectSeq(2); + expect(model.selectedSeqs).toEqual([1, 2]); + model.toggleSelectSeq(1); + expect(model.selectedSeqs).toEqual([2]); + }); + // #endregion + + // #region ReportsLogModel.CopyAll [C:2] [TYPE Test] + it("copyAll writes displayText to clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + model.lines = [makeLine(1, "INFO", "hello"), makeLine(2, "WARN", "world")]; + await model.copyAll(); + expect(writeText).toHaveBeenCalledWith("hello\nworld"); + expect(notifications.success).toHaveBeenCalledWith("Copied 2 lines"); + }); + + it("copyAll shows error notification when text is empty", async () => { + await model.copyAll(); + expect(notifications.error).toHaveBeenCalledWith("Nothing to copy"); + }); + + it("copyAll handles clipboard error", async () => { + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Permission denied")) } }); + model.lines = [makeLine(1)]; + await model.copyAll(); + expect(notifications.error).toHaveBeenCalledWith("Permission denied"); + }); + + it("copyAll handles non-Error clipboard rejection", async () => { + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue("blocked") } }); + model.lines = [makeLine(1)]; + await model.copyAll(); + expect(notifications.error).toHaveBeenCalledWith("Clipboard not available"); + }); + // #endregion + + // #region ReportsLogModel.CopySelection [C:2] [TYPE Test] + it("copySelection writes selected lines to clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + model.lines = [makeLine(1, "INFO", "first"), makeLine(2, "WARN", "second"), makeLine(3, "ERROR", "third")]; + model.selectedSeqs = [1, 3]; + await model.copySelection(); + expect(writeText).toHaveBeenCalledWith("first\nthird"); + expect(notifications.success).toHaveBeenCalledWith("Copied 2 lines"); + }); + + it("copySelection falls back to copyAll when selection is empty", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + model.lines = [makeLine(1)]; + model.selectedSeqs = []; + await model.copySelection(); + expect(writeText).toHaveBeenCalled(); // copyAll writes the full text + }); + + it("copySelection handles clipboard error", async () => { + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard error")) } }); + model.lines = [makeLine(1, "INFO", "text")]; + model.selectedSeqs = [1]; + await model.copySelection(); + expect(notifications.error).toHaveBeenCalledWith("Clipboard error"); + }); + // #endregion + + // #region ReportsLogModel.DownloadRaw [C:2] [TYPE Test] + it("downloadRaw creates blob and triggers download", () => { + const createObjectURL = vi.fn(() => "blob:download"); + const revokeObjectURL = vi.fn(); + vi.spyOn(URL, "createObjectURL").mockImplementation(createObjectURL); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeObjectURL); + + model.lines = [makeLine(1, "INFO", "log line")]; + model.downloadRaw(); + + expect(createObjectURL).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:download"); + expect(notifications.success).toHaveBeenCalledWith("Downloaded"); + + vi.restoreAllMocks(); + }); + + it("downloadRaw shows error when text is empty", () => { + model.downloadRaw(); + expect(notifications.error).toHaveBeenCalledWith("Nothing to download"); + }); + // #endregion + + // #region ReportsLogModel.WsLifecycle [C:2] [TYPE Test] + it("WS onopen sets wsConnected flag", async () => { + await model.startStream(); + expect(model.wsConnected).toBe(false); + ws.onopen?.(); + expect(model.wsConnected).toBe(true); + }); + + it("WS onmessage adds valid log line to lines", async () => { + await model.startStream(); + ws.onmessage?.({ data: JSON.stringify(makeLine(1)) } as MessageEvent); + expect(model.lines).toHaveLength(1); + expect(model.lines[0].seq).toBe(1); + }); + + it("WS onmessage ignores invalid JSON", async () => { + await model.startStream(); + ws.onmessage?.({ data: "not json" } as MessageEvent); + expect(model.lines).toHaveLength(0); + }); + + it("WS onmessage ignores messages missing raw or seq field", async () => { + await model.startStream(); + // Missing raw + ws.onmessage?.({ data: JSON.stringify({ seq: 1 }) } as MessageEvent); + expect(model.lines).toHaveLength(0); + // Missing seq (seq not a number) + ws.onmessage?.({ data: JSON.stringify({ raw: "text" }) } as MessageEvent); + expect(model.lines).toHaveLength(0); + }); + + it("WS onmessage ignores duplicate seq", async () => { + await model.startStream(); + ws.onmessage?.({ data: JSON.stringify(makeLine(1)) } as MessageEvent); + ws.onmessage?.({ data: JSON.stringify(makeLine(1)) } as MessageEvent); + expect(model.lines).toHaveLength(1); + }); + + it("WS onmessage accepts newer seq even when older seq exists", async () => { + await model.startStream(); + ws.onmessage?.({ data: JSON.stringify(makeLine(1)) } as MessageEvent); + ws.onmessage?.({ data: JSON.stringify(makeLine(5)) } as MessageEvent); + ws.onmessage?.({ data: JSON.stringify(makeLine(1)) } as MessageEvent); // seq <= lastSeq AND exists in lines + expect(model.lines).toHaveLength(2); + }); + + it("WS onmessage buffers lines when paused", async () => { + await model.startStream(); + model.togglePause(); // pause + ws.onmessage?.({ data: JSON.stringify(makeLine(1)) } as MessageEvent); + ws.onmessage?.({ data: JSON.stringify(makeLine(2)) } as MessageEvent); + expect(model.lines).toHaveLength(0); // not added to display + expect((model as any)["_pausedBuffer"]).toHaveLength(2); + }); + + it("WS onerror sets wsConnected to false", async () => { + await model.startStream(); + ws.onopen?.(); + expect(model.wsConnected).toBe(true); + ws.onerror?.(); + expect(model.wsConnected).toBe(false); + }); + + it("WS onmessage trims paused buffer when it exceeds MAX_LINES", async () => { + await model.startStream(); + model.togglePause(); // pause + // Fill buffer with 3001 items (just over MAX_LINES=3000) + const manyLines = Array.from({ length: 3001 }, (_, i) => makeLine(i)); + (model as any)["_pausedBuffer"] = manyLines; + // Add one more — triggers the overflow check (line 139) + ws.onmessage?.({ data: JSON.stringify(makeLine(9999)) } as MessageEvent); + expect((model as any)["_pausedBuffer"]).toHaveLength(3000); + }); + + it("_openWs handles getAppLogsWsUrl throwing an Error", async () => { + vi.mocked(getAppLogsWsUrl).mockImplementationOnce(() => { + throw new Error("WS URL error"); + }); + await model.startStream(); + expect(model.error).toBe("WS URL error"); + expect(model.screenState).toBe("error"); + }); + + it("_openWs handles non-Error throw in WS constructor", async () => { + vi.mocked(getAppLogsWsUrl).mockImplementationOnce(() => { + throw "string error"; // not an Error instance + }); + await model.startStream(); + expect(model.error).toBe("WS failed"); + expect(model.screenState).toBe("error"); + }); + // #endregion + + // #region ReportsLogModel.WsReconnect [C:2] [TYPE Test] + it("WS onclose schedules reconnect with exponential backoff", async () => { + await model.startStream(); + ws.onclose?.({ code: 1006 } as CloseEvent); + expect(model.wsConnected).toBe(false); + // Should have scheduled a reconnect + expect(model["_reconnectTimer"]).not.toBeNull(); + // Advance time past the reconnect delay (RECONNECT_BASE_MS = 1000) + vi.advanceTimersByTime(1000); + // Reconnect should have fired — getAppLogsWsUrl is called again + expect(getAppLogsWsUrl).toHaveBeenCalledTimes(2); + }); + + it("WS close with destroyed flag does not reconnect", async () => { + await model.startStream(); + model.destroy(); + ws.onclose?.({ code: 1006 } as CloseEvent); + expect(model["_reconnectTimer"]).toBeNull(); + }); + + it("WS onopen resets reconnect attempt counter", async () => { + await model.startStream(); + // Force a close to increment reconnectAttempt + ws.onclose?.({ code: 1006 } as CloseEvent); + expect(model["_reconnectAttempt"]).toBe(1); + // Simulate reconnect opening + // Advance timers to trigger the reconnect's _openWs call + vi.advanceTimersByTime(1000); + // After reconnect, the new WS handlers are on the shared ws object + ws.onopen?.(); + expect(model["_reconnectAttempt"]).toBe(0); + }); + // #endregion + + // #region ReportsLogModel.GetAppLogsWsUrlParams [C:2] [TYPE Test] + it("getAppLogsWsUrl called with level and taskIds params", async () => { + model.levelFilter = "WARN"; + model.filterByTasks = true; + model.filterTaskIds = ["t1"]; + await model.startStream(); + expect(getAppLogsWsUrl).toHaveBeenCalledWith({ + level: "WARN", + taskIds: ["t1"], + }); + }); + + it("getAppLogsWsUrl level param is undefined when level is 'all'", async () => { + model.levelFilter = "all"; + await model.startStream(); + expect(getAppLogsWsUrl).toHaveBeenCalledWith({ + level: undefined, + taskIds: undefined, + }); + }); + + it("getAppLogsWsUrl omits taskIds when filter not active", async () => { + await model.startStream(); + expect(getAppLogsWsUrl).toHaveBeenCalledWith({ + level: "INFO", + taskIds: undefined, + }); + }); + // #endregion +}); +// #endregion diff --git a/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts b/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts index 71ef7cb2a..459f22141 100644 --- a/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts +++ b/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts @@ -6,6 +6,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn(), notifications: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), show: vi.fn() } })); vi.mock("$lib/i18n/index.svelte.js", () => ({ getT: () => ({}), _: vi.fn((k) => k) })); +vi.mock("$lib/components/translate/runOutcome", () => ({ + detectionStats: vi.fn(), +})); vi.mock("$lib/api/translate.js", () => ({ fetchAllRuns: vi.fn().mockResolvedValue([]), fetchRunDetail: vi.fn(), @@ -22,6 +25,7 @@ import { fetchAllRuns, fetchRunDetail, fetchAllMetrics, fetchJobs, downloadSkippedCsv, downloadFailedCsv, cancelRun, retryFailedBatches, } from "$lib/api/translate.js"; +import { detectionStats as readDetectionStats } from "$lib/components/translate/runOutcome"; // #endregion Models.TranslateHistoryModel.TranslateHistoryModelTests // #region TranslateHistoryModel.InvariantTests [C:2] [TYPE Function] @@ -426,5 +430,270 @@ describe("TranslateHistoryModel — Utilities", () => { expect(fetchRunDetail).not.toHaveBeenCalled(); }); // #endregion + + // #region TranslateHistoryModel.CancelSuccessNotify [C:2] [TYPE Test] + it("handleCancelRun calls success notification", async () => { + vi.mocked(cancelRun).mockResolvedValue({}); + vi.mocked(fetchAllRuns).mockResolvedValue({ items: [], total: 0 }); + await model.handleCancelRun("r1"); + expect(notifications.success).toHaveBeenCalled(); + }); + + it("handleRetryRun calls success notification", async () => { + vi.mocked(retryFailedBatches).mockResolvedValue({}); + vi.mocked(fetchAllRuns).mockResolvedValue({ items: [], total: 0 }); + await model.handleRetryRun("r1"); + expect(notifications.success).toHaveBeenCalled(); + }); + // #endregion +}); +// #endregion + +// #region TranslateHistoryModel.SnapshotHelpers [C:2] [TYPE Function] +describe("TranslateHistoryModel — Snapshot helpers", () => { + let model: TranslateHistoryModel; + beforeEach(() => { vi.clearAllMocks(); model = new TranslateHistoryModel(); }); + + // #region TranslateHistoryModel.ConfigValue [C:2] [TYPE Test] + describe("configValue", () => { + it("returns '—' when config_snapshot is missing", () => { + expect(model.configValue("any")).toBe("—"); + }); + + it("returns '—' when config_snapshot is not an object", () => { + model.selectedRunDetail = { config_snapshot: "string" } as any; + expect(model.configValue("any")).toBe("—"); + }); + + it("returns '—' when field does not exist in snapshot", () => { + model.selectedRunDetail = { config_snapshot: { exists: "val" } } as any; + expect(model.configValue("missing")).toBe("—"); + }); + + it("returns string for existing field", () => { + model.selectedRunDetail = { config_snapshot: { my_field: "hello" } } as any; + expect(model.configValue("my_field")).toBe("hello"); + }); + + it("converts number to string", () => { + model.selectedRunDetail = { config_snapshot: { count: 42 } } as any; + expect(model.configValue("count")).toBe("42"); + }); + + it("returns '—' when field value is null", () => { + model.selectedRunDetail = { config_snapshot: { my_field: null } } as any; + expect(model.configValue("my_field")).toBe("—"); + }); + + it("returns '—' when field value is empty string", () => { + model.selectedRunDetail = { config_snapshot: { my_field: "" } } as any; + expect(model.configValue("my_field")).toBe("—"); + }); + + it("joins array values with comma", () => { + model.selectedRunDetail = { config_snapshot: { list: ["a", "b", "c"] } } as any; + expect(model.configValue("list")).toBe("a, b, c"); + }); + + it("returns '—' for empty array", () => { + model.selectedRunDetail = { config_snapshot: { list: [] } } as any; + expect(model.configValue("list")).toBe("—"); + }); + }); + // #endregion + + // #region TranslateHistoryModel.TargetTable [C:2] [TYPE Test] + describe("targetTable", () => { + it("returns schema.table when both present", () => { + model.selectedRunDetail = { config_snapshot: { target_schema: "public", target_table: "users" } } as any; + expect(model.targetTable()).toBe("public.users"); + }); + + it("returns schema when table is '—'", () => { + model.selectedRunDetail = { config_snapshot: { target_schema: "public" } } as any; + expect(model.targetTable()).toBe("public"); + }); + + it("returns table when schema is '—'", () => { + model.selectedRunDetail = { config_snapshot: { target_table: "users" } } as any; + expect(model.targetTable()).toBe("users"); + }); + + it("returns '—' when both missing", () => { + model.selectedRunDetail = {} as any; + expect(model.targetTable()).toBe("—"); + }); + }); + // #endregion + + // #region TranslateHistoryModel.IsFullTranslation [C:2] [TYPE Test] + describe("isFullTranslation", () => { + it("returns null when config_snapshot is missing", () => { + expect(model.isFullTranslation()).toBeNull(); + }); + + it("returns null when config_snapshot is not object", () => { + model.selectedRunDetail = { config_snapshot: "str" } as any; + expect(model.isFullTranslation()).toBeNull(); + }); + + it("returns true when full_translation is true", () => { + model.selectedRunDetail = { config_snapshot: { full_translation: true } } as any; + expect(model.isFullTranslation()).toBe(true); + }); + + it("returns false when full_translation is false", () => { + model.selectedRunDetail = { config_snapshot: { full_translation: false } } as any; + expect(model.isFullTranslation()).toBe(false); + }); + + it("returns null when full_translation is not boolean", () => { + model.selectedRunDetail = { config_snapshot: { full_translation: "yes" } } as any; + expect(model.isFullTranslation()).toBeNull(); + }); + }); + // #endregion +}); +// #endregion + +// #region TranslateHistoryModel.ExtendedUtilities [C:2] [TYPE Function] +describe("TranslateHistoryModel — Extended Utilities", () => { + let model: TranslateHistoryModel; + beforeEach(() => { vi.clearAllMocks(); model = new TranslateHistoryModel(); }); + + // #region TranslateHistoryModel.StatusClass [C:2] [TYPE Test] + it("getStatusClass returns correct classes for RUNNING", () => { + expect(model.getStatusClass("RUNNING")).toContain("primary"); + }); + + it("getStatusClass returns correct classes for CANCELLED", () => { + expect(model.getStatusClass("CANCELLED")).toContain("muted"); + }); + // #endregion + + // #region TranslateHistoryModel.LanguageStatsFormats [C:2] [TYPE Test] + it("handles keyed-object language_stats format", () => { + model.selectedRunDetail = { + language_stats: { + en: { language_code: "en", translated_rows: 10, failed_rows: 1, skipped_rows: 2, token_count: 30, estimated_cost: 0.5 }, + ru: { language_code: "ru", translated_rows: 8, failed_rows: 0, skipped_rows: 0, token_count: 20, estimated_cost: 0.3 }, + }, + }; + + const stats = model.languageStats(); + expect(stats).toHaveLength(2); + expect(stats[0].language_code).toBe("en"); + expect(stats[1].language_code).toBe("ru"); + expect(stats[0].estimated_cost).toBe(0.5); + }); + + it("returns empty array when language_stats is null", () => { + model.selectedRunDetail = { language_stats: null }; + expect(model.languageStats()).toEqual([]); + }); + + it("returns empty array when language_stats is a string", () => { + model.selectedRunDetail = { language_stats: "invalid" }; + expect(model.languageStats()).toEqual([]); + }); + + it("skips entries with non-object values in keyed format", () => { + model.selectedRunDetail = { + language_stats: { + en: { language_code: "en", translated_rows: 5 }, + ru: "not an object", + fr: null, + }, + }; + expect(model.languageStats()).toHaveLength(1); + expect(model.languageStats()[0].language_code).toBe("en"); + }); + + it("falls back to object key when language_code is missing in keyed format", () => { + model.selectedRunDetail = { + language_stats: { + en: { translated_rows: 5 }, + }, + }; + const stats = model.languageStats(); + expect(stats).toHaveLength(1); + expect(stats[0].language_code).toBe("en"); + }); + + it("handles mixed array with null entries", () => { + model.selectedRunDetail = { + language_stats: [ + { language_code: "en", translated_rows: 10 }, + null, + undefined, + "string", + ], + }; + const stats = model.languageStats(); + expect(stats).toHaveLength(1); + expect(stats[0].language_code).toBe("en"); + }); + // #endregion + + // #region TranslateHistoryModel.DetailMetric [C:2] [TYPE Test] + it("detailMetric returns locale string for numbers", () => { + model.selectedRunDetail = { records_processed: 1234567 } as any; + const result = model.detailMetric("records_processed"); + expect(result).toBe(1234567..toLocaleString()); + expect(result).not.toBe("—"); + }); + + it("detailMetric returns '—' for non-numeric values", () => { + model.selectedRunDetail = { records_processed: "lots" } as any; + expect(model.detailMetric("records_processed")).toBe("—"); + }); + // #endregion + + // #region TranslateHistoryModel.LoadJobsEdge [C:2] [TYPE Test] + it("loadJobs stops fetching when page returns empty results", async () => { + vi.mocked(fetchJobs) + .mockResolvedValueOnce({ items: Array.from({ length: 100 }, (_, i) => ({ id: `j${i}` })), total: 200 }) + .mockResolvedValueOnce({ items: [], total: 200 }); + await model.loadJobs(); + expect(fetchJobs).toHaveBeenCalledTimes(2); + expect(model.jobs).toHaveLength(100); + }); + + it("loadJobs with empty items array", async () => { + vi.mocked(fetchJobs).mockResolvedValue({ items: [], total: 0 }); + await model.loadJobs(); + expect(model.jobs).toEqual([]); + }); + // #endregion + + // #region TranslateHistoryModel.LoadRunsTotalFallback [C:2] [TYPE Test] + it("loadRuns defaults total to 0 when total field is missing", async () => { + vi.mocked(fetchAllRuns).mockResolvedValue({ items: [{ id: "r1" }] }); + await model.loadRuns(); + expect(model.total).toBe(0); + }); + // #endregion + + // #region TranslateHistoryModel.HasObservedProcessingMetrics [C:2] [TYPE Test] + it("hasObservedProcessingMetrics returns false when all fields are missing", () => { + model.selectedRunDetail = {}; + expect(model.hasObservedProcessingMetrics()).toBe(false); + }); + + it("hasObservedProcessingMetrics returns true when any field is a number", () => { + model.selectedRunDetail = { translated_records: 100 }; + expect(model.hasObservedProcessingMetrics()).toBe(true); + }); + // #endregion + + // #region TranslateHistoryModel.DetectionStats [C:2] [TYPE Test] + it("detectionStats delegates to readDetectionStats", () => { + vi.mocked(readDetectionStats).mockReturnValue({ auto: 5, llm_corrected: 1, unresolved: 0, overridden: 0 }); + model.selectedRunDetail = { detection_stats: { auto: 5 } } as any; + const result = model.detectionStats(); + expect(readDetectionStats).toHaveBeenCalledWith(model.selectedRunDetail); + expect(result).toEqual({ auto: 5, llm_corrected: 1, unresolved: 0, overridden: 0 }); + }); + // #endregion }); // #endregion diff --git a/frontend/src/lib/models/__tests__/TranslationJobModel.test.ts b/frontend/src/lib/models/__tests__/TranslationJobModel.test.ts index 6d51b2ad4..e9d07c6d8 100644 --- a/frontend/src/lib/models/__tests__/TranslationJobModel.test.ts +++ b/frontend/src/lib/models/__tests__/TranslationJobModel.test.ts @@ -909,10 +909,196 @@ describe('TranslationJobModel — Data Loading Helpers', () => { expect(model.error).toBe('Failed to load data'); }); - it('_onRunComplete calls notifications.info', async () => { - const { notifications } = await import('$lib/toasts.svelte.js'); - model.jobId = JOB_ID; - model['_onRunComplete']({ status: 'COMPLETED' }); - expect(vi.mocked(notifications.info)).toHaveBeenCalled(); - }); + it('_onRunComplete calls notifications.info', async () => { + const { notifications } = await import('$lib/toasts.svelte.js'); + model.jobId = JOB_ID; + model['_onRunComplete']({ status: 'COMPLETED' }); + expect(vi.mocked(notifications.info)).toHaveBeenCalled(); + }); + + it('saveJob transitions DRAFT to READY when runReady', async () => { + model.isNewJob = true; + model.status = 'DRAFT'; + model.name = 'Test Job'; + model.translationColumn = 'text'; model.datasourceId = DS_ID; + model.targetLanguages = ['en']; model.providerId = 'p1'; + model.targetTable = 'target_table_name'; + vi.mocked(api.requestApi).mockResolvedValue({ id: JOB_ID }); + await model.saveJob(); + expect(model.status).toBe('READY'); + }); + + it('saveJob transitions READY to DRAFT when not runReady', async () => { + model.isNewJob = false; + model.jobId = JOB_ID; + model.status = 'READY'; + model.name = ''; // not runReady because name is empty + vi.mocked(api.requestApi).mockResolvedValue({}); + await model.saveJob(); + expect(model.status).toBe('DRAFT'); + }); + + it('saveJob parses Pydantic 422 detail into validationErrors', async () => { + model.isNewJob = true; + const pydanticErr = new Error('Validation failed') as any; + pydanticErr.detail = [ + { loc: ['body', 'name'], msg: 'field required', type: 'value_error.missing' }, + { loc: ['body', 'translation_column'], msg: 'ensure this value has at most 255 characters', type: 'value_error' }, + ]; + vi.mocked(api.requestApi).mockRejectedValue(pydanticErr); + await model.saveJob(); + expect(model.validationErrors['name']).toBe('field required'); + expect(model.validationErrors['translationColumn']).toBe('ensure this value has at most 255 characters'); + expect(model.uxState).toBe('validation_error'); + }); + + it('calculateRunPreflight returns early for new job', async () => { + model.isNewJob = true; + await model.calculateRunPreflight(false); + expect(model.preflightLoading).toBe(false); + expect(api.postApi).not.toHaveBeenCalled(); + }); + + it('calculateRunPreflight returns early without jobId', async () => { + model.jobId = ''; + await model.calculateRunPreflight(false); + expect(model.preflightLoading).toBe(false); + expect(api.postApi).not.toHaveBeenCalled(); + }); + + it('handleTriggerRun is blocked when isRunActive via isRunning', async () => { + model.isRunning = true; + model.jobId = JOB_ID; + await model.handleTriggerRun(false); + expect(api.postApi).not.toHaveBeenCalled(); + expect(model.runError).toBeTruthy(); + }); + + it('handleTriggerRun is blocked when isRunActive via activeRunId', async () => { + const { translationRunStore } = await import('$lib/stores/translationRun.svelte.js'); + (translationRunStore as any).value = { runId: 'active-run', uxState: 'running', jobId: JOB_ID }; + model.jobId = JOB_ID; + await model.handleTriggerRun(false); + expect(api.postApi).not.toHaveBeenCalled(); + expect(model.runError).toBeTruthy(); + }); + + it('runReadiness includes direct_db connection item when insertMethod is direct_db', () => { + model.insertMethod = 'direct_db'; + model.connectionId = 'conn-1'; + const items = model.runReadiness; + const connItem = items.find(i => i.key === 'connectionId'); + expect(connItem).toBeDefined(); + expect(connItem!.ok).toBe(true); + }); + + it('runReadiness connectionId not ok when insertMethod=direct_db but no connectionId', () => { + model.insertMethod = 'direct_db'; + model.connectionId = ''; + const items = model.runReadiness; + const connItem = items.find(i => i.key === 'connectionId'); + expect(connItem).toBeDefined(); + expect(connItem!.ok).toBe(false); + }); + + it('warnings includes non-required items that are not ok', () => { + model.targetSchema = ''; // non-required, not ok + const warns = model.warnings; + expect(warns.length).toBeGreaterThan(0); + expect(warns.some(w => w.includes('Target schema'))).toBe(true); + }); + + it('warnings empty when all non-required items are ok', () => { + model.targetSchema = 'public'; + expect(model.warnings).toEqual([]); + }); + + it('loadRunHistory append mode accumulates runs', async () => { + model.completedRuns = [{ id: 'existing-run' }] as any; + vi.mocked(api.fetchApi).mockResolvedValue({ items: [{ id: 'new-run-1' }] }); + await model.loadRunHistory(true); + expect(model.completedRuns).toHaveLength(2); + expect(model.runHistoryPage).toBe(2); + }); + + it('loadRunHistory catch block reconnects stored active run', async () => { + const { getStoredActiveRun } = await import('$lib/stores/translationRun.svelte.js'); + (vi.mocked(getStoredActiveRun) as any).mockReturnValue({ runId: JOB_ID, jobId: JOB_ID, isFullRun: false }); + model.jobId = JOB_ID; + vi.mocked(api.fetchApi).mockRejectedValue(new Error('History fetch failed')); + await model.loadRunHistory(); + expect(model.completedRuns).toEqual([]); + expect(model.isRunning).toBe(true); + }); + + it('loadInitialData transitions READY to DRAFT when not runReady', async () => { + model.isNewJob = false; + vi.mocked(api.requestApi).mockImplementation(async (url: string) => { + if (url === '/llm/providers') return { providers: [] }; + if (url === '/translate/dictionaries?page_size=100') return { items: [] }; + if (url.startsWith('/translate/jobs/')) return makeJob({ status: 'READY', name: '' }); + return { items: [] }; + }); + vi.mocked(api.getEnvironmentDatabases).mockResolvedValue([]); + vi.mocked(api.fetchApi).mockImplementation(async () => ({ columns: [], virtual: [] })); + await model.loadInitialData(); + // name is '' → not runReady → status should revert to DRAFT + expect(model.status).toBe('DRAFT'); + }); + + it('calculateRunPreflight auto-saves when dirty and then calls preflight API', async () => { + model.jobId = JOB_ID; + model.isDirty = true; + model.name = 'Test Job'; + model.translationColumn = 'text'; model.datasourceId = DS_ID; + model.targetLanguages = ['en']; model.providerId = 'p1'; + model.targetTable = 'target_table'; + // saveJob should succeed first + vi.mocked(api.requestApi).mockResolvedValue({ id: JOB_ID }); + // Then preflight API + vi.mocked(api.postApi).mockResolvedValue({ + full_translation: false, eligible_rows: 10, skipped_rows: 2, + recommended_language_detection: 'auto', lingua_accepted: true, + }); + await model.calculateRunPreflight(false); + expect(api.requestApi).toHaveBeenCalled(); // save was called + expect(api.postApi).toHaveBeenCalledWith(`/translate/jobs/${JOB_ID}/run-preflight`, {}); + expect(model.preflightLoading).toBe(false); + }); + + it('calculateRunPreflight catches error', async () => { + model.jobId = JOB_ID; + model.preflightLoading = true; + // calculateRunPreflight from $lib/api/translate re-throws via normalizeTranslateError + // as a plain object (not Error), so the fallback message is used + vi.mocked(api.postApi).mockRejectedValue(new Error('Preflight error')); + await model.calculateRunPreflight(false); + expect(model.runPreflight).toBeNull(); + expect(model.preflightError).toBe('Failed to calculate run scope'); + expect(model.preflightLoading).toBe(false); + }); + + it('calculateRunPreflight returns early after failed save', async () => { + model.jobId = JOB_ID; + model.isDirty = true; + vi.mocked(api.requestApi).mockRejectedValue(new Error('Save failed')); + await model.calculateRunPreflight(false); + expect(api.postApi).not.toHaveBeenCalled(); + expect(model.preflightLoading).toBe(false); + }); + + it('handleTriggerRun auto-saves when dirty and existing job — save fails', async () => { + // Reset shared store mock to avoid pollution from previous tests + const { translationRunStore } = await import('$lib/stores/translationRun.svelte.js'); + (translationRunStore as any).value = null; + model.isNewJob = false; + model.jobId = JOB_ID; + model.isDirty = true; + // Make saveJob fail via API rejection so flow proceeds through error path + vi.mocked(api.requestApi).mockRejectedValue(new Error('Save failed')); + await model.handleTriggerRun(false); + // saveJob should have been called (auto-save), and runError should be set because save failed + expect(model.runError).toBe('Save failed'); + expect(api.postApi).not.toHaveBeenCalled(); // run was not triggered + }); }); diff --git a/frontend/src/lib/stores/__tests__/assistantChat.test.ts b/frontend/src/lib/stores/__tests__/assistantChat.test.ts index d5d0b85ed..e06262ea0 100644 --- a/frontend/src/lib/stores/__tests__/assistantChat.test.ts +++ b/frontend/src/lib/stores/__tests__/assistantChat.test.ts @@ -159,6 +159,42 @@ describe('assistantChatStore', () => { // ── openAssistantChatWithContext edge cases ──────────────────── + it('openAssistantChatWithContext opens panel and sets seedMessage', () => { + openAssistantChatWithContext({ seedMessage: 'Hello context' }); + const state = get(assistantChatStore); + expect(state.isOpen).toBe(true); + expect(state.seedMessage).toBe('Hello context'); + }); + + it('openAssistantChatWithContext sets focusTarget when provided', () => { + openAssistantChatWithContext({ focusTarget: 'dashboard-123' }); + const state = get(assistantChatStore); + expect(state.isOpen).toBe(true); + expect(state.focusTarget).toBe('dashboard-123'); + }); + + it('openAssistantChatWithContext sets both seedMessage and focusTarget', () => { + openAssistantChatWithContext({ seedMessage: 'Explain', focusTarget: 'dashboard-456' }); + const state = get(assistantChatStore); + expect(state.seedMessage).toBe('Explain'); + expect(state.focusTarget).toBe('dashboard-456'); + }); + + it('openAssistantChatWithContext with empty context uses defaults', () => { + openAssistantChatWithContext(); + const state = get(assistantChatStore); + expect(state.isOpen).toBe(true); + expect(state.seedMessage).toBe(''); + expect(state.focusTarget).toBeNull(); + }); + + it('openAssistantChatWithContext preserves existing conversationId', () => { + setAssistantConversationId('conv-existing'); + openAssistantChatWithContext({ seedMessage: 'Preserve test' }); + const state = get(assistantChatStore); + expect(state.conversationId).toBe('conv-existing'); + expect(state.seedMessage).toBe('Preserve test'); + }); }); // #endregion Tests.AssistantChat.AssistantChatStoreTestsFunction // #endregion Tests.AssistantChat.EXTFrontendAssistantChatTestModule diff --git a/frontend/src/lib/stores/__tests__/test_maintenance.ts b/frontend/src/lib/stores/__tests__/test_maintenance.ts index c30323d09..4a2c5844b 100644 --- a/frontend/src/lib/stores/__tests__/test_maintenance.ts +++ b/frontend/src/lib/stores/__tests__/test_maintenance.ts @@ -694,5 +694,19 @@ describe('maintenanceStore', () => { await expect(store.updateSettings({ test: true })).rejects.toThrow('Fail'); expect(store.isLoading).toBe(false); }); + + // ── isEventRemoving / pendingRemovals ───────────────────────── + + it('isEventRemoving returns false for unknown event', async () => { + const { createMaintenanceStore } = await import('../maintenance.svelte.js'); + const store = createMaintenanceStore(); + expect(store.isEventRemoving('nonexistent-id')).toBe(false); + }); + + it('pendingRemovals getter returns current state', async () => { + const { createMaintenanceStore } = await import('../maintenance.svelte.js'); + const store = createMaintenanceStore(); + expect(store.pendingRemovals).toEqual({}); + }); }); // #endregion Test.Maintenance diff --git a/frontend/src/lib/stores/__tests__/test_selectedTask.ts b/frontend/src/lib/stores/__tests__/test_selectedTask.ts index ce5383fe4..7d5391026 100644 --- a/frontend/src/lib/stores/__tests__/test_selectedTask.ts +++ b/frontend/src/lib/stores/__tests__/test_selectedTask.ts @@ -56,5 +56,71 @@ describe('SelectedTaskStore (post-выпиливание)', () => { unsubTask(); unsubLogs(); }); + + // ── Direct accessor coverage (setter, update, value, set) ── + + it('selectedTask.current setter updates value and notifies', () => { + const received: any[] = []; + const unsub = selectedTask.subscribe(t => received.push(t)); + selectedTask.current = { id: 'direct-set' }; + expect(selectedTask.current).toEqual({ id: 'direct-set' }); + expect(received.at(-1)).toEqual({ id: 'direct-set' }); + unsub(); + }); + + it('selectedTask.update applies transformation', () => { + selectedTask.current = { id: 'orig', count: 1 }; + selectedTask.update(t => ({ ...t!, count: (t as any).count + 1 })); + expect((selectedTask.current as any).count).toBe(2); + }); + + it('selectedTask.value getter returns current state', () => { + selectedTask.current = { id: 'val-test' }; + expect(selectedTask.value).toEqual({ id: 'val-test' }); + }); + + it('selectedTask.set directly sets value', () => { + selectedTask.set({ id: 'set-direct' }); + expect(selectedTask.current).toEqual({ id: 'set-direct' }); + }); + + it('selectedTask.set(null) clears selection', () => { + selectedTask.set({ id: 'temp' }); + selectedTask.set(null); + expect(selectedTask.current).toBeNull(); + }); + + it('taskLogs.current setter normalizes non-array to array', () => { + taskLogs.current = null as any; + expect(taskLogs.current).toEqual([]); + }); + + it('taskLogs.current setter with valid array', () => { + taskLogs.current = [{ id: 1 }]; + expect(taskLogs.current).toEqual([{ id: 1 }]); + }); + + it('taskLogs.set normalizes null to array', () => { + taskLogs.set(null as any); + expect(taskLogs.current).toEqual([]); + }); + + it('taskLogs.update handles null result safely', () => { + taskLogs.current = [{ id: 1 }]; + taskLogs.update(() => null as any); + expect(taskLogs.current).toEqual([]); + }); + + it('taskLogs.update appends entries', () => { + taskLogs.current = [{ id: 1 }]; + taskLogs.update(logs => [...logs, { id: 2 }]); + expect(taskLogs.current).toHaveLength(2); + }); + + it('setSelectedTask normalizes non-array logs', () => { + setSelectedTask({ id: 'non-array' }, null as any); + expect(selectedTask.current).toEqual({ id: 'non-array' }); + expect(taskLogs.current).toEqual([]); + }); }); // #endregion Test.SelectedTaskStore diff --git a/frontend/src/lib/stores/__tests__/test_translationRun.ts b/frontend/src/lib/stores/__tests__/test_translationRun.ts index c5b017a51..2b8a62ca9 100644 --- a/frontend/src/lib/stores/__tests__/test_translationRun.ts +++ b/frontend/src/lib/stores/__tests__/test_translationRun.ts @@ -837,5 +837,43 @@ describe('translationRun store', () => { expect(translationRunStore.value.uxState).toBe('failed'); }); + + // ── markTranslationRunCancelled ────────────────────────────── + + it('markTranslationRunCancelled sets state to cancelled', async () => { + const { translationRunStore, startTranslationRun, markTranslationRunCancelled } = + await import('../translationRun.svelte.js'); + + startTranslationRun('run-cancel-test'); + markTranslationRunCancelled('run-cancel-test'); + + expect(translationRunStore.value.uxState).toBe('cancelled'); + expect(translationRunStore.value.status?.status).toBe('CANCELLED'); + }); + + it('markTranslationRunCancelled invokes onComplete callback when set', async () => { + const onComplete = vi.fn(); + const { translationRunStore, startTranslationRun, markTranslationRunCancelled } = + await import('../translationRun.svelte.js'); + + startTranslationRun('run-cancel-cb', { onComplete }); + + markTranslationRunCancelled('run-cancel-cb'); + + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ status: 'CANCELLED' }), + ); + expect(translationRunStore.value.uxState).toBe('cancelled'); + }); + + it('markTranslationRunCancelled is no-op when runId does not match', async () => { + const { translationRunStore, startTranslationRun, markTranslationRunCancelled } = + await import('../translationRun.svelte.js'); + + startTranslationRun('run-other'); + markTranslationRunCancelled('different-run-id'); + + expect(translationRunStore.value.uxState).not.toBe('cancelled'); + }); }); // #endregion Test.TranslationRun diff --git a/frontend/src/lib/ui/__tests__/ConfirmDialog.test.ts b/frontend/src/lib/ui/__tests__/ConfirmDialog.test.ts index a4b018658..232af6a07 100644 --- a/frontend/src/lib/ui/__tests__/ConfirmDialog.test.ts +++ b/frontend/src/lib/ui/__tests__/ConfirmDialog.test.ts @@ -221,5 +221,81 @@ describe('ConfirmDialog', () => { expect(confirmBtn.textContent).toContain('Delete'); }); }); + + describe('confirmDisabled', () => { + it('disables confirm button when confirmDisabled is true', () => { + const { container } = render(ConfirmDialog, { + props: { show: true, title: 'Test', confirmDisabled: true }, + }); + const confirmBtn = container.querySelectorAll('button')[1] as HTMLButtonElement; + expect(confirmBtn.disabled).toBe(true); + }); + + it('confirm button is enabled by default', () => { + const { container } = render(ConfirmDialog, { + props: { show: true, title: 'Test' }, + }); + const confirmBtn = container.querySelectorAll('button')[1] as HTMLButtonElement; + expect(confirmBtn.disabled).toBe(false); + }); + }); + + describe('children snippet', () => { + it('renders children container when snippet is provided', () => { + const { container } = render(ConfirmDialog, { + props: { + show: true, + title: 'Checklist', + children: () => '

Pre-flight checks OK

', + }, + }); + // The mb-6 div wraps the {@render children()} call — appears when children is provided + const childWrapper = container.querySelector('.mb-6'); + expect(childWrapper).not.toBeNull(); + }); + + it('does not render children container when no snippet', () => { + const { container } = render(ConfirmDialog, { + props: { show: true, title: 'Test' }, + }); + // Verify the "mb-6" div that wraps children is not rendered + const snippets = container.querySelectorAll('.mb-6'); + expect(snippets.length).toBe(0); + }); + }); + + describe('Tab key trapping', () => { + it('wraps focus from last to first element on Tab', async () => { + const { container } = render(ConfirmDialog, { + props: { show: true, title: 'Test', message: 'Hello' }, + }); + const backdrop = container.querySelector('.fixed.inset-0') as HTMLElement; + const buttons = container.querySelectorAll('button'); + const first = buttons[0]; + const last = buttons[buttons.length - 1]; + // Focus on last element, press Tab → should focus first + last.focus(); + fireEvent.keyDown(backdrop, { key: 'Tab' }); + await waitFor(() => { + expect(document.activeElement).toBe(first); + }); + }); + + it('wraps focus from first to last element on Shift+Tab', async () => { + const { container } = render(ConfirmDialog, { + props: { show: true, title: 'Test', message: 'Hello' }, + }); + const backdrop = container.querySelector('.fixed.inset-0') as HTMLElement; + const buttons = container.querySelectorAll('button'); + const first = buttons[0]; + const last = buttons[buttons.length - 1]; + // Focus on first element, press Shift+Tab → should focus last + first.focus(); + fireEvent.keyDown(backdrop, { key: 'Tab', shiftKey: true }); + await waitFor(() => { + expect(document.activeElement).toBe(last); + }); + }); + }); }); // #endregion Tests.ConfirmDialog.ConfirmDialogTest diff --git a/frontend/src/lib/utils/__tests__/cron.test.ts b/frontend/src/lib/utils/__tests__/cron.test.ts new file mode 100644 index 000000000..218bab180 --- /dev/null +++ b/frontend/src/lib/utils/__tests__/cron.test.ts @@ -0,0 +1,253 @@ +// #region Test.Cron.CronUtils [C:2] [TYPE Module] [SEMANTICS test,cron,parsing,validation] +// @BRIEF L1 unit tests for cron utilities — validateCron, calcNextCronRun, formatNextRun. +// @RELATION BINDS_TO -> [Cron.CronUtils] +// @TEST_EDGE: missing_field -> Empty string returns error message +// @TEST_EDGE: invalid_type -> Wrong field count, NaN values, out-of-bounds return error +// @TEST_EDGE: external_fail -> No match within 2 years returns null +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { validateCron, calcNextCronRun, formatNextRun } from "../cron"; + +// #endregion Test.Cron.CronUtils +// #region Test.Cron.ValidateCron [C:2] [TYPE Function] +// @BRIEF ValidateCron: parser validation, error messages, and null return for valid expressions. +describe("validateCron", () => { + // #region Test.Cron.ValidateCron.EmptyString [C:2] [TYPE Test] + it("returns error for empty or whitespace-only input", () => { + expect(validateCron("")).toBe("Cron expression is empty"); + expect(validateCron(" ")).toBe("Cron expression is empty"); + expect(validateCron(null as unknown as string)).toBe("Cron expression is empty"); + }); + // #endregion + + // #region Test.Cron.ValidateCron.FieldCount [C:2] [TYPE Test] + it("returns error for wrong number of fields", () => { + const r1 = validateCron("0 0 * *"); + expect(r1).not.toBeNull(); + expect(r1!).toContain("5 fields"); + + const r2 = validateCron("0 0 * * * *"); + expect(r2).not.toBeNull(); + expect(r2!).toContain("5 fields"); + + const r3 = validateCron("0"); + expect(r3).not.toBeNull(); + expect(r3!).toContain("5 fields"); + }); + // #endregion + + // #region Test.Cron.ValidateCron.FieldBounds [C:2] [TYPE Test] + it("returns error for each field when value exceeds bounds", () => { + expect(validateCron("60 0 * * *")).toContain("minute"); + expect(validateCron("0 24 * * *")).toContain("hour"); + expect(validateCron("0 0 32 * *")).toContain("day of month"); + expect(validateCron("0 0 * 13 *")).toContain("month"); + expect(validateCron("0 0 * * 8")).toContain("day of week"); + }); + // #endregion + + // #region Test.Cron.ValidateCron.InvalidStep [C:2] [TYPE Test] + it("returns error for invalid step value", () => { + const r1 = validateCron("*/0 * * * *"); + expect(r1).not.toBeNull(); + expect(r1!).toContain("Invalid step"); + + const r2 = validateCron("*/* * * * *"); + expect(r2).not.toBeNull(); + expect(r2!).toContain("Invalid step"); + }); + // #endregion + + // #region Test.Cron.ValidateCron.InvalidRange [C:2] [TYPE Test] + it("returns error for invalid range syntax", () => { + const r1 = validateCron("a-b 0 * * *"); + expect(r1).not.toBeNull(); + + const r2 = validateCron("0 0 1-32 * *"); + expect(r2).not.toBeNull(); + expect(r2!).toContain("out of bounds"); + + const r3 = validateCron("0 0 * * 0-8"); + expect(r3).not.toBeNull(); + expect(r3!).toContain("out of bounds"); + }); + // #endregion + + // #region Test.Cron.ValidateCron.InvalidValue [C:2] [TYPE Test] + it("returns error for non-numeric single value", () => { + const r = validateCron("abc 0 * * *"); + expect(r).not.toBeNull(); + }); + // #endregion + + // #region Test.Cron.ValidateCron.ValidExpressions [C:2] [TYPE Test] + it("returns null for standard valid expressions", () => { + expect(validateCron("0 0 * * *")).toBeNull(); + expect(validateCron("* * * * *")).toBeNull(); + expect(validateCron("*/5 0-23 1-15 * 1-5")).toBeNull(); + expect(validateCron("0,30 0,12 1,15 * 0,6")).toBeNull(); + expect(validateCron("0 0 1 1 0")).toBeNull(); + expect(validateCron("30 4 1,15 * 0")).toBeNull(); + }); + // #endregion + + // #region Test.Cron.ValidateCron.CommaWithEmptyParts [C:2] [TYPE Test] + it("handles comma with empty parts (trailing/double comma)", () => { + // Trailing comma creates empty part → parseCronField skips it via line 19 + expect(validateCron("0, 0 * * *")).toBeNull(); + // Field with only commas → all parts empty → parseCronField returns [] → line 56 fires + const r1 = validateCron(",,, 0 * * *"); + expect(r1).not.toBeNull(); + expect(r1!).toContain("no valid values"); + }); + // #endregion +}); +// #endregion + +// #region Test.Cron.CalcNextCronRun [C:2] [TYPE Function] +// @BRIEF CalcNextCronRun: next-fire computation with time-based search. +// NOTE: calcNextCronRun uses local time methods (getHours, getDate, getDay) +// so all assertions use local time to match the function's behavior. +describe("calcNextCronRun", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-23T10:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // #region Test.Cron.CalcNextCronRun.EmptyInvalid [C:2] [TYPE Test] + it("returns null for empty or invalid input", () => { + expect(calcNextCronRun("")).toBeNull(); + expect(calcNextCronRun(" ")).toBeNull(); + expect(calcNextCronRun("invalid")).toBeNull(); + expect(calcNextCronRun("0 0 * *")).toBeNull(); + expect(calcNextCronRun("0 0 * * * *")).toBeNull(); + }); + // #endregion + + // #region Test.Cron.CalcNextCronRun.EveryMinute [C:2] [TYPE Test] + it("returns next minute for every-minute expression (1 min delta)", () => { + const beforeMs = new Date("2026-07-23T10:00:00Z").getTime(); + const result = calcNextCronRun("* * * * *"); + expect(result).toBeInstanceOf(Date); + // Should be exactly 1 minute later (the Date stores UTC, so getTime() is timezone-agnostic) + expect(result!.getTime()).toBe(beforeMs + 60000); + }); + // #endregion + + // #region Test.Cron.CalcNextCronRun.SpecificHour [C:2] [TYPE Test] + it("returns next match at specific hour", () => { + const result = calcNextCronRun("0 11 * * *"); + expect(result).toBeInstanceOf(Date); + // Function uses local time — assert via local time + expect(result!.getHours()).toBe(11); + expect(result!.getMinutes()).toBe(0); + }); + // #endregion + + // #region Test.Cron.CalcNextCronRun.SpecificDay [C:2] [TYPE Test] + it("returns next match on specific day of month (local time)", () => { + const result = calcNextCronRun("0 0 25 * *"); + expect(result).toBeInstanceOf(Date); + expect(result!.getDate()).toBe(25); + expect(result!.getHours()).toBe(0); + expect(result!.getMinutes()).toBe(0); + }); + // #endregion + + // #region Test.Cron.CalcNextCronRun.SpecificWeekday [C:2] [TYPE Test] + it("returns next match on specific weekday (local time)", () => { + // July 23, 2026 is Thursday in any timezone + // Next Friday (getDay === 5) should match + const result = calcNextCronRun("0 0 * * 5"); + expect(result).toBeInstanceOf(Date); + expect(result!.getDay()).toBe(5); + }); + // #endregion + + // #region Test.Cron.CalcNextCronRun.BothDomDow [C:2] [TYPE Test] + it("matches on EITHER DOM or DOW when both are specified", () => { + // 15th OR Monday — both specified, either is accepted + const result = calcNextCronRun("0 0 15 * 1"); + expect(result).toBeInstanceOf(Date); + // The next matching date will either be a 15th or a Monday + expect(result!.getDate() === 15 || result!.getDay() === 1).toBe(true); + }); + // #endregion + + // #region Test.Cron.CalcNextCronRun.OnlyDomSpecified [C:2] [TYPE Test] + it("uses DOM-only matching when only day-of-month is non-star", () => { + const result = calcNextCronRun("0 0 15 * *"); + expect(result).toBeInstanceOf(Date); + expect(result!.getDate()).toBe(15); + }); + // #endregion + + // #region Test.Cron.CalcNextCronRun.OnlyDowSpecified [C:2] [TYPE Test] + it("uses DOW-only matching when only day-of-week is non-star", () => { + const result = calcNextCronRun("0 0 * * 5"); + expect(result).toBeInstanceOf(Date); + expect(result!.getDay()).toBe(5); + }); + // #endregion + + // #region Test.Cron.CalcNextCronRun.NoMatchWithin2Years [C:2] [TYPE Test] + it("returns null when no match within 2 years (Feb 30)", () => { + const result = calcNextCronRun("0 0 30 2 *"); + expect(result).toBeNull(); + }); + // #endregion + + // #region Test.Cron.CalcNextCronRun.ParseErrorCatch [C:2] [TYPE Test] + it("returns null when parseCronField throws (invalid field syntax)", () => { + expect(calcNextCronRun("*/a * * * *")).toBeNull(); + }); + // #endregion +}); +// #endregion + +// #region Test.Cron.FormatNextRun [C:2] [TYPE Function] +// @BRIEF FormatNextRun: locale-friendly formatting with optional timezone. +describe("formatNextRun", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-23T10:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // #region Test.Cron.FormatNextRun.Invalid [C:2] [TYPE Test] + it("returns empty string for invalid expression", () => { + expect(formatNextRun("")).toBe(""); + expect(formatNextRun("invalid")).toBe(""); + expect(formatNextRun("0 0 * *")).toBe(""); + }); + // #endregion + + // #region Test.Cron.FormatNextRun.Valid [C:2] [TYPE Test] + it("returns a non-empty formatted string for valid expression", () => { + const result = formatNextRun("* * * * *"); + expect(result).toBeTruthy(); + expect(typeof result).toBe("string"); + }); + // #endregion + + // #region Test.Cron.FormatNextRun.WithTimezone [C:2] [TYPE Test] + it("accepts a valid timezone parameter", () => { + const result = formatNextRun("* * * * *", "UTC"); + expect(result).toBeTruthy(); + }); + // #endregion + + // #region Test.Cron.FormatNextRun.InvalidTimezoneFallback [C:2] [TYPE Test] + it("falls back to locale when timezone is invalid", () => { + const result = formatNextRun("* * * * *", "Invalid/Timezone"); + expect(result).toBeTruthy(); + }); + // #endregion +}); +// #endregion