fix: commit semantic repair changes

This commit is contained in:
2026-03-21 11:22:25 +03:00
parent 0900208c1a
commit abee05558f
272 changed files with 4603 additions and 1668 deletions

View File

@@ -24,11 +24,13 @@ import starlette.requests
# [/SECTION]
# [DEF:router:Variable]
# @RELATION: DEPENDS_ON -> fastapi.APIRouter
# @COMPLEXITY: 1
# @PURPOSE: APIRouter instance for authentication routes.
router = APIRouter(prefix="/api/auth", tags=["auth"])
# [/DEF:router:Variable]
# [DEF:login_for_access_token:Function]
# @COMPLEXITY: 3
# @PURPOSE: Authenticates a user and returns a JWT access token.
@@ -38,18 +40,19 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
# @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials.
# @PARAM: db (Session) - Auth database session.
# @RETURN: Token - The generated JWT token.
# @RELATION: CALLS -> [AuthService.authenticate_user]
# @RELATION: CALLS -> [AuthService.create_session]
# @RELATION: CALLS -> [authenticate_user]
# @RELATION: CALLS -> [create_session]
@router.post("/login", response_model=Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_auth_db)
form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_auth_db)
):
with belief_scope("api.auth.login"):
auth_service = AuthService(db)
user = auth_service.authenticate_user(form_data.username, form_data.password)
if not user:
log_security_event("LOGIN_FAILED", form_data.username, {"reason": "Invalid credentials"})
log_security_event(
"LOGIN_FAILED", form_data.username, {"reason": "Invalid credentials"}
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
@@ -57,8 +60,11 @@ async def login_for_access_token(
)
log_security_event("LOGIN_SUCCESS", user.username, {"source": "LOCAL"})
return auth_service.create_session(user)
# [/DEF:login_for_access_token:Function]
# [DEF:read_users_me:Function]
# @COMPLEXITY: 3
# @PURPOSE: Retrieves the profile of the currently authenticated user.
@@ -71,8 +77,11 @@ async def login_for_access_token(
async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.me"):
return current_user
# [/DEF:read_users_me:Function]
# [DEF:logout:Function]
# @COMPLEXITY: 3
# @PURPOSE: Logs out the current user (placeholder for session revocation).
@@ -87,8 +96,11 @@ async def logout(current_user: UserSchema = Depends(get_current_user)):
# In a stateless JWT setup, client-side token deletion is primary.
# Server-side revocation (blacklisting) can be added here if needed.
return {"message": "Successfully logged out"}
# [/DEF:logout:Function]
# [DEF:login_adfs:Function]
# @COMPLEXITY: 3
# @PURPOSE: Initiates the ADFS OIDC login flow.
@@ -100,34 +112,43 @@ async def login_adfs(request: starlette.requests.Request):
if not is_adfs_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables."
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables.",
)
redirect_uri = request.url_for('auth_callback_adfs')
redirect_uri = request.url_for("auth_callback_adfs")
return await oauth.adfs.authorize_redirect(request, str(redirect_uri))
# [/DEF:login_adfs:Function]
# [DEF:auth_callback_adfs:Function]
# @COMPLEXITY: 3
# @PURPOSE: Handles the callback from ADFS after successful authentication.
# @POST: Provisions user JIT and returns session token.
# @RELATION: CALLS -> [AuthService.provision_adfs_user]
# @RELATION: CALLS -> [AuthService.create_session]
# @RELATION: CALLS -> [provision_adfs_user]
# @RELATION: CALLS -> [create_session]
@router.get("/callback/adfs", name="auth_callback_adfs")
async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)):
async def auth_callback_adfs(
request: starlette.requests.Request, db: Session = Depends(get_auth_db)
):
with belief_scope("api.auth.callback_adfs"):
if not is_adfs_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables."
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables.",
)
token = await oauth.adfs.authorize_access_token(request)
user_info = token.get('userinfo')
user_info = token.get("userinfo")
if not user_info:
raise HTTPException(status_code=400, detail="Failed to retrieve user info from ADFS")
raise HTTPException(
status_code=400, detail="Failed to retrieve user info from ADFS"
)
auth_service = AuthService(db)
user = auth_service.provision_adfs_user(user_info)
return auth_service.create_session(user)
# [/DEF:auth_callback_adfs:Function]
# [/DEF:AuthApi:Module]
# [/DEF:AuthApi:Module]

View File

@@ -1,5 +1,5 @@
# [DEF:AssistantApiTests:Module]
# @C: 3
# @COMPLEXITY: 3
# @SEMANTICS: tests, assistant, api
# @PURPOSE: Validate assistant API endpoint logic via direct async handler invocation.
# @RELATION: DEPENDS_ON -> backend.src.api.routes.assistant
@@ -21,15 +21,26 @@ from src.models.assistant import AssistantMessageRecord
# [DEF:_run_async:Function]
# @RELATION: BINDS_TO -> AssistantApiTests
def _run_async(coro):
return asyncio.run(coro)
# [/DEF:_run_async:Function]
# [DEF:_FakeTask:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
class _FakeTask:
def __init__(self, id, status="SUCCESS", plugin_id="unknown", params=None, result=None, user_id=None):
def __init__(
self,
id,
status="SUCCESS",
plugin_id="unknown",
params=None,
result=None,
user_id=None,
):
self.id = id
self.status = status
self.plugin_id = plugin_id
@@ -38,18 +49,29 @@ class _FakeTask:
self.user_id = user_id
self.started_at = datetime.utcnow()
self.finished_at = datetime.utcnow()
# [/DEF:_FakeTask:Class]
# [DEF:_FakeTaskManager:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 2
# @PURPOSE: In-memory task manager stub that records created tasks for route-level assertions.
# @INVARIANT: create_task stores tasks retrievable by get_task/get_tasks without external side effects.
class _FakeTaskManager:
def __init__(self):
self.tasks = {}
async def create_task(self, plugin_id, params, user_id=None):
task_id = f"task-{uuid.uuid4().hex[:8]}"
task = _FakeTask(task_id, status="STARTED", plugin_id=plugin_id, params=params, user_id=user_id)
task = _FakeTask(
task_id,
status="STARTED",
plugin_id=plugin_id,
params=params,
user_id=user_id,
)
self.tasks[task_id] = task
return task
@@ -57,10 +79,14 @@ class _FakeTaskManager:
return self.tasks.get(task_id)
def get_tasks(self, limit=20, offset=0):
return sorted(self.tasks.values(), key=lambda t: t.id, reverse=True)[offset : offset + limit]
return sorted(self.tasks.values(), key=lambda t: t.id, reverse=True)[
offset : offset + limit
]
def get_all_tasks(self):
return list(self.tasks.values())
# [/DEF:_FakeTaskManager:Class]
@@ -79,14 +105,19 @@ class _FakeConfigManager:
class _Settings:
default_environment_id = "dev"
llm = {}
class _Config:
settings = _Settings()
environments = []
return _Config()
# [/DEF:_FakeConfigManager:Class]
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> AssistantApiTests
def _admin_user():
user = MagicMock(spec=User)
user.id = "u-admin"
@@ -95,16 +126,21 @@ def _admin_user():
role.name = "Admin"
user.roles = [role]
return user
# [/DEF:_admin_user:Function]
# [DEF:_limited_user:Function]
# @RELATION: BINDS_TO -> AssistantApiTests
def _limited_user():
user = MagicMock(spec=User)
user.id = "u-limited"
user.username = "limited"
user.roles = []
return user
# [/DEF:_limited_user:Function]
@@ -136,11 +172,16 @@ class _FakeQuery:
def count(self):
return len(self.items)
# [/DEF:_FakeQuery:Class]
# [DEF:_FakeDb:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 2
# @PURPOSE: Explicit in-memory DB session double limited to assistant message persistence paths.
# @INVARIANT: query/add/merge stay deterministic and never emulate unrelated SQLAlchemy behavior.
class _FakeDb:
def __init__(self):
self.added = []
@@ -164,56 +205,71 @@ class _FakeDb:
def refresh(self, obj):
pass
# [/DEF:_FakeDb:Class]
# [DEF:_clear_assistant_state:Function]
# @RELATION: BINDS_TO -> AssistantApiTests
def _clear_assistant_state():
assistant_routes.CONVERSATIONS.clear()
assistant_routes.USER_ACTIVE_CONVERSATION.clear()
assistant_routes.CONFIRMATIONS.clear()
assistant_routes.ASSISTANT_AUDIT.clear()
# [/DEF:_clear_assistant_state:Function]
# [DEF:test_unknown_command_returns_needs_clarification:Function]
# @RELATION: BINDS_TO -> AssistantApiTests
# @PURPOSE: Unknown command should return clarification state and unknown intent.
def test_unknown_command_returns_needs_clarification(monkeypatch):
_clear_assistant_state()
req = assistant_routes.AssistantMessageRequest(message="some random gibberish")
# We mock LLM planner to return low confidence
monkeypatch.setattr(assistant_routes, "_plan_intent_with_llm", lambda *a, **k: None)
resp = _run_async(assistant_routes.send_message(
req,
current_user=_admin_user(),
task_manager=_FakeTaskManager(),
config_manager=_FakeConfigManager(),
db=_FakeDb()
))
resp = _run_async(
assistant_routes.send_message(
req,
current_user=_admin_user(),
task_manager=_FakeTaskManager(),
config_manager=_FakeConfigManager(),
db=_FakeDb(),
)
)
assert resp.state == "needs_clarification"
assert "уточните" in resp.text.lower() or "неоднозначна" in resp.text.lower()
# [/DEF:test_unknown_command_returns_needs_clarification:Function]
# [DEF:test_capabilities_question_returns_successful_help:Function]
# @RELATION: BINDS_TO -> AssistantApiTests
# @PURPOSE: Capability query should return deterministic help response.
def test_capabilities_question_returns_successful_help(monkeypatch):
_clear_assistant_state()
req = assistant_routes.AssistantMessageRequest(message="что ты умеешь?")
resp = _run_async(assistant_routes.send_message(
req,
current_user=_admin_user(),
task_manager=_FakeTaskManager(),
config_manager=_FakeConfigManager(),
db=_FakeDb()
))
resp = _run_async(
assistant_routes.send_message(
req,
current_user=_admin_user(),
task_manager=_FakeTaskManager(),
config_manager=_FakeConfigManager(),
db=_FakeDb(),
)
)
assert resp.state == "success"
assert "я могу сделать" in resp.text.lower()
# [/DEF:test_capabilities_question_returns_successful_help:Function]
# ... (rest of file trimmed for length, I've seen it and I'll keep the existing [DEF]s as is but add @RELATION)

View File

