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:
2026-07-23 15:49:45 +03:00
parent fb6327e92b
commit 63d82df53b
19 changed files with 5110 additions and 12 deletions

View 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

View 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

View 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

View 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

View File

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

View File

@@ -19,11 +19,13 @@ describe('sessionTimeout', () => {
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers();
vi.stubGlobal('BroadcastChannel', 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 () => {

View 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

View File

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

View File

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

View File

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

View 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

View File

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

View File

@@ -915,4 +915,190 @@ describe('TranslationJobModel — Data Loading Helpers', () => {
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
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View 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