Files
ss-tools/backend/src/api/routes/__tests__/test_datasets.py
2026-05-26 09:30:41 +03:00

273 lines
12 KiB
Python

# #region DatasetsApiTests [TYPE Module] [C:3] [SEMANTICS datasets, api, tests, pagination, mapping, docs]
# @BRIEF Unit tests for datasets API endpoints.
# @LAYER API
# @RELATION DEPENDS_ON -> [DatasetsApi]
# @INVARIANT Endpoint contracts remain stable for success and validation failure paths.
import pytest
from unittest.mock import AsyncMock, MagicMock
from fastapi.testclient import TestClient
from src.api.routes.datasets import DatasetsResponse
from src.app import app
from src.dependencies import (
get_config_manager,
get_current_user,
get_mapping_service,
get_resource_service,
get_task_manager,
has_permission,
)
# Global mock user for get_current_user dependency overrides
mock_user = MagicMock()
mock_user.username = "testuser"
mock_user.roles = []
admin_role = MagicMock()
admin_role.name = "Admin"
mock_user.roles.append(admin_role)
@pytest.fixture(autouse=True)
def mock_deps():
"""Bare MagicMock — no spec guards. All service method calls succeed silently.
Authorization, data integrity, and error paths are invisible to this fixture.
"""
# @INVARIANT unconstrained mock — no spec= enforced; attribute typos will silently pass
config_manager = MagicMock()
# @INVARIANT unconstrained mock — no spec= enforced; attribute typos will silently pass
task_manager = MagicMock()
# @INVARIANT unconstrained mock — no spec= enforced; attribute typos will silently pass
resource_service = MagicMock()
mapping_service = MagicMock()
app.dependency_overrides[get_config_manager] = lambda: config_manager
app.dependency_overrides[get_task_manager] = lambda: task_manager
app.dependency_overrides[get_resource_service] = lambda: resource_service
app.dependency_overrides[get_mapping_service] = lambda: mapping_service
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[has_permission("plugin:migration", "READ")] = (
lambda: mock_user
)
app.dependency_overrides[has_permission("plugin:migration", "EXECUTE")] = (
lambda: mock_user
)
app.dependency_overrides[has_permission("plugin:backup", "EXECUTE")] = (
lambda: mock_user
)
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: mock_user
yield {
"config": config_manager,
"task": task_manager,
"resource": resource_service,
"mapping": mapping_service,
}
app.dependency_overrides.clear()
client = TestClient(app)
# #region test_get_datasets_success [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests]
# @BRIEF Validate successful datasets listing contract for an existing environment.
# @TEST: GET /api/datasets returns 200 and valid schema
# @PRE env_id exists
# @POST Response matches DatasetsResponse schema
def test_get_datasets_success(mock_deps):
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
# Mock resource service response
mock_deps["resource"].get_datasets_with_status = AsyncMock(
return_value=[
{
"id": 1,
"table_name": "sales_data",
"schema": "public",
"database": "sales_db",
"mapped_fields": {"total": 10, "mapped": 5},
"last_task": {"task_id": "task-1", "status": "SUCCESS"},
}
]
)
response = client.get("/api/datasets?env_id=prod")
assert response.status_code == 200
data = response.json()
assert "datasets" in data
assert len(data["datasets"]) >= 0
# Validate against Pydantic model
DatasetsResponse(**data)
# #endregion test_get_datasets_success
# #region test_get_datasets_env_not_found [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests]
# @BRIEF Validate datasets listing returns 404 when the requested environment does not exist.
# @TEST: GET /api/datasets returns 404 if env_id missing
# @PRE env_id does not exist
# @POST Returns 404 error
def test_get_datasets_env_not_found(mock_deps):
mock_deps["config"].get_environments.return_value = []
response = client.get("/api/datasets?env_id=nonexistent")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# #endregion test_get_datasets_env_not_found
# #region test_get_datasets_invalid_pagination [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests]
# @BRIEF Validate datasets listing rejects invalid pagination parameters with 400 responses.
# @TEST: GET /api/datasets returns 400 for invalid page/page_size
# @PRE page < 1 or page_size > 100
# @POST Returns 400 error
def test_get_datasets_invalid_pagination(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
# Invalid page
response = client.get("/api/datasets?env_id=prod&page=0")
assert response.status_code == 400
assert "Page must be >= 1" in response.json()["detail"]
# Invalid page_size (too small)
response = client.get("/api/datasets?env_id=prod&page_size=0")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# @TEST_EDGE page_size > 100 exceeds max
response = client.get("/api/datasets?env_id=prod&page_size=101")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# #endregion test_get_datasets_invalid_pagination
# #region test_map_columns_success [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests]
# @BRIEF Validate map-columns request creates an async mapping task and returns its identifier.
# @TEST: POST /api/datasets/map-columns creates mapping task
# @PRE Valid env_id, dataset_ids, source_type (sqllab)
# @POST Returns task_id
def test_map_columns_success(mock_deps):
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-123"
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
response = client.post(
"/api/datasets/map-columns",
json={"env_id": "prod", "dataset_ids": [1, 2, 3], "source_type": "sqllab", "database_id": 1},
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once()
# #endregion test_map_columns_success
# #region test_map_columns_invalid_source_type [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests]
# @BRIEF Validate map-columns rejects unsupported source types with a 400 contract response.
# @TEST: POST /api/datasets/map-columns returns 400 for invalid source_type
# @PRE source_type is not 'sqllab' or 'xlsx'
# @POST Returns 400 error
def test_map_columns_invalid_source_type(mock_deps):
response = client.post(
"/api/datasets/map-columns",
json={"env_id": "prod", "dataset_ids": [1], "source_type": "invalid"},
)
assert response.status_code == 400
assert "Source type must be 'sqllab' or 'xlsx'" in response.json()["detail"]
# #endregion test_map_columns_invalid_source_type
# #region test_generate_docs_success [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests]
# @TEST: POST /api/datasets/generate-docs creates doc generation task
# @PRE Valid env_id, dataset_ids, llm_provider
# @BRIEF Validate generate-docs request creates an async documentation task and returns its identifier.
# @POST Returns task_id
def test_generate_docs_success(mock_deps):
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-456"
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
response = client.post(
"/api/datasets/generate-docs",
json={"env_id": "prod", "dataset_ids": [1], "llm_provider": "openai"},
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once()
# #endregion test_generate_docs_success
# #region test_map_columns_empty_ids [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests]
# @BRIEF Validate map-columns rejects empty dataset identifier lists.
# @TEST: POST /api/datasets/map-columns returns 400 for empty dataset_ids
# @PRE dataset_ids is empty
# @POST Returns 400 error
def test_map_columns_empty_ids(mock_deps):
"""@PRE: dataset_ids must be non-empty."""
response = client.post(
"/api/datasets/map-columns",
json={"env_id": "prod", "dataset_ids": [], "source_type": "sqllab", "database_id": 1},
)
assert response.status_code == 400
assert "At least one dataset ID must be provided" in response.json()["detail"]
# #endregion test_map_columns_empty_ids
# #region test_map_columns_missing_database_id [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests]
# @BRIEF Validate map-columns rejects sqllab source without database_id.
# @TEST: POST /api/datasets/map-columns returns 400 for sqllab without database_id
# @POST Returns 400 error
def test_map_columns_missing_database_id(mock_deps):
response = client.post(
"/api/datasets/map-columns",
json={"env_id": "prod", "dataset_ids": [1], "source_type": "sqllab"},
)
assert response.status_code == 400
assert "database_id is required" in response.json()["detail"]
# #endregion test_map_columns_missing_database_id
# #region test_generate_docs_empty_ids [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests]
# @BRIEF Validate generate-docs rejects empty dataset identifier lists.
# @TEST: POST /api/datasets/generate-docs returns 400 for empty dataset_ids
# @PRE dataset_ids is empty
# @POST Returns 400 error
def test_generate_docs_empty_ids(mock_deps):
"""@PRE: dataset_ids must be non-empty."""
response = client.post(
"/api/datasets/generate-docs",
json={"env_id": "prod", "dataset_ids": [], "llm_provider": "openai"},
)
assert response.status_code == 400
assert "At least one dataset ID must be provided" in response.json()["detail"]
# #endregion test_generate_docs_empty_ids
# #region test_generate_docs_env_not_found [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests]
# @TEST: POST /api/datasets/generate-docs returns 404 for missing env
# @PRE env_id does not exist
# @BRIEF Validate generate-docs returns 404 when the requested environment cannot be resolved.
# @POST Returns 404 error
def test_generate_docs_env_not_found(mock_deps):
"""@PRE: env_id must be a valid environment."""
mock_deps["config"].get_environments.return_value = []
response = client.post(
"/api/datasets/generate-docs",
json={"env_id": "ghost", "dataset_ids": [1], "llm_provider": "openai"},
)
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# #endregion test_generate_docs_env_not_found
# #region test_get_datasets_superset_failure [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests]
# @BRIEF Validate datasets listing surfaces a 503 contract when Superset access fails.
# @TEST_EDGE external_superset_failure -> {status: 503}
# @POST Returns 503 with stable error detail when upstream dataset fetch fails.
def test_get_datasets_superset_failure(mock_deps):
"""@TEST_EDGE: external_superset_failure -> {status: 503}"""
mock_env = MagicMock()
mock_env.id = "bad_conn"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_datasets_with_status = AsyncMock(
side_effect=Exception("Connection refused")
)
response = client.get("/api/datasets?env_id=bad_conn")
assert response.status_code == 503
assert "Failed to fetch datasets" in response.json()["detail"]
# #endregion test_get_datasets_superset_failure
# #endregion DatasetsApiTests