@@ -1,4 +1,4 @@
# [DEF:backend.src.api.routes.__tests__.test_assistant_authz:Module]
# [DEF:TestAssistantAuthz:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, assistant, authz, confirmation, rbac
# @PURPOSE: Verify assistant confirmation ownership, expiration, and deny behavior for restricted users.
@@ -16,8 +16,12 @@ from fastapi import HTTPException
# Force isolated sqlite databases for test module before dependencies import.
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz.db")
os.environ.setdefault("TASKS_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_tasks.db")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_auth.db")
os.environ.setdefault(
"TASKS_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_tasks.db"
)
os.environ.setdefault(
"AUTH_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_auth.db"
)
from src.api.routes import assistant as assistant_module
from src.models.assistant import (
@@ -28,6 +32,7 @@ from src.models.assistant import (
# [DEF:_run_async:Function]
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @COMPLEXITY: 1
# @PURPOSE: Execute async endpoint handler in synchronous test context.
# @PRE: coroutine is awaitable endpoint invocation.
@@ -37,7 +42,10 @@ def _run_async(coroutine):
# [/DEF:_run_async:Function]
# [DEF:_FakeTask:Class]
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @COMPLEXITY: 1
# @PURPOSE: Lightweight task model used for assistant authz tests.
class _FakeTask:
@@ -49,8 +57,10 @@ class _FakeTask:
# [/DEF:_FakeTask:Class]
# [DEF:_FakeTaskManager:Class]
# @COMPLEXITY: 1
# @PURPOSE: Minimal task manager for deterministic operation creation and lookup.
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @COMPLEXITY: 2
# @PURPOSE: In-memory task manager double that records assistant-created tasks deterministically.
# @INVARIANT: Only create_task/get_task/get_tasks behavior used by assistant authz routes is emulated.
class _FakeTaskManager:
def __init__(self):
self._created = []
@@ -73,6 +83,7 @@ class _FakeTaskManager:
# [/DEF:_FakeTaskManager:Class]
# [DEF:_FakeConfigManager:Class]
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @COMPLEXITY: 1
# @PURPOSE: Provide deterministic environment aliases required by intent parsing.
class _FakeConfigManager:
@@ -85,6 +96,7 @@ class _FakeConfigManager:
# [/DEF:_FakeConfigManager:Class]
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @COMPLEXITY: 1
# @PURPOSE: Build admin principal fixture.
# @PRE: Test requires privileged principal for risky operations.
@@ -96,6 +108,7 @@ def _admin_user():
# [/DEF:_admin_user:Function]
# [DEF:_other_admin_user:Function]
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @COMPLEXITY: 1
# @PURPOSE: Build second admin principal fixture for ownership tests.
# @PRE: Ownership mismatch scenario needs distinct authenticated actor.
@@ -107,6 +120,7 @@ def _other_admin_user():
# [/DEF:_other_admin_user:Function]
# [DEF:_limited_user:Function]
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @COMPLEXITY: 1
# @PURPOSE: Build limited principal without required assistant execution privileges.
# @PRE: Permission denial scenario needs non-admin actor.
@@ -117,7 +131,10 @@ def _limited_user():
# [/DEF:_limited_user:Function]
# [DEF:_FakeQuery:Class]
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @COMPLEXITY: 1
# @PURPOSE: Minimal chainable query object for fake DB interactions.
class _FakeQuery:
@@ -150,8 +167,10 @@ class _FakeQuery:
# [/DEF:_FakeQuery:Class]
# [DEF:_FakeDb:Class]
# @COMPLEXITY: 1
# @PURPOSE: In-memory session substitute for assistant route persistence calls.
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @COMPLEXITY: 2
# @PURPOSE: In-memory DB session double constrained to assistant message/confirmation/audit persistence paths.
# @INVARIANT: query/add/merge are intentionally narrow and must not claim full SQLAlchemy Session semantics.
class _FakeDb:
def __init__(self):
self._messages = []
@@ -197,6 +216,7 @@ class _FakeDb:
# [/DEF:_FakeDb:Class]
# [DEF:_clear_assistant_state:Function]
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @COMPLEXITY: 1
# @PURPOSE: Reset assistant process-local state between test cases.
# @PRE: Assistant globals may contain state from prior tests.
@@ -209,7 +229,10 @@ def _clear_assistant_state():
# [/DEF:_clear_assistant_state:Function]
# [DEF:test_confirmation_owner_mismatch_returns_403:Function]
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @PURPOSE: Confirm endpoint should reject requests from user that does not own the confirmation token.
# @PRE: Confirmation token is created by first admin actor.
# @POST: Second actor receives 403 on confirm operation.
@@ -245,7 +268,10 @@ def test_confirmation_owner_mismatch_returns_403():
# [/DEF:test_confirmation_owner_mismatch_returns_403:Function]
# [DEF:test_expired_confirmation_cannot_be_confirmed:Function]
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @PURPOSE: Expired confirmation token should be rejected and not create task.
# @PRE: Confirmation token exists and is manually expired before confirm request.
# @POST: Confirm endpoint raises 400 and no task is created.
@@ -265,7 +291,9 @@ def test_expired_confirmation_cannot_be_confirmed():
db=db,
)
)
assistant_module.CONFIRMATIONS[create.confirmation_id].expires_at = datetime.utcnow() - timedelta(minutes=1)
assistant_module.CONFIRMATIONS[create.confirmation_id].expires_at = (
datetime.utcnow() - timedelta(minutes=1)
)
with pytest.raises(HTTPException) as exc:
_run_async(
@@ -282,7 +310,10 @@ def test_expired_confirmation_cannot_be_confirmed():
# [/DEF:test_expired_confirmation_cannot_be_confirmed:Function]
# [DEF:test_limited_user_cannot_launch_restricted_operation:Function]
# @RELATION: BINDS_TO -> TestAssistantAuthz
# @PURPOSE: Limited user should receive denied state for privileged operation.
# @PRE: Restricted user attempts dangerous deploy command.
# @POST: Assistant returns denied state and does not execute operation.
@@ -303,4 +334,4 @@ def test_limited_user_cannot_launch_restricted_operation():
# [/DEF:test_limited_user_cannot_launch_restricted_operation:Function]
# [/DEF:backend.src.api.routes.__tests__.test_assistant_authz:Module]
# [/DEF:TestAssistantAuthz:Module]

View File

@@ -1,9 +1,9 @@
# [DEF:backend.tests.api.routes.test_clean_release_api:Module]
# [DEF:TestCleanReleaseApi:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, api, clean-release, checks, reports
# @PURPOSE: Contract tests for clean release checks and reports endpoints.
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.api.routes.clean_release
# @INVARIANT: API returns deterministic payload shapes for checks and reports.
from datetime import datetime, timezone
@@ -25,6 +25,8 @@ from src.models.clean_release import (
from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:_repo_with_seed_data:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
def _repo_with_seed_data() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
repo.save_candidate(
@@ -72,6 +74,11 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
return repo
# [/DEF:_repo_with_seed_data:Function]
# [DEF:test_start_check_and_get_status_contract:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
def test_start_check_and_get_status_contract():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -89,7 +96,9 @@ def test_start_check_and_get_status_contract():
)
assert start.status_code == 202
payload = start.json()
assert set(["check_run_id", "candidate_id", "status", "started_at"]).issubset(payload.keys())
assert set(["check_run_id", "candidate_id", "status", "started_at"]).issubset(
payload.keys()
)
check_run_id = payload["check_run_id"]
status_resp = client.get(f"/api/clean-release/checks/{check_run_id}")
@@ -102,6 +111,11 @@ def test_start_check_and_get_status_contract():
app.dependency_overrides.clear()
# [/DEF:test_start_check_and_get_status_contract:Function]
# [DEF:test_get_report_not_found_returns_404:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
def test_get_report_not_found_returns_404():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -112,6 +126,12 @@ def test_get_report_not_found_returns_404():
finally:
app.dependency_overrides.clear()
# [/DEF:test_get_report_not_found_returns_404:Function]
# [DEF:test_get_report_success:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
def test_get_report_success():
repo = _repo_with_seed_data()
report = ComplianceReport(
@@ -123,7 +143,7 @@ def test_get_report_success():
operator_summary="all systems go",
structured_payload_ref="manifest-1",
violations_count=0,
blocking_violations_count=0
blocking_violations_count=0,
)
repo.save_report(report)
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -135,8 +155,12 @@ def test_get_report_success():
finally:
app.dependency_overrides.clear()
# [/DEF:backend.tests.api.routes.test_clean_release_api:Module]
# [/DEF:test_get_report_success:Function]
# [DEF:test_prepare_candidate_api_success:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
def test_prepare_candidate_api_success():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -146,7 +170,9 @@ def test_prepare_candidate_api_success():
"/api/clean-release/candidates/prepare",
json={
"candidate_id": "2026.03.03-rc1",
"artifacts": [{"path": "file1.txt", "category": "system-init", "reason": "core"}],
"artifacts": [
{"path": "file1.txt", "category": "system-init", "reason": "core"}
],
"sources": ["repo.intra.company.local"],
"operator_id": "operator-1",
},
@@ -156,4 +182,8 @@ def test_prepare_candidate_api_success():
assert data["status"] == "prepared"
assert "manifest_id" in data
finally:
app.dependency_overrides.clear()
app.dependency_overrides.clear()
# [/DEF:test_prepare_candidate_api_success:Function]
# [/DEF:TestCleanReleaseApi:Module]

View File

@@ -1,8 +1,8 @@
# [DEF:backend.src.api.routes.__tests__.test_clean_release_legacy_compat:Module]
# [DEF:TestCleanReleaseLegacyCompat:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @PURPOSE: Compatibility tests for legacy clean-release API paths retained during v2 migration.
# @LAYER: Tests
# @RELATION: TESTS -> backend.src.api.routes.clean_release
from __future__ import annotations
@@ -29,6 +29,7 @@ from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:_seed_legacy_repo:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseLegacyCompat
# @PURPOSE: Seed in-memory repository with minimum trusted data for legacy endpoint contracts.
# @PRE: Repository is empty.
# @POST: Candidate, policy, registry and manifest are available for legacy checks flow.
@@ -111,6 +112,8 @@ def _seed_legacy_repo() -> CleanReleaseRepository:
# [/DEF:_seed_legacy_repo:Function]
# [DEF:test_legacy_prepare_endpoint_still_available:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseLegacyCompat
def test_legacy_prepare_endpoint_still_available() -> None:
repo = _seed_legacy_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -133,6 +136,10 @@ def test_legacy_prepare_endpoint_still_available() -> None:
app.dependency_overrides.clear()
# [/DEF:test_legacy_prepare_endpoint_still_available:Function]
# [DEF:test_legacy_checks_endpoints_still_available:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseLegacyCompat
def test_legacy_checks_endpoints_still_available() -> None:
repo = _seed_legacy_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -162,4 +169,4 @@ def test_legacy_checks_endpoints_still_available() -> None:
app.dependency_overrides.clear()
# [/DEF:backend.src.api.routes.__tests__.test_clean_release_legacy_compat:Module]
# [/DEF:TestCleanReleaseLegacyCompat:Module]# [/DEF:test_legacy_checks_endpoints_still_available:Function]

View File

@@ -1,9 +1,9 @@
# [DEF:backend.tests.api.routes.test_clean_release_source_policy:Module]
# [DEF:TestCleanReleaseSourcePolicy:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, api, clean-release, source-policy
# @PURPOSE: Validate API behavior for source isolation violations in clean release preparation.
# @LAYER: Domain
# @RELATION: TESTS -> backend.src.api.routes.clean_release
# @INVARIANT: External endpoints must produce blocking violation entries.
from datetime import datetime, timezone
@@ -22,6 +22,8 @@ from src.models.clean_release import (
from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:_repo_with_seed_data:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseSourcePolicy
def _repo_with_seed_data() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
@@ -72,6 +74,10 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
return repo
# [/DEF:_repo_with_seed_data:Function]
# [DEF:test_prepare_candidate_blocks_external_source:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseSourcePolicy
def test_prepare_candidate_blocks_external_source():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -97,4 +103,4 @@ def test_prepare_candidate_blocks_external_source():
app.dependency_overrides.clear()
# [/DEF:backend.tests.api.routes.test_clean_release_source_policy:Module]
# [/DEF:TestCleanReleaseSourcePolicy:Module]# [/DEF:test_prepare_candidate_blocks_external_source:Function]

View File

@@ -23,7 +23,10 @@ from src.services.clean_release.enums import CandidateStatus
client = TestClient(app)
# [REASON] Implementing API contract tests for candidate/artifact/manifest endpoints (T012).
# [DEF:test_candidate_registration_contract:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ApiTests
def test_candidate_registration_contract():
"""
@TEST_SCENARIO: candidate_registration -> Should return 201 and candidate DTO.
@@ -33,7 +36,7 @@ def test_candidate_registration_contract():
"id": "rc-test-001",
"version": "1.0.0",
"source_snapshot_ref": "git:sha123",
"created_by": "test-user"
"created_by": "test-user",
}
response = client.post("/api/v2/clean-release/candidates", json=payload)
assert response.status_code == 201
@@ -41,6 +44,12 @@ def test_candidate_registration_contract():
assert data["id"] == "rc-test-001"
assert data["status"] == CandidateStatus.DRAFT.value
# [/DEF:test_candidate_registration_contract:Function]
# [DEF:test_artifact_import_contract:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ApiTests
def test_artifact_import_contract():
"""
@TEST_SCENARIO: artifact_import -> Should return 200 and success status.
@@ -51,25 +60,30 @@ def test_artifact_import_contract():
"id": candidate_id,
"version": "1.0.0",
"source_snapshot_ref": "git:sha123",
"created_by": "test-user"
"created_by": "test-user",
}
create_response = client.post("/api/v2/clean-release/candidates", json=bootstrap_candidate)
create_response = client.post(
"/api/v2/clean-release/candidates", json=bootstrap_candidate
)
assert create_response.status_code == 201
payload = {
"artifacts": [
{
"id": "art-1",
"path": "bin/app.exe",
"sha256": "hash123",
"size": 1024
}
{"id": "art-1", "path": "bin/app.exe", "sha256": "hash123", "size": 1024}
]
}
response = client.post(f"/api/v2/clean-release/candidates/{candidate_id}/artifacts", json=payload)
response = client.post(
f"/api/v2/clean-release/candidates/{candidate_id}/artifacts", json=payload
)
assert response.status_code == 200
assert response.json()["status"] == "success"
# [/DEF:test_artifact_import_contract:Function]
# [DEF:test_manifest_build_contract:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ApiTests
def test_manifest_build_contract():
"""
@TEST_SCENARIO: manifest_build -> Should return 201 and manifest DTO.
@@ -80,9 +94,11 @@ def test_manifest_build_contract():
"id": candidate_id,
"version": "1.0.0",
"source_snapshot_ref": "git:sha123",
"created_by": "test-user"
"created_by": "test-user",
}
create_response = client.post("/api/v2/clean-release/candidates", json=bootstrap_candidate)
create_response = client.post(
"/api/v2/clean-release/candidates", json=bootstrap_candidate
)
assert create_response.status_code == 201
response = client.post(f"/api/v2/clean-release/candidates/{candidate_id}/manifests")
@@ -91,4 +107,6 @@ def test_manifest_build_contract():
assert "manifest_digest" in data
assert data["candidate_id"] == candidate_id
# [/DEF:CleanReleaseV2ApiTests:Module]
# [/DEF:test_manifest_build_contract:Function]
# [/DEF:CleanReleaseV2ApiTests:Module]

View File

@@ -23,6 +23,8 @@ test_app.include_router(clean_release_v2_router)
client = TestClient(test_app)
# [DEF:_seed_candidate_and_passed_report:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ReleaseApiTests
def _seed_candidate_and_passed_report() -> tuple[str, str]:
repository = get_clean_release_repository()
candidate_id = f"api-release-candidate-{uuid4()}"
@@ -52,6 +54,10 @@ def _seed_candidate_and_passed_report() -> tuple[str, str]:
return candidate_id, report_id
# [/DEF:_seed_candidate_and_passed_report:Function]
# [DEF:test_release_approve_and_publish_revoke_contract:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ReleaseApiTests
def test_release_approve_and_publish_revoke_contract() -> None:
"""Contract for approve -> publish -> revoke lifecycle endpoints."""
candidate_id, report_id = _seed_candidate_and_passed_report()
@@ -90,6 +96,10 @@ def test_release_approve_and_publish_revoke_contract() -> None:
assert revoke_payload["publication"]["status"] == "REVOKED"
# [/DEF:test_release_approve_and_publish_revoke_contract:Function]
# [DEF:test_release_reject_contract:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ReleaseApiTests
def test_release_reject_contract() -> None:
"""Contract for reject endpoint."""
candidate_id, report_id = _seed_candidate_and_passed_report()
@@ -104,4 +114,4 @@ def test_release_reject_contract() -> None:
assert payload["decision"] == "REJECTED"
# [/DEF:CleanReleaseV2ReleaseApiTests:Module]
# [/DEF:CleanReleaseV2ReleaseApiTests:Module]# [/DEF:test_release_reject_contract:Function]

View File

@@ -39,6 +39,8 @@ def db_session():
session.close()
# [DEF:test_list_connections_bootstraps_missing_table:Function]
# @RELATION: BINDS_TO -> ConnectionsRoutesTests
def test_list_connections_bootstraps_missing_table(db_session):
from src.api.routes.connections import list_connections
@@ -49,6 +51,10 @@ def test_list_connections_bootstraps_missing_table(db_session):
assert "connection_configs" in inspector.get_table_names()
# [/DEF:test_list_connections_bootstraps_missing_table:Function]
# [DEF:test_create_connection_bootstraps_missing_table:Function]
# @RELATION: BINDS_TO -> ConnectionsRoutesTests
def test_create_connection_bootstraps_missing_table(db_session):
from src.api.routes.connections import ConnectionCreate, create_connection
@@ -70,3 +76,4 @@ def test_create_connection_bootstraps_missing_table(db_session):
assert "connection_configs" in inspector.get_table_names()
# [/DEF:ConnectionsRoutesTests:Module]
# [/DEF:test_create_connection_bootstraps_missing_table:Function]

View File

@@ -10,7 +10,14 @@ from datetime import datetime, timezone
from fastapi.testclient import TestClient
from src.app import app
from src.api.routes.dashboards import DashboardsResponse
from src.dependencies import get_current_user, has_permission, get_config_manager, get_task_manager, get_resource_service, get_mapping_service
from src.dependencies import (
get_current_user,
has_permission,
get_config_manager,
get_task_manager,
get_resource_service,
get_mapping_service,
)
from src.core.database import get_db
from src.services.profile_service import ProfileService as DomainProfileService
@@ -23,13 +30,14 @@ admin_role = MagicMock()
admin_role.name = "Admin"
mock_user.roles.append(admin_role)
@pytest.fixture(autouse=True)
def mock_deps():
config_manager = MagicMock()
task_manager = MagicMock()
resource_service = MagicMock()
mapping_service = MagicMock()
db = MagicMock()
app.dependency_overrides[get_config_manager] = lambda: config_manager
@@ -38,12 +46,18 @@ def mock_deps():
app.dependency_overrides[get_mapping_service] = lambda: mapping_service
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_db] = lambda: db
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("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,
@@ -53,10 +67,12 @@ def mock_deps():
}
app.dependency_overrides.clear()
client = TestClient(app)
# [DEF:test_get_dashboards_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing returns a populated response that satisfies the schema contract.
# @TEST: GET /api/dashboards returns 200 and valid schema
# @PRE: env_id exists
@@ -69,15 +85,17 @@ def test_get_dashboards_success(mock_deps):
mock_deps["task"].get_all_tasks.return_value = []
# @TEST_FIXTURE: dashboard_list_happy -> {"id": 1, "title": "Main Revenue"}
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{
"id": 1,
"title": "Main Revenue",
"slug": "main-revenue",
"git_status": {"branch": "main", "sync_status": "OK"},
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
}
])
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
return_value=[
{
"id": 1,
"title": "Main Revenue",
"slug": "main-revenue",
"git_status": {"branch": "main", "sync_status": "OK"},
"last_task": {"task_id": "task-1", "status": "SUCCESS"},
}
]
)
response = client.get("/api/dashboards?env_id=prod")
@@ -96,6 +114,7 @@ def test_get_dashboards_success(mock_deps):
# [DEF:test_get_dashboards_with_search:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing applies the search filter and returns only matching rows.
# @TEST: GET /api/dashboards filters by search term
# @PRE: search parameter provided
@@ -108,15 +127,28 @@ def test_get_dashboards_with_search(mock_deps):
async def mock_get_dashboards(env, tasks, include_git_status=False):
return [
{"id": 1, "title": "Sales Report", "slug": "sales", "git_status": {"branch": "main", "sync_status": "OK"}, "last_task": None},
{"id": 2, "title": "Marketing Dashboard", "slug": "marketing", "git_status": {"branch": "main", "sync_status": "OK"}, "last_task": None}
{
"id": 1,
"title": "Sales Report",
"slug": "sales",
"git_status": {"branch": "main", "sync_status": "OK"},
"last_task": None,
},
{
"id": 2,
"title": "Marketing Dashboard",
"slug": "marketing",
"git_status": {"branch": "main", "sync_status": "OK"},
"last_task": None,
},
]
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
side_effect=mock_get_dashboards
)
response = client.get("/api/dashboards?env_id=prod&search=sales")
assert response.status_code == 200
data = response.json()
# @POST: Filtered result count must match search
@@ -128,6 +160,7 @@ def test_get_dashboards_with_search(mock_deps):
# [DEF:test_get_dashboards_empty:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing returns an empty payload for an environment without dashboards.
# @TEST_EDGE: empty_dashboards -> {env_id: 'empty_env', expected_total: 0}
def test_get_dashboards_empty(mock_deps):
@@ -145,10 +178,13 @@ def test_get_dashboards_empty(mock_deps):
assert len(data["dashboards"]) == 0
assert data["total_pages"] == 1
DashboardsResponse(**data)
# [/DEF:test_get_dashboards_empty:Function]
# [DEF:test_get_dashboards_superset_failure:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing surfaces a 503 contract when Superset access fails.
# @TEST_EDGE: external_superset_failure -> {env_id: 'bad_conn', status: 503}
def test_get_dashboards_superset_failure(mock_deps):
@@ -164,10 +200,13 @@ def test_get_dashboards_superset_failure(mock_deps):
response = client.get("/api/dashboards?env_id=bad_conn")
assert response.status_code == 503
assert "Failed to fetch dashboards" in response.json()["detail"]
# [/DEF:test_get_dashboards_superset_failure:Function]
# [DEF:test_get_dashboards_env_not_found:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing returns 404 when the requested environment does not exist.
# @TEST: GET /api/dashboards returns 404 if env_id missing
# @PRE: env_id does not exist
@@ -175,7 +214,7 @@ def test_get_dashboards_superset_failure(mock_deps):
def test_get_dashboards_env_not_found(mock_deps):
mock_deps["config"].get_environments.return_value = []
response = client.get("/api/dashboards?env_id=nonexistent")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
@@ -184,6 +223,7 @@ def test_get_dashboards_env_not_found(mock_deps):
# [DEF:test_get_dashboards_invalid_pagination:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing rejects invalid pagination parameters with 400 responses.
# @TEST: GET /api/dashboards returns 400 for invalid page/page_size
# @PRE: page < 1 or page_size > 100
@@ -196,15 +236,18 @@ def test_get_dashboards_invalid_pagination(mock_deps):
response = client.get("/api/dashboards?env_id=prod&page=0")
assert response.status_code == 400
assert "Page must be >= 1" in response.json()["detail"]
# Invalid page_size
response = client.get("/api/dashboards?env_id=prod&page_size=101")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# [/DEF:test_get_dashboards_invalid_pagination:Function]
# [DEF:test_get_dashboard_detail_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboard detail returns charts and datasets for an existing dashboard.
# @TEST: GET /api/dashboards/{id} returns dashboard detail with charts and datasets
def test_get_dashboard_detail_success(mock_deps):
@@ -229,7 +272,7 @@ def test_get_dashboard_detail_success(mock_deps):
"viz_type": "line",
"dataset_id": 7,
"last_modified": "2026-02-19T10:00:00+00:00",
"overview": "line"
"overview": "line",
}
],
"datasets": [
@@ -239,11 +282,11 @@ def test_get_dashboard_detail_success(mock_deps):
"schema": "mart",
"database": "Analytics",
"last_modified": "2026-02-18T10:00:00+00:00",
"overview": "mart.fact_revenue"
"overview": "mart.fact_revenue",
}
],
"chart_count": 1,
"dataset_count": 1
"dataset_count": 1,
}
mock_client_cls.return_value = mock_client
@@ -254,23 +297,29 @@ def test_get_dashboard_detail_success(mock_deps):
assert payload["id"] == 42
assert payload["chart_count"] == 1
assert payload["dataset_count"] == 1
# [/DEF:test_get_dashboard_detail_success:Function]
# [DEF:test_get_dashboard_detail_env_not_found:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboard detail returns 404 when the requested environment is missing.
# @TEST: GET /api/dashboards/{id} returns 404 for missing environment
def test_get_dashboard_detail_env_not_found(mock_deps):
mock_deps["config"].get_environments.return_value = []
response = client.get("/api/dashboards/42?env_id=missing")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_get_dashboard_detail_env_not_found:Function]
# [DEF:test_migrate_dashboards_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: POST /api/dashboards/migrate creates migration task
# @PRE: Valid source_env_id, target_env_id, dashboard_ids
# @PURPOSE: Validate dashboard migration request creates an async task and returns its identifier.
@@ -292,8 +341,8 @@ def test_migrate_dashboards_success(mock_deps):
"source_env_id": "source",
"target_env_id": "target",
"dashboard_ids": [1, 2, 3],
"db_mappings": {"old_db": "new_db"}
}
"db_mappings": {"old_db": "new_db"},
},
)
assert response.status_code == 200
@@ -307,6 +356,7 @@ def test_migrate_dashboards_success(mock_deps):
# [DEF:test_migrate_dashboards_no_ids:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: POST /api/dashboards/migrate returns 400 for empty dashboard_ids
# @PRE: dashboard_ids is empty
# @PURPOSE: Validate dashboard migration rejects empty dashboard identifier lists.
@@ -317,8 +367,8 @@ def test_migrate_dashboards_no_ids(mock_deps):
json={
"source_env_id": "source",
"target_env_id": "target",
"dashboard_ids": []
}
"dashboard_ids": [],
},
)
assert response.status_code == 400
@@ -329,6 +379,7 @@ def test_migrate_dashboards_no_ids(mock_deps):
# [DEF:test_migrate_dashboards_env_not_found:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate migration creation returns 404 when the source environment cannot be resolved.
# @PRE: source_env_id and target_env_id are valid environment IDs
def test_migrate_dashboards_env_not_found(mock_deps):
@@ -336,18 +387,17 @@ def test_migrate_dashboards_env_not_found(mock_deps):
mock_deps["config"].get_environments.return_value = []
response = client.post(
"/api/dashboards/migrate",
json={
"source_env_id": "ghost",
"target_env_id": "t",
"dashboard_ids": [1]
}
json={"source_env_id": "ghost", "target_env_id": "t", "dashboard_ids": [1]},
)
assert response.status_code == 404
assert "Source environment not found" in response.json()["detail"]
# [/DEF:test_migrate_dashboards_env_not_found:Function]
# [DEF:test_backup_dashboards_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: POST /api/dashboards/backup creates backup task
# @PRE: Valid env_id, dashboard_ids
# @PURPOSE: Validate dashboard backup request creates an async backup task and returns its identifier.
@@ -363,11 +413,7 @@ def test_backup_dashboards_success(mock_deps):
response = client.post(
"/api/dashboards/backup",
json={
"env_id": "prod",
"dashboard_ids": [1, 2, 3],
"schedule": "0 0 * * *"
}
json={"env_id": "prod", "dashboard_ids": [1, 2, 3], "schedule": "0 0 * * *"},
)
assert response.status_code == 200
@@ -381,24 +427,24 @@ def test_backup_dashboards_success(mock_deps):
# [DEF:test_backup_dashboards_env_not_found:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate backup task creation returns 404 when the target environment is missing.
# @PRE: env_id is a valid environment ID
def test_backup_dashboards_env_not_found(mock_deps):
"""@PRE: env_id is a valid environment ID."""
mock_deps["config"].get_environments.return_value = []
response = client.post(
"/api/dashboards/backup",
json={
"env_id": "ghost",
"dashboard_ids": [1]
}
"/api/dashboards/backup", json={"env_id": "ghost", "dashboard_ids": [1]}
)
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_backup_dashboards_env_not_found:Function]
# [DEF:test_get_database_mappings_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards/db-mappings returns mapping suggestions
# @PRE: Valid source_env_id, target_env_id
# @PURPOSE: Validate database mapping suggestions are returned for valid source and target environments.
@@ -410,17 +456,21 @@ def test_get_database_mappings_success(mock_deps):
mock_target.id = "staging"
mock_deps["config"].get_environments.return_value = [mock_source, mock_target]
mock_deps["mapping"].get_suggestions = AsyncMock(return_value=[
{
"source_db": "old_sales",
"target_db": "new_sales",
"source_db_uuid": "uuid-1",
"target_db_uuid": "uuid-2",
"confidence": 0.95
}
])
mock_deps["mapping"].get_suggestions = AsyncMock(
return_value=[
{
"source_db": "old_sales",
"target_db": "new_sales",
"source_db_uuid": "uuid-1",
"target_db_uuid": "uuid-2",
"confidence": 0.95,
}
]
)
response = client.get("/api/dashboards/db-mappings?source_env_id=prod&target_env_id=staging")
response = client.get(
"/api/dashboards/db-mappings?source_env_id=prod&target_env_id=staging"
)
assert response.status_code == 200
data = response.json()
@@ -433,17 +483,23 @@ def test_get_database_mappings_success(mock_deps):
# [DEF:test_get_database_mappings_env_not_found:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate database mapping suggestions return 404 when either environment is missing.
# @PRE: source_env_id and target_env_id are valid environment IDs
def test_get_database_mappings_env_not_found(mock_deps):
"""@PRE: source_env_id must be a valid environment."""
mock_deps["config"].get_environments.return_value = []
response = client.get("/api/dashboards/db-mappings?source_env_id=ghost&target_env_id=t")
response = client.get(
"/api/dashboards/db-mappings?source_env_id=ghost&target_env_id=t"
)
assert response.status_code == 404
# [/DEF:test_get_database_mappings_env_not_found:Function]
# [DEF:test_get_dashboard_tasks_history_filters_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboard task history returns only related backup and LLM tasks.
# @TEST: GET /api/dashboards/{id}/tasks returns backup and llm tasks for dashboard
def test_get_dashboard_tasks_history_filters_success(mock_deps):
@@ -484,11 +540,17 @@ def test_get_dashboard_tasks_history_filters_success(mock_deps):
data = response.json()
assert data["dashboard_id"] == 42
assert len(data["items"]) == 2
assert {item["plugin_id"] for item in data["items"]} == {"llm_dashboard_validation", "superset-backup"}
assert {item["plugin_id"] for item in data["items"]} == {
"llm_dashboard_validation",
"superset-backup",
}
# [/DEF:test_get_dashboard_tasks_history_filters_success:Function]
# [DEF:test_get_dashboard_thumbnail_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboard thumbnail endpoint proxies image bytes and content type from Superset.
# @TEST: GET /api/dashboards/{id}/thumbnail proxies image bytes from Superset
def test_get_dashboard_thumbnail_success(mock_deps):
@@ -516,26 +578,34 @@ def test_get_dashboard_thumbnail_success(mock_deps):
assert response.status_code == 200
assert response.content == b"fake-image-bytes"
assert response.headers["content-type"].startswith("image/png")
# [/DEF:test_get_dashboard_thumbnail_success:Function]
# [DEF:_build_profile_preference_stub:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Creates profile preference payload stub for dashboards filter contract tests.
# @PRE: username can be empty; enabled indicates profile-default toggle state.
# @POST: Returns object compatible with ProfileService.get_my_preference contract.
def _build_profile_preference_stub(username: str, enabled: bool):
preference = MagicMock()
preference.superset_username = username
preference.superset_username_normalized = str(username or "").strip().lower() or None
preference.superset_username_normalized = (
str(username or "").strip().lower() or None
)
preference.show_only_my_dashboards = bool(enabled)
payload = MagicMock()
payload.preference = preference
return payload
# [/DEF:_build_profile_preference_stub:Function]
# [DEF:_matches_actor_case_insensitive:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Applies trim + case-insensitive owners OR modified_by matching used by route contract tests.
# @PRE: owners can be None or list-like values.
# @POST: Returns True when bound username matches any owner or modified_by.
@@ -551,11 +621,16 @@ def _matches_actor_case_insensitive(bound_username, owners, modified_by):
owner_tokens.append(token)
modified_token = str(modified_by or "").strip().lower()
return normalized_bound in owner_tokens or bool(modified_token and modified_token == normalized_bound)
return normalized_bound in owner_tokens or bool(
modified_token and modified_token == normalized_bound
)
# [/DEF:_matches_actor_case_insensitive:Function]
# [DEF:test_get_dashboards_profile_filter_contract_owners_or_modified_by:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards applies profile-default filter with owners OR modified_by trim+case-insensitive semantics.
# @PURPOSE: Validate profile-default filtering matches owner and modifier aliases using normalized Superset actor values.
# @PRE: Current user has enabled profile-default preference and bound username.
@@ -565,29 +640,31 @@ def test_get_dashboards_profile_filter_contract_owners_or_modified_by(mock_deps)
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{
"id": 1,
"title": "Owner Match",
"slug": "owner-match",
"owners": [" John_Doe "],
"modified_by": "someone_else",
},
{
"id": 2,
"title": "Modifier Match",
"slug": "modifier-match",
"owners": ["analytics-team"],
"modified_by": " JOHN_DOE ",
},
{
"id": 3,
"title": "No Match",
"slug": "no-match",
"owners": ["another-user"],
"modified_by": "nobody",
},
])
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
return_value=[
{
"id": 1,
"title": "Owner Match",
"slug": "owner-match",
"owners": [" John_Doe "],
"modified_by": "someone_else",
},
{
"id": 2,
"title": "Modifier Match",
"slug": "modifier-match",
"owners": ["analytics-team"],
"modified_by": " JOHN_DOE ",
},
{
"id": 3,
"title": "No Match",
"slug": "no-match",
"owners": ["another-user"],
"modified_by": "nobody",
},
]
)
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
profile_service = MagicMock()
@@ -595,7 +672,9 @@ def test_get_dashboards_profile_filter_contract_owners_or_modified_by(mock_deps)
username=" JOHN_DOE ",
enabled=True,
)
profile_service.matches_dashboard_actor.side_effect = _matches_actor_case_insensitive
profile_service.matches_dashboard_actor.side_effect = (
_matches_actor_case_insensitive
)
profile_service_cls.return_value = profile_service
response = client.get(
@@ -612,10 +691,13 @@ def test_get_dashboards_profile_filter_contract_owners_or_modified_by(mock_deps)
assert payload["effective_profile_filter"]["override_show_all"] is False
assert payload["effective_profile_filter"]["username"] == "john_doe"
assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by"
# [/DEF:test_get_dashboards_profile_filter_contract_owners_or_modified_by:Function]
# [DEF:test_get_dashboards_override_show_all_contract:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards honors override_show_all and disables profile-default filter for current page.
# @PURPOSE: Validate override_show_all bypasses profile-default filtering without changing dashboard list semantics.
# @PRE: Profile-default preference exists but override_show_all=true query is provided.
@@ -625,10 +707,24 @@ def test_get_dashboards_override_show_all_contract(mock_deps):
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{"id": 1, "title": "Dash A", "slug": "dash-a", "owners": ["john_doe"], "modified_by": "john_doe"},
{"id": 2, "title": "Dash B", "slug": "dash-b", "owners": ["other"], "modified_by": "other"},
])
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
return_value=[
{
"id": 1,
"title": "Dash A",
"slug": "dash-a",
"owners": ["john_doe"],
"modified_by": "john_doe",
},
{
"id": 2,
"title": "Dash B",
"slug": "dash-b",
"owners": ["other"],
"modified_by": "other",
},
]
)
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
profile_service = MagicMock()
@@ -636,7 +732,9 @@ def test_get_dashboards_override_show_all_contract(mock_deps):
username="john_doe",
enabled=True,
)
profile_service.matches_dashboard_actor.side_effect = _matches_actor_case_insensitive
profile_service.matches_dashboard_actor.side_effect = (
_matches_actor_case_insensitive
)
profile_service_cls.return_value = profile_service
response = client.get(
@@ -654,10 +752,13 @@ def test_get_dashboards_override_show_all_contract(mock_deps):
assert payload["effective_profile_filter"]["username"] is None
assert payload["effective_profile_filter"]["match_logic"] is None
profile_service.matches_dashboard_actor.assert_not_called()
# [/DEF:test_get_dashboards_override_show_all_contract:Function]
# [DEF:test_get_dashboards_profile_filter_no_match_results_contract:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards returns empty result set when profile-default filter is active and no dashboard actors match.
# @PURPOSE: Validate profile-default filtering returns an empty dashboard page when no actor aliases match the bound user.
# @PRE: Profile-default preference is enabled with bound username and all dashboards are non-matching.
@@ -667,22 +768,24 @@ def test_get_dashboards_profile_filter_no_match_results_contract(mock_deps):
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{
"id": 101,
"title": "Team Dashboard",
"slug": "team-dashboard",
"owners": ["analytics-team"],
"modified_by": "someone_else",
},
{
"id": 102,
"title": "Ops Dashboard",
"slug": "ops-dashboard",
"owners": ["ops-user"],
"modified_by": "ops-user",
},
])
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
return_value=[
{
"id": 101,
"title": "Team Dashboard",
"slug": "team-dashboard",
"owners": ["analytics-team"],
"modified_by": "someone_else",
},
{
"id": 102,
"title": "Ops Dashboard",
"slug": "ops-dashboard",
"owners": ["ops-user"],
"modified_by": "ops-user",
},
]
)
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
profile_service = MagicMock()
@@ -690,7 +793,9 @@ def test_get_dashboards_profile_filter_no_match_results_contract(mock_deps):
username="john_doe",
enabled=True,
)
profile_service.matches_dashboard_actor.side_effect = _matches_actor_case_insensitive
profile_service.matches_dashboard_actor.side_effect = (
_matches_actor_case_insensitive
)
profile_service_cls.return_value = profile_service
response = client.get(
@@ -710,10 +815,13 @@ def test_get_dashboards_profile_filter_no_match_results_contract(mock_deps):
assert payload["effective_profile_filter"]["override_show_all"] is False
assert payload["effective_profile_filter"]["username"] == "john_doe"
assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by"
# [/DEF:test_get_dashboards_profile_filter_no_match_results_contract:Function]
# [DEF:test_get_dashboards_page_context_other_disables_profile_default:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards does not auto-apply profile-default filter outside dashboards_main page context.
# @PURPOSE: Validate non-dashboard page contexts suppress profile-default filtering and preserve unfiltered results.
# @PRE: Profile-default preference exists but page_context=other query is provided.
@@ -723,10 +831,24 @@ def test_get_dashboards_page_context_other_disables_profile_default(mock_deps):
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{"id": 1, "title": "Dash A", "slug": "dash-a", "owners": ["john_doe"], "modified_by": "john_doe"},
{"id": 2, "title": "Dash B", "slug": "dash-b", "owners": ["other"], "modified_by": "other"},
])
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
return_value=[
{
"id": 1,
"title": "Dash A",
"slug": "dash-a",
"owners": ["john_doe"],
"modified_by": "john_doe",
},
{
"id": 2,
"title": "Dash B",
"slug": "dash-b",
"owners": ["other"],
"modified_by": "other",
},
]
)
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
profile_service = MagicMock()
@@ -734,7 +856,9 @@ def test_get_dashboards_page_context_other_disables_profile_default(mock_deps):
username="john_doe",
enabled=True,
)
profile_service.matches_dashboard_actor.side_effect = _matches_actor_case_insensitive
profile_service.matches_dashboard_actor.side_effect = (
_matches_actor_case_insensitive
)
profile_service_cls.return_value = profile_service
response = client.get(
@@ -752,49 +876,60 @@ def test_get_dashboards_page_context_other_disables_profile_default(mock_deps):
assert payload["effective_profile_filter"]["username"] is None
assert payload["effective_profile_filter"]["match_logic"] is None
profile_service.matches_dashboard_actor.assert_not_called()
# [/DEF:test_get_dashboards_page_context_other_disables_profile_default:Function]
# [DEF:test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards resolves Superset display-name alias once and filters without per-dashboard detail calls.
# @PURPOSE: Validate profile-default filtering reuses resolved Superset display aliases without triggering per-dashboard detail fanout.
# @PRE: Profile-default filter is active, bound username is `admin`, dashboard actors contain display labels.
# @POST: Route matches by alias (`Superset Admin`) and does not call `SupersetClient.get_dashboard` in list filter path.
def test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout(mock_deps):
def test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout(
mock_deps,
):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{
"id": 5,
"title": "Alias Match",
"slug": "alias-match",
"owners": [],
"created_by": None,
"modified_by": "Superset Admin",
},
{
"id": 6,
"title": "Alias No Match",
"slug": "alias-no-match",
"owners": [],
"created_by": None,
"modified_by": "Other User",
},
])
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
return_value=[
{
"id": 5,
"title": "Alias Match",
"slug": "alias-match",
"owners": [],
"created_by": None,
"modified_by": "Superset Admin",
},
{
"id": 6,
"title": "Alias No Match",
"slug": "alias-no-match",
"owners": [],
"created_by": None,
"modified_by": "Other User",
},
]
)
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls, patch(
"src.api.routes.dashboards.SupersetClient"
) as superset_client_cls, patch(
"src.api.routes.dashboards.SupersetAccountLookupAdapter"
) as lookup_adapter_cls:
with (
patch("src.api.routes.dashboards.ProfileService") as profile_service_cls,
patch("src.api.routes.dashboards.SupersetClient") as superset_client_cls,
patch(
"src.api.routes.dashboards.SupersetAccountLookupAdapter"
) as lookup_adapter_cls,
):
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
username="admin",
enabled=True,
)
profile_service.matches_dashboard_actor.side_effect = _matches_actor_case_insensitive
profile_service.matches_dashboard_actor.side_effect = (
_matches_actor_case_insensitive
)
profile_service_cls.return_value = profile_service
superset_client = MagicMock()
@@ -826,10 +961,13 @@ def test_get_dashboards_profile_filter_matches_display_alias_without_detail_fano
assert payload["effective_profile_filter"]["applied"] is True
lookup_adapter.get_users_page.assert_called_once()
superset_client.get_dashboard.assert_not_called()
# [/DEF:test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout:Function]
# [DEF:test_get_dashboards_profile_filter_matches_owner_object_payload_contract:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards profile-default filter matches Superset owner object payloads.
# @PURPOSE: Validate profile-default filtering accepts owner object payloads once aliases resolve to the bound Superset username.
# @PRE: Profile-default preference is enabled and owners list contains dict payloads.
@@ -839,42 +977,47 @@ def test_get_dashboards_profile_filter_matches_owner_object_payload_contract(moc
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{
"id": 701,
"title": "Featured Charts",
"slug": "featured-charts",
"owners": [
{
"id": 11,
"first_name": "user",
"last_name": "1",
"username": None,
"email": "user_1@example.local",
}
],
"modified_by": "another_user",
},
{
"id": 702,
"title": "Other Dashboard",
"slug": "other-dashboard",
"owners": [
{
"id": 12,
"first_name": "other",
"last_name": "user",
"username": None,
"email": "other@example.local",
}
],
"modified_by": "other_user",
},
])
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
return_value=[
{
"id": 701,
"title": "Featured Charts",
"slug": "featured-charts",
"owners": [
{
"id": 11,
"first_name": "user",
"last_name": "1",
"username": None,
"email": "user_1@example.local",
}
],
"modified_by": "another_user",
},
{
"id": 702,
"title": "Other Dashboard",
"slug": "other-dashboard",
"owners": [
{
"id": 12,
"first_name": "other",
"last_name": "user",
"username": None,
"email": "other@example.local",
}
],
"modified_by": "other_user",
},
]
)
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls, patch(
"src.api.routes.dashboards._resolve_profile_actor_aliases",
return_value=["user_1"],
with (
patch("src.api.routes.dashboards.ProfileService") as profile_service_cls,
patch(
"src.api.routes.dashboards._resolve_profile_actor_aliases",
return_value=["user_1"],
),
):
profile_service = MagicMock(spec=DomainProfileService)
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
@@ -883,7 +1026,8 @@ def test_get_dashboards_profile_filter_matches_owner_object_payload_contract(moc
)
profile_service.matches_dashboard_actor.side_effect = (
lambda bound_username, owners, modified_by: any(
str(owner.get("email", "")).split("@", 1)[0].strip().lower() == str(bound_username).strip().lower()
str(owner.get("email", "")).split("@", 1)[0].strip().lower()
== str(bound_username).strip().lower()
for owner in (owners or [])
if isinstance(owner, dict)
)
@@ -899,6 +1043,8 @@ def test_get_dashboards_profile_filter_matches_owner_object_payload_contract(moc
assert payload["total"] == 1
assert {item["id"] for item in payload["dashboards"]} == {701}
assert payload["dashboards"][0]["title"] == "Featured Charts"
# [/DEF:test_get_dashboards_profile_filter_matches_owner_object_payload_contract:Function]

View File

@@ -71,6 +71,7 @@ client = TestClient(app)
# [DEF:_make_user:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_user():
permissions = [
SimpleNamespace(resource="dataset:session", action="READ"),
@@ -83,6 +84,7 @@ def _make_user():
# [DEF:_make_config_manager:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_config_manager():
env = Environment(
id="env-1",
@@ -100,6 +102,7 @@ def _make_config_manager():
# [DEF:_make_session:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_session():
now = datetime.now(timezone.utc)
return DatasetReviewSession(
@@ -123,6 +126,7 @@ def _make_session():
# [DEF:_make_us2_session:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_us2_session():
now = datetime.now(timezone.utc)
session = _make_session()
@@ -238,6 +242,7 @@ def _make_us2_session():
# [DEF:_make_us3_session:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_us3_session():
now = datetime.now(timezone.utc)
session = _make_session()
@@ -300,6 +305,7 @@ def _make_us3_session():
# [DEF:_make_preview_ready_session:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_preview_ready_session():
session = _make_us3_session()
session.readiness_state = ReadinessState.COMPILED_PREVIEW_READY
@@ -310,6 +316,7 @@ def _make_preview_ready_session():
# [DEF:dataset_review_api_dependencies:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
@pytest.fixture(autouse=True)
def dataset_review_api_dependencies():
mock_user = _make_user()
@@ -330,6 +337,7 @@ def dataset_review_api_dependencies():
# [DEF:test_parse_superset_link_dashboard_partial_recovery:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify dashboard links recover dataset context and preserve explicit partial-recovery markers.
def test_parse_superset_link_dashboard_partial_recovery():
env = Environment(
@@ -364,6 +372,7 @@ def test_parse_superset_link_dashboard_partial_recovery():
# [DEF:test_parse_superset_link_dashboard_slug_recovery:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify dashboard slug links resolve through dashboard detail endpoints and recover dataset context.
def test_parse_superset_link_dashboard_slug_recovery():
env = Environment(
@@ -398,6 +407,7 @@ def test_parse_superset_link_dashboard_slug_recovery():
# [DEF:test_parse_superset_link_dashboard_permalink_partial_recovery:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify dashboard permalink links no longer fail parsing and preserve permalink filter state for partial recovery.
def test_parse_superset_link_dashboard_permalink_partial_recovery():
env = Environment(
@@ -442,6 +452,7 @@ def test_parse_superset_link_dashboard_permalink_partial_recovery():
# [DEF:test_parse_superset_link_dashboard_permalink_recovers_dataset_from_nested_dashboard_state:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify permalink state with nested dashboard id recovers dataset binding and keeps imported filters.
def test_parse_superset_link_dashboard_permalink_recovers_dataset_from_nested_dashboard_state():
env = Environment(
@@ -481,6 +492,7 @@ def test_parse_superset_link_dashboard_permalink_recovers_dataset_from_nested_da
# [DEF:test_resolve_from_dictionary_prefers_exact_match:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify trusted dictionary exact matches outrank fuzzy candidates and unresolved fields stay explicit.
def test_resolve_from_dictionary_prefers_exact_match():
resolver = SemanticSourceResolver()
@@ -519,6 +531,7 @@ def test_resolve_from_dictionary_prefers_exact_match():
# [DEF:test_orchestrator_start_session_preserves_partial_recovery:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify session start persists usable recovery-required state when Superset intake is partial.
def test_orchestrator_start_session_preserves_partial_recovery(dataset_review_api_dependencies):
repository = MagicMock()
@@ -580,6 +593,7 @@ def test_orchestrator_start_session_preserves_partial_recovery(dataset_review_ap
# [DEF:test_orchestrator_start_session_bootstraps_recovery_state:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify session start persists recovered filters, template variables, and initial execution mappings for review workspace bootstrap.
def test_orchestrator_start_session_bootstraps_recovery_state(dataset_review_api_dependencies):
repository = MagicMock()
@@ -677,6 +691,7 @@ def test_orchestrator_start_session_bootstraps_recovery_state(dataset_review_api
# [DEF:test_start_session_endpoint_returns_created_summary:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify POST session lifecycle endpoint returns a persisted ownership-scoped summary.
def test_start_session_endpoint_returns_created_summary(dataset_review_api_dependencies):
session = _make_session()
@@ -703,6 +718,7 @@ def test_start_session_endpoint_returns_created_summary(dataset_review_api_depen
# [DEF:test_get_session_detail_export_and_lifecycle_endpoints:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify lifecycle get/patch/delete plus documentation and validation exports remain ownership-scoped and usable.
def test_get_session_detail_export_and_lifecycle_endpoints(dataset_review_api_dependencies):
now = datetime.now(timezone.utc)
@@ -802,6 +818,7 @@ def test_get_session_detail_export_and_lifecycle_endpoints(dataset_review_api_de
# [DEF:test_us2_clarification_endpoints_persist_answer_and_feedback:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Clarification endpoints should expose one current question, persist the answer before advancement, and store feedback on the answer audit record.
def test_us2_clarification_endpoints_persist_answer_and_feedback(dataset_review_api_dependencies):
session = _make_us2_session()
@@ -853,6 +870,7 @@ def test_us2_clarification_endpoints_persist_answer_and_feedback(dataset_review_
# [DEF:test_us2_field_semantic_override_lock_unlock_and_feedback:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Semantic field endpoints should apply manual overrides with lock/provenance invariants and persist feedback independently.
def test_us2_field_semantic_override_lock_unlock_and_feedback(dataset_review_api_dependencies):
session = _make_us2_session()
@@ -913,6 +931,7 @@ def test_us2_field_semantic_override_lock_unlock_and_feedback(dataset_review_api
# [DEF:test_us3_mapping_patch_approval_preview_and_launch_endpoints:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: US3 execution endpoints should persist manual overrides, preserve explicit approval semantics, return contract-shaped preview truth, and expose audited launch handoff.
def test_us3_mapping_patch_approval_preview_and_launch_endpoints(dataset_review_api_dependencies):
session = _make_us3_session()
@@ -1067,6 +1086,7 @@ def test_us3_mapping_patch_approval_preview_and_launch_endpoints(dataset_review_
# [DEF:test_us3_preview_endpoint_returns_failed_preview_without_false_dashboard_not_found_contract_drift:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Preview endpoint should preserve API contract and surface generic upstream preview failures without fabricating dashboard-not-found semantics for non-dashboard 404s.
def test_us3_preview_endpoint_returns_failed_preview_without_false_dashboard_not_found_contract_drift(
dataset_review_api_dependencies,
@@ -1115,6 +1135,7 @@ def test_us3_preview_endpoint_returns_failed_preview_without_false_dashboard_not
# [DEF:test_execution_snapshot_includes_recovered_imported_filters_without_template_mapping:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Recovered imported filters with values should flow into preview filter context even when no template variable mapping exists.
def test_execution_snapshot_includes_recovered_imported_filters_without_template_mapping(
dataset_review_api_dependencies,
@@ -1175,6 +1196,7 @@ def test_execution_snapshot_includes_recovered_imported_filters_without_template
# [DEF:test_execution_snapshot_preserves_mapped_template_variables_and_filter_context:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Mapped template variables should still populate template params while contributing their effective filter context.
def test_execution_snapshot_preserves_mapped_template_variables_and_filter_context(
dataset_review_api_dependencies,
@@ -1209,6 +1231,7 @@ def test_execution_snapshot_preserves_mapped_template_variables_and_filter_conte
# [DEF:test_execution_snapshot_skips_partial_imported_filters_without_values:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Partial imported filters without raw or normalized values must not emit bogus active preview filters.
def test_execution_snapshot_skips_partial_imported_filters_without_values(
dataset_review_api_dependencies,
@@ -1246,6 +1269,7 @@ def test_execution_snapshot_skips_partial_imported_filters_without_values(
# [DEF:test_us3_launch_endpoint_requires_launch_permission:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Launch endpoint should enforce the contract RBAC permission instead of the generic session-manage permission.
def test_us3_launch_endpoint_requires_launch_permission(dataset_review_api_dependencies):
session = _make_us3_session()
@@ -1293,6 +1317,7 @@ def test_us3_launch_endpoint_requires_launch_permission(dataset_review_api_depen
# [/DEF:test_us3_launch_endpoint_requires_launch_permission:Function]
# [DEF:test_semantic_source_version_propagation_preserves_locked_fields:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Updated semantic source versions should mark unlocked fields reviewable while preserving locked manual values.
def test_semantic_source_version_propagation_preserves_locked_fields():
resolver = SemanticSourceResolver()

View File

@@ -51,10 +51,13 @@ client = TestClient(app)
# [DEF:test_get_datasets_success:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
# @PURPOSE: 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:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
def test_get_datasets_success(mock_deps):
# Mock environment
mock_env = MagicMock()
@@ -89,10 +92,15 @@ def test_get_datasets_success(mock_deps):
# [DEF:test_get_datasets_env_not_found:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
# @PURPOSE: 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_success:Function]
# [DEF:test_get_datasets_env_not_found:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
def test_get_datasets_env_not_found(mock_deps):
mock_deps["config"].get_environments.return_value = []
@@ -106,10 +114,15 @@ def test_get_datasets_env_not_found(mock_deps):
# [DEF:test_get_datasets_invalid_pagination:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
# @PURPOSE: 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_env_not_found:Function]
# [DEF:test_get_datasets_invalid_pagination:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
def test_get_datasets_invalid_pagination(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
@@ -135,10 +148,15 @@ def test_get_datasets_invalid_pagination(mock_deps):
# [DEF:test_map_columns_success:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
# @PURPOSE: 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
# @POST: Returns task_id
# [/DEF:test_get_datasets_invalid_pagination:Function]
# [DEF:test_map_columns_success:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
def test_map_columns_success(mock_deps):
# Mock environment
mock_env = MagicMock()
@@ -170,10 +188,15 @@ def test_map_columns_success(mock_deps):
# [DEF:test_map_columns_invalid_source_type:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
# @PURPOSE: 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 'postgresql' or 'xlsx'
# @POST: Returns 400 error
# [/DEF:test_map_columns_success:Function]
# [DEF:test_map_columns_invalid_source_type:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
def test_map_columns_invalid_source_type(mock_deps):
response = client.post(
"/api/datasets/map-columns",
@@ -192,10 +215,15 @@ def test_map_columns_invalid_source_type(mock_deps):
# [DEF:test_generate_docs_success:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
# @TEST: POST /api/datasets/generate-docs creates doc generation task
# @PRE: Valid env_id, dataset_ids, llm_provider
# @PURPOSE: Validate generate-docs request creates an async documentation task and returns its identifier.
# @POST: Returns task_id
# [/DEF:test_map_columns_invalid_source_type:Function]
# [DEF:test_generate_docs_success:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
def test_generate_docs_success(mock_deps):
# Mock environment
mock_env = MagicMock()
@@ -227,10 +255,15 @@ def test_generate_docs_success(mock_deps):
# [DEF:test_map_columns_empty_ids:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
# @PURPOSE: 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_generate_docs_success:Function]
# [DEF:test_map_columns_empty_ids:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
def test_map_columns_empty_ids(mock_deps):
"""@PRE: dataset_ids must be non-empty."""
response = client.post(
@@ -247,10 +280,15 @@ def test_map_columns_empty_ids(mock_deps):
# [DEF:test_generate_docs_empty_ids:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
# @PURPOSE: 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_map_columns_empty_ids:Function]
# [DEF:test_generate_docs_empty_ids:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
def test_generate_docs_empty_ids(mock_deps):
"""@PRE: dataset_ids must be non-empty."""
response = client.post(
@@ -267,10 +305,15 @@ def test_generate_docs_empty_ids(mock_deps):
# [DEF:test_generate_docs_env_not_found:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
# @TEST: POST /api/datasets/generate-docs returns 404 for missing env
# @PRE: env_id does not exist
# @PURPOSE: Validate generate-docs returns 404 when the requested environment cannot be resolved.
# @POST: Returns 404 error
# [/DEF:test_generate_docs_empty_ids:Function]
# [DEF:test_generate_docs_env_not_found:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
def test_generate_docs_env_not_found(mock_deps):
"""@PRE: env_id must be a valid environment."""
mock_deps["config"].get_environments.return_value = []
@@ -288,8 +331,11 @@ def test_generate_docs_env_not_found(mock_deps):
# [DEF:test_get_datasets_superset_failure:Function]
# @RELATION: BINDS_TO -> DatasetsApiTests
# @PURPOSE: Validate datasets listing surfaces a 503 contract when Superset access fails.
# @TEST_EDGE: external_superset_failure -> {status: 503}
# [/DEF:test_generate_docs_env_not_found:Function]
def test_get_datasets_superset_failure(mock_deps):
"""@TEST_EDGE: external_superset_failure -> {status: 503}"""
mock_env = MagicMock()

View File

@@ -11,6 +11,11 @@ from src.api.routes import git as git_routes
from src.models.git import GitServerConfig, GitProvider, GitStatus, GitRepository
# [DEF:DbMock:Class]
# @RELATION: BINDS_TO ->[TestGitApi]
# @COMPLEXITY: 2
# @PURPOSE: In-memory session double for git route tests with minimal query/filter persistence semantics.
# @INVARIANT: Supports only the SQLAlchemy-like operations exercised by this test module.
class DbMock:
def __init__(self, data=None):
self._data = data or []
@@ -79,6 +84,9 @@ class DbMock:
item.last_validated = "2026-03-08T00:00:00Z"
# [/DEF:DbMock:Class]
# [DEF:test_get_git_configs_masks_pat:Function]
# @RELATION: BINDS_TO ->[TestGitApi]
def test_get_git_configs_masks_pat():

View File

@@ -1,9 +1,9 @@
# [DEF:backend.src.api.routes.__tests__.test_git_status_route:Module]
# [DEF:TestGitStatusRoute:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, git, api, status, no_repo
# @PURPOSE: Validate status endpoint behavior for missing and error repository states.
# @LAYER: Domain (Tests)
# @RELATION: VERIFIES -> [backend.src.api.routes.git]
# @RELATION: VERIFIES -> [GitApi]
from fastapi import HTTPException
import pytest
@@ -14,6 +14,7 @@ from src.api.routes import git as git_routes
# [DEF:test_get_repository_status_returns_no_repo_payload_for_missing_repo:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure missing local repository is represented as NO_REPO payload instead of an API error.
# @PRE: GitService.get_status raises HTTPException(404).
# @POST: Route returns a deterministic NO_REPO status payload.
@@ -37,6 +38,7 @@ def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypa
# [DEF:test_get_repository_status_propagates_non_404_http_exception:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure HTTP exceptions other than 404 are not masked.
# @PRE: GitService.get_status raises HTTPException with non-404 status.
# @POST: Raised exception preserves original status and detail.
@@ -60,6 +62,7 @@ def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
# [DEF:test_get_repository_diff_propagates_http_exception:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure diff endpoint preserves domain HTTP errors from GitService.
# @PRE: GitService.get_diff raises HTTPException.
# @POST: Endpoint raises same HTTPException values.
@@ -79,6 +82,7 @@ def test_get_repository_diff_propagates_http_exception(monkeypatch):
# [DEF:test_get_history_wraps_unexpected_error_as_500:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure non-HTTP exceptions in history endpoint become deterministic 500 errors.
# @PRE: GitService.get_commit_history raises ValueError.
# @POST: Endpoint returns HTTPException with status 500 and route context.
@@ -98,6 +102,7 @@ def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
# [DEF:test_commit_changes_wraps_unexpected_error_as_500:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure commit endpoint does not leak unexpected errors as 400.
# @PRE: GitService.commit_changes raises RuntimeError.
# @POST: Endpoint raises HTTPException(500) with route context.
@@ -121,6 +126,7 @@ def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
# [DEF:test_get_repository_status_batch_returns_mixed_statuses:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure batch endpoint returns per-dashboard statuses in one response.
# @PRE: Some repositories are missing and some are initialized.
# @POST: Returned map includes resolved status for each requested dashboard ID.
@@ -148,6 +154,7 @@ def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
# [DEF:test_get_repository_status_batch_marks_item_as_error_on_service_failure:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure batch endpoint marks failed items as ERROR without failing entire request.
# @PRE: GitService raises non-HTTP exception for one dashboard.
# @POST: Failed dashboard status is marked as ERROR.
@@ -173,6 +180,7 @@ def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monk
# [DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure batch endpoint protects server from oversized payloads.
# @PRE: request includes duplicate IDs and more than MAX_REPOSITORY_STATUS_BATCH entries.
# @POST: Result contains unique IDs up to configured cap.
@@ -198,6 +206,7 @@ def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch)
# [DEF:test_commit_changes_applies_profile_identity_before_commit:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure commit route configures repository identity from profile preferences before commit call.
# @PRE: Profile preference contains git_username/git_email for current user.
# @POST: git_service.configure_identity receives resolved identity and commit proceeds.
@@ -259,6 +268,7 @@ def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
# [DEF:test_pull_changes_applies_profile_identity_before_pull:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure pull route configures repository identity from profile preferences before pull call.
# @PRE: Profile preference contains git_username/git_email for current user.
# @POST: git_service.configure_identity receives resolved identity and pull proceeds.
@@ -315,6 +325,7 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
# [DEF:test_get_merge_status_returns_service_payload:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure merge status route returns service payload as-is.
# @PRE: git_service.get_merge_status returns unfinished merge payload.
# @POST: Route response contains has_unfinished_merge=True.
@@ -347,6 +358,7 @@ def test_get_merge_status_returns_service_payload(monkeypatch):
# [DEF:test_resolve_merge_conflicts_passes_resolution_items_to_service:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure merge resolve route forwards parsed resolutions to service.
# @PRE: resolve_data has one file strategy.
# @POST: Service receives normalized list and route returns resolved files.
@@ -384,6 +396,7 @@ def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch)
# [DEF:test_abort_merge_calls_service_and_returns_result:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure abort route delegates to service.
# @PRE: Service abort_merge returns aborted status.
# @POST: Route returns aborted status.
@@ -408,6 +421,7 @@ def test_abort_merge_calls_service_and_returns_result(monkeypatch):
# [DEF:test_continue_merge_passes_message_and_returns_commit:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure continue route passes commit message to service.
# @PRE: continue_data.message is provided.
# @POST: Route returns committed status and hash.
@@ -437,4 +451,4 @@ def test_continue_merge_passes_message_and_returns_commit(monkeypatch):
# [/DEF:test_continue_merge_passes_message_and_returns_commit:Function]
# [/DEF:backend.src.api.routes.__tests__.test_git_status_route:Module]
# [/DEF:TestGitStatusRoute:Module]

View File

@@ -1,4 +1,4 @@
# [DEF:backend.src.api.routes.__tests__.test_migration_routes:Module]
# [DEF:TestMigrationRoutes:Module]
#
# @COMPLEXITY: 3
# @PURPOSE: Unit tests for migration API route handlers.
@@ -52,6 +52,8 @@ def db_session():
session.close()
# [DEF:_make_config_manager:Function]
# @RELATION: BINDS_TO -> TestMigrationRoutes
def _make_config_manager(cron="0 2 * * *"):
"""Creates a mock config manager with a realistic AppConfig-like object."""
settings = MagicMock()
@@ -66,6 +68,8 @@ def _make_config_manager(cron="0 2 * * *"):
# --- get_migration_settings tests ---
# [/DEF:_make_config_manager:Function]
@pytest.mark.asyncio
async def test_get_migration_settings_returns_default_cron():
"""Verify the settings endpoint returns the stored cron string."""
@@ -227,6 +231,8 @@ async def test_get_resource_mappings_filter_by_type(db_session):
# --- trigger_sync_now tests ---
@pytest.fixture
# [DEF:_mock_env:Function]
# @RELATION: BINDS_TO -> TestMigrationRoutes
def _mock_env():
"""Creates a mock config environment object."""
env = MagicMock()
@@ -240,6 +246,10 @@ def _mock_env():
return env
# [/DEF:_mock_env:Function]
# [DEF:_make_sync_config_manager:Function]
# @RELATION: BINDS_TO -> TestMigrationRoutes
def _make_sync_config_manager(environments):
"""Creates a mock config manager with environments list."""
settings = MagicMock()
@@ -253,6 +263,8 @@ def _make_sync_config_manager(environments):
return cm
# [/DEF:_make_sync_config_manager:Function]
@pytest.mark.asyncio
async def test_trigger_sync_now_creates_env_row_and_syncs(db_session, _mock_env):
"""Verify that trigger_sync_now creates an Environment row in DB before syncing,
@@ -507,4 +519,4 @@ async def test_dry_run_migration_rejects_same_environment(db_session):
assert exc.value.status_code == 400
# [/DEF:backend.src.api.routes.__tests__.test_migration_routes:Module]
# [/DEF:TestMigrationRoutes:Module]

View File

@@ -1,9 +1,9 @@
# [DEF:backend.src.api.routes.__tests__.test_profile_api:Module]
# [DEF:TestProfileApi:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, profile, api, preferences, lookup, contract
# @PURPOSE: Verifies profile API route contracts for preference read/update and Superset account lookup.
# @LAYER: API
# @RELATION: TESTS -> backend.src.api.routes.profile
# [SECTION: IMPORTS]
from datetime import datetime, timezone
@@ -34,6 +34,7 @@ client = TestClient(app)
# [DEF:mock_profile_route_dependencies:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Provides deterministic dependency overrides for profile route tests.
# @PRE: App instance is initialized.
# @POST: Dependencies are overridden for current test and restored afterward.
@@ -54,6 +55,7 @@ def mock_profile_route_dependencies():
# [DEF:profile_route_deps_fixture:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Pytest fixture wrapper for profile route dependency overrides.
# @PRE: None.
# @POST: Yields overridden dependencies and clears overrides after test.
@@ -69,6 +71,7 @@ def profile_route_deps_fixture():
# [DEF:_build_preference_response:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Builds stable profile preference response payload for route tests.
# @PRE: user_id is provided.
# @POST: Returns ProfilePreferenceResponse object with deterministic timestamps.
@@ -109,6 +112,7 @@ def _build_preference_response(user_id: str = "u-1") -> ProfilePreferenceRespons
# [DEF:test_get_profile_preferences_returns_self_payload:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies GET /api/profile/preferences returns stable self-scoped payload.
# @PRE: Authenticated user context is available.
# @POST: Response status is 200 and payload contains current user preference.
@@ -141,6 +145,7 @@ def test_get_profile_preferences_returns_self_payload(profile_route_deps_fixture
# [DEF:test_patch_profile_preferences_success:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies PATCH /api/profile/preferences persists valid payload through route mapping.
# @PRE: Valid request payload and authenticated user.
# @POST: Response status is 200 with saved preference payload.
@@ -191,6 +196,7 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
# [DEF:test_patch_profile_preferences_validation_error:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies route maps domain validation failure to HTTP 422 with actionable details.
# @PRE: Service raises ProfileValidationError.
# @POST: Response status is 422 and includes validation messages.
@@ -217,6 +223,7 @@ def test_patch_profile_preferences_validation_error(profile_route_deps_fixture):
# [DEF:test_patch_profile_preferences_cross_user_denied:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies route maps domain authorization guard failure to HTTP 403.
# @PRE: Service raises ProfileAuthorizationError.
# @POST: Response status is 403 with denial message.
@@ -242,6 +249,7 @@ def test_patch_profile_preferences_cross_user_denied(profile_route_deps_fixture)
# [DEF:test_lookup_superset_accounts_success:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies lookup route returns success payload with normalized candidates.
# @PRE: Valid environment_id and service success response.
# @POST: Response status is 200 and items list is returned.
@@ -278,6 +286,7 @@ def test_lookup_superset_accounts_success(profile_route_deps_fixture):
# [DEF:test_lookup_superset_accounts_env_not_found:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies lookup route maps missing environment to HTTP 404.
# @PRE: Service raises EnvironmentNotFoundError.
# @POST: Response status is 404 with explicit message.
@@ -295,4 +304,4 @@ def test_lookup_superset_accounts_env_not_found(profile_route_deps_fixture):
assert payload["detail"] == "Environment 'missing-env' not found"
# [/DEF:test_lookup_superset_accounts_env_not_found:Function]
# [/DEF:backend.src.api.routes.__tests__.test_profile_api:Module]
# [/DEF:TestProfileApi:Module]

View File

@@ -1,9 +1,9 @@
# [DEF:backend.tests.test_reports_api:Module]
# [DEF:TestReportsApi:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, reports, api, contract, pagination, filtering
# @PURPOSE: Contract tests for GET /api/reports defaults, pagination, and filtering behavior.
# @LAYER: Domain (Tests)
# @RELATION: TESTS -> backend.src.api.routes.reports
# @INVARIANT: API response contract contains {items,total,page,page_size,has_next,applied_filters}.
from datetime import datetime, timedelta, timezone
@@ -24,12 +24,26 @@ class _FakeTaskManager:
return self._tasks
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> TestReportsApi
def _admin_user():
admin_role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[admin_role])
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, started_at: datetime, finished_at: datetime = None, result=None):
# [/DEF:_admin_user:Function]
# [DEF:_make_task:Function]
# @RELATION: BINDS_TO -> TestReportsApi
def _make_task(
task_id: str,
plugin_id: str,
status: TaskStatus,
started_at: datetime,
finished_at: datetime = None,
result=None,
):
return Task(
id=task_id,
plugin_id=plugin_id,
@@ -41,12 +55,35 @@ def _make_task(task_id: str, plugin_id: str, status: TaskStatus, started_at: dat
)
# [/DEF:_make_task:Function]
# [DEF:test_get_reports_default_pagination_contract:Function]
# @RELATION: BINDS_TO -> TestReportsApi
def test_get_reports_default_pagination_contract():
now = datetime.utcnow()
tasks = [
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=10), now - timedelta(minutes=9)),
_make_task("t-2", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=8), now - timedelta(minutes=7)),
_make_task("t-3", "llm_dashboard_validation", TaskStatus.RUNNING, now - timedelta(minutes=6), None),
_make_task(
"t-1",
"superset-backup",
TaskStatus.SUCCESS,
now - timedelta(minutes=10),
now - timedelta(minutes=9),
),
_make_task(
"t-2",
"superset-migration",
TaskStatus.FAILED,
now - timedelta(minutes=8),
now - timedelta(minutes=7),
),
_make_task(
"t-3",
"llm_dashboard_validation",
TaskStatus.RUNNING,
now - timedelta(minutes=6),
None,
),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
@@ -58,7 +95,9 @@ def test_get_reports_default_pagination_contract():
assert response.status_code == 200
data = response.json()
assert set(["items", "total", "page", "page_size", "has_next", "applied_filters"]).issubset(data.keys())
assert set(
["items", "total", "page", "page_size", "has_next", "applied_filters"]
).issubset(data.keys())
assert data["page"] == 1
assert data["page_size"] == 20
assert data["total"] == 3
@@ -69,12 +108,35 @@ def test_get_reports_default_pagination_contract():
app.dependency_overrides.clear()
# [/DEF:test_get_reports_default_pagination_contract:Function]
# [DEF:test_get_reports_filter_and_pagination:Function]
# @RELATION: BINDS_TO -> TestReportsApi
def test_get_reports_filter_and_pagination():
now = datetime.utcnow()
tasks = [
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=30), now - timedelta(minutes=29)),
_make_task("t-2", "superset-backup", TaskStatus.FAILED, now - timedelta(minutes=20), now - timedelta(minutes=19)),
_make_task("t-3", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=10), now - timedelta(minutes=9)),
_make_task(
"t-1",
"superset-backup",
TaskStatus.SUCCESS,
now - timedelta(minutes=30),
now - timedelta(minutes=29),
),
_make_task(
"t-2",
"superset-backup",
TaskStatus.FAILED,
now - timedelta(minutes=20),
now - timedelta(minutes=19),
),
_make_task(
"t-3",
"superset-migration",
TaskStatus.FAILED,
now - timedelta(minutes=10),
now - timedelta(minutes=9),
),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
@@ -82,7 +144,9 @@ def test_get_reports_filter_and_pagination():
try:
client = TestClient(app)
response = client.get("/api/reports?task_types=backup&statuses=failed&page=1&page_size=1")
response = client.get(
"/api/reports?task_types=backup&statuses=failed&page=1&page_size=1"
)
assert response.status_code == 200
data = response.json()
@@ -97,12 +161,29 @@ def test_get_reports_filter_and_pagination():
app.dependency_overrides.clear()
# [/DEF:test_get_reports_filter_and_pagination:Function]
# [DEF:test_get_reports_handles_mixed_naive_and_aware_datetimes:Function]
# @RELATION: BINDS_TO -> TestReportsApi
def test_get_reports_handles_mixed_naive_and_aware_datetimes():
naive_now = datetime.utcnow()
aware_now = datetime.now(timezone.utc)
tasks = [
_make_task("t-naive", "superset-backup", TaskStatus.SUCCESS, naive_now - timedelta(minutes=5), naive_now - timedelta(minutes=4)),
_make_task("t-aware", "superset-migration", TaskStatus.FAILED, aware_now - timedelta(minutes=3), aware_now - timedelta(minutes=2)),
_make_task(
"t-naive",
"superset-backup",
TaskStatus.SUCCESS,
naive_now - timedelta(minutes=5),
naive_now - timedelta(minutes=4),
),
_make_task(
"t-aware",
"superset-migration",
TaskStatus.FAILED,
aware_now - timedelta(minutes=3),
aware_now - timedelta(minutes=2),
),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
@@ -119,9 +200,22 @@ def test_get_reports_handles_mixed_naive_and_aware_datetimes():
app.dependency_overrides.clear()
# [/DEF:test_get_reports_handles_mixed_naive_and_aware_datetimes:Function]
# [DEF:test_get_reports_invalid_filter_returns_400:Function]
# @RELATION: BINDS_TO -> TestReportsApi
def test_get_reports_invalid_filter_returns_400():
now = datetime.utcnow()
tasks = [_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=5), now - timedelta(minutes=4))]
tasks = [
_make_task(
"t-1",
"superset-backup",
TaskStatus.SUCCESS,
now - timedelta(minutes=5),
now - timedelta(minutes=4),
)
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
@@ -136,4 +230,5 @@ def test_get_reports_invalid_filter_returns_400():
app.dependency_overrides.clear()
# [/DEF:backend.tests.test_reports_api:Module]
# [/DEF:test_get_reports_invalid_filter_returns_400:Function]
# [/DEF:TestReportsApi:Module]

View File

@@ -1,9 +1,9 @@
# [DEF:backend.tests.test_reports_detail_api:Module]
# [DEF:TestReportsDetailApi:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, reports, api, detail, diagnostics
# @PURPOSE: Contract tests for GET /api/reports/{report_id} detail endpoint behavior.
# @LAYER: Domain (Tests)
# @RELATION: TESTS -> backend.src.api.routes.reports
# @INVARIANT: Detail endpoint tests must keep deterministic assertions for success and not-found contracts.
from datetime import datetime, timedelta
@@ -24,11 +24,18 @@ class _FakeTaskManager:
return self._tasks
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> TestReportsDetailApi
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[role])
# [/DEF:_admin_user:Function]
# [DEF:_make_task:Function]
# @RELATION: BINDS_TO -> TestReportsDetailApi
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=None):
now = datetime.utcnow()
return Task(
@@ -36,18 +43,30 @@ def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=None):
plugin_id=plugin_id,
status=status,
started_at=now - timedelta(minutes=2),
finished_at=now - timedelta(minutes=1) if status != TaskStatus.RUNNING else None,
finished_at=now - timedelta(minutes=1)
if status != TaskStatus.RUNNING
else None,
params={"environment_id": "env-1"},
result=result or {"summary": f"{plugin_id} result"},
)
# [/DEF:_make_task:Function]
# [DEF:test_get_report_detail_success:Function]
# @RELATION: BINDS_TO -> TestReportsDetailApi
def test_get_report_detail_success():
task = _make_task(
"detail-1",
"superset-migration",
TaskStatus.FAILED,
result={"error": {"message": "Step failed", "next_actions": ["Check mapping", "Retry"]}},
result={
"error": {
"message": "Step failed",
"next_actions": ["Check mapping", "Retry"],
}
},
)
app.dependency_overrides[get_current_user] = lambda: _admin_user()
@@ -67,6 +86,11 @@ def test_get_report_detail_success():
app.dependency_overrides.clear()
# [/DEF:test_get_report_detail_success:Function]
# [DEF:test_get_report_detail_not_found:Function]
# @RELATION: BINDS_TO -> TestReportsDetailApi
def test_get_report_detail_not_found():
task = _make_task("detail-2", "superset-backup", TaskStatus.SUCCESS)
@@ -81,4 +105,5 @@ def test_get_report_detail_not_found():
app.dependency_overrides.clear()
# [/DEF:backend.tests.test_reports_detail_api:Module]
# [/DEF:test_get_report_detail_not_found:Function]
# [/DEF:TestReportsDetailApi:Module]

