test(coverage): add 200+ tests to push frontend + backend coverage above thresholds
Backend (4 files, 73 tests): - test_agent_superset_routes.py (27 tests, 35% -> 92%) - test_agent_lifecycle_routes.py (11 tests, 50% -> 100%) - test_agent_status_routes.py (6 tests, 57% -> 100%) - test_git_release_routes.py (31 tests, 30% -> 99%) Frontend (~15 files, ~120 tests): - cron.ts: 0% -> 100% - ReportsLogModel: 0% -> 99% - parseCot.ts: 10% -> 100% - sessionTimeout.ts: 64% -> 93% - MappingsModel: 65% -> 100% - TranslateHistoryModel: 65% -> 93% - Migration.ExecutorModel: 70% -> 100% - GitManagerModel: 78% -> 90% - TranslationJobModel: 77% -> 80% - ConfirmDialog: 59% -> 80% - api.ts: 78% -> 80% Coverage: frontend 0 violations, backend 7518 passed.
This commit is contained in:
280
backend/tests/api/test_agent_lifecycle_routes.py
Normal file
280
backend/tests/api/test_agent_lifecycle_routes.py
Normal file
@@ -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
|
||||
110
backend/tests/api/test_agent_status_routes.py
Normal file
110
backend/tests/api/test_agent_status_routes.py
Normal file
@@ -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
|
||||
395
backend/tests/api/test_agent_superset_routes.py
Normal file
395
backend/tests/api/test_agent_superset_routes.py
Normal file
@@ -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
|
||||
731
backend/tests/api/test_git_release_routes.py
Normal file
731
backend/tests/api/test_git_release_routes.py
Normal file
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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<void>
|
||||
// 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 () => {
|
||||
|
||||
308
frontend/src/lib/logs/__tests__/parseCot.test.ts
Normal file
308
frontend/src/lib/logs/__tests__/parseCot.test.ts
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<typeof createParent>;
|
||||
|
||||
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<string, unknown>).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<string, unknown>).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<Record<string, unknown>>) = [{ 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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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
|
||||
625
frontend/src/lib/models/__tests__/ReportsLogModel.test.ts
Normal file
625
frontend/src/lib/models/__tests__/ReportsLogModel.test.ts
Normal file
@@ -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> = {}): 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: () => '<p>Pre-flight checks OK</p>',
|
||||
},
|
||||
});
|
||||
// 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
|
||||
|
||||
253
frontend/src/lib/utils/__tests__/cron.test.ts
Normal file
253
frontend/src/lib/utils/__tests__/cron.test.ts
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user