273 lines
12 KiB
Python
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
|