View File

@@ -1,9 +1,9 @@
# [DEF:backend.tests.test_reports_openapi_conformance:Module]
# [DEF:TestReportsOpenapiConformance:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, reports, openapi, conformance
# @PURPOSE: Validate implemented reports payload shape against OpenAPI-required top-level contract fields.
# @LAYER: Domain (Tests)
# @RELATION: TESTS -> specs/020-task-reports-design/contracts/reports-api.openapi.yaml
# @INVARIANT: List and detail payloads include required contract keys.
from datetime import datetime
@@ -24,11 +24,18 @@ class _FakeTaskManager:
return self._tasks
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> TestReportsOpenapiConformance
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[role])
# [/DEF:_admin_user:Function]
# [DEF:_task:Function]
# @RELATION: BINDS_TO -> TestReportsOpenapiConformance
def _task(task_id: str, plugin_id: str, status: TaskStatus):
now = datetime.utcnow()
return Task(
@@ -42,6 +49,11 @@ def _task(task_id: str, plugin_id: str, status: TaskStatus):
)
# [/DEF:_task:Function]
# [DEF:test_reports_list_openapi_required_keys:Function]
# @RELATION: BINDS_TO -> TestReportsOpenapiConformance
def test_reports_list_openapi_required_keys():
tasks = [
_task("r-1", "superset-backup", TaskStatus.SUCCESS),
@@ -56,12 +68,24 @@ def test_reports_list_openapi_required_keys():
assert response.status_code == 200
body = response.json()
required = {"items", "total", "page", "page_size", "has_next", "applied_filters"}
required = {
"items",
"total",
"page",
"page_size",
"has_next",
"applied_filters",
}
assert required.issubset(body.keys())
finally:
app.dependency_overrides.clear()
# [/DEF:test_reports_list_openapi_required_keys:Function]
# [DEF:test_reports_detail_openapi_required_keys:Function]
# @RELATION: BINDS_TO -> TestReportsOpenapiConformance
def test_reports_detail_openapi_required_keys():
tasks = [_task("r-3", "llm_dashboard_validation", TaskStatus.SUCCESS)]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
@@ -78,4 +102,5 @@ def test_reports_detail_openapi_required_keys():
app.dependency_overrides.clear()
# [/DEF:backend.tests.test_reports_openapi_conformance:Module]
# [/DEF:test_reports_detail_openapi_required_keys:Function]
# [/DEF:TestReportsOpenapiConformance:Module]

View File

@@ -27,6 +27,8 @@ def client():
# @TEST_CONTRACT: get_task_logs_api -> Invariants
# @TEST_FIXTURE: valid_task_logs_request
# [DEF:test_get_task_logs_success:Function]
# @RELATION: BINDS_TO -> __tests__/test_tasks_logs
def test_get_task_logs_success(client):
tc, tm = client
@@ -46,6 +48,10 @@ def test_get_task_logs_success(client):
assert args[0][1].level == "INFO"
# @TEST_EDGE: task_not_found
# [/DEF:test_get_task_logs_success:Function]
# [DEF:test_get_task_logs_not_found:Function]
# @RELATION: BINDS_TO -> __tests__/test_tasks_logs
def test_get_task_logs_not_found(client):
tc, tm = client
tm.get_task.return_value = None
@@ -55,6 +61,10 @@ def test_get_task_logs_not_found(client):
assert response.json()["detail"] == "Task not found"
# @TEST_EDGE: invalid_limit
# [/DEF:test_get_task_logs_not_found:Function]
# [DEF:test_get_task_logs_invalid_limit:Function]
# @RELATION: BINDS_TO -> __tests__/test_tasks_logs
def test_get_task_logs_invalid_limit(client):
tc, tm = client
# limit=0 is ge=1 in Query
@@ -62,6 +72,10 @@ def test_get_task_logs_invalid_limit(client):
assert response.status_code == 422
# @TEST_INVARIANT: response_purity
# [/DEF:test_get_task_logs_invalid_limit:Function]
# [DEF:test_get_task_log_stats_success:Function]
# @RELATION: BINDS_TO -> __tests__/test_tasks_logs
def test_get_task_log_stats_success(client):
tc, tm = client
tm.get_task.return_value = MagicMock()
@@ -71,3 +85,4 @@ def test_get_task_log_stats_success(client):
assert response.status_code == 200
# response_model=LogStats might wrap this, but let's check basic structure
# assuming tm.get_task_log_stats returns something compatible with LogStats
# [/DEF:test_get_task_log_stats_success:Function]

View File

@@ -31,6 +31,7 @@ from ...services.rbac_permission_catalog import (
# [/SECTION]
# [DEF:router:Variable]
# @RELATION: DEPENDS_ON -> fastapi.APIRouter
# @PURPOSE: APIRouter instance for admin routes.
router = APIRouter(prefix="/api/admin", tags=["admin"])
# [/DEF:router:Variable]
@@ -42,6 +43,7 @@ router = APIRouter(prefix="/api/admin", tags=["admin"])
# @POST: Returns a list of UserSchema objects.
# @PARAM: db (Session) - Auth database session.
# @RETURN: List[UserSchema] - List of users.
# @RELATION: CALLS -> User
@router.get("/users", response_model=List[UserSchema])
async def list_users(
db: Session = Depends(get_auth_db),
@@ -60,6 +62,7 @@ async def list_users(
# @PARAM: user_in (UserCreate) - New user data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: UserSchema - The created user.
# @RELATION: CALLS -> AuthRepository
@router.post("/users", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_user(
user_in: UserCreate,
@@ -99,6 +102,7 @@ async def create_user(
# @PARAM: user_in (UserUpdate) - Updated user data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: UserSchema - The updated user profile.
# @RELATION: CALLS -> AuthRepository
@router.put("/users/{user_id}", response_model=UserSchema)
async def update_user(
user_id: str,
@@ -139,6 +143,7 @@ async def update_user(
# @PARAM: user_id (str) - Target user UUID.
# @PARAM: db (Session) - Auth database session.
# @RETURN: None
# @RELATION: CALLS -> AuthRepository
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
@@ -313,6 +318,7 @@ async def list_permissions(
# [DEF:list_ad_mappings:Function]
# @COMPLEXITY: 3
# @PURPOSE: Lists all AD Group to Role mappings.
# @RELATION: CALLS -> ADGroupMapping
@router.get("/ad-mappings", response_model=List[ADGroupMappingSchema])
async def list_ad_mappings(
db: Session = Depends(get_auth_db),
@@ -323,7 +329,8 @@ async def list_ad_mappings(
# [/DEF:list_ad_mappings:Function]
# [DEF:create_ad_mapping:Function]
# @COMPLEXITY: 3
# @RELATION: CALLS -> AuthRepository
# @COMPLEXITY: 2
# @PURPOSE: Creates a new AD Group mapping.
@router.post("/ad-mappings", response_model=ADGroupMappingSchema)
async def create_ad_mapping(

View File

@@ -1,5 +1,5 @@
# [DEF:backend.src.api.routes.clean_release:Module]
# @COMPLEXITY: 3
# @COMPLEXITY: 4
# @SEMANTICS: api, clean-release, candidate-preparation, compliance
# @PURPOSE: Expose clean release endpoints for candidate preparation and subsequent compliance flow.
# @LAYER: API
@@ -19,10 +19,20 @@ from ...core.logger import belief_scope, logger
from ...dependencies import get_clean_release_repository, get_config_manager
from ...services.clean_release.preparation_service import prepare_candidate
from ...services.clean_release.repository import CleanReleaseRepository
from ...services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
from ...services.clean_release.compliance_orchestrator import (
CleanComplianceOrchestrator,
)
from ...services.clean_release.report_builder import ComplianceReportBuilder
from ...services.clean_release.compliance_execution_service import ComplianceExecutionService, ComplianceRunError
from ...services.clean_release.dto import CandidateDTO, ManifestDTO, CandidateOverviewDTO, ComplianceRunDTO
from ...services.clean_release.compliance_execution_service import (
ComplianceExecutionService,
ComplianceRunError,
)
from ...services.clean_release.dto import (
CandidateDTO,
ManifestDTO,
CandidateOverviewDTO,
ComplianceRunDTO,
)
from ...services.clean_release.enums import (
ComplianceDecision,
ComplianceStageName,
@@ -49,6 +59,8 @@ class PrepareCandidateRequest(BaseModel):
artifacts: List[Dict[str, Any]] = Field(default_factory=list)
sources: List[str] = Field(default_factory=list)
operator_id: str = Field(min_length=1)
# [/DEF:PrepareCandidateRequest:Class]
@@ -59,6 +71,8 @@ class StartCheckRequest(BaseModel):
profile: str = Field(default="enterprise-clean")
execution_mode: str = Field(default="tui")
triggered_by: str = Field(default="system")
# [/DEF:StartCheckRequest:Class]
@@ -69,6 +83,8 @@ class RegisterCandidateRequest(BaseModel):
version: str = Field(min_length=1)
source_snapshot_ref: str = Field(min_length=1)
created_by: str = Field(min_length=1)
# [/DEF:RegisterCandidateRequest:Class]
@@ -76,6 +92,8 @@ class RegisterCandidateRequest(BaseModel):
# @PURPOSE: Request schema for candidate artifact import endpoint.
class ImportArtifactsRequest(BaseModel):
artifacts: List[Dict[str, Any]] = Field(default_factory=list)
# [/DEF:ImportArtifactsRequest:Class]
@@ -83,6 +101,8 @@ class ImportArtifactsRequest(BaseModel):
# @PURPOSE: Request schema for manifest build endpoint.
class BuildManifestRequest(BaseModel):
created_by: str = Field(default="system")
# [/DEF:BuildManifestRequest:Class]
@@ -91,6 +111,8 @@ class BuildManifestRequest(BaseModel):
class CreateComplianceRunRequest(BaseModel):
requested_by: str = Field(min_length=1)
manifest_id: str | None = None
# [/DEF:CreateComplianceRunRequest:Class]
@@ -98,14 +120,19 @@ class CreateComplianceRunRequest(BaseModel):
# @PURPOSE: Register a clean-release candidate for headless lifecycle.
# @PRE: Candidate identifier is unique.
# @POST: Candidate is persisted in DRAFT status.
@router.post("/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED)
@router.post(
"/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED
)
async def register_candidate_v2_endpoint(
payload: RegisterCandidateRequest,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
existing = repository.get_candidate(payload.id)
if existing is not None:
raise HTTPException(status_code=409, detail={"message": "Candidate already exists", "code": "CANDIDATE_EXISTS"})
raise HTTPException(
status_code=409,
detail={"message": "Candidate already exists", "code": "CANDIDATE_EXISTS"},
)
candidate = ReleaseCandidate(
id=payload.id,
@@ -125,6 +152,8 @@ async def register_candidate_v2_endpoint(
created_by=candidate.created_by,
status=CandidateStatus(candidate.status),
)
# [/DEF:register_candidate_v2_endpoint:Function]
@@ -140,9 +169,15 @@ async def import_candidate_artifacts_v2_endpoint(
):
candidate = repository.get_candidate(candidate_id)
if candidate is None:
raise HTTPException(status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
raise HTTPException(
status_code=404,
detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"},
)
if not payload.artifacts:
raise HTTPException(status_code=400, detail={"message": "Artifacts list is required", "code": "ARTIFACTS_EMPTY"})
raise HTTPException(
status_code=400,
detail={"message": "Artifacts list is required", "code": "ARTIFACTS_EMPTY"},
)
for artifact in payload.artifacts:
required = ("id", "path", "sha256", "size")
@@ -150,7 +185,10 @@ async def import_candidate_artifacts_v2_endpoint(
if field_name not in artifact:
raise HTTPException(
status_code=400,
detail={"message": f"Artifact missing field '{field_name}'", "code": "ARTIFACT_INVALID"},
detail={
"message": f"Artifact missing field '{field_name}'",
"code": "ARTIFACT_INVALID",
},
)
artifact_model = CandidateArtifact(
@@ -172,6 +210,8 @@ async def import_candidate_artifacts_v2_endpoint(
repository.save_candidate(candidate)
return {"status": "success"}
# [/DEF:import_candidate_artifacts_v2_endpoint:Function]
@@ -179,7 +219,11 @@ async def import_candidate_artifacts_v2_endpoint(
# @PURPOSE: Build immutable manifest snapshot for prepared candidate.
# @PRE: Candidate exists and has imported artifacts.
# @POST: Returns created ManifestDTO with incremented version.
@router.post("/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED)
@router.post(
"/candidates/{candidate_id}/manifests",
response_model=ManifestDTO,
status_code=status.HTTP_201_CREATED,
)
async def build_candidate_manifest_v2_endpoint(
candidate_id: str,
payload: BuildManifestRequest,
@@ -194,7 +238,10 @@ async def build_candidate_manifest_v2_endpoint(
created_by=payload.created_by,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail={"message": str(exc), "code": "MANIFEST_BUILD_ERROR"})
raise HTTPException(
status_code=400,
detail={"message": str(exc), "code": "MANIFEST_BUILD_ERROR"},
)
return ManifestDTO(
id=manifest.id,
@@ -207,6 +254,8 @@ async def build_candidate_manifest_v2_endpoint(
source_snapshot_ref=manifest.source_snapshot_ref,
content_json=manifest.content_json,
)
# [/DEF:build_candidate_manifest_v2_endpoint:Function]
@@ -221,26 +270,53 @@ async def get_candidate_overview_v2_endpoint(
):
candidate = repository.get_candidate(candidate_id)
if candidate is None:
raise HTTPException(status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
raise HTTPException(
status_code=404,
detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"},
)
manifests = repository.get_manifests_by_candidate(candidate_id)
latest_manifest = sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0] if manifests else None
latest_manifest = (
sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0]
if manifests
else None
)
runs = [run for run in repository.check_runs.values() if run.candidate_id == candidate_id]
latest_run = sorted(runs, key=lambda run: run.requested_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)[0] if runs else None
runs = [
run
for run in repository.check_runs.values()
if run.candidate_id == candidate_id
]
latest_run = (
sorted(
runs,
key=lambda run: run.requested_at
or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)[0]
if runs
else None
)
latest_report = None
if latest_run is not None:
latest_report = next((r for r in repository.reports.values() if r.run_id == latest_run.id), None)
latest_report = next(
(r for r in repository.reports.values() if r.run_id == latest_run.id), None
)
latest_policy_snapshot = repository.get_policy(latest_run.policy_snapshot_id) if latest_run else None
latest_registry_snapshot = repository.get_registry(latest_run.registry_snapshot_id) if latest_run else None
latest_policy_snapshot = (
repository.get_policy(latest_run.policy_snapshot_id) if latest_run else None
)
latest_registry_snapshot = (
repository.get_registry(latest_run.registry_snapshot_id) if latest_run else None
)
approval_decisions = getattr(repository, "approval_decisions", [])
latest_approval = (
sorted(
[item for item in approval_decisions if item.candidate_id == candidate_id],
key=lambda item: item.decided_at or datetime.min.replace(tzinfo=timezone.utc),
key=lambda item: item.decided_at
or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)[0]
if approval_decisions
@@ -252,7 +328,8 @@ async def get_candidate_overview_v2_endpoint(
latest_publication = (
sorted(
[item for item in publication_records if item.candidate_id == candidate_id],
key=lambda item: item.published_at or datetime.min.replace(tzinfo=timezone.utc),
key=lambda item: item.published_at
or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)[0]
if publication_records
@@ -266,19 +343,35 @@ async def get_candidate_overview_v2_endpoint(
source_snapshot_ref=candidate.source_snapshot_ref,
status=CandidateStatus(candidate.status),
latest_manifest_id=latest_manifest.id if latest_manifest else None,
latest_manifest_digest=latest_manifest.manifest_digest if latest_manifest else None,
latest_manifest_digest=latest_manifest.manifest_digest
if latest_manifest
else None,
latest_run_id=latest_run.id if latest_run else None,
latest_run_status=RunStatus(latest_run.status) if latest_run else None,
latest_report_id=latest_report.id if latest_report else None,
latest_report_final_status=ComplianceDecision(latest_report.final_status) if latest_report else None,
latest_policy_snapshot_id=latest_policy_snapshot.id if latest_policy_snapshot else None,
latest_policy_version=latest_policy_snapshot.policy_version if latest_policy_snapshot else None,
latest_registry_snapshot_id=latest_registry_snapshot.id if latest_registry_snapshot else None,
latest_registry_version=latest_registry_snapshot.registry_version if latest_registry_snapshot else None,
latest_report_final_status=ComplianceDecision(latest_report.final_status)
if latest_report
else None,
latest_policy_snapshot_id=latest_policy_snapshot.id
if latest_policy_snapshot
else None,
latest_policy_version=latest_policy_snapshot.policy_version
if latest_policy_snapshot
else None,
latest_registry_snapshot_id=latest_registry_snapshot.id
if latest_registry_snapshot
else None,
latest_registry_version=latest_registry_snapshot.registry_version
if latest_registry_snapshot
else None,
latest_approval_decision=latest_approval.decision if latest_approval else None,
latest_publication_id=latest_publication.id if latest_publication else None,
latest_publication_status=latest_publication.status if latest_publication else None,
latest_publication_status=latest_publication.status
if latest_publication
else None,
)
# [/DEF:get_candidate_overview_v2_endpoint:Function]
@@ -311,6 +404,8 @@ async def prepare_candidate_endpoint(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"message": str(exc), "code": "CLEAN_PREPARATION_ERROR"},
)
# [/DEF:prepare_candidate_endpoint:Function]
@@ -327,27 +422,46 @@ async def start_check(
logger.reason("Starting clean-release compliance check run")
policy = repository.get_active_policy()
if policy is None:
raise HTTPException(status_code=409, detail={"message": "Active policy not found", "code": "POLICY_NOT_FOUND"})
raise HTTPException(
status_code=409,
detail={
"message": "Active policy not found",
"code": "POLICY_NOT_FOUND",
},
)
candidate = repository.get_candidate(payload.candidate_id)
if candidate is None:
raise HTTPException(status_code=409, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
raise HTTPException(
status_code=409,
detail={
"message": "Candidate not found",
"code": "CANDIDATE_NOT_FOUND",
},
)
manifests = repository.get_manifests_by_candidate(payload.candidate_id)
if not manifests:
logger.explore("No manifest found for candidate; bootstrapping legacy empty manifest for compatibility")
from ...services.clean_release.manifest_builder import build_distribution_manifest
logger.explore(
"No manifest found for candidate; bootstrapping legacy empty manifest for compatibility"
)
from ...services.clean_release.manifest_builder import (
build_distribution_manifest,
)
boot_manifest = build_distribution_manifest(
manifest_id=f"manifest-{payload.candidate_id}",
candidate_id=payload.candidate_id,
policy_id=getattr(policy, "policy_id", None) or getattr(policy, "id", ""),
policy_id=getattr(policy, "policy_id", None)
or getattr(policy, "id", ""),
generated_by=payload.triggered_by,
artifacts=[],
)
repository.save_manifest(boot_manifest)
manifests = [boot_manifest]
latest_manifest = sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0]
latest_manifest = sorted(
manifests, key=lambda m: m.manifest_version, reverse=True
)[0]
orchestrator = CleanComplianceOrchestrator(repository)
run = orchestrator.start_check_run(
@@ -364,7 +478,7 @@ async def start_check(
stage_name=ComplianceStageName.DATA_PURITY.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
details_json={"message": "ok"},
),
ComplianceStageRun(
id=f"stage-{run.id}-2",
@@ -372,7 +486,7 @@ async def start_check(
stage_name=ComplianceStageName.INTERNAL_SOURCES_ONLY.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
details_json={"message": "ok"},
),
ComplianceStageRun(
id=f"stage-{run.id}-3",
@@ -380,7 +494,7 @@ async def start_check(
stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
details_json={"message": "ok"},
),
ComplianceStageRun(
id=f"stage-{run.id}-4",
@@ -388,14 +502,20 @@ async def start_check(
stage_name=ComplianceStageName.MANIFEST_CONSISTENCY.value,
status=RunStatus.SUCCEEDED.value,
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"}
details_json={"message": "ok"},
),
]
run = orchestrator.execute_stages(run, forced_results=forced)
run = orchestrator.finalize_run(run)
if str(run.final_status) in {ComplianceDecision.BLOCKED.value, "CheckFinalStatus.BLOCKED", "BLOCKED"}:
logger.explore("Run ended as BLOCKED, persisting synthetic external-source violation")
if str(run.final_status) in {
ComplianceDecision.BLOCKED.value,
"CheckFinalStatus.BLOCKED",
"BLOCKED",
}:
logger.explore(
"Run ended as BLOCKED, persisting synthetic external-source violation"
)
violation = ComplianceViolation(
id=f"viol-{run.id}",
run_id=run.id,
@@ -403,12 +523,14 @@ async def start_check(
code="EXTERNAL_SOURCE_DETECTED",
severity=ViolationSeverity.CRITICAL.value,
message="Replace with approved internal server",
evidence_json={"location": "external.example.com"}
evidence_json={"location": "external.example.com"},
)
repository.save_violation(violation)
builder = ComplianceReportBuilder(repository)
report = builder.build_report_payload(run, repository.get_violations_by_run(run.id))
report = builder.build_report_payload(
run, repository.get_violations_by_run(run.id)
)
builder.persist_report(report)
logger.reflect(f"Compliance report persisted for run_id={run.id}")
@@ -418,6 +540,8 @@ async def start_check(
"status": "running",
"started_at": run.started_at.isoformat() if run.started_at else None,
}
# [/DEF:start_check:Function]
@@ -426,11 +550,17 @@ async def start_check(
# @PRE: check_run_id references an existing run.
# @POST: Deterministic payload shape includes checks and violations arrays.
@router.get("/checks/{check_run_id}")
async def get_check_status(check_run_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository)):
async def get_check_status(
check_run_id: str,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
with belief_scope("clean_release.get_check_status"):
run = repository.get_check_run(check_run_id)
if run is None:
raise HTTPException(status_code=404, detail={"message": "Check run not found", "code": "CHECK_NOT_FOUND"})
raise HTTPException(
status_code=404,
detail={"message": "Check run not found", "code": "CHECK_NOT_FOUND"},
)
logger.reflect(f"Returning check status for check_run_id={check_run_id}")
checks = [
@@ -462,6 +592,8 @@ async def get_check_status(check_run_id: str, repository: CleanReleaseRepository
"checks": checks,
"violations": violations,
}
# [/DEF:get_check_status:Function]
@@ -470,11 +602,17 @@ async def get_check_status(check_run_id: str, repository: CleanReleaseRepository
# @PRE: report_id references an existing report.
# @POST: Returns serialized report object.
@router.get("/reports/{report_id}")
async def get_report(report_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository)):
async def get_report(
report_id: str,
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
with belief_scope("clean_release.get_report"):
report = repository.get_report(report_id)
if report is None:
raise HTTPException(status_code=404, detail={"message": "Report not found", "code": "REPORT_NOT_FOUND"})
raise HTTPException(
status_code=404,
detail={"message": "Report not found", "code": "REPORT_NOT_FOUND"},
)
logger.reflect(f"Returning compliance report report_id={report_id}")
return {
@@ -482,11 +620,17 @@ async def get_report(report_id: str, repository: CleanReleaseRepository = Depend
"check_run_id": report.run_id,
"candidate_id": report.candidate_id,
"final_status": getattr(report.final_status, "value", report.final_status),
"generated_at": report.generated_at.isoformat() if getattr(report, "generated_at", None) else None,
"generated_at": report.generated_at.isoformat()
if getattr(report, "generated_at", None)
else None,
"operator_summary": getattr(report, "operator_summary", ""),
"structured_payload_ref": getattr(report, "structured_payload_ref", None),
"violations_count": getattr(report, "violations_count", 0),
"blocking_violations_count": getattr(report, "blocking_violations_count", 0),
"blocking_violations_count": getattr(
report, "blocking_violations_count", 0
),
}
# [/DEF:get_report:Function]
# [/DEF:backend.src.api.routes.clean_release:Module]
# [/DEF:backend.src.api.routes.clean_release:Module]

View File

@@ -1,16 +1,26 @@
# [DEF:backend.src.api.routes.clean_release_v2:Module]
# @COMPLEXITY: 3
# [DEF:CleanReleaseV2Api:Module]
# @COMPLEXITY: 4
# @PURPOSE: Redesigned clean release API for headless candidate lifecycle.
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Dict, Any
from datetime import datetime, timezone
from ...services.clean_release.approval_service import approve_candidate, reject_candidate
from ...services.clean_release.publication_service import publish_candidate, revoke_publication
from ...services.clean_release.approval_service import (
approve_candidate,
reject_candidate,
)
from ...services.clean_release.publication_service import (
publish_candidate,
revoke_publication,
)
from ...services.clean_release.repository import CleanReleaseRepository
from ...dependencies import get_clean_release_repository
from ...services.clean_release.enums import CandidateStatus
from ...models.clean_release import ReleaseCandidate, CandidateArtifact, DistributionManifest
from ...models.clean_release import (
ReleaseCandidate,
CandidateArtifact,
DistributionManifest,
)
from ...services.clean_release.dto import CandidateDTO, ManifestDTO
router = APIRouter(prefix="/api/v2/clean-release", tags=["Clean Release V2"])
@@ -22,6 +32,8 @@ router = APIRouter(prefix="/api/v2/clean-release", tags=["Clean Release V2"])
# @RELATION: USES -> [CandidateDTO]
class ApprovalRequest(dict):
pass
# [/DEF:ApprovalRequest:Class]
@@ -31,6 +43,8 @@ class ApprovalRequest(dict):
# @RELATION: USES -> [CandidateDTO]
class PublishRequest(dict):
pass
# [/DEF:PublishRequest:Class]
@@ -40,8 +54,11 @@ class PublishRequest(dict):
# @RELATION: USES -> [CandidateDTO]
class RevokeRequest(dict):
pass
# [/DEF:RevokeRequest:Class]
# [DEF:register_candidate:Function]
# @COMPLEXITY: 3
# @PURPOSE: Register a new release candidate.
@@ -50,10 +67,12 @@ class RevokeRequest(dict):
# @RETURN: CandidateDTO
# @RELATION: CALLS -> [CleanReleaseRepository.save_candidate]
# @RELATION: USES -> [CandidateDTO]
@router.post("/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED)
@router.post(
"/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED
)
async def register_candidate(
payload: Dict[str, Any],
repository: CleanReleaseRepository = Depends(get_clean_release_repository)
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
candidate = ReleaseCandidate(
id=payload["id"],
@@ -61,7 +80,7 @@ async def register_candidate(
source_snapshot_ref=payload["source_snapshot_ref"],
created_by=payload["created_by"],
created_at=datetime.now(timezone.utc),
status=CandidateStatus.DRAFT.value
status=CandidateStatus.DRAFT.value,
)
repository.save_candidate(candidate)
return CandidateDTO(
@@ -70,10 +89,13 @@ async def register_candidate(
source_snapshot_ref=candidate.source_snapshot_ref,
created_at=candidate.created_at,
created_by=candidate.created_by,
status=CandidateStatus(candidate.status)
status=CandidateStatus(candidate.status),
)
# [/DEF:register_candidate:Function]
# [DEF:import_artifacts:Function]
# @COMPLEXITY: 3
# @PURPOSE: Associate artifacts with a release candidate.
@@ -84,27 +106,30 @@ async def register_candidate(
async def import_artifacts(
candidate_id: str,
payload: Dict[str, Any],
repository: CleanReleaseRepository = Depends(get_clean_release_repository)
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
candidate = repository.get_candidate(candidate_id)
if not candidate:
raise HTTPException(status_code=404, detail="Candidate not found")
for art_data in payload.get("artifacts", []):
artifact = CandidateArtifact(
id=art_data["id"],
candidate_id=candidate_id,
path=art_data["path"],
sha256=art_data["sha256"],
size=art_data["size"]
size=art_data["size"],
)
# In a real repo we'd have save_artifact
# repository.save_artifact(artifact)
pass
return {"status": "success"}
# [/DEF:import_artifacts:Function]
# [DEF:build_manifest:Function]
# @COMPLEXITY: 3
# @PURPOSE: Generate distribution manifest for a candidate.
@@ -113,15 +138,19 @@ async def import_artifacts(
# @RETURN: ManifestDTO
# @RELATION: CALLS -> [CleanReleaseRepository.save_manifest]
# @RELATION: CALLS -> [CleanReleaseRepository.get_candidate]
@router.post("/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED)
@router.post(
"/candidates/{candidate_id}/manifests",
response_model=ManifestDTO,
status_code=status.HTTP_201_CREATED,
)
async def build_manifest(
candidate_id: str,
repository: CleanReleaseRepository = Depends(get_clean_release_repository)
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
):
candidate = repository.get_candidate(candidate_id)
if not candidate:
raise HTTPException(status_code=404, detail="Candidate not found")
manifest = DistributionManifest(
id=f"manifest-{candidate_id}",
candidate_id=candidate_id,
@@ -131,10 +160,10 @@ async def build_manifest(
created_by="system",
created_at=datetime.now(timezone.utc),
source_snapshot_ref=candidate.source_snapshot_ref,
content_json={"items": [], "summary": {}}
content_json={"items": [], "summary": {}},
)
repository.save_manifest(manifest)
return ManifestDTO(
id=manifest.id,
candidate_id=manifest.candidate_id,
@@ -144,10 +173,13 @@ async def build_manifest(
created_at=manifest.created_at,
created_by=manifest.created_by,
source_snapshot_ref=manifest.source_snapshot_ref,
content_json=manifest.content_json
content_json=manifest.content_json,
)
# [/DEF:build_manifest:Function]
# [DEF:approve_candidate_endpoint:Function]
# @COMPLEXITY: 3
# @PURPOSE: Endpoint to record candidate approval.
@@ -167,9 +199,13 @@ async def approve_candidate_endpoint(
comment=payload.get("comment"),
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"})
raise HTTPException(
status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"}
)
return {"status": "ok", "decision": decision.decision, "decision_id": decision.id}
# [/DEF:approve_candidate_endpoint:Function]
@@ -192,9 +228,13 @@ async def reject_candidate_endpoint(
comment=payload.get("comment"),
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"})
raise HTTPException(
status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"}
)
return {"status": "ok", "decision": decision.decision, "decision_id": decision.id}
# [/DEF:reject_candidate_endpoint:Function]
@@ -218,7 +258,10 @@ async def publish_candidate_endpoint(
publication_ref=payload.get("publication_ref"),
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"})
raise HTTPException(
status_code=409,
detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"},
)
return {
"status": "ok",
@@ -227,12 +270,16 @@ async def publish_candidate_endpoint(
"candidate_id": publication.candidate_id,
"report_id": publication.report_id,
"published_by": publication.published_by,
"published_at": publication.published_at.isoformat() if publication.published_at else None,
"published_at": publication.published_at.isoformat()
if publication.published_at
else None,
"target_channel": publication.target_channel,
"publication_ref": publication.publication_ref,
"status": publication.status,
},
}
# [/DEF:publish_candidate_endpoint:Function]
@@ -254,7 +301,10 @@ async def revoke_publication_endpoint(
comment=payload.get("comment"),
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"})
raise HTTPException(
status_code=409,
detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"},
)
return {
"status": "ok",
@@ -263,12 +313,16 @@ async def revoke_publication_endpoint(
"candidate_id": publication.candidate_id,
"report_id": publication.report_id,
"published_by": publication.published_by,
"published_at": publication.published_at.isoformat() if publication.published_at else None,
"published_at": publication.published_at.isoformat()
if publication.published_at
else None,
"target_channel": publication.target_channel,
"publication_ref": publication.publication_ref,
"status": publication.status,
},
}
# [/DEF:revoke_publication_endpoint:Function]
# [/DEF:backend.src.api.routes.clean_release_v2:Module]
# [/DEF:CleanReleaseV2Api:Module]

View File

@@ -269,7 +269,7 @@ class LaunchDatasetResponse(BaseModel):
# [DEF:_require_auto_review_flag:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Guard US1 dataset review endpoints behind the configured feature flag.
# @RELATION: [DEPENDS_ON] ->[ConfigManager]
def _require_auto_review_flag(config_manager=Depends(get_config_manager)) -> bool:
@@ -284,7 +284,7 @@ def _require_auto_review_flag(config_manager=Depends(get_config_manager)) -> boo
# [DEF:_require_clarification_flag:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Guard clarification-specific US2 endpoints behind the configured feature flag.
# @RELATION: [DEPENDS_ON] ->[ConfigManager]
def _require_clarification_flag(config_manager=Depends(get_config_manager)) -> bool:
@@ -299,7 +299,7 @@ def _require_clarification_flag(config_manager=Depends(get_config_manager)) -> b
# [DEF:_require_execution_flag:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Guard US3 execution endpoints behind the configured feature flag.
# @RELATION: [DEPENDS_ON] ->[ConfigManager]
def _require_execution_flag(config_manager=Depends(get_config_manager)) -> bool:
@@ -322,7 +322,7 @@ def _get_repository(db: Session = Depends(get_db)) -> DatasetReviewSessionReposi
# [DEF:_get_orchestrator:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Build orchestrator dependency for session lifecycle actions.
# @RELATION: [DEPENDS_ON] ->[DatasetReviewOrchestrator]
def _get_orchestrator(
@@ -339,7 +339,7 @@ def _get_orchestrator(
# [DEF:_get_clarification_engine:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Build clarification engine dependency for one-question-at-a-time guided clarification mutations.
# @RELATION: [DEPENDS_ON] ->[ClarificationEngine]
def _get_clarification_engine(
@@ -350,7 +350,7 @@ def _get_clarification_engine(
# [DEF:_serialize_session_summary:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Map SQLAlchemy session aggregate root into stable API summary DTO.
# @RELATION: [DEPENDS_ON] ->[SessionSummary]
def _serialize_session_summary(session: DatasetReviewSession) -> SessionSummary:
@@ -359,7 +359,7 @@ def _serialize_session_summary(session: DatasetReviewSession) -> SessionSummary:
# [DEF:_serialize_session_detail:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Map SQLAlchemy session aggregate root into stable API detail DTO.
# @RELATION: [DEPENDS_ON] ->[SessionDetail]
def _serialize_session_detail(session: DatasetReviewSession) -> SessionDetail:
@@ -368,7 +368,7 @@ def _serialize_session_detail(session: DatasetReviewSession) -> SessionDetail:
# [DEF:_serialize_semantic_field:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Map one semantic field aggregate into stable field-level DTO output.
# @RELATION: [DEPENDS_ON] ->[SemanticFieldEntryDto]
def _serialize_semantic_field(field: SemanticFieldEntry) -> SemanticFieldEntryDto:
@@ -377,7 +377,7 @@ def _serialize_semantic_field(field: SemanticFieldEntry) -> SemanticFieldEntryDt
# [DEF:_serialize_clarification_question_payload:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Convert clarification engine payload into API DTO aligned with the clarification contract.
# @RELATION: [DEPENDS_ON] ->[ClarificationQuestionDto]
def _serialize_clarification_question_payload(
@@ -405,7 +405,7 @@ def _serialize_clarification_question_payload(
# [DEF:_serialize_clarification_state:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Convert clarification engine state into stable API response payload.
# @RELATION: [DEPENDS_ON] ->[ClarificationStateResponse]
def _serialize_clarification_state(
@@ -473,7 +473,7 @@ def _require_owner_mutation_scope(
# [DEF:_record_session_event:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Persist one explicit audit event for an owned dataset-review mutation endpoint.
# @RELATION: [CALLS] ->[SessionEventLogger.log_for_session]
def _record_session_event(
@@ -534,7 +534,7 @@ def _get_owned_field_or_404(
# [DEF:_get_latest_clarification_session_or_404:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Resolve the latest clarification aggregate for one session or raise when clarification is unavailable.
# @RELATION: [DEPENDS_ON] ->[ClarificationSession]
def _get_latest_clarification_session_or_404(
@@ -565,7 +565,7 @@ def _map_candidate_provenance(candidate: SemanticCandidate) -> FieldProvenance:
# [DEF:_resolve_candidate_source_version:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Resolve the semantic source version for one accepted candidate from the loaded session aggregate.
# @RELATION: [DEPENDS_ON] ->[SemanticFieldEntry]
# @RELATION: [DEPENDS_ON] ->[SemanticSource]
@@ -653,7 +653,7 @@ def _update_semantic_field_state(
# [DEF:_serialize_execution_mapping:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Map one persisted execution mapping into stable API DTO output.
# @RELATION: [DEPENDS_ON] ->[ExecutionMappingDto]
def _serialize_execution_mapping(mapping: ExecutionMapping) -> ExecutionMappingDto:
@@ -662,7 +662,7 @@ def _serialize_execution_mapping(mapping: ExecutionMapping) -> ExecutionMappingD
# [DEF:_serialize_run_context:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Map one persisted launch run context into stable API DTO output for SQL Lab handoff confirmation.
# @RELATION: [DEPENDS_ON] ->[DatasetRunContextDto]
def _serialize_run_context(run_context) -> DatasetRunContextDto:
@@ -671,7 +671,7 @@ def _serialize_run_context(run_context) -> DatasetRunContextDto:
# [DEF:_build_sql_lab_redirect_url:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Build a stable SQL Lab redirect URL from the configured Superset environment and persisted run context reference.
# @RELATION: [DEPENDS_ON] ->[DatasetRunContextDto]
def _build_sql_lab_redirect_url(environment_url: str, sql_lab_session_ref: str) -> str:
@@ -692,7 +692,7 @@ def _build_sql_lab_redirect_url(environment_url: str, sql_lab_session_ref: str)
# [DEF:_build_documentation_export:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Produce session documentation export content from current persisted review state.
# @RELATION: [DEPENDS_ON] ->[DatasetReviewSession]
def _build_documentation_export(session: DatasetReviewSession, export_format: ArtifactFormat) -> Dict[str, Any]:
@@ -747,7 +747,7 @@ def _build_documentation_export(session: DatasetReviewSession, export_format: Ar
# [DEF:_build_validation_export:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Produce validation-focused export content from persisted findings and readiness state.
# @RELATION: [DEPENDS_ON] ->[DatasetReviewSession]
def _build_validation_export(session: DatasetReviewSession, export_format: ArtifactFormat) -> Dict[str, Any]:

View File

@@ -1,4 +1,4 @@
# [DEF:backend.src.api.routes.datasets:Module]
# [DEF:DatasetsApi:Module]
#
# @COMPLEXITY: 3
# @SEMANTICS: api, datasets, resources, hub
@@ -423,4 +423,4 @@ async def get_dataset_detail(
raise HTTPException(status_code=503, detail=f"Failed to fetch dataset detail: {str(e)}")
# [/DEF:get_dataset_detail:Function]
# [/DEF:backend.src.api.routes.datasets:Module]
# [/DEF:DatasetsApi:Module]

View File

@@ -1,4 +1,4 @@
# [DEF:backend.src.api.routes.environments:Module]
# [DEF:EnvironmentsApi:Module]
#
# @COMPLEXITY: 3
# @SEMANTICS: api, environments, superset, databases
@@ -156,4 +156,4 @@ async def get_environment_databases(
raise HTTPException(status_code=500, detail=f"Failed to fetch databases: {str(e)}")
# [/DEF:get_environment_databases:Function]
# [/DEF:backend.src.api.routes.environments:Module]
# [/DEF:EnvironmentsApi:Module]

View File

@@ -1,6 +1,6 @@
# [DEF:backend.src.api.routes.git_schemas:Module]
# [DEF:GitSchemas:Module]
#
# @COMPLEXITY: 3
# @COMPLEXITY: 1
# @SEMANTICS: git, schemas, pydantic, api, contracts
# @PURPOSE: Defines Pydantic models for the Git integration API layer.
# @LAYER: API
@@ -290,4 +290,4 @@ class PromoteResponse(BaseModel):
policy_violation: bool = False
# [/DEF:PromoteResponse:Class]
# [/DEF:backend.src.api.routes.git_schemas:Module]
# [/DEF:GitSchemas:Module]

View File

@@ -1,5 +1,5 @@
# [DEF:backend/src/api/routes/llm.py:Module]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @SEMANTICS: api, routes, llm
# @PURPOSE: API routes for LLM provider configuration and management.
# @LAYER: UI (API)

View File

@@ -1,4 +1,4 @@
# [DEF:backend.src.api.routes.mappings:Module]
# [DEF:MappingsApi:Module]
#
# @COMPLEXITY: 3
# @SEMANTICS: api, mappings, database, fuzzy-matching
@@ -127,4 +127,4 @@ async def suggest_mappings_api(
raise HTTPException(status_code=500, detail=str(e))
# [/DEF:suggest_mappings_api:Function]
# [/DEF:backend.src.api.routes.mappings:Module]
# [/DEF:MappingsApi:Module]

View File

@@ -1,4 +1,4 @@
# [DEF:backend.src.api.routes.profile:Module]
# [DEF:ProfileApiModule:Module]
#
# @COMPLEXITY: 5
# @SEMANTICS: api, profile, preferences, self-service, account-lookup
@@ -47,6 +47,7 @@ router = APIRouter(prefix="/api/profile", tags=["profile"])
# [DEF:_get_profile_service:Function]
# @RELATION: CALLS -> ProfileService
# @PURPOSE: Build profile service for current request scope.
# @PRE: db session and config manager are available.
# @POST: Returns a ready ProfileService instance.
@@ -60,6 +61,7 @@ def _get_profile_service(db: Session, config_manager, plugin_loader=None) -> Pro
# [DEF:get_preferences:Function]
# @RELATION: CALLS -> ProfileService
# @PURPOSE: Get authenticated user's dashboard filter preference.
# @PRE: Valid JWT and authenticated user context.
# @POST: Returns preference payload for current user only.
@@ -78,6 +80,7 @@ async def get_preferences(
# [DEF:update_preferences:Function]
# @RELATION: CALLS -> ProfileService
# @PURPOSE: Update authenticated user's dashboard filter preference.
# @PRE: Valid JWT and valid request payload.
# @POST: Persists normalized preference for current user or raises validation/authorization errors.
@@ -104,6 +107,7 @@ async def update_preferences(
# [DEF:lookup_superset_accounts:Function]
# @RELATION: CALLS -> ProfileService
# @PURPOSE: Lookup Superset account candidates in selected environment.
# @PRE: Valid JWT, authenticated context, and environment_id query parameter.
# @POST: Returns success or degraded lookup payload with stable shape.
@@ -144,4 +148,4 @@ async def lookup_superset_accounts(
raise HTTPException(status_code=404, detail=str(exc)) from exc
# [/DEF:lookup_superset_accounts:Function]
# [/DEF:backend.src.api.routes.profile:Module]
# [/DEF:ProfileApiModule:Module]

View File

@@ -64,7 +64,7 @@ def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
# [DEF:list_reports:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Return paginated unified reports list.
# @PRE: authenticated/authorized request and validated query params.
# @POST: returns {items,total,page,page_size,has_next,applied_filters}.
@@ -131,7 +131,7 @@ async def list_reports(
# [DEF:get_report_detail:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Return one normalized report detail with diagnostics and next actions.
# @PRE: authenticated/authorized request and existing report_id.
# @POST: returns normalized detail envelope or 404 when report is not found.

View File

@@ -1,6 +1,6 @@
# [DEF:SettingsRouter:Module]
#
# @COMPLEXITY: 3
# @COMPLEXITY: 4
# @SEMANTICS: settings, api, router, fastapi
# @PURPOSE: Provides API endpoints for managing application settings and Superset environments.
# @LAYER: UI (API)
@@ -23,11 +23,16 @@ from ...core.superset_client import SupersetClient
from ...services.llm_prompt_templates import normalize_llm_settings
from ...models.llm import ValidationPolicy
from ...models.config import AppConfigRecord
from ...schemas.settings import ValidationPolicyCreate, ValidationPolicyUpdate, ValidationPolicyResponse
from ...schemas.settings import (
ValidationPolicyCreate,
ValidationPolicyUpdate,
ValidationPolicyResponse,
)
from ...core.database import get_db
from sqlalchemy.orm import Session
# [/SECTION]
# [DEF:LoggingConfigResponse:Class]
# @COMPLEXITY: 1
# @PURPOSE: Response model for logging configuration with current task log level.
@@ -36,6 +41,8 @@ class LoggingConfigResponse(BaseModel):
level: str
task_log_level: str
enable_belief_state: bool
# [/DEF:LoggingConfigResponse:Class]
router = APIRouter()
@@ -49,13 +56,15 @@ router = APIRouter()
def _normalize_superset_env_url(raw_url: str) -> str:
normalized = str(raw_url or "").strip().rstrip("/")
if normalized.lower().endswith("/api/v1"):
normalized = normalized[:-len("/api/v1")]
normalized = normalized[: -len("/api/v1")]
return normalized.rstrip("/")
# [/DEF:_normalize_superset_env_url:Function]
# [DEF:_validate_superset_connection_fast:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Run lightweight Superset connectivity validation without full pagination scan.
# @PRE: env contains valid URL and credentials.
# @POST: Raises on auth/API failures; returns None on success.
@@ -71,10 +80,13 @@ def _validate_superset_connection_fast(env: Environment) -> None:
"columns": ["id"],
}
)
# [/DEF:_validate_superset_connection_fast:Function]
# [DEF:get_settings:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Retrieves all application settings.
# @PRE: Config manager is available.
# @POST: Returns masked AppConfig.
@@ -82,7 +94,7 @@ def _validate_superset_connection_fast(env: Environment) -> None:
@router.get("", response_model=AppConfig)
async def get_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
_=Depends(has_permission("admin:settings", "READ")),
):
with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings")
@@ -93,10 +105,13 @@ async def get_settings(
if env.password:
env.password = "********"
return config
# [/DEF:get_settings:Function]
# [DEF:update_global_settings:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Updates global application settings.
# @PRE: New settings are provided.
# @POST: Global settings are updated.
@@ -106,30 +121,36 @@ async def get_settings(
async def update_global_settings(
settings: GlobalSettings,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("update_global_settings"):
logger.info("[update_global_settings][Entry] Updating global settings")
config_manager.update_global_settings(settings)
return settings
# [/DEF:update_global_settings:Function]
# [DEF:get_storage_settings:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Retrieves storage-specific settings.
# @RETURN: StorageConfig - The storage configuration.
@router.get("/storage", response_model=StorageConfig)
async def get_storage_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
_=Depends(has_permission("admin:settings", "READ")),
):
with belief_scope("get_storage_settings"):
return config_manager.get_config().settings.storage
# [/DEF:get_storage_settings:Function]
# [DEF:update_storage_settings:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Updates storage-specific settings.
# @PARAM: storage (StorageConfig) - The new storage settings.
# @POST: Storage settings are updated and saved.
@@ -138,21 +159,24 @@ async def get_storage_settings(
async def update_storage_settings(
storage: StorageConfig,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("update_storage_settings"):
is_valid, message = config_manager.validate_path(storage.root_path)
if not is_valid:
raise HTTPException(status_code=400, detail=message)
settings = config_manager.get_config().settings
settings.storage = storage
config_manager.update_global_settings(settings)
return config_manager.get_config().settings.storage
# [/DEF:update_storage_settings:Function]
# [DEF:get_environments:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Lists all configured Superset environments.
# @PRE: Config manager is available.
# @POST: Returns list of environments.
@@ -160,7 +184,7 @@ async def update_storage_settings(
@router.get("/environments", response_model=List[Environment])
async def get_environments(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
_=Depends(has_permission("admin:settings", "READ")),
):
with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments")
@@ -169,10 +193,13 @@ async def get_environments(
env.copy(update={"url": _normalize_superset_env_url(env.url)})
for env in environments
]
# [/DEF:get_environments:Function]
# [DEF:add_environment:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Adds a new Superset environment.
# @PRE: Environment data is valid and reachable.
# @POST: Environment is added to config.
@@ -182,25 +209,32 @@ async def get_environments(
async def add_environment(
env: Environment,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
# Validate connection before adding (fast path)
try:
_validate_superset_connection_fast(env)
except Exception as e:
logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}")
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
logger.error(
f"[add_environment][Coherence:Failed] Connection validation failed: {e}"
)
raise HTTPException(
status_code=400, detail=f"Connection validation failed: {e}"
)
config_manager.add_environment(env)
return env
# [/DEF:add_environment:Function]
# [DEF:update_environment:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Updates an existing Superset environment.
# @PRE: ID and valid environment data are provided.
# @POST: Environment is updated in config.
@@ -211,17 +245,19 @@ async def add_environment(
async def update_environment(
id: str,
env: Environment,
config_manager: ConfigManager = Depends(get_config_manager)
config_manager: ConfigManager = Depends(get_config_manager),
):
with belief_scope("update_environment"):
logger.info(f"[update_environment][Entry] Updating environment {id}")
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
# If password is masked, we need the real one for validation
env_to_validate = env.copy(deep=True)
if env_to_validate.password == "********":
old_env = next((e for e in config_manager.get_environments() if e.id == id), None)
old_env = next(
(e for e in config_manager.get_environments() if e.id == id), None
)
if old_env:
env_to_validate.password = old_env.password
@@ -229,33 +265,42 @@ async def update_environment(
try:
_validate_superset_connection_fast(env_to_validate)
except Exception as e:
logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}")
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
logger.error(
f"[update_environment][Coherence:Failed] Connection validation failed: {e}"
)
raise HTTPException(
status_code=400, detail=f"Connection validation failed: {e}"
)
if config_manager.update_environment(id, env):
return env
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
# [/DEF:update_environment:Function]
# [DEF:delete_environment:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Deletes a Superset environment.
# @PRE: ID is provided.
# @POST: Environment is removed from config.
# @PARAM: id (str) - The ID of the environment to delete.
@router.delete("/environments/{id}")
async def delete_environment(
id: str,
config_manager: ConfigManager = Depends(get_config_manager)
id: str, config_manager: ConfigManager = Depends(get_config_manager)
):
with belief_scope("delete_environment"):
logger.info(f"[delete_environment][Entry] Deleting environment {id}")
config_manager.delete_environment(id)
return {"message": f"Environment {id} deleted"}
# [/DEF:delete_environment:Function]
# [DEF:test_environment_connection:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Tests the connection to a Superset environment.
# @PRE: ID is provided.
# @POST: Returns success or error status.
@@ -263,29 +308,35 @@ async def delete_environment(
# @RETURN: dict - Success message or error.
@router.post("/environments/{id}/test")
async def test_environment_connection(
id: str,
config_manager: ConfigManager = Depends(get_config_manager)
id: str, config_manager: ConfigManager = Depends(get_config_manager)
):
with belief_scope("test_environment_connection"):
logger.info(f"[test_environment_connection][Entry] Testing environment {id}")
# Find environment
env = next((e for e in config_manager.get_environments() if e.id == id), None)
if not env:
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
try:
_validate_superset_connection_fast(env)
logger.info(f"[test_environment_connection][Coherence:OK] Connection successful for {id}")
logger.info(
f"[test_environment_connection][Coherence:OK] Connection successful for {id}"
)
return {"status": "success", "message": "Connection successful"}
except Exception as e:
logger.error(f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}")
logger.error(
f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}"
)
return {"status": "error", "message": str(e)}
# [/DEF:test_environment_connection:Function]
# [DEF:get_logging_config:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Retrieves current logging configuration.
# @PRE: Config manager is available.
# @POST: Returns logging configuration.
@@ -293,19 +344,22 @@ async def test_environment_connection(
@router.get("/logging", response_model=LoggingConfigResponse)
async def get_logging_config(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
_=Depends(has_permission("admin:settings", "READ")),
):
with belief_scope("get_logging_config"):
logging_config = config_manager.get_config().settings.logging
return LoggingConfigResponse(
level=logging_config.level,
task_log_level=logging_config.task_log_level,
enable_belief_state=logging_config.enable_belief_state
enable_belief_state=logging_config.enable_belief_state,
)
# [/DEF:get_logging_config:Function]
# [DEF:update_logging_config:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Updates logging configuration.
# @PRE: New logging config is provided.
# @POST: Logging configuration is updated and saved.
@@ -315,23 +369,28 @@ async def get_logging_config(
async def update_logging_config(
config: LoggingConfig,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("update_logging_config"):
logger.info(f"[update_logging_config][Entry] Updating logging config: level={config.level}, task_log_level={config.task_log_level}")
logger.info(
f"[update_logging_config][Entry] Updating logging config: level={config.level}, task_log_level={config.task_log_level}"
)
# Get current settings and update logging config
settings = config_manager.get_config().settings
settings.logging = config
config_manager.update_global_settings(settings)
return LoggingConfigResponse(
level=config.level,
task_log_level=config.task_log_level,
enable_belief_state=config.enable_belief_state
enable_belief_state=config.enable_belief_state,
)
# [/DEF:update_logging_config:Function]
# [DEF:ConsolidatedSettingsResponse:Class]
# @COMPLEXITY: 1
# @PURPOSE: Response model for consolidated application settings.
@@ -343,10 +402,13 @@ class ConsolidatedSettingsResponse(BaseModel):
logging: dict
storage: dict
notifications: dict = {}
# [/DEF:ConsolidatedSettingsResponse:Class]
# [DEF:get_consolidated_settings:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 4
# @PURPOSE: Retrieves all settings categories in a single call
# @PRE: Config manager is available.
# @POST: Returns all consolidated settings.
@@ -354,15 +416,18 @@ class ConsolidatedSettingsResponse(BaseModel):
@router.get("/consolidated", response_model=ConsolidatedSettingsResponse)
async def get_consolidated_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
_=Depends(has_permission("admin:settings", "READ")),
):
with belief_scope("get_consolidated_settings"):
logger.info("[get_consolidated_settings][Entry] Fetching all consolidated settings")
logger.info(
"[get_consolidated_settings][Entry] Fetching all consolidated settings"
)
config = config_manager.get_config()
from ...services.llm_provider import LLMProviderService
from ...core.database import SessionLocal
db = SessionLocal()
notifications_payload = {}
try:
@@ -376,13 +441,18 @@ async def get_consolidated_settings(
"base_url": p.base_url,
"api_key": "********",
"default_model": p.default_model,
"is_active": p.is_active
} for p in providers
"is_active": p.is_active,
}
for p in providers
]
config_record = db.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
config_record = (
db.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
)
if config_record and isinstance(config_record.payload, dict):
notifications_payload = config_record.payload.get("notifications", {}) or {}
notifications_payload = (
config_record.payload.get("notifications", {}) or {}
)
finally:
db.close()
@@ -395,12 +465,15 @@ async def get_consolidated_settings(
llm_providers=llm_providers_list,
logging=config.settings.logging.dict(),
storage=config.settings.storage.dict(),
notifications=notifications_payload
notifications=notifications_payload,
)
# [/DEF:get_consolidated_settings:Function]
# [DEF:update_consolidated_settings:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Bulk update application settings from the consolidated view.
# @PRE: User has admin permissions, config is valid.
# @POST: Settings are updated and saved via ConfigManager.
@@ -408,32 +481,34 @@ async def get_consolidated_settings(
async def update_consolidated_settings(
settings_patch: dict,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("update_consolidated_settings"):
logger.info("[update_consolidated_settings][Entry] Applying consolidated settings patch")
logger.info(
"[update_consolidated_settings][Entry] Applying consolidated settings patch"
)
current_config = config_manager.get_config()
current_settings = current_config.settings
# Update connections if provided
if "connections" in settings_patch:
current_settings.connections = settings_patch["connections"]
# Update LLM if provided
if "llm" in settings_patch:
current_settings.llm = normalize_llm_settings(settings_patch["llm"])
# Update Logging if provided
if "logging" in settings_patch:
current_settings.logging = LoggingConfig(**settings_patch["logging"])
# Update Storage if provided
if "storage" in settings_patch:
new_storage = StorageConfig(**settings_patch["storage"])
is_valid, message = config_manager.validate_path(new_storage.root_path)
if not is_valid:
raise HTTPException(status_code=400, detail=message)
raise HTTPException(status_code=400, detail=message)
current_settings.storage = new_storage
if "notifications" in settings_patch:
@@ -443,23 +518,28 @@ async def update_consolidated_settings(
config_manager.update_global_settings(current_settings)
return {"status": "success", "message": "Settings updated"}
# [/DEF:update_consolidated_settings:Function]
# [DEF:get_validation_policies:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Lists all validation policies.
# @RETURN: List[ValidationPolicyResponse] - List of policies.
@router.get("/automation/policies", response_model=List[ValidationPolicyResponse])
async def get_validation_policies(
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "READ"))
db: Session = Depends(get_db), _=Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_validation_policies"):
return db.query(ValidationPolicy).all()
# [/DEF:get_validation_policies:Function]
# [DEF:create_validation_policy:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Creates a new validation policy.
# @PARAM: policy (ValidationPolicyCreate) - The policy data.
# @RETURN: ValidationPolicyResponse - The created policy.
@@ -467,7 +547,7 @@ async def get_validation_policies(
async def create_validation_policy(
policy: ValidationPolicyCreate,
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("create_validation_policy"):
db_policy = ValidationPolicy(**policy.dict())
@@ -475,10 +555,13 @@ async def create_validation_policy(
db.commit()
db.refresh(db_policy)
return db_policy
# [/DEF:create_validation_policy:Function]
# [DEF:update_validation_policy:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Updates an existing validation policy.
# @PARAM: id (str) - The ID of the policy to update.
# @PARAM: policy (ValidationPolicyUpdate) - The updated policy data.
@@ -488,40 +571,45 @@ async def update_validation_policy(
id: str,
policy: ValidationPolicyUpdate,
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("update_validation_policy"):
db_policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == id).first()
if not db_policy:
raise HTTPException(status_code=404, detail="Policy not found")
update_data = policy.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_policy, key, value)
db.commit()
db.refresh(db_policy)
return db_policy
# [/DEF:update_validation_policy:Function]
# [DEF:delete_validation_policy:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Deletes a validation policy.
# @PARAM: id (str) - The ID of the policy to delete.
@router.delete("/automation/policies/{id}")
async def delete_validation_policy(
id: str,
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("delete_validation_policy"):
db_policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == id).first()
if not db_policy:
raise HTTPException(status_code=404, detail="Policy not found")
db.delete(db_policy)
db.commit()
return {"message": "Policy deleted"}
# [/DEF:delete_validation_policy:Function]
# [/DEF:SettingsRouter:Module]

View File

@@ -1,11 +1,11 @@
# [DEF:TasksRouter:Module]
# @COMPLEXITY: 4
# @COMPLEXITY: 3
# @SEMANTICS: api, router, tasks, create, list, get, logs
# @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
# @LAYER: UI (API)
# @RELATION: DEPENDS_ON -> [backend.src.core.task_manager.manager.TaskManager]
# @RELATION: DEPENDS_ON -> [backend.src.core.config_manager.ConfigManager]
# @RELATION: DEPENDS_ON -> [backend.src.services.llm_provider.LLMProviderService]
# @RELATION: DEPENDS_ON -> [TaskManager]
# @RELATION: DEPENDS_ON -> [ConfigManager]
# @RELATION: DEPENDS_ON -> [LLMProviderService]
# [SECTION: IMPORTS]
from typing import List, Dict, Any, Optional
@@ -107,7 +107,7 @@ async def create_task(
# [/DEF:create_task:Function]
# [DEF:list_tasks:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Retrieve a list of tasks with pagination and optional status filter.
# @PARAM: limit (int) - Maximum number of tasks to return.
# @PARAM: offset (int) - Number of tasks to skip.
@@ -147,7 +147,7 @@ async def list_tasks(
# [/DEF:list_tasks:Function]
# [DEF:get_task:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Retrieve the details of a specific task.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
@@ -213,7 +213,7 @@ async def get_task_logs(
# [/DEF:get_task_logs:Function]
# [DEF:get_task_log_stats:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Get statistics about logs for a task (counts by level and source).
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
@@ -249,7 +249,7 @@ async def get_task_log_stats(
# [/DEF:get_task_log_stats:Function]
# [DEF:get_task_log_sources:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Get unique sources for a task's logs.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
@@ -269,7 +269,7 @@ async def get_task_log_sources(
# [/DEF:get_task_log_sources:Function]
# [DEF:resolve_task:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Resolve a task that is awaiting mapping.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: request (ResolveTaskRequest) - The resolution parameters.
@@ -293,7 +293,7 @@ async def resolve_task(
# [/DEF:resolve_task:Function]
# [DEF:resume_task:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Resume a task that is awaiting input (e.g., passwords).
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: request (ResumeTaskRequest) - The input (passwords).
@@ -317,7 +317,7 @@ async def resume_task(
# [/DEF:resume_task:Function]
# [DEF:clear_tasks:Function]
# @COMPLEXITY: 3
# @COMPLEXITY: 2
# @PURPOSE: Clear tasks matching the status filter.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @PARAM: task_manager (TaskManager) - The task manager instance.