diff --git a/backend/src/api/auth.py b/backend/src/api/auth.py index 7483352d..668bb48e 100755 --- a/backend/src/api/auth.py +++ b/backend/src/api/auth.py @@ -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] \ No newline at end of file +# [/DEF:AuthApi:Module] diff --git a/backend/src/api/routes/__tests__/test_assistant_api.py b/backend/src/api/routes/__tests__/test_assistant_api.py index 6483bad0..612b3553 100644 --- a/backend/src/api/routes/__tests__/test_assistant_api.py +++ b/backend/src/api/routes/__tests__/test_assistant_api.py @@ -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) diff --git a/backend/src/api/routes/__tests__/test_assistant_authz.py b/backend/src/api/routes/__tests__/test_assistant_authz.py index bbb299fb..6ec4f916 100644 --- a/backend/src/api/routes/__tests__/test_assistant_authz.py +++ b/backend/src/api/routes/__tests__/test_assistant_authz.py @@ -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] diff --git a/backend/src/api/routes/__tests__/test_clean_release_api.py b/backend/src/api/routes/__tests__/test_clean_release_api.py index 25a6bade..b56dba56 100644 --- a/backend/src/api/routes/__tests__/test_clean_release_api.py +++ b/backend/src/api/routes/__tests__/test_clean_release_api.py @@ -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() \ No newline at end of file + app.dependency_overrides.clear() + + +# [/DEF:test_prepare_candidate_api_success:Function] +# [/DEF:TestCleanReleaseApi:Module] diff --git a/backend/src/api/routes/__tests__/test_clean_release_legacy_compat.py b/backend/src/api/routes/__tests__/test_clean_release_legacy_compat.py index 42dc09ea..c9be0d1e 100644 --- a/backend/src/api/routes/__tests__/test_clean_release_legacy_compat.py +++ b/backend/src/api/routes/__tests__/test_clean_release_legacy_compat.py @@ -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] \ No newline at end of file +# [/DEF:TestCleanReleaseLegacyCompat:Module]# [/DEF:test_legacy_checks_endpoints_still_available:Function] diff --git a/backend/src/api/routes/__tests__/test_clean_release_source_policy.py b/backend/src/api/routes/__tests__/test_clean_release_source_policy.py index c6c7818e..a0b3f0ee 100644 --- a/backend/src/api/routes/__tests__/test_clean_release_source_policy.py +++ b/backend/src/api/routes/__tests__/test_clean_release_source_policy.py @@ -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] \ No newline at end of file +# [/DEF:TestCleanReleaseSourcePolicy:Module]# [/DEF:test_prepare_candidate_blocks_external_source:Function] diff --git a/backend/src/api/routes/__tests__/test_clean_release_v2_api.py b/backend/src/api/routes/__tests__/test_clean_release_v2_api.py index 21118297..414c8af7 100644 --- a/backend/src/api/routes/__tests__/test_clean_release_v2_api.py +++ b/backend/src/api/routes/__tests__/test_clean_release_v2_api.py @@ -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] \ No newline at end of file + +# [/DEF:test_manifest_build_contract:Function] +# [/DEF:CleanReleaseV2ApiTests:Module] diff --git a/backend/src/api/routes/__tests__/test_clean_release_v2_release_api.py b/backend/src/api/routes/__tests__/test_clean_release_v2_release_api.py index cb641646..38141aa6 100644 --- a/backend/src/api/routes/__tests__/test_clean_release_v2_release_api.py +++ b/backend/src/api/routes/__tests__/test_clean_release_v2_release_api.py @@ -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] \ No newline at end of file +# [/DEF:CleanReleaseV2ReleaseApiTests:Module]# [/DEF:test_release_reject_contract:Function] diff --git a/backend/src/api/routes/__tests__/test_connections_routes.py b/backend/src/api/routes/__tests__/test_connections_routes.py index 7a7dbe2f..a65fd962 100644 --- a/backend/src/api/routes/__tests__/test_connections_routes.py +++ b/backend/src/api/routes/__tests__/test_connections_routes.py @@ -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] diff --git a/backend/src/api/routes/__tests__/test_dashboards.py b/backend/src/api/routes/__tests__/test_dashboards.py index 32c3378a..6b77cce0 100644 --- a/backend/src/api/routes/__tests__/test_dashboards.py +++ b/backend/src/api/routes/__tests__/test_dashboards.py @@ -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] diff --git a/backend/src/api/routes/__tests__/test_dataset_review_api.py b/backend/src/api/routes/__tests__/test_dataset_review_api.py index 7bc96b83..d179b3b4 100644 --- a/backend/src/api/routes/__tests__/test_dataset_review_api.py +++ b/backend/src/api/routes/__tests__/test_dataset_review_api.py @@ -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() diff --git a/backend/src/api/routes/__tests__/test_datasets.py b/backend/src/api/routes/__tests__/test_datasets.py index 9f933816..ae5bec79 100644 --- a/backend/src/api/routes/__tests__/test_datasets.py +++ b/backend/src/api/routes/__tests__/test_datasets.py @@ -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() diff --git a/backend/src/api/routes/__tests__/test_git_api.py b/backend/src/api/routes/__tests__/test_git_api.py index e8ff79f3..4233c768 100644 --- a/backend/src/api/routes/__tests__/test_git_api.py +++ b/backend/src/api/routes/__tests__/test_git_api.py @@ -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(): diff --git a/backend/src/api/routes/__tests__/test_git_status_route.py b/backend/src/api/routes/__tests__/test_git_status_route.py index acd61cbd..867fd706 100644 --- a/backend/src/api/routes/__tests__/test_git_status_route.py +++ b/backend/src/api/routes/__tests__/test_git_status_route.py @@ -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] diff --git a/backend/src/api/routes/__tests__/test_migration_routes.py b/backend/src/api/routes/__tests__/test_migration_routes.py index 6cb619d8..f22ab00d 100644 --- a/backend/src/api/routes/__tests__/test_migration_routes.py +++ b/backend/src/api/routes/__tests__/test_migration_routes.py @@ -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] diff --git a/backend/src/api/routes/__tests__/test_profile_api.py b/backend/src/api/routes/__tests__/test_profile_api.py index 63d72561..4a3500de 100644 --- a/backend/src/api/routes/__tests__/test_profile_api.py +++ b/backend/src/api/routes/__tests__/test_profile_api.py @@ -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] diff --git a/backend/src/api/routes/__tests__/test_reports_api.py b/backend/src/api/routes/__tests__/test_reports_api.py index 3c6e3b82..8d72d073 100644 --- a/backend/src/api/routes/__tests__/test_reports_api.py +++ b/backend/src/api/routes/__tests__/test_reports_api.py @@ -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] \ No newline at end of file +# [/DEF:test_get_reports_invalid_filter_returns_400:Function] +# [/DEF:TestReportsApi:Module] diff --git a/backend/src/api/routes/__tests__/test_reports_detail_api.py b/backend/src/api/routes/__tests__/test_reports_detail_api.py index bb78d7b3..66884f04 100644 --- a/backend/src/api/routes/__tests__/test_reports_detail_api.py +++ b/backend/src/api/routes/__tests__/test_reports_detail_api.py @@ -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] \ No newline at end of file +# [/DEF:test_get_report_detail_not_found:Function] +# [/DEF:TestReportsDetailApi:Module] diff --git a/backend/src/api/routes/__tests__/test_reports_openapi_conformance.py b/backend/src/api/routes/__tests__/test_reports_openapi_conformance.py index 14d30982..5014f190 100644 --- a/backend/src/api/routes/__tests__/test_reports_openapi_conformance.py +++ b/backend/src/api/routes/__tests__/test_reports_openapi_conformance.py @@ -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] \ No newline at end of file +# [/DEF:test_reports_detail_openapi_required_keys:Function] +# [/DEF:TestReportsOpenapiConformance:Module] diff --git a/backend/src/api/routes/__tests__/test_tasks_logs.py b/backend/src/api/routes/__tests__/test_tasks_logs.py index 476bcb81..5eed78c6 100644 --- a/backend/src/api/routes/__tests__/test_tasks_logs.py +++ b/backend/src/api/routes/__tests__/test_tasks_logs.py @@ -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] diff --git a/backend/src/api/routes/admin.py b/backend/src/api/routes/admin.py index c8a1fa34..eb70d2c0 100644 --- a/backend/src/api/routes/admin.py +++ b/backend/src/api/routes/admin.py @@ -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( diff --git a/backend/src/api/routes/clean_release.py b/backend/src/api/routes/clean_release.py index 78c43093..e6cba53e 100644 --- a/backend/src/api/routes/clean_release.py +++ b/backend/src/api/routes/clean_release.py @@ -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] \ No newline at end of file +# [/DEF:backend.src.api.routes.clean_release:Module] diff --git a/backend/src/api/routes/clean_release_v2.py b/backend/src/api/routes/clean_release_v2.py index d10409d8..8b74fe05 100644 --- a/backend/src/api/routes/clean_release_v2.py +++ b/backend/src/api/routes/clean_release_v2.py @@ -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] \ No newline at end of file +# [/DEF:CleanReleaseV2Api:Module] diff --git a/backend/src/api/routes/dataset_review.py b/backend/src/api/routes/dataset_review.py index e5e051b6..29752d42 100644 --- a/backend/src/api/routes/dataset_review.py +++ b/backend/src/api/routes/dataset_review.py @@ -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]: diff --git a/backend/src/api/routes/datasets.py b/backend/src/api/routes/datasets.py index a34273ea..2dc14ba6 100644 --- a/backend/src/api/routes/datasets.py +++ b/backend/src/api/routes/datasets.py @@ -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] diff --git a/backend/src/api/routes/environments.py b/backend/src/api/routes/environments.py index fa45eec7..e9dc1965 100644 --- a/backend/src/api/routes/environments.py +++ b/backend/src/api/routes/environments.py @@ -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] diff --git a/backend/src/api/routes/git_schemas.py b/backend/src/api/routes/git_schemas.py index 3026d8f0..473c8865 100644 --- a/backend/src/api/routes/git_schemas.py +++ b/backend/src/api/routes/git_schemas.py @@ -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] diff --git a/backend/src/api/routes/llm.py b/backend/src/api/routes/llm.py index 8a92a978..31b7b305 100644 --- a/backend/src/api/routes/llm.py +++ b/backend/src/api/routes/llm.py @@ -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) diff --git a/backend/src/api/routes/mappings.py b/backend/src/api/routes/mappings.py index 81f9851d..0e1337ce 100644 --- a/backend/src/api/routes/mappings.py +++ b/backend/src/api/routes/mappings.py @@ -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] diff --git a/backend/src/api/routes/profile.py b/backend/src/api/routes/profile.py index bb0f9339..0a1e15fc 100644 --- a/backend/src/api/routes/profile.py +++ b/backend/src/api/routes/profile.py @@ -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] \ No newline at end of file +# [/DEF:ProfileApiModule:Module] \ No newline at end of file diff --git a/backend/src/api/routes/reports.py b/backend/src/api/routes/reports.py index 3988c31d..45a73c8d 100644 --- a/backend/src/api/routes/reports.py +++ b/backend/src/api/routes/reports.py @@ -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. diff --git a/backend/src/api/routes/settings.py b/backend/src/api/routes/settings.py index 2ea668f8..de1545c4 100755 --- a/backend/src/api/routes/settings.py +++ b/backend/src/api/routes/settings.py @@ -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] diff --git a/backend/src/api/routes/tasks.py b/backend/src/api/routes/tasks.py index 6fc8beef..be9df800 100755 --- a/backend/src/api/routes/tasks.py +++ b/backend/src/api/routes/tasks.py @@ -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. diff --git a/backend/src/core/__tests__/test_config_manager_compat.py b/backend/src/core/__tests__/test_config_manager_compat.py index 2a0bab2c..45c0299f 100644 --- a/backend/src/core/__tests__/test_config_manager_compat.py +++ b/backend/src/core/__tests__/test_config_manager_compat.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.core.__tests__.test_config_manager_compat:Module] +# [DEF:TestConfigManagerCompat:Module] # @COMPLEXITY: 3 # @SEMANTICS: config-manager, compatibility, payload, tests # @PURPOSE: Verifies ConfigManager compatibility wrappers preserve legacy payload sections. @@ -12,6 +12,7 @@ from src.core.config_models import AppConfig, Environment, GlobalSettings # [DEF:test_get_payload_preserves_legacy_sections:Function] +# @RELATION: BINDS_TO -> TestConfigManagerCompat # @PURPOSE: Ensure get_payload merges typed config into raw payload without dropping legacy sections. def test_get_payload_preserves_legacy_sections(): manager = ConfigManager.__new__(ConfigManager) @@ -26,6 +27,7 @@ def test_get_payload_preserves_legacy_sections(): # [DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function] +# @RELATION: BINDS_TO -> TestConfigManagerCompat # @PURPOSE: Ensure save_config accepts raw dict payload, refreshes typed config, and preserves extra sections. def test_save_config_accepts_raw_payload_and_keeps_extras(monkeypatch): manager = ConfigManager.__new__(ConfigManager) @@ -53,6 +55,7 @@ def test_save_config_accepts_raw_payload_and_keeps_extras(monkeypatch): # [DEF:test_save_config_syncs_environment_records_for_fk_backed_flows:Function] +# @RELATION: BINDS_TO -> TestConfigManagerCompat # @PURPOSE: Ensure saving config mirrors typed environments into relational records required by FK-backed session persistence. def test_save_config_syncs_environment_records_for_fk_backed_flows(): manager = ConfigManager.__new__(ConfigManager) @@ -108,6 +111,7 @@ def test_save_config_syncs_environment_records_for_fk_backed_flows(): # [DEF:test_load_config_syncs_environment_records_from_existing_db_payload:Function] +# @RELATION: BINDS_TO -> TestConfigManagerCompat # @PURPOSE: Ensure loading an existing DB-backed config also mirrors environment rows required by FK-backed runtime flows. def test_load_config_syncs_environment_records_from_existing_db_payload(monkeypatch): manager = ConfigManager.__new__(ConfigManager) @@ -161,4 +165,4 @@ def test_load_config_syncs_environment_records_from_existing_db_payload(monkeypa assert closed["value"] is True # [/DEF:test_load_config_syncs_environment_records_from_existing_db_payload:Function] -# [/DEF:backend.src.core.__tests__.test_config_manager_compat:Module] +# [/DEF:TestConfigManagerCompat:Module] diff --git a/backend/src/core/__tests__/test_native_filters.py b/backend/src/core/__tests__/test_native_filters.py index 0493fbf2..7d1b3b07 100644 --- a/backend/src/core/__tests__/test_native_filters.py +++ b/backend/src/core/__tests__/test_native_filters.py @@ -28,6 +28,7 @@ from src.models.filter_state import ( # [DEF:_make_environment:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests def _make_environment() -> Environment: return Environment( id="env-1", @@ -40,6 +41,7 @@ def _make_environment() -> Environment: # [DEF:test_extract_native_filters_from_permalink:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Extract native filters from a permalink key. def test_extract_native_filters_from_permalink(): client = SupersetClient(_make_environment()) @@ -86,6 +88,7 @@ def test_extract_native_filters_from_permalink(): # [DEF:test_extract_native_filters_from_permalink_direct_response:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Handle permalink response without result wrapper. def test_extract_native_filters_from_permalink_direct_response(): client = SupersetClient(_make_environment()) @@ -111,6 +114,7 @@ def test_extract_native_filters_from_permalink_direct_response(): # [DEF:test_extract_native_filters_from_key:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Extract native filters from a native_filters_key. def test_extract_native_filters_from_key(): client = SupersetClient(_make_environment()) @@ -141,6 +145,7 @@ def test_extract_native_filters_from_key(): # [DEF:test_extract_native_filters_from_key_single_filter:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Handle single filter format in native filter state. def test_extract_native_filters_from_key_single_filter(): client = SupersetClient(_make_environment()) @@ -165,6 +170,7 @@ def test_extract_native_filters_from_key_single_filter(): # [DEF:test_extract_native_filters_from_key_dict_value:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Handle filter state value as dict instead of JSON string. def test_extract_native_filters_from_key_dict_value(): client = SupersetClient(_make_environment()) @@ -189,6 +195,7 @@ def test_extract_native_filters_from_key_dict_value(): # [DEF:test_parse_dashboard_url_for_filters_permalink:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Parse permalink URL format. def test_parse_dashboard_url_for_filters_permalink(): client = SupersetClient(_make_environment()) @@ -206,6 +213,7 @@ def test_parse_dashboard_url_for_filters_permalink(): # [DEF:test_parse_dashboard_url_for_filters_native_key:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Parse native_filters_key URL format with numeric dashboard ID. def test_parse_dashboard_url_for_filters_native_key(): client = SupersetClient(_make_environment()) @@ -224,6 +232,7 @@ def test_parse_dashboard_url_for_filters_native_key(): # [DEF:test_parse_dashboard_url_for_filters_native_key_slug:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Parse native_filters_key URL format when dashboard reference is a slug, not a numeric ID. def test_parse_dashboard_url_for_filters_native_key_slug(): client = SupersetClient(_make_environment()) @@ -250,6 +259,7 @@ def test_parse_dashboard_url_for_filters_native_key_slug(): # [DEF:test_parse_dashboard_url_for_filters_native_key_slug_resolution_fails:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Gracefully handle slug resolution failure for native_filters_key URL. def test_parse_dashboard_url_for_filters_native_key_slug_resolution_fails(): client = SupersetClient(_make_environment()) @@ -265,6 +275,7 @@ def test_parse_dashboard_url_for_filters_native_key_slug_resolution_fails(): # [DEF:test_parse_dashboard_url_for_filters_native_filters_direct:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Parse native_filters direct query param. def test_parse_dashboard_url_for_filters_native_filters_direct(): client = SupersetClient(_make_environment()) @@ -280,6 +291,7 @@ def test_parse_dashboard_url_for_filters_native_filters_direct(): # [DEF:test_parse_dashboard_url_for_filters_no_filters:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Return empty result when no filters present. def test_parse_dashboard_url_for_filters_no_filters(): client = SupersetClient(_make_environment()) @@ -294,6 +306,7 @@ def test_parse_dashboard_url_for_filters_no_filters(): # [DEF:test_extra_form_data_merge:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Test ExtraFormDataMerge correctly merges dictionaries. def test_extra_form_data_merge(): merger = ExtraFormDataMerge() @@ -329,6 +342,7 @@ def test_extra_form_data_merge(): # [DEF:test_filter_state_model:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Test FilterState Pydantic model. def test_filter_state_model(): state = FilterState( @@ -344,6 +358,7 @@ def test_filter_state_model(): # [DEF:test_parsed_native_filters_model:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Test ParsedNativeFilters Pydantic model. def test_parsed_native_filters_model(): filters = ParsedNativeFilters( @@ -360,6 +375,7 @@ def test_parsed_native_filters_model(): # [DEF:test_parsed_native_filters_empty:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Test ParsedNativeFilters with no filters. def test_parsed_native_filters_empty(): filters = ParsedNativeFilters() @@ -370,6 +386,7 @@ def test_parsed_native_filters_empty(): # [DEF:test_native_filter_data_mask_model:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Test NativeFilterDataMask model. def test_native_filter_data_mask_model(): data_mask = NativeFilterDataMask( @@ -386,6 +403,7 @@ def test_native_filter_data_mask_model(): # [DEF:test_recover_imported_filters_reconciles_raw_native_filter_ids_to_metadata_names:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Reconcile raw native filter ids from state to canonical metadata filter names. def test_recover_imported_filters_reconciles_raw_native_filter_ids_to_metadata_names(): client = MagicMock() @@ -444,6 +462,7 @@ def test_recover_imported_filters_reconciles_raw_native_filter_ids_to_metadata_n # [DEF:test_recover_imported_filters_collapses_state_and_metadata_duplicates_into_one_canonical_filter:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Collapse raw-id state entries and metadata entries into one canonical filter. def test_recover_imported_filters_collapses_state_and_metadata_duplicates_into_one_canonical_filter(): client = MagicMock() @@ -499,6 +518,7 @@ def test_recover_imported_filters_collapses_state_and_metadata_duplicates_into_o # [DEF:test_recover_imported_filters_preserves_unmatched_raw_native_filter_ids:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Preserve unmatched raw native filter ids as fallback diagnostics when metadata mapping is unavailable. def test_recover_imported_filters_preserves_unmatched_raw_native_filter_ids(): client = MagicMock() @@ -550,6 +570,7 @@ def test_recover_imported_filters_preserves_unmatched_raw_native_filter_ids(): # [DEF:test_extract_imported_filters_preserves_clause_level_native_filter_payload_for_preview:Function] +# @RELATION: BINDS_TO -> NativeFilterExtractionTests # @PURPOSE: Recovered native filter state should preserve exact Superset clause payload and time extras for preview compilation. def test_extract_imported_filters_preserves_clause_level_native_filter_payload_for_preview(): extractor = SupersetContextExtractor(_make_environment(), client=MagicMock()) diff --git a/backend/src/core/__tests__/test_superset_preview_pipeline.py b/backend/src/core/__tests__/test_superset_preview_pipeline.py index 31bc916c..96a33417 100644 --- a/backend/src/core/__tests__/test_superset_preview_pipeline.py +++ b/backend/src/core/__tests__/test_superset_preview_pipeline.py @@ -19,6 +19,7 @@ from src.core.utils.network import APIClient, DashboardNotFoundError, SupersetAP # [DEF:_make_environment:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests def _make_environment() -> Environment: return Environment( id="env-1", @@ -33,6 +34,7 @@ def _make_environment() -> Environment: # [DEF:_make_requests_http_error:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests def _make_requests_http_error( status_code: int, url: str ) -> requests.exceptions.HTTPError: @@ -49,6 +51,7 @@ def _make_requests_http_error( # [DEF:_make_httpx_status_error:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests def _make_httpx_status_error(status_code: int, url: str) -> httpx.HTTPStatusError: request = httpx.Request("GET", url) response = httpx.Response( @@ -61,6 +64,7 @@ def _make_httpx_status_error(status_code: int, url: str) -> httpx.HTTPStatusErro # [DEF:test_compile_dataset_preview_prefers_legacy_explore_form_data_strategy:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests # @PURPOSE: Superset preview compilation should prefer the legacy form_data transport inferred from browser traffic before falling back to chart-data. def test_compile_dataset_preview_prefers_legacy_explore_form_data_strategy(): client = SupersetClient(_make_environment()) @@ -146,6 +150,7 @@ def test_compile_dataset_preview_prefers_legacy_explore_form_data_strategy(): # [DEF:test_compile_dataset_preview_falls_back_to_chart_data_after_legacy_failures:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests # @PURPOSE: Superset preview compilation should fall back to chart-data when legacy form_data strategies are rejected. def test_compile_dataset_preview_falls_back_to_chart_data_after_legacy_failures(): client = SupersetClient(_make_environment()) @@ -242,6 +247,7 @@ def test_compile_dataset_preview_falls_back_to_chart_data_after_legacy_failures( # [DEF:test_build_dataset_preview_query_context_places_recovered_filters_in_chart_style_form_data:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests # @PURPOSE: Preview query context should mirror chart-style filter transport so recovered native filters reach Superset compilation. def test_build_dataset_preview_query_context_places_recovered_filters_in_chart_style_form_data(): client = SupersetClient(_make_environment()) @@ -304,6 +310,7 @@ def test_build_dataset_preview_query_context_places_recovered_filters_in_chart_s # [DEF:test_build_dataset_preview_query_context_merges_dataset_template_params_and_preserves_user_values:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests # @PURPOSE: Preview query context should merge dataset template params for parity with real dataset definitions while preserving explicit session overrides. def test_build_dataset_preview_query_context_merges_dataset_template_params_and_preserves_user_values(): client = SupersetClient(_make_environment()) @@ -334,6 +341,7 @@ def test_build_dataset_preview_query_context_merges_dataset_template_params_and_ # [DEF:test_build_dataset_preview_query_context_preserves_time_range_from_native_filter_payload:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests # @PURPOSE: Preview query context should preserve time-range native filter extras even when dataset defaults differ. def test_build_dataset_preview_query_context_preserves_time_range_from_native_filter_payload(): client = SupersetClient(_make_environment()) @@ -372,6 +380,7 @@ def test_build_dataset_preview_query_context_preserves_time_range_from_native_fi # [DEF:test_build_dataset_preview_legacy_form_data_preserves_native_filter_clauses:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests # @PURPOSE: Legacy preview form_data should preserve recovered native filter clauses in browser-style fields without duplicating datasource for QueryObjectFactory. def test_build_dataset_preview_legacy_form_data_preserves_native_filter_clauses(): client = SupersetClient(_make_environment()) @@ -425,6 +434,7 @@ def test_build_dataset_preview_legacy_form_data_preserves_native_filter_clauses( # [DEF:test_sync_network_404_mapping_keeps_non_dashboard_endpoints_generic:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests # @PURPOSE: Sync network client should reserve dashboard-not-found translation for dashboard endpoints only. def test_sync_network_404_mapping_keeps_non_dashboard_endpoints_generic(): client = APIClient( @@ -448,6 +458,7 @@ def test_sync_network_404_mapping_keeps_non_dashboard_endpoints_generic(): # [DEF:test_sync_network_404_mapping_translates_dashboard_endpoints:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests # @PURPOSE: Sync network client should still translate dashboard endpoint 404 responses into dashboard-not-found errors. def test_sync_network_404_mapping_translates_dashboard_endpoints(): client = APIClient( @@ -470,6 +481,7 @@ def test_sync_network_404_mapping_translates_dashboard_endpoints(): # [DEF:test_async_network_404_mapping_keeps_non_dashboard_endpoints_generic:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests # @PURPOSE: Async network client should reserve dashboard-not-found translation for dashboard endpoints only. @pytest.mark.asyncio async def test_async_network_404_mapping_keeps_non_dashboard_endpoints_generic(): @@ -499,6 +511,7 @@ async def test_async_network_404_mapping_keeps_non_dashboard_endpoints_generic() # [DEF:test_async_network_404_mapping_translates_dashboard_endpoints:Function] +# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests # @PURPOSE: Async network client should still translate dashboard endpoint 404 responses into dashboard-not-found errors. @pytest.mark.asyncio async def test_async_network_404_mapping_translates_dashboard_endpoints(): diff --git a/backend/src/core/__tests__/test_superset_profile_lookup.py b/backend/src/core/__tests__/test_superset_profile_lookup.py index fb8f4d6e..8690f5e5 100644 --- a/backend/src/core/__tests__/test_superset_profile_lookup.py +++ b/backend/src/core/__tests__/test_superset_profile_lookup.py @@ -1,9 +1,9 @@ -# [DEF:backend.src.core.__tests__.test_superset_profile_lookup:Module] +# [DEF:TestSupersetProfileLookup:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, superset, profile, lookup, fallback, sorting # @PURPOSE: Verifies Superset profile lookup adapter payload normalization and fallback error precedence. # @LAYER: Domain -# @RELATION: TESTS -> backend.src.core.superset_profile_lookup # [SECTION: IMPORTS] import json @@ -23,7 +23,10 @@ from src.core.utils.network import AuthenticationError, SupersetAPIError # [DEF:_RecordingNetworkClient:Class] +# @RELATION: BINDS_TO -> TestSupersetProfileLookup +# @COMPLEXITY: 2 # @PURPOSE: Records request payloads and returns scripted responses for deterministic adapter tests. +# @INVARIANT: Each request consumes one scripted response in call order and persists call metadata. class _RecordingNetworkClient: # [DEF:__init__:Function] # @PURPOSE: Initializes scripted network responses. @@ -32,6 +35,7 @@ class _RecordingNetworkClient: def __init__(self, scripted_responses: List[Any]): self._scripted_responses = scripted_responses self.calls: List[Dict[str, Any]] = [] + # [/DEF:__init__:Function] # [DEF:request:Function] @@ -57,11 +61,15 @@ class _RecordingNetworkClient: if isinstance(response, Exception): raise response return response + # [/DEF:request:Function] + + # [/DEF:_RecordingNetworkClient:Class] # [DEF:test_get_users_page_sends_lowercase_order_direction:Function] +# @RELATION: BINDS_TO -> TestSupersetProfileLookup # @PURPOSE: Ensures adapter sends lowercase order_direction compatible with Superset rison schema. # @PRE: Adapter is initialized with recording network client. # @POST: First request query payload contains order_direction='asc' for asc sort. @@ -69,7 +77,9 @@ def test_get_users_page_sends_lowercase_order_direction(): client = _RecordingNetworkClient( scripted_responses=[{"result": [{"username": "admin"}], "count": 1}] ) - adapter = SupersetAccountLookupAdapter(network_client=client, environment_id="ss-dev") + adapter = SupersetAccountLookupAdapter( + network_client=client, environment_id="ss-dev" + ) adapter.get_users_page( search="admin", @@ -81,10 +91,13 @@ def test_get_users_page_sends_lowercase_order_direction(): sent_query = json.loads(client.calls[0]["params"]["q"]) assert sent_query["order_direction"] == "asc" + + # [/DEF:test_get_users_page_sends_lowercase_order_direction:Function] # [DEF:test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error:Function] +# @RELATION: BINDS_TO -> TestSupersetProfileLookup # @PURPOSE: Ensures fallback auth error does not mask primary schema/query failure. # @PRE: Primary endpoint fails with SupersetAPIError and fallback fails with AuthenticationError. # @POST: Raised exception remains primary SupersetAPIError (non-auth) to preserve root cause. @@ -95,17 +108,22 @@ def test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error( AuthenticationError(), ] ) - adapter = SupersetAccountLookupAdapter(network_client=client, environment_id="ss-dev") + adapter = SupersetAccountLookupAdapter( + network_client=client, environment_id="ss-dev" + ) with pytest.raises(SupersetAPIError) as exc_info: adapter.get_users_page(sort_order="asc") assert "API Error 400" in str(exc_info.value) assert not isinstance(exc_info.value, AuthenticationError) + + # [/DEF:test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error:Function] # [DEF:test_get_users_page_uses_fallback_endpoint_when_primary_fails:Function] +# @RELATION: BINDS_TO -> TestSupersetProfileLookup # @PURPOSE: Verifies adapter retries second users endpoint and succeeds when fallback is healthy. # @PRE: Primary endpoint fails; fallback returns valid users payload. # @POST: Result status is success and both endpoints were attempted in order. @@ -116,13 +134,20 @@ def test_get_users_page_uses_fallback_endpoint_when_primary_fails(): {"result": [{"username": "admin"}], "count": 1}, ] ) - adapter = SupersetAccountLookupAdapter(network_client=client, environment_id="ss-dev") + adapter = SupersetAccountLookupAdapter( + network_client=client, environment_id="ss-dev" + ) result = adapter.get_users_page() assert result["status"] == "success" - assert [call["endpoint"] for call in client.calls] == ["/security/users/", "/security/users"] + assert [call["endpoint"] for call in client.calls] == [ + "/security/users/", + "/security/users", + ] + + # [/DEF:test_get_users_page_uses_fallback_endpoint_when_primary_fails:Function] -# [/DEF:backend.src.core.__tests__.test_superset_profile_lookup:Module] \ No newline at end of file +# [/DEF:TestSupersetProfileLookup:Module] diff --git a/backend/src/core/__tests__/test_throttled_scheduler.py b/backend/src/core/__tests__/test_throttled_scheduler.py index ff0cf697..8a8e7142 100644 --- a/backend/src/core/__tests__/test_throttled_scheduler.py +++ b/backend/src/core/__tests__/test_throttled_scheduler.py @@ -3,9 +3,12 @@ from datetime import time, date, datetime, timedelta from src.core.scheduler import ThrottledSchedulerConfigurator # [DEF:test_throttled_scheduler:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @PURPOSE: Unit tests for ThrottledSchedulerConfigurator distribution logic. +# [DEF:test_calculate_schedule_even_distribution:Function] +# @RELATION: BINDS_TO -> test_throttled_scheduler def test_calculate_schedule_even_distribution(): """ @TEST_SCENARIO: 3 tasks in a 2-hour window should be spaced 1 hour apart. @@ -22,6 +25,10 @@ def test_calculate_schedule_even_distribution(): assert schedule[1] == datetime(2024, 1, 1, 2, 0) assert schedule[2] == datetime(2024, 1, 1, 3, 0) +# [/DEF:test_calculate_schedule_even_distribution:Function] + +# [DEF:test_calculate_schedule_midnight_crossing:Function] +# @RELATION: BINDS_TO -> test_throttled_scheduler def test_calculate_schedule_midnight_crossing(): """ @TEST_SCENARIO: Window from 23:00 to 01:00 (next day). @@ -38,6 +45,10 @@ def test_calculate_schedule_midnight_crossing(): assert schedule[1] == datetime(2024, 1, 2, 0, 0) assert schedule[2] == datetime(2024, 1, 2, 1, 0) +# [/DEF:test_calculate_schedule_midnight_crossing:Function] + +# [DEF:test_calculate_schedule_single_task:Function] +# @RELATION: BINDS_TO -> test_throttled_scheduler def test_calculate_schedule_single_task(): """ @TEST_SCENARIO: Single task should be scheduled at start time. @@ -52,6 +63,10 @@ def test_calculate_schedule_single_task(): assert len(schedule) == 1 assert schedule[0] == datetime(2024, 1, 1, 1, 0) +# [/DEF:test_calculate_schedule_single_task:Function] + +# [DEF:test_calculate_schedule_empty_list:Function] +# @RELATION: BINDS_TO -> test_throttled_scheduler def test_calculate_schedule_empty_list(): """ @TEST_SCENARIO: Empty dashboard list returns empty schedule. @@ -65,6 +80,10 @@ def test_calculate_schedule_empty_list(): assert schedule == [] +# [/DEF:test_calculate_schedule_empty_list:Function] + +# [DEF:test_calculate_schedule_zero_window:Function] +# @RELATION: BINDS_TO -> test_throttled_scheduler def test_calculate_schedule_zero_window(): """ @TEST_SCENARIO: Window start == end. All tasks at start time. @@ -80,6 +99,10 @@ def test_calculate_schedule_zero_window(): assert schedule[0] == datetime(2024, 1, 1, 1, 0) assert schedule[1] == datetime(2024, 1, 1, 1, 0) +# [/DEF:test_calculate_schedule_zero_window:Function] + +# [DEF:test_calculate_schedule_very_small_window:Function] +# @RELATION: BINDS_TO -> test_throttled_scheduler def test_calculate_schedule_very_small_window(): """ @TEST_SCENARIO: Window smaller than number of tasks (in seconds). @@ -96,4 +119,4 @@ def test_calculate_schedule_very_small_window(): assert schedule[1] == datetime(2024, 1, 1, 1, 0, 0, 500000) # 0.5s assert schedule[2] == datetime(2024, 1, 1, 1, 0, 1) -# [/DEF:test_throttled_scheduler:Module] \ No newline at end of file +# [/DEF:test_throttled_scheduler:Module]# [/DEF:test_calculate_schedule_very_small_window:Function] diff --git a/backend/src/core/async_superset_client.py b/backend/src/core/async_superset_client.py index f77d0c66..6ed3adc5 100644 --- a/backend/src/core/async_superset_client.py +++ b/backend/src/core/async_superset_client.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.core.async_superset_client:Module] +# [DEF:AsyncSupersetClientModule:Module] # # @COMPLEXITY: 5 # @SEMANTICS: superset, async, client, httpx, dashboards, datasets @@ -8,8 +8,8 @@ # @POST: Provides non-blocking API access to Superset resources. # @SIDE_EFFECT: Performs network I/O via httpx. # @DATA_CONTRACT: Input[Environment] -> Model[dashboard, chart, dataset] -# @RELATION: [DEPENDS_ON] ->[backend.src.core.superset_client] -# @RELATION: [DEPENDS_ON] ->[backend.src.core.utils.async_network.AsyncAPIClient] +# @RELATION: [DEPENDS_ON] ->[SupersetClientModule] +# @RELATION: [DEPENDS_ON] ->[AsyncAPIClient] # @INVARIANT: Async dashboard operations reuse shared auth cache and avoid sync requests in async routes. # [SECTION: IMPORTS] @@ -25,12 +25,12 @@ from .utils.async_network import AsyncAPIClient # [/SECTION] -# [DEF:backend.src.core.async_superset_client.AsyncSupersetClient:Class] +# [DEF:AsyncSupersetClient:Class] # @COMPLEXITY: 3 # @PURPOSE: Async sibling of SupersetClient for dashboard read paths. -# @RELATION: [INHERITS] ->[backend.src.core.superset_client.SupersetClient] -# @RELATION: [DEPENDS_ON] ->[backend.src.core.utils.async_network.AsyncAPIClient] -# @RELATION: [CALLS] ->[backend.src.core.utils.async_network.AsyncAPIClient.request] +# @RELATION: [INHERITS] ->[SupersetClient] +# @RELATION: [DEPENDS_ON] ->[AsyncAPIClient] +# @RELATION: [CALLS] ->[AsyncAPIClient.request] class AsyncSupersetClient(SupersetClient): # [DEF:AsyncSupersetClientInit:Function] # @COMPLEXITY: 3 @@ -67,11 +67,12 @@ class AsyncSupersetClient(SupersetClient): # [/DEF:AsyncSupersetClientClose:Function] - # [DEF:backend.src.core.async_superset_client.AsyncSupersetClient.get_dashboards_page_async:Function] + # [DEF:get_dashboards_page_async:Function] # @COMPLEXITY: 3 # @PURPOSE: Fetch one dashboards page asynchronously. # @POST: Returns total count and page result list. # @DATA_CONTRACT: Input[query: Optional[Dict]] -> Output[Tuple[int, List[Dict]]] + # @RELATION: [CALLS] -> [AsyncAPIClient.request] async def get_dashboards_page_async( self, query: Optional[Dict] = None ) -> Tuple[int, List[Dict]]: @@ -687,4 +688,4 @@ class AsyncSupersetClient(SupersetClient): # [/DEF:AsyncSupersetClient:Class] -# [/DEF:backend.src.core.async_superset_client:Module] +# [/DEF:AsyncSupersetClientModule:Module] diff --git a/backend/src/core/auth/__init__.py b/backend/src/core/auth/__init__.py index 038a3a80..29e2a055 100644 --- a/backend/src/core/auth/__init__.py +++ b/backend/src/core/auth/__init__.py @@ -1,3 +1,3 @@ -# [DEF:src.core.auth:Package] +# [DEF:AuthPackage:Package] # @PURPOSE: Authentication and authorization package root. -# [/DEF:src.core.auth:Package] +# [/DEF:AuthPackage:Package] diff --git a/backend/src/core/auth/__tests__/test_auth.py b/backend/src/core/auth/__tests__/test_auth.py index e9b83b88..3da432d0 100644 --- a/backend/src/core/auth/__tests__/test_auth.py +++ b/backend/src/core/auth/__tests__/test_auth.py @@ -2,7 +2,7 @@ # @COMPLEXITY: 3 # @PURPOSE: Unit tests for authentication module # @LAYER: Domain -# @RELATION: VERIFIES -> src.core.auth +# @RELATION: VERIFIES -> AuthPackage import sys from pathlib import Path @@ -14,6 +14,7 @@ import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from src.core.database import Base + # Import all models to ensure they are registered with Base before create_all - must import both auth and mapping to ensure Base knows about all tables from src.models import mapping, auth, task, report from src.models.auth import User, Role, Permission, ADGroupMapping @@ -24,7 +25,9 @@ from src.core.auth.security import verify_password, get_password_hash # Create in-memory SQLite database for testing SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" -engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) # Create all tables @@ -37,9 +40,9 @@ def db_session(): connection = engine.connect() transaction = connection.begin() session = TestingSessionLocal(bind=connection) - + yield session - + session.close() transaction.rollback() connection.close() @@ -55,18 +58,21 @@ def auth_repo(db_session): return AuthRepository(db_session) +# [DEF:test_create_user:Function] +# @PURPOSE: Verifies that a persisted user can be retrieved with intact credential hash. +# @RELATION: BINDS_TO -> test_auth def test_create_user(auth_repo): """Test user creation""" user = User( username="testuser", email="test@example.com", password_hash=get_password_hash("testpassword123"), - auth_source="LOCAL" + auth_source="LOCAL", ) - + auth_repo.db.add(user) auth_repo.db.commit() - + retrieved_user = auth_repo.get_user_by_username("testuser") assert retrieved_user is not None assert retrieved_user.username == "testuser" @@ -74,44 +80,56 @@ def test_create_user(auth_repo): assert verify_password("testpassword123", retrieved_user.password_hash) +# [/DEF:test_create_user:Function] + + +# [DEF:test_authenticate_user:Function] +# @PURPOSE: Validates authentication outcomes for valid, wrong-password, and unknown-user cases. +# @RELATION: BINDS_TO -> test_auth def test_authenticate_user(auth_service, auth_repo): """Test user authentication with valid and invalid credentials""" user = User( username="testuser", email="test@example.com", password_hash=get_password_hash("testpassword123"), - auth_source="LOCAL" + auth_source="LOCAL", ) - + auth_repo.db.add(user) auth_repo.db.commit() - + # Test valid credentials authenticated_user = auth_service.authenticate_user("testuser", "testpassword123") assert authenticated_user is not None assert authenticated_user.username == "testuser" - + # Test invalid password invalid_user = auth_service.authenticate_user("testuser", "wrongpassword") assert invalid_user is None - + # Test invalid username invalid_user = auth_service.authenticate_user("nonexistent", "testpassword123") assert invalid_user is None +# [/DEF:test_authenticate_user:Function] + + +# [DEF:test_create_session:Function] +# @PURPOSE: Ensures session creation returns bearer token payload fields. +# @RELATION: BINDS_TO -> test_auth def test_create_session(auth_service, auth_repo): """Test session token creation""" user = User( username="testuser", email="test@example.com", password_hash=get_password_hash("testpassword123"), - auth_source="LOCAL" + auth_source="LOCAL", ) - + auth_repo.db.add(user) auth_repo.db.commit() - + session = auth_service.create_session(user) assert "access_token" in session assert "token_type" in session @@ -119,26 +137,38 @@ def test_create_session(auth_service, auth_repo): assert len(session["access_token"]) > 0 +# [/DEF:test_create_session:Function] + + +# [DEF:test_role_permission_association:Function] +# @PURPOSE: Confirms role-permission many-to-many assignments persist and reload correctly. +# @RELATION: BINDS_TO -> test_auth def test_role_permission_association(auth_repo): """Test role and permission association""" role = Role(name="Admin", description="System administrator") perm1 = Permission(resource="admin:users", action="READ") perm2 = Permission(resource="admin:users", action="WRITE") - + role.permissions.extend([perm1, perm2]) - + auth_repo.db.add(role) auth_repo.db.commit() - + retrieved_role = auth_repo.get_role_by_name("Admin") assert retrieved_role is not None assert len(retrieved_role.permissions) == 2 - + permissions = [f"{p.resource}:{p.action}" for p in retrieved_role.permissions] assert "admin:users:READ" in permissions assert "admin:users:WRITE" in permissions +# [/DEF:test_role_permission_association:Function] + + +# [DEF:test_user_role_association:Function] +# @PURPOSE: Confirms user-role assignment persists and is queryable from repository reads. +# @RELATION: BINDS_TO -> test_auth def test_user_role_association(auth_repo): """Test user and role association""" role = Role(name="Admin", description="System administrator") @@ -146,45 +176,61 @@ def test_user_role_association(auth_repo): username="adminuser", email="admin@example.com", password_hash=get_password_hash("adminpass123"), - auth_source="LOCAL" + auth_source="LOCAL", ) - + user.roles.append(role) - + auth_repo.db.add(role) auth_repo.db.add(user) auth_repo.db.commit() - + retrieved_user = auth_repo.get_user_by_username("adminuser") assert retrieved_user is not None assert len(retrieved_user.roles) == 1 assert retrieved_user.roles[0].name == "Admin" +# [/DEF:test_user_role_association:Function] + + +# [DEF:test_ad_group_mapping:Function] +# @PURPOSE: Verifies AD group mapping rows persist and reference the expected role. +# @RELATION: BINDS_TO -> test_auth def test_ad_group_mapping(auth_repo): """Test AD group mapping""" role = Role(name="ADFS_Admin", description="ADFS administrators") - + auth_repo.db.add(role) auth_repo.db.commit() - + mapping = ADGroupMapping(ad_group="DOMAIN\\ADFS_Admins", role_id=role.id) - + auth_repo.db.add(mapping) auth_repo.db.commit() - - retrieved_mapping = auth_repo.db.query(ADGroupMapping).filter_by(ad_group="DOMAIN\\ADFS_Admins").first() + + retrieved_mapping = ( + auth_repo.db.query(ADGroupMapping) + .filter_by(ad_group="DOMAIN\\ADFS_Admins") + .first() + ) assert retrieved_mapping is not None assert retrieved_mapping.role_id == role.id +# [/DEF:test_ad_group_mapping:Function] + + +# [DEF:test_authenticate_user_updates_last_login:Function] +# @PURPOSE: Verifies successful authentication updates last_login audit field. +# @RELATION: BINDS_TO -> test_auth def test_authenticate_user_updates_last_login(auth_service, auth_repo): """@SIDE_EFFECT: authenticate_user updates last_login timestamp on success.""" user = User( username="loginuser", email="login@example.com", password_hash=get_password_hash("mypassword"), - auth_source="LOCAL" + auth_source="LOCAL", ) auth_repo.db.add(user) auth_repo.db.commit() @@ -196,6 +242,12 @@ def test_authenticate_user_updates_last_login(auth_service, auth_repo): assert authenticated.last_login is not None +# [/DEF:test_authenticate_user_updates_last_login:Function] + + +# [DEF:test_authenticate_inactive_user:Function] +# @PURPOSE: Verifies inactive accounts are rejected during password authentication. +# @RELATION: BINDS_TO -> test_auth def test_authenticate_inactive_user(auth_service, auth_repo): """@PRE: User with is_active=False should not authenticate.""" user = User( @@ -203,7 +255,7 @@ def test_authenticate_inactive_user(auth_service, auth_repo): email="inactive@example.com", password_hash=get_password_hash("testpass"), auth_source="LOCAL", - is_active=False + is_active=False, ) auth_repo.db.add(user) auth_repo.db.commit() @@ -212,12 +264,24 @@ def test_authenticate_inactive_user(auth_service, auth_repo): assert result is None +# [/DEF:test_authenticate_inactive_user:Function] + + +# [DEF:test_verify_password_empty_hash:Function] +# @PURPOSE: Verifies password verification safely rejects empty or null password hashes. +# @RELATION: BINDS_TO -> test_auth def test_verify_password_empty_hash(): """@PRE: verify_password with empty/None hash returns False.""" assert verify_password("anypassword", "") is False assert verify_password("anypassword", None) is False +# [/DEF:test_verify_password_empty_hash:Function] + + +# [DEF:test_provision_adfs_user_new:Function] +# @PURPOSE: Verifies JIT provisioning creates a new ADFS user and maps group-derived roles. +# @RELATION: BINDS_TO -> test_auth def test_provision_adfs_user_new(auth_service, auth_repo): """@POST: provision_adfs_user creates a new ADFS user with correct roles.""" # Set up a role and AD group mapping @@ -232,7 +296,7 @@ def test_provision_adfs_user_new(auth_service, auth_repo): user_info = { "upn": "newadfsuser@domain.com", "email": "newadfsuser@domain.com", - "groups": ["DOMAIN\\Viewers"] + "groups": ["DOMAIN\\Viewers"], } user = auth_service.provision_adfs_user(user_info) @@ -244,6 +308,12 @@ def test_provision_adfs_user_new(auth_service, auth_repo): assert user.roles[0].name == "ADFS_Viewer" +# [/DEF:test_provision_adfs_user_new:Function] + + +# [DEF:test_provision_adfs_user_existing:Function] +# @PURPOSE: Verifies JIT provisioning reuses existing ADFS user and refreshes role assignments. +# @RELATION: BINDS_TO -> test_auth def test_provision_adfs_user_existing(auth_service, auth_repo): """@POST: provision_adfs_user updates roles for existing user.""" # Create existing user @@ -251,7 +321,7 @@ def test_provision_adfs_user_existing(auth_service, auth_repo): username="existingadfs@domain.com", email="existingadfs@domain.com", auth_source="ADFS", - is_active=True + is_active=True, ) auth_repo.db.add(existing) auth_repo.db.commit() @@ -259,7 +329,7 @@ def test_provision_adfs_user_existing(auth_service, auth_repo): user_info = { "upn": "existingadfs@domain.com", "email": "existingadfs@domain.com", - "groups": [] + "groups": [], } user = auth_service.provision_adfs_user(user_info) @@ -269,3 +339,4 @@ def test_provision_adfs_user_existing(auth_service, auth_repo): # [/DEF:test_auth:Module] +# [/DEF:test_provision_adfs_user_existing:Function] diff --git a/backend/src/core/auth/config.py b/backend/src/core/auth/config.py index 2fd4a743..79cd193a 100644 --- a/backend/src/core/auth/config.py +++ b/backend/src/core/auth/config.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.core.auth.config:Module] +# [DEF:AuthConfigModule:Module] # # @SEMANTICS: auth, config, settings, jwt, adfs # @PURPOSE: Centralized configuration for authentication and authorization. @@ -16,6 +16,7 @@ from pydantic_settings import BaseSettings # @PURPOSE: Holds authentication-related settings. # @PRE: Environment variables may be provided via .env file. # @POST: Returns a configuration object with validated settings. +# @RELATION: INHERITS -> pydantic_settings.BaseSettings class AuthConfig(BaseSettings): # JWT Settings SECRET_KEY: str = Field(default="super-secret-key-change-in-production", env="AUTH_SECRET_KEY") @@ -41,7 +42,8 @@ class AuthConfig(BaseSettings): # [DEF:auth_config:Variable] # @PURPOSE: Singleton instance of AuthConfig. +# @RELATION: DEPENDS_ON -> AuthConfig auth_config = AuthConfig() # [/DEF:auth_config:Variable] -# [/DEF:backend.src.core.auth.config:Module] +# [/DEF:AuthConfigModule:Module] diff --git a/backend/src/core/auth/jwt.py b/backend/src/core/auth/jwt.py index e80bb611..9a8a3d74 100644 --- a/backend/src/core/auth/jwt.py +++ b/backend/src/core/auth/jwt.py @@ -1,11 +1,11 @@ -# [DEF:backend.src.core.auth.jwt:Module] +# [DEF:AuthJwtModule:Module] # # @COMPLEXITY: 3 # @SEMANTICS: jwt, token, session, auth # @PURPOSE: JWT token generation and validation logic. # @LAYER: Core # @RELATION: DEPENDS_ON -> jose -# @RELATION: USES -> backend.src.core.auth.config.auth_config +# @RELATION: USES -> auth_config # # @INVARIANT: Tokens must include expiration time and user identifier. @@ -21,6 +21,7 @@ from ..logger import belief_scope # @PURPOSE: Generates a new JWT access token. # @PRE: data dict contains 'sub' (user_id) and optional 'scopes' (roles). # @POST: Returns a signed JWT string. +# @RELATION: DEPENDS_ON -> auth_config # # @PARAM: data (dict) - Payload data for the token. # @PARAM: expires_delta (Optional[timedelta]) - Custom expiration time. @@ -42,6 +43,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - # @PURPOSE: Decodes and validates a JWT token. # @PRE: token is a signed JWT string. # @POST: Returns the decoded payload if valid. +# @RELATION: DEPENDS_ON -> auth_config # # @PARAM: token (str) - The JWT to decode. # @RETURN: dict - The decoded payload. @@ -52,4 +54,4 @@ def decode_token(token: str) -> dict: return payload # [/DEF:decode_token:Function] -# [/DEF:backend.src.core.auth.jwt:Module] \ No newline at end of file +# [/DEF:AuthJwtModule:Module] \ No newline at end of file diff --git a/backend/src/core/auth/logger.py b/backend/src/core/auth/logger.py index d25cd50e..9052668b 100644 --- a/backend/src/core/auth/logger.py +++ b/backend/src/core/auth/logger.py @@ -1,10 +1,10 @@ -# [DEF:backend.src.core.auth.logger:Module] +# [DEF:AuthLoggerModule:Module] # # @COMPLEXITY: 3 # @SEMANTICS: auth, logger, audit, security # @PURPOSE: Audit logging for security-related events. # @LAYER: Core -# @RELATION: USES -> backend.src.core.logger.belief_scope +# @RELATION: USES -> belief_scope # # @INVARIANT: Must not log sensitive data like passwords or full tokens. @@ -17,6 +17,7 @@ from datetime import datetime # @PURPOSE: Logs a security-related event for audit trails. # @PRE: event_type and username are strings. # @POST: Security event is written to the application log. +# @RELATION: USES -> logger # @PARAM: event_type (str) - Type of event (e.g., LOGIN_SUCCESS, PERMISSION_DENIED). # @PARAM: username (str) - The user involved in the event. # @PARAM: details (dict) - Additional non-sensitive metadata. @@ -29,4 +30,4 @@ def log_security_event(event_type: str, username: str, details: dict = None): logger.info(msg) # [/DEF:log_security_event:Function] -# [/DEF:backend.src.core.auth.logger:Module] \ No newline at end of file +# [/DEF:AuthLoggerModule:Module] \ No newline at end of file diff --git a/backend/src/core/auth/oauth.py b/backend/src/core/auth/oauth.py index 4a99c7e0..1a462a58 100644 --- a/backend/src/core/auth/oauth.py +++ b/backend/src/core/auth/oauth.py @@ -1,10 +1,10 @@ -# [DEF:backend.src.core.auth.oauth:Module] +# [DEF:AuthOauthModule:Module] # # @SEMANTICS: auth, oauth, oidc, adfs # @PURPOSE: ADFS OIDC configuration and client using Authlib. # @LAYER: Core # @RELATION: DEPENDS_ON -> authlib -# @RELATION: USES -> backend.src.core.auth.config.auth_config +# @RELATION: USES -> auth_config # # @INVARIANT: Must use secure OIDC flows. @@ -15,6 +15,7 @@ from .config import auth_config # [DEF:oauth:Variable] # @PURPOSE: Global Authlib OAuth registry. +# @RELATION: DEPENDS_ON -> OAuth oauth = OAuth() # [/DEF:oauth:Variable] @@ -22,6 +23,8 @@ oauth = OAuth() # @PURPOSE: Registers the ADFS OIDC client. # @PRE: ADFS configuration is provided in auth_config. # @POST: ADFS client is registered in oauth registry. +# @RELATION: USES -> oauth +# @RELATION: USES -> auth_config def register_adfs(): if auth_config.ADFS_CLIENT_ID: oauth.register( @@ -39,6 +42,7 @@ def register_adfs(): # @PURPOSE: Checks if ADFS is properly configured. # @PRE: None. # @POST: Returns True if ADFS client is registered, False otherwise. +# @RELATION: USES -> oauth # @RETURN: bool - Configuration status. def is_adfs_configured() -> bool: """Check if ADFS OAuth client is registered.""" @@ -48,4 +52,4 @@ def is_adfs_configured() -> bool: # Initial registration register_adfs() -# [/DEF:backend.src.core.auth.oauth:Module] \ No newline at end of file +# [/DEF:AuthOauthModule:Module] \ No newline at end of file diff --git a/backend/src/core/auth/repository.py b/backend/src/core/auth/repository.py index 0fb7db0d..be1cc1a8 100644 --- a/backend/src/core/auth/repository.py +++ b/backend/src/core/auth/repository.py @@ -1,4 +1,4 @@ -# [DEF:AuthRepository:Module] +# [DEF:AuthRepositoryModule:Module] # @TIER: CRITICAL # @COMPLEXITY: 5 # @SEMANTICS: auth, repository, database, user, role, permission @@ -12,6 +12,9 @@ # @RELATION: DEPENDS_ON ->[belief_scope:Function] # @INVARIANT: All database read/write operations must execute via the injected SQLAlchemy session boundary. # @DATA_CONTRACT: Session -> [User | Role | Permission | UserDashboardPreference] +# @PRE: Database connection is active. +# @POST: Provides valid access to identity data. +# @SIDE_EFFECT: None at module level. # [SECTION: IMPORTS] from typing import List, Optional @@ -23,6 +26,10 @@ from ..logger import belief_scope, logger # [DEF:AuthRepository:Class] # @PURPOSE: Provides low-level CRUD operations for identity and authorization records. +# @PRE: Database session is bound. +# @POST: Entity instances returned safely. +# @SIDE_EFFECT: Performs database reads. +# @RELATION: DEPENDS_ON -> sqlalchemy.orm.Session class AuthRepository: # @PURPOSE: Initialize repository with database session. def __init__(self, db: Session): @@ -32,6 +39,7 @@ class AuthRepository: # @PURPOSE: Retrieve user by UUID. # @PRE: user_id is a valid UUID string. # @POST: Returns User object if found, else None. + # @RELATION: DEPENDS_ON -> User def get_user_by_id(self, user_id: str) -> Optional[User]: with belief_scope("AuthRepository.get_user_by_id"): logger.reason(f"Fetching user by id: {user_id}") @@ -44,6 +52,7 @@ class AuthRepository: # @PURPOSE: Retrieve user by username. # @PRE: username is a non-empty string. # @POST: Returns User object if found, else None. + # @RELATION: DEPENDS_ON -> User def get_user_by_username(self, username: str) -> Optional[User]: with belief_scope("AuthRepository.get_user_by_username"): logger.reason(f"Fetching user by username: {username}") @@ -54,6 +63,8 @@ class AuthRepository: # [DEF:get_role_by_id:Function] # @PURPOSE: Retrieve role by UUID with permissions preloaded. + # @RELATION: DEPENDS_ON -> Role + # @RELATION: DEPENDS_ON -> Permission def get_role_by_id(self, role_id: str) -> Optional[Role]: with belief_scope("AuthRepository.get_role_by_id"): return self.db.query(Role).options(selectinload(Role.permissions)).filter(Role.id == role_id).first() @@ -61,6 +72,7 @@ class AuthRepository: # [DEF:get_role_by_name:Function] # @PURPOSE: Retrieve role by unique name. + # @RELATION: DEPENDS_ON -> Role def get_role_by_name(self, name: str) -> Optional[Role]: with belief_scope("AuthRepository.get_role_by_name"): return self.db.query(Role).filter(Role.name == name).first() @@ -68,6 +80,7 @@ class AuthRepository: # [DEF:get_permission_by_id:Function] # @PURPOSE: Retrieve permission by UUID. + # @RELATION: DEPENDS_ON -> Permission def get_permission_by_id(self, permission_id: str) -> Optional[Permission]: with belief_scope("AuthRepository.get_permission_by_id"): return self.db.query(Permission).filter(Permission.id == permission_id).first() @@ -75,6 +88,7 @@ class AuthRepository: # [DEF:get_permission_by_resource_action:Function] # @PURPOSE: Retrieve permission by resource and action tuple. + # @RELATION: DEPENDS_ON -> Permission def get_permission_by_resource_action(self, resource: str, action: str) -> Optional[Permission]: with belief_scope("AuthRepository.get_permission_by_resource_action"): return self.db.query(Permission).filter( @@ -85,6 +99,7 @@ class AuthRepository: # [DEF:list_permissions:Function] # @PURPOSE: List all system permissions. + # @RELATION: DEPENDS_ON -> Permission def list_permissions(self) -> List[Permission]: with belief_scope("AuthRepository.list_permissions"): return self.db.query(Permission).all() @@ -92,6 +107,7 @@ class AuthRepository: # [DEF:get_user_dashboard_preference:Function] # @PURPOSE: Retrieve dashboard filters/preferences for a user. + # @RELATION: DEPENDS_ON -> UserDashboardPreference def get_user_dashboard_preference(self, user_id: str) -> Optional[UserDashboardPreference]: with belief_scope("AuthRepository.get_user_dashboard_preference"): return self.db.query(UserDashboardPreference).filter( @@ -103,6 +119,8 @@ class AuthRepository: # @PURPOSE: Retrieve roles that match a list of AD group names. # @PRE: groups is a list of strings representing AD group identifiers. # @POST: Returns a list of Role objects mapped to the provided AD groups. + # @RELATION: DEPENDS_ON -> Role + # @RELATION: DEPENDS_ON -> ADGroupMapping def get_roles_by_ad_groups(self, groups: List[str]) -> List[Role]: with belief_scope("AuthRepository.get_roles_by_ad_groups"): logger.reason(f"Fetching roles for AD groups: {groups}") @@ -115,4 +133,4 @@ class AuthRepository: # [/DEF:AuthRepository:Class] -# [/DEF:AuthRepository:Module] +# [/DEF:AuthRepositoryModule:Module] diff --git a/backend/src/core/auth/security.py b/backend/src/core/auth/security.py index 7e375217..f04a734b 100644 --- a/backend/src/core/auth/security.py +++ b/backend/src/core/auth/security.py @@ -1,9 +1,9 @@ -# [DEF:backend.src.core.auth.security:Module] +# [DEF:AuthSecurityModule:Module] # # @SEMANTICS: security, password, hashing, bcrypt # @PURPOSE: Utility for password hashing and verification using Passlib. # @LAYER: Core -# @RELATION: DEPENDS_ON -> passlib +# @RELATION: DEPENDS_ON -> bcrypt # # @INVARIANT: Uses bcrypt for hashing with standard work factor. @@ -15,6 +15,7 @@ import bcrypt # @PURPOSE: Verifies a plain password against a hashed password. # @PRE: plain_password is a string, hashed_password is a bcrypt hash. # @POST: Returns True if password matches, False otherwise. +# @RELATION: DEPENDS_ON -> bcrypt # # @PARAM: plain_password (str) - The unhashed password. # @PARAM: hashed_password (str) - The stored hash. @@ -35,6 +36,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: # @PURPOSE: Generates a bcrypt hash for a plain password. # @PRE: password is a string. # @POST: Returns a secure bcrypt hash string. +# @RELATION: DEPENDS_ON -> bcrypt # # @PARAM: password (str) - The password to hash. # @RETURN: str - The generated hash. @@ -42,4 +44,4 @@ def get_password_hash(password: str) -> str: return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") # [/DEF:get_password_hash:Function] -# [/DEF:backend.src.core.auth.security:Module] +# [/DEF:AuthSecurityModule:Module] diff --git a/backend/src/core/config_manager.py b/backend/src/core/config_manager.py index e596886b..16e1bb26 100644 --- a/backend/src/core/config_manager.py +++ b/backend/src/core/config_manager.py @@ -45,7 +45,9 @@ class ConfigManager: def __init__(self, config_path: str = "config.json"): with belief_scope("ConfigManager.__init__"): if not isinstance(config_path, str) or not config_path: - logger.explore("Invalid config_path provided", extra={"path": config_path}) + logger.explore( + "Invalid config_path provided", extra={"path": config_path} + ) raise ValueError("config_path must be a non-empty string") logger.reason(f"Initializing ConfigManager with legacy path: {config_path}") @@ -57,10 +59,14 @@ class ConfigManager: configure_logger(self.config.settings.logging) if not isinstance(self.config, AppConfig): - logger.explore("Config loading resulted in invalid type", extra={"type": type(self.config)}) + logger.explore( + "Config loading resulted in invalid type", + extra={"type": type(self.config)}, + ) raise TypeError("self.config must be an instance of AppConfig") logger.reflect("ConfigManager initialization complete") + # [/DEF:__init__:Function] # [DEF:_default_config:Function] @@ -69,6 +75,7 @@ class ConfigManager: with belief_scope("ConfigManager._default_config"): logger.reason("Building default AppConfig fallback") return AppConfig(environments=[], settings=GlobalSettings()) + # [/DEF:_default_config:Function] # [DEF:_sync_raw_payload_from_config:Function] @@ -83,14 +90,19 @@ class ConfigManager: logger.reason( "Synchronized raw payload from typed config", extra={ - "environments_count": len(merged_payload.get("environments", []) or []), + "environments_count": len( + merged_payload.get("environments", []) or [] + ), "has_settings": "settings" in merged_payload, "extra_sections": sorted( - key for key in merged_payload.keys() if key not in {"environments", "settings"} + key + for key in merged_payload.keys() + if key not in {"environments", "settings"} ), }, ) return merged_payload + # [/DEF:_sync_raw_payload_from_config:Function] # [DEF:_load_from_legacy_file:Function] @@ -104,14 +116,19 @@ class ConfigManager: ) return {} - logger.reason("Loading legacy config file", extra={"path": str(self.config_path)}) + logger.reason( + "Loading legacy config file", extra={"path": str(self.config_path)} + ) with self.config_path.open("r", encoding="utf-8") as fh: payload = json.load(fh) if not isinstance(payload, dict): logger.explore( "Legacy config payload is not a JSON object", - extra={"path": str(self.config_path), "type": type(payload).__name__}, + extra={ + "path": str(self.config_path), + "type": type(payload).__name__, + }, ) raise ValueError("Legacy config payload must be a JSON object") @@ -120,15 +137,23 @@ class ConfigManager: extra={"path": str(self.config_path), "keys": sorted(payload.keys())}, ) return payload + # [/DEF:_load_from_legacy_file:Function] # [DEF:_get_record:Function] # @PURPOSE: Resolve global configuration record from DB. def _get_record(self, session: Session) -> Optional[AppConfigRecord]: with belief_scope("ConfigManager._get_record"): - record = session.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first() - logger.reason("Resolved app config record", extra={"exists": record is not None}) + record = ( + session.query(AppConfigRecord) + .filter(AppConfigRecord.id == "global") + .first() + ) + logger.reason( + "Resolved app config record", extra={"exists": record is not None} + ) return record + # [/DEF:_get_record:Function] # [DEF:_load_config:Function] @@ -139,7 +164,10 @@ class ConfigManager: try: record = self._get_record(session) if record and isinstance(record.payload, dict): - logger.reason("Loading configuration from database", extra={"record_id": record.id}) + logger.reason( + "Loading configuration from database", + extra={"record_id": record.id}, + ) self.raw_payload = dict(record.payload) config = AppConfig.model_validate( { @@ -182,7 +210,9 @@ class ConfigManager: self._save_config_to_db(config, session=session) return config - logger.reason("No persisted config found; falling back to default configuration") + logger.reason( + "No persisted config found; falling back to default configuration" + ) config = self._default_config() self.raw_payload = config.model_dump() self._save_config_to_db(config, session=session) @@ -203,6 +233,7 @@ class ConfigManager: raise finally: session.close() + # [/DEF:_load_config:Function] # [DEF:_sync_environment_records:Function] @@ -210,29 +241,32 @@ class ConfigManager: def _sync_environment_records(self, session: Session, config: AppConfig) -> None: with belief_scope("ConfigManager._sync_environment_records"): configured_envs = list(config.environments or []) - configured_ids = { - str(environment.id or "").strip() - for environment in configured_envs - if str(environment.id or "").strip() - } - persisted_records = session.query(EnvironmentRecord).all() - persisted_by_id = {str(record.id or "").strip(): record for record in persisted_records} + persisted_by_id = { + str(record.id or "").strip(): record for record in persisted_records + } for environment in configured_envs: normalized_id = str(environment.id or "").strip() if not normalized_id: continue - display_name = str(environment.name or normalized_id).strip() or normalized_id + display_name = ( + str(environment.name or normalized_id).strip() or normalized_id + ) normalized_url = str(environment.url or "").strip() - credentials_id = str(environment.username or "").strip() or normalized_id + credentials_id = ( + str(environment.username or "").strip() or normalized_id + ) record = persisted_by_id.get(normalized_id) if record is None: logger.reason( "Creating relational environment record from typed config", - extra={"environment_id": normalized_id, "environment_name": display_name}, + extra={ + "environment_id": normalized_id, + "environment_name": display_name, + }, ) session.add( EnvironmentRecord( @@ -248,20 +282,13 @@ class ConfigManager: record.url = normalized_url record.credentials_id = credentials_id - for record in persisted_records: - normalized_id = str(record.id or "").strip() - if normalized_id and normalized_id not in configured_ids: - logger.reason( - "Removing stale relational environment record absent from typed config", - extra={"environment_id": normalized_id}, - ) - session.delete(record) - # [/DEF:_sync_environment_records:Function] # [DEF:_save_config_to_db:Function] # @PURPOSE: Persist provided AppConfig into the global DB configuration record. - def _save_config_to_db(self, config: AppConfig, session: Optional[Session] = None) -> None: + def _save_config_to_db( + self, config: AppConfig, session: Optional[Session] = None + ) -> None: with belief_scope("ConfigManager._save_config_to_db"): owns_session = session is None db = session or SessionLocal() @@ -274,7 +301,10 @@ class ConfigManager: record = AppConfigRecord(id="global", payload=payload) db.add(record) else: - logger.reason("Updating existing global app config record", extra={"record_id": record.id}) + logger.reason( + "Updating existing global app config record", + extra={"record_id": record.id}, + ) record.payload = payload self._sync_environment_records(db, config) @@ -283,7 +313,9 @@ class ConfigManager: logger.reason( "Configuration persisted to database", extra={ - "environments_count": len(payload.get("environments", []) or []), + "environments_count": len( + payload.get("environments", []) or [] + ), "payload_keys": sorted(payload.keys()), }, ) @@ -294,6 +326,7 @@ class ConfigManager: finally: if owns_session: db.close() + # [/DEF:_save_config_to_db:Function] # [DEF:save:Function] @@ -302,6 +335,7 @@ class ConfigManager: with belief_scope("ConfigManager.save"): logger.reason("Persisting current in-memory configuration") self._save_config_to_db(self.config) + # [/DEF:save:Function] # [DEF:get_config:Function] @@ -309,6 +343,7 @@ class ConfigManager: def get_config(self) -> AppConfig: with belief_scope("ConfigManager.get_config"): return self.config + # [/DEF:get_config:Function] # [DEF:get_payload:Function] @@ -316,6 +351,7 @@ class ConfigManager: def get_payload(self) -> dict[str, Any]: with belief_scope("ConfigManager.get_payload"): return self._sync_raw_payload_from_config() + # [/DEF:get_payload:Function] # [DEF:save_config:Function] @@ -345,8 +381,12 @@ class ConfigManager: self._save_config_to_db(typed_config) return self.config - logger.explore("Unsupported config type supplied to save_config", extra={"type": type(config).__name__}) + logger.explore( + "Unsupported config type supplied to save_config", + extra={"type": type(config).__name__}, + ) raise TypeError("config must be AppConfig or dict") + # [/DEF:save_config:Function] # [DEF:update_global_settings:Function] @@ -357,6 +397,7 @@ class ConfigManager: self.config.settings = settings self.save() return self.config + # [/DEF:update_global_settings:Function] # [DEF:validate_path:Function] @@ -381,8 +422,11 @@ class ConfigManager: logger.reason("Path validation succeeded", extra={"path": str(target)}) return True, "OK" except Exception as exc: - logger.explore("Path validation failed", extra={"path": path, "error": str(exc)}) + logger.explore( + "Path validation failed", extra={"path": path, "error": str(exc)} + ) return False, str(exc) + # [/DEF:validate_path:Function] # [DEF:get_environments:Function] @@ -390,6 +434,7 @@ class ConfigManager: def get_environments(self) -> List[Environment]: with belief_scope("ConfigManager.get_environments"): return list(self.config.environments) + # [/DEF:get_environments:Function] # [DEF:has_environments:Function] @@ -397,6 +442,7 @@ class ConfigManager: def has_environments(self) -> bool: with belief_scope("ConfigManager.has_environments"): return len(self.config.environments) > 0 + # [/DEF:has_environments:Function] # [DEF:get_environment:Function] @@ -411,13 +457,21 @@ class ConfigManager: if env.id == normalized or env.name == normalized: return env return None + # [/DEF:get_environment:Function] # [DEF:add_environment:Function] # @PURPOSE: Upsert environment by id into configuration and persist. def add_environment(self, env: Environment) -> AppConfig: with belief_scope("ConfigManager.add_environment", f"env_id={env.id}"): - existing_index = next((i for i, item in enumerate(self.config.environments) if item.id == env.id), None) + existing_index = next( + ( + i + for i, item in enumerate(self.config.environments) + if item.id == env.id + ), + None, + ) if env.is_default: for item in self.config.environments: item.is_default = False @@ -426,14 +480,20 @@ class ConfigManager: logger.reason("Appending new environment", extra={"env_id": env.id}) self.config.environments.append(env) else: - logger.reason("Replacing existing environment during add", extra={"env_id": env.id}) + logger.reason( + "Replacing existing environment during add", + extra={"env_id": env.id}, + ) self.config.environments[existing_index] = env - if len(self.config.environments) == 1 and not any(item.is_default for item in self.config.environments): + if len(self.config.environments) == 1 and not any( + item.is_default for item in self.config.environments + ): self.config.environments[0].is_default = True self.save() return self.config + # [/DEF:add_environment:Function] # [DEF:update_environment:Function] @@ -461,8 +521,11 @@ class ConfigManager: self.save() return True - logger.explore("Environment update skipped; env not found", extra={"env_id": env_id}) + logger.explore( + "Environment update skipped; env not found", extra={"env_id": env_id} + ) return False + # [/DEF:update_environment:Function] # [DEF:delete_environment:Function] @@ -471,22 +534,35 @@ class ConfigManager: with belief_scope("ConfigManager.delete_environment", f"env_id={env_id}"): before = len(self.config.environments) removed = [env for env in self.config.environments if env.id == env_id] - self.config.environments = [env for env in self.config.environments if env.id != env_id] + self.config.environments = [ + env for env in self.config.environments if env.id != env_id + ] if len(self.config.environments) == before: - logger.explore("Environment delete skipped; env not found", extra={"env_id": env_id}) + logger.explore( + "Environment delete skipped; env not found", + extra={"env_id": env_id}, + ) return False if removed and removed[0].is_default and self.config.environments: self.config.environments[0].is_default = True if self.config.settings.default_environment_id == env_id: - replacement = next((env.id for env in self.config.environments if env.is_default), None) + replacement = next( + (env.id for env in self.config.environments if env.is_default), None + ) self.config.settings.default_environment_id = replacement - logger.reason("Environment deleted", extra={"env_id": env_id, "remaining": len(self.config.environments)}) + logger.reason( + "Environment deleted", + extra={"env_id": env_id, "remaining": len(self.config.environments)}, + ) self.save() return True + # [/DEF:delete_environment:Function] + + # [/DEF:ConfigManager:Class] # [/DEF:ConfigManager:Module] diff --git a/backend/src/core/logger/__tests__/test_logger.py b/backend/src/core/logger/__tests__/test_logger.py index 4d08b1b8..c93f9028 100644 --- a/backend/src/core/logger/__tests__/test_logger.py +++ b/backend/src/core/logger/__tests__/test_logger.py @@ -44,6 +44,7 @@ def reset_logger_state(): # [DEF:test_belief_scope_logs_entry_action_exit_at_debug:Function] +# @RELATION: BINDS_TO -> test_logger # @PURPOSE: Test that belief_scope generates [ID][Entry], [ID][Action], and [ID][Exit] logs at DEBUG level. # @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG. # @POST: Logs are verified to contain Entry, Action, and Exit tags at DEBUG level. @@ -76,6 +77,7 @@ def test_belief_scope_logs_entry_action_exit_at_debug(caplog): # [DEF:test_belief_scope_error_handling:Function] +# @RELATION: BINDS_TO -> test_logger # @PURPOSE: Test that belief_scope logs Coherence:Failed on exception. # @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG. # @POST: Logs are verified to contain Coherence:Failed tag. @@ -108,6 +110,7 @@ def test_belief_scope_error_handling(caplog): # [DEF:test_belief_scope_success_coherence:Function] +# @RELATION: BINDS_TO -> test_logger # @PURPOSE: Test that belief_scope logs Coherence:OK on success. # @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG. # @POST: Logs are verified to contain Coherence:OK tag. @@ -135,6 +138,7 @@ def test_belief_scope_success_coherence(caplog): # [DEF:test_belief_scope_not_visible_at_info:Function] +# @RELATION: BINDS_TO -> test_logger # @PURPOSE: Test that belief_scope Entry/Exit/Coherence logs are NOT visible at INFO level. # @PRE: belief_scope is available. caplog fixture is used. # @POST: Entry/Exit/Coherence logs are not captured at INFO level. @@ -157,6 +161,7 @@ def test_belief_scope_not_visible_at_info(caplog): # [DEF:test_task_log_level_default:Function] +# @RELATION: BINDS_TO -> test_logger # @PURPOSE: Test that default task log level is INFO. # @PRE: None. # @POST: Default level is INFO. @@ -168,6 +173,7 @@ def test_task_log_level_default(): # [DEF:test_should_log_task_level:Function] +# @RELATION: BINDS_TO -> test_logger # @PURPOSE: Test that should_log_task_level correctly filters log levels. # @PRE: None. # @POST: Filtering works correctly for all level combinations. @@ -182,6 +188,7 @@ def test_should_log_task_level(): # [DEF:test_configure_logger_task_log_level:Function] +# @RELATION: BINDS_TO -> test_logger # @PURPOSE: Test that configure_logger updates task_log_level. # @PRE: LoggingConfig is available. # @POST: task_log_level is updated correctly. @@ -200,6 +207,7 @@ def test_configure_logger_task_log_level(): # [DEF:test_enable_belief_state_flag:Function] +# @RELATION: BINDS_TO -> test_logger # @PURPOSE: Test that enable_belief_state flag controls belief_scope logging. # @PRE: LoggingConfig is available. caplog fixture is used. # @POST: belief_scope logs are controlled by the flag. @@ -229,6 +237,7 @@ def test_enable_belief_state_flag(caplog): # [DEF:test_belief_scope_missing_anchor:Function] +# @RELATION: BINDS_TO -> test_logger # @PURPOSE: Test @PRE condition: anchor_id must be provided def test_belief_scope_missing_anchor(): """Test that belief_scope enforces anchor_id to be provided.""" @@ -241,6 +250,7 @@ def test_belief_scope_missing_anchor(): # [/DEF:test_belief_scope_missing_anchor:Function] # [DEF:test_configure_logger_post_conditions:Function] +# @RELATION: BINDS_TO -> test_logger # @PURPOSE: Test @POST condition: Logger level, handlers, belief state flag, and task log level are updated. def test_configure_logger_post_conditions(tmp_path): """Test that configure_logger satisfies all @POST conditions.""" diff --git a/backend/src/core/migration/archive_parser.py b/backend/src/core/migration/archive_parser.py index da125941..c45627f9 100644 --- a/backend/src/core/migration/archive_parser.py +++ b/backend/src/core/migration/archive_parser.py @@ -19,13 +19,17 @@ from ..logger import logger, belief_scope # [DEF:MigrationArchiveParser:Class] # @PURPOSE: Extract normalized dashboards/charts/datasets metadata from ZIP archives. +# @RELATION: CONTAINS -> [extract_objects_from_zip, _collect_yaml_objects, _normalize_object_payload] class MigrationArchiveParser: # [DEF:extract_objects_from_zip:Function] # @PURPOSE: Extract object catalogs from Superset archive. + # @RELATION: DEPENDS_ON -> _collect_yaml_objects # @PRE: zip_path points to a valid readable ZIP. # @POST: Returns object lists grouped by resource type. # @RETURN: Dict[str, List[Dict[str, Any]]] - def extract_objects_from_zip(self, zip_path: str) -> Dict[str, List[Dict[str, Any]]]: + def extract_objects_from_zip( + self, zip_path: str + ) -> Dict[str, List[Dict[str, Any]]]: with belief_scope("MigrationArchiveParser.extract_objects_from_zip"): result: Dict[str, List[Dict[str, Any]]] = { "dashboards": [], @@ -37,20 +41,28 @@ class MigrationArchiveParser: with zipfile.ZipFile(zip_path, "r") as zip_file: zip_file.extractall(temp_dir) - result["dashboards"] = self._collect_yaml_objects(temp_dir, "dashboards") + result["dashboards"] = self._collect_yaml_objects( + temp_dir, "dashboards" + ) result["charts"] = self._collect_yaml_objects(temp_dir, "charts") result["datasets"] = self._collect_yaml_objects(temp_dir, "datasets") return result + # [/DEF:extract_objects_from_zip:Function] # [DEF:_collect_yaml_objects:Function] # @PURPOSE: Read and normalize YAML manifests for one object type. + # @RELATION: DEPENDS_ON -> _normalize_object_payload # @PRE: object_type is one of dashboards/charts/datasets. # @POST: Returns only valid normalized objects. - def _collect_yaml_objects(self, root_dir: Path, object_type: str) -> List[Dict[str, Any]]: + def _collect_yaml_objects( + self, root_dir: Path, object_type: str + ) -> List[Dict[str, Any]]: with belief_scope("MigrationArchiveParser._collect_yaml_objects"): - files = list(root_dir.glob(f"**/{object_type}/**/*.yaml")) + list(root_dir.glob(f"**/{object_type}/*.yaml")) + files = list(root_dir.glob(f"**/{object_type}/**/*.yaml")) + list( + root_dir.glob(f"**/{object_type}/*.yaml") + ) objects: List[Dict[str, Any]] = [] for file_path in set(files): try: @@ -66,13 +78,16 @@ class MigrationArchiveParser: exc, ) return objects + # [/DEF:_collect_yaml_objects:Function] # [DEF:_normalize_object_payload:Function] # @PURPOSE: Convert raw YAML payload to stable diff signature shape. # @PRE: payload is parsed YAML mapping. # @POST: Returns normalized descriptor with `uuid`, `title`, and `signature`. - def _normalize_object_payload(self, payload: Dict[str, Any], object_type: str) -> Optional[Dict[str, Any]]: + def _normalize_object_payload( + self, payload: Dict[str, Any], object_type: str + ) -> Optional[Dict[str, Any]]: with belief_scope("MigrationArchiveParser._normalize_object_payload"): if not isinstance(payload, dict): return None @@ -111,7 +126,8 @@ class MigrationArchiveParser: "uuid": str(uuid), "title": title or f"Chart {uuid}", "signature": json.dumps(signature, sort_keys=True, default=str), - "dataset_uuid": payload.get("datasource_uuid") or payload.get("dataset_uuid"), + "dataset_uuid": payload.get("datasource_uuid") + or payload.get("dataset_uuid"), } if object_type == "datasets": @@ -132,6 +148,7 @@ class MigrationArchiveParser: } return None + # [/DEF:_normalize_object_payload:Function] diff --git a/backend/src/core/migration/dry_run_orchestrator.py b/backend/src/core/migration/dry_run_orchestrator.py index 50bf3c1c..58192f9a 100644 --- a/backend/src/core/migration/dry_run_orchestrator.py +++ b/backend/src/core/migration/dry_run_orchestrator.py @@ -27,6 +27,7 @@ from ..utils.fileio import create_temp_file # [DEF:MigrationDryRunService:Class] # @PURPOSE: Build deterministic diff/risk payload for migration pre-flight. +# @RELATION: CONTAINS -> [__init__, run, _load_db_mapping, _accumulate_objects, _index_by_uuid, _build_object_diff, _build_target_signatures, _build_risks] class MigrationDryRunService: # [DEF:__init__:Function] # @PURPOSE: Wire parser dependency for archive object extraction. @@ -34,10 +35,12 @@ class MigrationDryRunService: # @POST: Service is ready to calculate dry-run payload. def __init__(self, parser: MigrationArchiveParser | None = None): self.parser = parser or MigrationArchiveParser() + # [/DEF:__init__:Function] # [DEF:run:Function] # @PURPOSE: Execute full dry-run computation for selected dashboards. + # @RELATION: DEPENDS_ON -> [_load_db_mapping, _accumulate_objects, _build_target_signatures, _build_object_diff, _build_risks] # @PRE: source/target clients are authenticated and selection validated by caller. # @POST: Returns JSON-serializable pre-flight payload with summary, diff and risk. # @SIDE_EFFECT: Reads source export archives and target metadata via network. @@ -49,9 +52,15 @@ class MigrationDryRunService: db: Session, ) -> Dict[str, Any]: with belief_scope("MigrationDryRunService.run"): - logger.explore("[MigrationDryRunService.run][EXPLORE] starting dry-run pipeline") + logger.explore( + "[MigrationDryRunService.run][EXPLORE] starting dry-run pipeline" + ) engine = MigrationEngine() - db_mapping = self._load_db_mapping(db, selection) if selection.replace_db_config else {} + db_mapping = ( + self._load_db_mapping(db, selection) + if selection.replace_db_config + else {} + ) transformed = {"dashboards": {}, "charts": {}, "datasets": {}} dashboards_preview = source_client.get_dashboards_summary() @@ -63,7 +72,9 @@ class MigrationDryRunService: for dashboard_id in selection.selected_ids: exported_content, _ = source_client.export_dashboard(int(dashboard_id)) - with create_temp_file(content=exported_content, suffix=".zip") as source_zip: + with create_temp_file( + content=exported_content, suffix=".zip" + ) as source_zip: with create_temp_file(suffix=".zip") as transformed_zip: success = engine.transform_zip( str(source_zip), @@ -74,23 +85,46 @@ class MigrationDryRunService: fix_cross_filters=selection.fix_cross_filters, ) if not success: - raise ValueError(f"Failed to transform export archive for dashboard {dashboard_id}") - extracted = self.parser.extract_objects_from_zip(str(transformed_zip)) + raise ValueError( + f"Failed to transform export archive for dashboard {dashboard_id}" + ) + extracted = self.parser.extract_objects_from_zip( + str(transformed_zip) + ) self._accumulate_objects(transformed, extracted) - source_objects = {key: list(value.values()) for key, value in transformed.items()} + source_objects = { + key: list(value.values()) for key, value in transformed.items() + } target_objects = self._build_target_signatures(target_client) diff = { - "dashboards": self._build_object_diff(source_objects["dashboards"], target_objects["dashboards"]), - "charts": self._build_object_diff(source_objects["charts"], target_objects["charts"]), - "datasets": self._build_object_diff(source_objects["datasets"], target_objects["datasets"]), + "dashboards": self._build_object_diff( + source_objects["dashboards"], target_objects["dashboards"] + ), + "charts": self._build_object_diff( + source_objects["charts"], target_objects["charts"] + ), + "datasets": self._build_object_diff( + source_objects["datasets"], target_objects["datasets"] + ), } - risk = self._build_risks(source_objects, target_objects, diff, target_client) + risk = self._build_risks( + source_objects, target_objects, diff, target_client + ) summary = { - "dashboards": {action: len(diff["dashboards"][action]) for action in ("create", "update", "delete")}, - "charts": {action: len(diff["charts"][action]) for action in ("create", "update", "delete")}, - "datasets": {action: len(diff["datasets"][action]) for action in ("create", "update", "delete")}, + "dashboards": { + action: len(diff["dashboards"][action]) + for action in ("create", "update", "delete") + }, + "charts": { + action: len(diff["charts"][action]) + for action in ("create", "update", "delete") + }, + "datasets": { + action: len(diff["datasets"][action]) + for action in ("create", "update", "delete") + }, "selected_dashboards": len(selection.selected_ids), } selected_titles = [ @@ -99,7 +133,9 @@ class MigrationDryRunService: if dash_id in selected_preview ] - logger.reason("[MigrationDryRunService.run][REASON] dry-run payload assembled") + logger.reason( + "[MigrationDryRunService.run][REASON] dry-run payload assembled" + ) return { "generated_at": datetime.now(timezone.utc).isoformat(), "selection": selection.model_dump(), @@ -108,42 +144,61 @@ class MigrationDryRunService: "summary": summary, "risk": score_risks(risk), } + # [/DEF:run:Function] # [DEF:_load_db_mapping:Function] # @PURPOSE: Resolve UUID mapping for optional DB config replacement. - def _load_db_mapping(self, db: Session, selection: DashboardSelection) -> Dict[str, str]: - rows = db.query(DatabaseMapping).filter( - DatabaseMapping.source_env_id == selection.source_env_id, - DatabaseMapping.target_env_id == selection.target_env_id, - ).all() + def _load_db_mapping( + self, db: Session, selection: DashboardSelection + ) -> Dict[str, str]: + rows = ( + db.query(DatabaseMapping) + .filter( + DatabaseMapping.source_env_id == selection.source_env_id, + DatabaseMapping.target_env_id == selection.target_env_id, + ) + .all() + ) return {row.source_db_uuid: row.target_db_uuid for row in rows} + # [/DEF:_load_db_mapping:Function] # [DEF:_accumulate_objects:Function] # @PURPOSE: Merge extracted resources by UUID to avoid duplicates. - def _accumulate_objects(self, target: Dict[str, Dict[str, Dict[str, Any]]], source: Dict[str, List[Dict[str, Any]]]) -> None: + def _accumulate_objects( + self, + target: Dict[str, Dict[str, Dict[str, Any]]], + source: Dict[str, List[Dict[str, Any]]], + ) -> None: for object_type in ("dashboards", "charts", "datasets"): for item in source.get(object_type, []): uuid = item.get("uuid") if uuid: target[object_type][str(uuid)] = item + # [/DEF:_accumulate_objects:Function] # [DEF:_index_by_uuid:Function] # @PURPOSE: Build UUID-index map for normalized resources. - def _index_by_uuid(self, objects: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + def _index_by_uuid( + self, objects: List[Dict[str, Any]] + ) -> Dict[str, Dict[str, Any]]: indexed: Dict[str, Dict[str, Any]] = {} for obj in objects: uuid = obj.get("uuid") if uuid: indexed[str(uuid)] = obj return indexed + # [/DEF:_index_by_uuid:Function] # [DEF:_build_object_diff:Function] # @PURPOSE: Compute create/update/delete buckets by UUID+signature. - def _build_object_diff(self, source_objects: List[Dict[str, Any]], target_objects: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]: + # @RELATION: DEPENDS_ON -> _index_by_uuid + def _build_object_diff( + self, source_objects: List[Dict[str, Any]], target_objects: List[Dict[str, Any]] + ) -> Dict[str, List[Dict[str, Any]]]: target_index = self._index_by_uuid(target_objects) created: List[Dict[str, Any]] = [] updated: List[Dict[str, Any]] = [] @@ -155,67 +210,128 @@ class MigrationDryRunService: created.append({"uuid": source_uuid, "title": source_obj.get("title")}) continue if source_obj.get("signature") != target_obj.get("signature"): - updated.append({ - "uuid": source_uuid, - "title": source_obj.get("title"), - "target_title": target_obj.get("title"), - }) + updated.append( + { + "uuid": source_uuid, + "title": source_obj.get("title"), + "target_title": target_obj.get("title"), + } + ) return {"create": created, "update": updated, "delete": deleted} + # [/DEF:_build_object_diff:Function] # [DEF:_build_target_signatures:Function] # @PURPOSE: Pull target metadata and normalize it into comparable signatures. - def _build_target_signatures(self, client: SupersetClient) -> Dict[str, List[Dict[str, Any]]]: - _, dashboards = client.get_dashboards(query={ - "columns": ["uuid", "dashboard_title", "slug", "position_json", "json_metadata", "description", "owners"], - }) - _, datasets = client.get_datasets(query={ - "columns": ["uuid", "table_name", "schema", "database_uuid", "sql", "columns", "metrics"], - }) - _, charts = client.get_charts(query={ - "columns": ["uuid", "slice_name", "viz_type", "params", "query_context", "datasource_uuid", "dataset_uuid"], - }) + def _build_target_signatures( + self, client: SupersetClient + ) -> Dict[str, List[Dict[str, Any]]]: + _, dashboards = client.get_dashboards( + query={ + "columns": [ + "uuid", + "dashboard_title", + "slug", + "position_json", + "json_metadata", + "description", + "owners", + ], + } + ) + _, datasets = client.get_datasets( + query={ + "columns": [ + "uuid", + "table_name", + "schema", + "database_uuid", + "sql", + "columns", + "metrics", + ], + } + ) + _, charts = client.get_charts( + query={ + "columns": [ + "uuid", + "slice_name", + "viz_type", + "params", + "query_context", + "datasource_uuid", + "dataset_uuid", + ], + } + ) return { - "dashboards": [{ - "uuid": str(item.get("uuid")), - "title": item.get("dashboard_title"), - "owners": item.get("owners") or [], - "signature": json.dumps({ + "dashboards": [ + { + "uuid": str(item.get("uuid")), "title": item.get("dashboard_title"), - "slug": item.get("slug"), - "position_json": item.get("position_json"), - "json_metadata": item.get("json_metadata"), - "description": item.get("description"), - "owners": item.get("owners"), - }, sort_keys=True, default=str), - } for item in dashboards if item.get("uuid")], - "datasets": [{ - "uuid": str(item.get("uuid")), - "title": item.get("table_name"), - "database_uuid": item.get("database_uuid"), - "signature": json.dumps({ + "owners": item.get("owners") or [], + "signature": json.dumps( + { + "title": item.get("dashboard_title"), + "slug": item.get("slug"), + "position_json": item.get("position_json"), + "json_metadata": item.get("json_metadata"), + "description": item.get("description"), + "owners": item.get("owners"), + }, + sort_keys=True, + default=str, + ), + } + for item in dashboards + if item.get("uuid") + ], + "datasets": [ + { + "uuid": str(item.get("uuid")), "title": item.get("table_name"), - "schema": item.get("schema"), "database_uuid": item.get("database_uuid"), - "sql": item.get("sql"), - "columns": item.get("columns"), - "metrics": item.get("metrics"), - }, sort_keys=True, default=str), - } for item in datasets if item.get("uuid")], - "charts": [{ - "uuid": str(item.get("uuid")), - "title": item.get("slice_name") or item.get("name"), - "dataset_uuid": item.get("datasource_uuid") or item.get("dataset_uuid"), - "signature": json.dumps({ + "signature": json.dumps( + { + "title": item.get("table_name"), + "schema": item.get("schema"), + "database_uuid": item.get("database_uuid"), + "sql": item.get("sql"), + "columns": item.get("columns"), + "metrics": item.get("metrics"), + }, + sort_keys=True, + default=str, + ), + } + for item in datasets + if item.get("uuid") + ], + "charts": [ + { + "uuid": str(item.get("uuid")), "title": item.get("slice_name") or item.get("name"), - "viz_type": item.get("viz_type"), - "params": item.get("params"), - "query_context": item.get("query_context"), - "datasource_uuid": item.get("datasource_uuid"), - "dataset_uuid": item.get("dataset_uuid"), - }, sort_keys=True, default=str), - } for item in charts if item.get("uuid")], + "dataset_uuid": item.get("datasource_uuid") + or item.get("dataset_uuid"), + "signature": json.dumps( + { + "title": item.get("slice_name") or item.get("name"), + "viz_type": item.get("viz_type"), + "params": item.get("params"), + "query_context": item.get("query_context"), + "datasource_uuid": item.get("datasource_uuid"), + "dataset_uuid": item.get("dataset_uuid"), + }, + sort_keys=True, + default=str, + ), + } + for item in charts + if item.get("uuid") + ], } + # [/DEF:_build_target_signatures:Function] # [DEF:_build_risks:Function] @@ -228,6 +344,7 @@ class MigrationDryRunService: target_client: SupersetClient, ) -> List[Dict[str, Any]]: return build_risks(source_objects, target_objects, diff, target_client) + # [/DEF:_build_risks:Function] diff --git a/backend/src/core/migration/risk_assessor.py b/backend/src/core/migration/risk_assessor.py index 75590617..9a983fab 100644 --- a/backend/src/core/migration/risk_assessor.py +++ b/backend/src/core/migration/risk_assessor.py @@ -3,8 +3,9 @@ # @SEMANTICS: migration, dry_run, risk, scoring, preflight # @PURPOSE: Compute deterministic migration risk items and aggregate score for dry-run reporting. # @LAYER: Domain -# @RELATION: [DEPENDS_ON] ->[backend.src.core.superset_client.SupersetClient] -# @RELATION: [DISPATCHES] ->[backend.src.core.migration.dry_run_orchestrator.MigrationDryRunService.run] +# @RELATION: DEPENDS_ON -> [backend.src.core.superset_client.SupersetClient] +# @RELATION: DISPATCHED_BY -> [backend.src.core.migration.dry_run_orchestrator.MigrationDryRunService.run] +# @RELATION: CONTAINS -> [index_by_uuid, extract_owner_identifiers, build_risks, score_risks] # @INVARIANT: Risk scoring must remain bounded to [0,100] and preserve severity-to-weight mapping. # @TEST_CONTRACT: [source_objects,target_objects,diff,target_client] -> [List[RiskItem]] # @TEST_SCENARIO: [overwrite_update_objects] -> [medium overwrite_existing risk is emitted for each update diff item] @@ -41,6 +42,8 @@ def index_by_uuid(objects: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: indexed[str(uuid)] = obj logger.reflect("UUID index built", extra={"indexed_count": len(indexed)}) return indexed + + # [/DEF:index_by_uuid:Function] @@ -66,13 +69,18 @@ def extract_owner_identifiers(owners: Any) -> List[str]: elif owner is not None: ids.append(str(owner)) normalized_ids = sorted(set(ids)) - logger.reflect("Owner identifiers normalized", extra={"owner_count": len(normalized_ids)}) + logger.reflect( + "Owner identifiers normalized", extra={"owner_count": len(normalized_ids)} + ) return normalized_ids + + # [/DEF:extract_owner_identifiers:Function] # [DEF:build_risks:Function] # @PURPOSE: Build risk list from computed diffs and target catalog state. +# @RELATION: DEPENDS_ON -> [index_by_uuid, extract_owner_identifiers] # @PRE: source_objects/target_objects/diff contain dashboards/charts/datasets keys with expected list structures. # @PRE: target_client is authenticated/usable for database list retrieval. # @POST: Returns list of deterministic risk items derived from overwrite, missing datasource, reference, and owner mismatch checks. @@ -94,39 +102,47 @@ def build_risks( risks: List[Dict[str, Any]] = [] for object_type in ("dashboards", "charts", "datasets"): for item in diff[object_type]["update"]: - risks.append({ - "code": "overwrite_existing", - "severity": "medium", - "object_type": object_type[:-1], - "object_uuid": item["uuid"], - "message": f"Object will be updated in target: {item.get('title') or item['uuid']}", - }) + risks.append( + { + "code": "overwrite_existing", + "severity": "medium", + "object_type": object_type[:-1], + "object_uuid": item["uuid"], + "message": f"Object will be updated in target: {item.get('title') or item['uuid']}", + } + ) target_dataset_uuids = set(index_by_uuid(target_objects["datasets"]).keys()) _, target_databases = target_client.get_databases(query={"columns": ["uuid"]}) - target_database_uuids = {str(item.get("uuid")) for item in target_databases if item.get("uuid")} + target_database_uuids = { + str(item.get("uuid")) for item in target_databases if item.get("uuid") + } for dataset in source_objects["datasets"]: db_uuid = dataset.get("database_uuid") if db_uuid and str(db_uuid) not in target_database_uuids: - risks.append({ - "code": "missing_datasource", - "severity": "high", - "object_type": "dataset", - "object_uuid": dataset.get("uuid"), - "message": f"Target datasource is missing for dataset {dataset.get('title') or dataset.get('uuid')}", - }) + risks.append( + { + "code": "missing_datasource", + "severity": "high", + "object_type": "dataset", + "object_uuid": dataset.get("uuid"), + "message": f"Target datasource is missing for dataset {dataset.get('title') or dataset.get('uuid')}", + } + ) for chart in source_objects["charts"]: ds_uuid = chart.get("dataset_uuid") if ds_uuid and str(ds_uuid) not in target_dataset_uuids: - risks.append({ - "code": "breaking_reference", - "severity": "high", - "object_type": "chart", - "object_uuid": chart.get("uuid"), - "message": f"Chart references dataset not found on target: {ds_uuid}", - }) + risks.append( + { + "code": "breaking_reference", + "severity": "high", + "object_type": "chart", + "object_uuid": chart.get("uuid"), + "message": f"Chart references dataset not found on target: {ds_uuid}", + } + ) source_dash = index_by_uuid(source_objects["dashboards"]) target_dash = index_by_uuid(target_objects["dashboards"]) @@ -138,15 +154,19 @@ def build_risks( source_owners = extract_owner_identifiers(source_obj.get("owners")) target_owners = extract_owner_identifiers(target_obj.get("owners")) if source_owners and target_owners and source_owners != target_owners: - risks.append({ - "code": "owner_mismatch", - "severity": "low", - "object_type": "dashboard", - "object_uuid": item["uuid"], - "message": f"Owner mismatch for dashboard {item.get('title') or item['uuid']}", - }) + risks.append( + { + "code": "owner_mismatch", + "severity": "low", + "object_type": "dashboard", + "object_uuid": item["uuid"], + "message": f"Owner mismatch for dashboard {item.get('title') or item['uuid']}", + } + ) logger.reflect("Risk list assembled", extra={"risk_count": len(risks)}) return risks + + # [/DEF:build_risks:Function] @@ -160,11 +180,15 @@ def score_risks(risk_items: List[Dict[str, Any]]) -> Dict[str, Any]: with belief_scope("risk_assessor.score_risks"): logger.reason("Scoring risk items", extra={"risk_items_count": len(risk_items)}) weights = {"high": 25, "medium": 10, "low": 5} - score = min(100, sum(weights.get(item.get("severity", "low"), 5) for item in risk_items)) + score = min( + 100, sum(weights.get(item.get("severity", "low"), 5) for item in risk_items) + ) level = "low" if score < 25 else "medium" if score < 60 else "high" result = {"score": score, "level": level, "items": risk_items} logger.reflect("Risk score computed", extra={"score": score, "level": level}) return result + + # [/DEF:score_risks:Function] diff --git a/backend/src/core/migration_engine.py b/backend/src/core/migration_engine.py index 90cbebaf..ed44941a 100644 --- a/backend/src/core/migration_engine.py +++ b/backend/src/core/migration_engine.py @@ -25,10 +25,11 @@ from src.core.mapping_service import IdMappingService from src.models.mapping import ResourceType # [/SECTION] + # [DEF:MigrationEngine:Class] # @PURPOSE: Engine for transforming Superset export ZIPs. +# @RELATION: CONTAINS -> [__init__, transform_zip, _transform_yaml, _extract_chart_uuids_from_archive, _patch_dashboard_metadata] class MigrationEngine: - # [DEF:__init__:Function] # @PURPOSE: Initializes migration orchestration dependencies for ZIP/YAML metadata transformations. # @PRE: mapping_service is None or implements batch remote ID lookup for ResourceType.CHART. @@ -41,10 +42,12 @@ class MigrationEngine: logger.reason("Initializing MigrationEngine") self.mapping_service = mapping_service logger.reflect("MigrationEngine initialized") + # [/DEF:__init__:Function] # [DEF:transform_zip:Function] # @PURPOSE: Extracts ZIP, replaces database UUIDs in YAMLs, patches cross-filters, and re-packages. + # @RELATION: DEPENDS_ON -> [_transform_yaml, _extract_chart_uuids_from_archive, _patch_dashboard_metadata] # @PARAM: zip_path (str) - Path to the source ZIP file. # @PARAM: output_path (str) - Path where the transformed ZIP will be saved. # @PARAM: db_mapping (Dict[str, str]) - Mapping of source UUID to target UUID. @@ -56,52 +59,76 @@ class MigrationEngine: # @SIDE_EFFECT: Reads/writes filesystem archives, creates temporary directory, emits structured logs. # @DATA_CONTRACT: Input[(str zip_path, str output_path, Dict[str,str] db_mapping, bool strip_databases, Optional[str] target_env_id, bool fix_cross_filters)] -> Output[bool] # @RETURN: bool - True if successful. - def transform_zip(self, zip_path: str, output_path: str, db_mapping: Dict[str, str], strip_databases: bool = True, target_env_id: Optional[str] = None, fix_cross_filters: bool = False) -> bool: + def transform_zip( + self, + zip_path: str, + output_path: str, + db_mapping: Dict[str, str], + strip_databases: bool = True, + target_env_id: Optional[str] = None, + fix_cross_filters: bool = False, + ) -> bool: """ Transform a Superset export ZIP by replacing database UUIDs and optionally fixing cross-filters. """ with belief_scope("MigrationEngine.transform_zip"): logger.reason(f"Starting ZIP transformation: {zip_path} -> {output_path}") - + with tempfile.TemporaryDirectory() as temp_dir_str: temp_dir = Path(temp_dir_str) try: # 1. Extract logger.reason(f"Extracting source archive to {temp_dir}") - with zipfile.ZipFile(zip_path, 'r') as zf: + with zipfile.ZipFile(zip_path, "r") as zf: zf.extractall(temp_dir) # 2. Transform YAMLs (Databases) - dataset_files = list(temp_dir.glob("**/datasets/**/*.yaml")) + list(temp_dir.glob("**/datasets/*.yaml")) + dataset_files = list(temp_dir.glob("**/datasets/**/*.yaml")) + list( + temp_dir.glob("**/datasets/*.yaml") + ) dataset_files = list(set(dataset_files)) - - logger.reason(f"Transforming {len(dataset_files)} dataset YAML files") + + logger.reason( + f"Transforming {len(dataset_files)} dataset YAML files" + ) for ds_file in dataset_files: self._transform_yaml(ds_file, db_mapping) # 2.5 Patch Cross-Filters (Dashboards) if fix_cross_filters: if self.mapping_service and target_env_id: - dash_files = list(temp_dir.glob("**/dashboards/**/*.yaml")) + list(temp_dir.glob("**/dashboards/*.yaml")) + dash_files = list( + temp_dir.glob("**/dashboards/**/*.yaml") + ) + list(temp_dir.glob("**/dashboards/*.yaml")) dash_files = list(set(dash_files)) - - logger.reason(f"Patching cross-filters for {len(dash_files)} dashboards") - + + logger.reason( + f"Patching cross-filters for {len(dash_files)} dashboards" + ) + # Gather all source UUID-to-ID mappings from the archive first - source_id_to_uuid_map = self._extract_chart_uuids_from_archive(temp_dir) - + source_id_to_uuid_map = ( + self._extract_chart_uuids_from_archive(temp_dir) + ) + for dash_file in dash_files: - self._patch_dashboard_metadata(dash_file, target_env_id, source_id_to_uuid_map) + self._patch_dashboard_metadata( + dash_file, target_env_id, source_id_to_uuid_map + ) else: - logger.explore("Cross-filter patching requested but mapping service or target_env_id is missing") + logger.explore( + "Cross-filter patching requested but mapping service or target_env_id is missing" + ) # 3. Re-package - logger.reason(f"Re-packaging transformed archive (strip_databases={strip_databases})") - with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf: + logger.reason( + f"Re-packaging transformed archive (strip_databases={strip_databases})" + ) + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: for root, dirs, files in os.walk(temp_dir): rel_root = Path(root).relative_to(temp_dir) - + if strip_databases and "databases" in rel_root.parts: continue @@ -109,12 +136,13 @@ class MigrationEngine: file_path = Path(root) / file arcname = file_path.relative_to(temp_dir) zf.write(file_path, arcname) - + logger.reflect("ZIP transformation completed successfully") return True except Exception as e: logger.explore(f"Error transforming ZIP: {e}") return False + # [/DEF:transform_zip:Function] # [DEF:_transform_yaml:Function] @@ -131,19 +159,20 @@ class MigrationEngine: logger.explore(f"YAML file not found: {file_path}") raise FileNotFoundError(str(file_path)) - with open(file_path, 'r') as f: + with open(file_path, "r") as f: data = yaml.safe_load(f) if not data: return - source_uuid = data.get('database_uuid') + source_uuid = data.get("database_uuid") if source_uuid in db_mapping: logger.reason(f"Replacing database UUID in {file_path.name}") - data['database_uuid'] = db_mapping[source_uuid] - with open(file_path, 'w') as f: + data["database_uuid"] = db_mapping[source_uuid] + with open(file_path, "w") as f: yaml.dump(data, f) logger.reflect(f"Database UUID patched in {file_path.name}") + # [/DEF:_transform_yaml:Function] # [DEF:_extract_chart_uuids_from_archive:Function] @@ -161,16 +190,19 @@ class MigrationEngine: # or manifesting the export metadata structure where source IDs are stored. # For simplicity in US1 MVP, we assume it's read from chart files if present. mapping = {} - chart_files = list(temp_dir.glob("**/charts/**/*.yaml")) + list(temp_dir.glob("**/charts/*.yaml")) + chart_files = list(temp_dir.glob("**/charts/**/*.yaml")) + list( + temp_dir.glob("**/charts/*.yaml") + ) for cf in set(chart_files): try: - with open(cf, 'r') as f: + with open(cf, "r") as f: cdata = yaml.safe_load(f) - if cdata and 'id' in cdata and 'uuid' in cdata: - mapping[cdata['id']] = cdata['uuid'] + if cdata and "id" in cdata and "uuid" in cdata: + mapping[cdata["id"]] = cdata["uuid"] except Exception: pass return mapping + # [/DEF:_extract_chart_uuids_from_archive:Function] # [DEF:_patch_dashboard_metadata:Function] @@ -182,29 +214,37 @@ class MigrationEngine: # @PARAM: file_path (Path) # @PARAM: target_env_id (str) # @PARAM: source_map (Dict[int, str]) - def _patch_dashboard_metadata(self, file_path: Path, target_env_id: str, source_map: Dict[int, str]): + def _patch_dashboard_metadata( + self, file_path: Path, target_env_id: str, source_map: Dict[int, str] + ): with belief_scope("MigrationEngine._patch_dashboard_metadata"): try: if not file_path.exists(): return - with open(file_path, 'r') as f: + with open(file_path, "r") as f: data = yaml.safe_load(f) - if not data or 'json_metadata' not in data: + if not data or "json_metadata" not in data: return - metadata_str = data['json_metadata'] + metadata_str = data["json_metadata"] if not metadata_str: return # Fetch target UUIDs for everything we know: uuids_needed = list(source_map.values()) - logger.reason(f"Resolving {len(uuids_needed)} remote IDs for dashboard metadata patching") - target_ids = self.mapping_service.get_remote_ids_batch(target_env_id, ResourceType.CHART, uuids_needed) - + logger.reason( + f"Resolving {len(uuids_needed)} remote IDs for dashboard metadata patching" + ) + target_ids = self.mapping_service.get_remote_ids_batch( + target_env_id, ResourceType.CHART, uuids_needed + ) + if not target_ids: - logger.reflect("No remote target IDs found in mapping database for this dashboard.") + logger.reflect( + "No remote target IDs found in mapping database for this dashboard." + ) return # Map Source Int -> Target Int @@ -215,33 +255,48 @@ class MigrationEngine: source_to_target[s_id] = target_ids[s_uuid] else: missing_targets.append(s_id) - + if missing_targets: - logger.explore(f"Missing target IDs for source IDs: {missing_targets}. Cross-filters might break.") + logger.explore( + f"Missing target IDs for source IDs: {missing_targets}. Cross-filters might break." + ) if not source_to_target: logger.reflect("No source IDs matched remotely. Skipping patch.") return - logger.reason(f"Patching {len(source_to_target)} ID references in json_metadata") + logger.reason( + f"Patching {len(source_to_target)} ID references in json_metadata" + ) new_metadata_str = metadata_str - + for s_id, t_id in source_to_target.items(): - new_metadata_str = re.sub(r'("datasetId"\s*:\s*)' + str(s_id) + r'(\b)', r'\g<1>' + str(t_id) + r'\g<2>', new_metadata_str) - new_metadata_str = re.sub(r'("chartId"\s*:\s*)' + str(s_id) + r'(\b)', r'\g<1>' + str(t_id) + r'\g<2>', new_metadata_str) + new_metadata_str = re.sub( + r'("datasetId"\s*:\s*)' + str(s_id) + r"(\b)", + r"\g<1>" + str(t_id) + r"\g<2>", + new_metadata_str, + ) + new_metadata_str = re.sub( + r'("chartId"\s*:\s*)' + str(s_id) + r"(\b)", + r"\g<1>" + str(t_id) + r"\g<2>", + new_metadata_str, + ) # Re-parse to validate valid JSON - data['json_metadata'] = json.dumps(json.loads(new_metadata_str)) - - with open(file_path, 'w') as f: + data["json_metadata"] = json.dumps(json.loads(new_metadata_str)) + + with open(file_path, "w") as f: yaml.dump(data, f) - logger.reflect(f"Dashboard metadata patched and saved: {file_path.name}") + logger.reflect( + f"Dashboard metadata patched and saved: {file_path.name}" + ) except Exception as e: logger.explore(f"Metadata patch failed for {file_path.name}: {e}") # [/DEF:_patch_dashboard_metadata:Function] + # [/DEF:MigrationEngine:Class] # [/DEF:backend.src.core.migration_engine:Module] diff --git a/backend/src/core/superset_client.py b/backend/src/core/superset_client.py index 236934a4..e633f2bf 100644 --- a/backend/src/core/superset_client.py +++ b/backend/src/core/superset_client.py @@ -694,7 +694,7 @@ class SupersetClient: # @PRE: Client is authenticated and chart_id exists. # @POST: Returns chart payload from Superset API. # @DATA_CONTRACT: Input[chart_id: int] -> Output[Dict] - # @RELATION: [CALLS] ->[APIClient.request] + # @RELATION: [CALLS] ->[request] def get_chart(self, chart_id: int) -> Dict: with belief_scope("SupersetClient.get_chart", f"id={chart_id}"): response = self.network.request(method="GET", endpoint=f"/chart/{chart_id}") @@ -996,7 +996,7 @@ class SupersetClient: # @PRE: Client is authenticated. # @POST: Returns total count and charts list. # @DATA_CONTRACT: Input[query: Optional[Dict]] -> Output[Tuple[int, List[Dict]]] - # @RELATION: [CALLS] ->[SupersetClient._fetch_all_pages] + # @RELATION: [CALLS] ->[_fetch_all_pages] def get_charts(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: with belief_scope("get_charts"): validated_query = self._validate_query_params(query or {}) diff --git a/backend/src/core/task_manager/__tests__/test_context.py b/backend/src/core/task_manager/__tests__/test_context.py index 4cea1c81..f2a5c816 100644 --- a/backend/src/core/task_manager/__tests__/test_context.py +++ b/backend/src/core/task_manager/__tests__/test_context.py @@ -1,4 +1,5 @@ -# [DEF:backend.src.core.task_manager.__tests__.test_context:Module] +# [DEF:TestContext:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, task-context, background-tasks, sub-context # @PURPOSE: Verify TaskContext preserves optional background task scheduler across sub-context creation. @@ -9,6 +10,7 @@ from src.core.task_manager.context import TaskContext # [DEF:test_task_context_preserves_background_tasks_across_sub_context:Function] +# @RELATION: BINDS_TO -> TestContext # @PURPOSE: Plugins must be able to access background_tasks from both root and sub-context loggers. # @PRE: TaskContext is initialized with a BackgroundTasks-like object. # @POST: background_tasks remains available on root and derived sub-contexts. @@ -26,4 +28,4 @@ def test_task_context_preserves_background_tasks_across_sub_context(): assert context.background_tasks is background_tasks assert sub_context.background_tasks is background_tasks # [/DEF:test_task_context_preserves_background_tasks_across_sub_context:Function] -# [/DEF:backend.src.core.task_manager.__tests__.test_context:Module] +# [/DEF:TestContext:Module] diff --git a/backend/src/core/task_manager/__tests__/test_task_logger.py b/backend/src/core/task_manager/__tests__/test_task_logger.py index aefc820e..7885d970 100644 --- a/backend/src/core/task_manager/__tests__/test_task_logger.py +++ b/backend/src/core/task_manager/__tests__/test_task_logger.py @@ -17,12 +17,18 @@ def task_logger(mock_add_log): return TaskLogger(task_id="test_123", add_log_fn=mock_add_log, source="test_plugin") # @TEST_CONTRACT: TaskLoggerModel -> Invariants +# [DEF:test_task_logger_initialization:Function] +# @RELATION: BINDS_TO -> __tests__/test_task_logger def test_task_logger_initialization(task_logger): """Verify TaskLogger is bound to specific task_id and source.""" assert task_logger._task_id == "test_123" assert task_logger._default_source == "test_plugin" # @TEST_CONTRACT: invariants -> "All specific log methods (info, error) delegate to _log" +# [/DEF:test_task_logger_initialization:Function] + +# [DEF:test_log_methods_delegation:Function] +# @RELATION: BINDS_TO -> __tests__/test_task_logger def test_log_methods_delegation(task_logger, mock_add_log): """Verify info, error, warning, debug delegate to internal _log.""" task_logger.info("info message", metadata={"k": "v"}) @@ -62,6 +68,10 @@ def test_log_methods_delegation(task_logger, mock_add_log): ) # @TEST_CONTRACT: invariants -> "with_source creates a new logger with the same task_id" +# [/DEF:test_log_methods_delegation:Function] + +# [DEF:test_with_source:Function] +# @RELATION: BINDS_TO -> __tests__/test_task_logger def test_with_source(task_logger): """Verify with_source returns a new instance with updated default source.""" new_logger = task_logger.with_source("new_source") @@ -71,18 +81,30 @@ def test_with_source(task_logger): assert new_logger is not task_logger # @TEST_EDGE: missing_task_id -> raises TypeError +# [/DEF:test_with_source:Function] + +# [DEF:test_missing_task_id:Function] +# @RELATION: BINDS_TO -> __tests__/test_task_logger def test_missing_task_id(): with pytest.raises(TypeError): TaskLogger(add_log_fn=lambda x: x) # @TEST_EDGE: invalid_add_log_fn -> raises TypeError # (Python doesn't strictly enforce this at init, but let's verify it fails on call if not callable) +# [/DEF:test_missing_task_id:Function] + +# [DEF:test_invalid_add_log_fn:Function] +# @RELATION: BINDS_TO -> __tests__/test_task_logger def test_invalid_add_log_fn(): logger = TaskLogger(task_id="msg", add_log_fn=None) with pytest.raises(TypeError): logger.info("test") # @TEST_INVARIANT: consistent_delegation +# [/DEF:test_invalid_add_log_fn:Function] + +# [DEF:test_progress_log:Function] +# @RELATION: BINDS_TO -> __tests__/test_task_logger def test_progress_log(task_logger, mock_add_log): """Verify progress method correctly formats metadata.""" task_logger.progress("Step 1", 45.5) @@ -100,3 +122,4 @@ def test_progress_log(task_logger, mock_add_log): task_logger.progress("Step low", -10) assert mock_add_log.call_args[1]["metadata"]["progress"] == 0 +# [/DEF:test_progress_log:Function] diff --git a/backend/src/core/utils/network.py b/backend/src/core/utils/network.py index ab41605c..f7e0ec26 100644 --- a/backend/src/core/utils/network.py +++ b/backend/src/core/utils/network.py @@ -309,7 +309,7 @@ class APIClient: except (requests.exceptions.RequestException, KeyError) as e: SupersetAuthCache.invalidate(self._auth_cache_key) raise NetworkError(f"Network or parsing error during authentication: {e}") from e - # [/DEF:authenticate:Function] + # [/DEF:APIClient.authenticate:Function] @property # [DEF:headers:Function] diff --git a/backend/src/core/utils/superset_compilation_adapter.py b/backend/src/core/utils/superset_compilation_adapter.py index d2157bb8..5596a64c 100644 --- a/backend/src/core/utils/superset_compilation_adapter.py +++ b/backend/src/core/utils/superset_compilation_adapter.py @@ -34,6 +34,8 @@ class PreviewCompilationPayload: preview_fingerprint: str template_params: Dict[str, Any] effective_filters: List[Dict[str, Any]] + + # [/DEF:PreviewCompilationPayload:Class] @@ -47,6 +49,8 @@ class SqlLabLaunchPayload: preview_id: str compiled_sql: str template_params: Dict[str, Any] + + # [/DEF:SqlLabLaunchPayload:Class] @@ -61,11 +65,25 @@ class SupersetCompilationAdapter: # [DEF:SupersetCompilationAdapter.__init__:Function] # @COMPLEXITY: 2 # @PURPOSE: Bind adapter to one Superset environment and client instance. - def __init__(self, environment: Environment, client: Optional[SupersetClient] = None) -> None: + def __init__( + self, environment: Environment, client: Optional[SupersetClient] = None + ) -> None: self.environment = environment self.client = client or SupersetClient(environment) + # [/DEF:SupersetCompilationAdapter.__init__:Function] + # [DEF:SupersetCompilationAdapter._supports_client_method:Function] + # @COMPLEXITY: 2 + # @PURPOSE: Detect explicitly implemented client capabilities without treating loose mocks as real methods. + def _supports_client_method(self, method_name: str) -> bool: + client_dict = getattr(self.client, "__dict__", {}) + if method_name in client_dict: + return callable(client_dict[method_name]) + return callable(getattr(type(self.client), method_name, None)) + + # [/DEF:SupersetCompilationAdapter._supports_client_method:Function] + # [DEF:SupersetCompilationAdapter.compile_preview:Function] # @COMPLEXITY: 4 # @PURPOSE: Request Superset-side compiled SQL preview for the current effective inputs. @@ -79,7 +97,10 @@ class SupersetCompilationAdapter: if payload.dataset_id <= 0: logger.explore( "Preview compilation rejected because dataset identifier is invalid", - extra={"dataset_id": payload.dataset_id, "session_id": payload.session_id}, + extra={ + "dataset_id": payload.dataset_id, + "session_id": payload.session_id, + }, ) raise ValueError("dataset_id must be a positive integer") @@ -155,6 +176,7 @@ class SupersetCompilationAdapter: }, ) return preview + # [/DEF:SupersetCompilationAdapter.compile_preview:Function] # [DEF:SupersetCompilationAdapter.mark_preview_stale:Function] @@ -165,6 +187,7 @@ class SupersetCompilationAdapter: def mark_preview_stale(self, preview: CompiledPreview) -> CompiledPreview: preview.preview_status = PreviewStatus.STALE return preview + # [/DEF:SupersetCompilationAdapter.mark_preview_stale:Function] # [DEF:SupersetCompilationAdapter.create_sql_lab_session:Function] @@ -181,7 +204,10 @@ class SupersetCompilationAdapter: if not compiled_sql: logger.explore( "SQL Lab launch rejected because compiled SQL is empty", - extra={"session_id": payload.session_id, "preview_id": payload.preview_id}, + extra={ + "session_id": payload.session_id, + "preview_id": payload.preview_id, + }, ) raise ValueError("compiled_sql must be non-empty") @@ -204,9 +230,14 @@ class SupersetCompilationAdapter: if not sql_lab_session_ref: logger.explore( "Superset SQL Lab launch response did not include a stable session reference", - extra={"session_id": payload.session_id, "preview_id": payload.preview_id}, + extra={ + "session_id": payload.session_id, + "preview_id": payload.preview_id, + }, + ) + raise RuntimeError( + "Superset SQL Lab launch response did not include a session reference" ) - raise RuntimeError("Superset SQL Lab launch response did not include a session reference") logger.reflect( "Canonical SQL Lab session created successfully", @@ -217,6 +248,7 @@ class SupersetCompilationAdapter: }, ) return sql_lab_session_ref + # [/DEF:SupersetCompilationAdapter.create_sql_lab_session:Function] # [DEF:SupersetCompilationAdapter._request_superset_preview:Function] @@ -227,7 +259,81 @@ class SupersetCompilationAdapter: # @POST: returns one normalized upstream compilation response including the chosen strategy metadata. # @SIDE_EFFECT: issues one or more Superset preview requests through the client fallback chain. # @DATA_CONTRACT: Input[PreviewCompilationPayload] -> Output[Dict[str,Any]] - def _request_superset_preview(self, payload: PreviewCompilationPayload) -> Dict[str, Any]: + def _request_superset_preview( + self, payload: PreviewCompilationPayload + ) -> Dict[str, Any]: + direct_compile_preview = getattr(self.client, "compile_preview", None) + if self._supports_client_method("compile_preview") and callable( + direct_compile_preview + ): + try: + logger.reason( + "Attempting preview compilation via direct client capability", + extra={ + "dataset_id": payload.dataset_id, + "session_id": payload.session_id, + }, + ) + response = direct_compile_preview(payload) + except TypeError: + response = direct_compile_preview( + payload.dataset_id, + template_params=payload.template_params, + effective_filters=payload.effective_filters, + ) + except Exception as exc: + logger.explore( + "Direct client preview capability failed; falling back to dataset preview strategies", + extra={ + "dataset_id": payload.dataset_id, + "session_id": payload.session_id, + "error": str(exc), + }, + ) + else: + normalized = self._normalize_preview_response(response) + if normalized is not None: + return normalized + + direct_compile_dataset_preview = getattr( + self.client, "compile_dataset_preview", None + ) + if self._supports_client_method("compile_dataset_preview") and callable( + direct_compile_dataset_preview + ): + try: + logger.reason( + "Attempting deterministic Superset preview compilation through supported endpoint strategies", + extra={ + "dataset_id": payload.dataset_id, + "session_id": payload.session_id, + "filter_count": len(payload.effective_filters), + "template_param_count": len(payload.template_params), + }, + ) + response = direct_compile_dataset_preview( + dataset_id=payload.dataset_id, + template_params=payload.template_params, + effective_filters=payload.effective_filters, + ) + except Exception as exc: + logger.explore( + "Superset preview compilation failed across supported endpoint strategies", + extra={ + "dataset_id": payload.dataset_id, + "session_id": payload.session_id, + "error": str(exc), + }, + ) + raise RuntimeError(str(exc)) from exc + + normalized = self._normalize_preview_response(response) + if normalized is None: + raise RuntimeError( + "Superset preview compilation response could not be normalized" + ) + return normalized + try: logger.reason( "Attempting deterministic Superset preview compilation through supported endpoint strategies", @@ -238,10 +344,45 @@ class SupersetCompilationAdapter: "template_param_count": len(payload.template_params), }, ) - response = self.client.compile_dataset_preview( - dataset_id=payload.dataset_id, - template_params=payload.template_params, - effective_filters=payload.effective_filters, + if self._supports_client_method("compile_dataset_preview"): + response = self.client.compile_dataset_preview( + dataset_id=payload.dataset_id, + template_params=payload.template_params, + effective_filters=payload.effective_filters, + ) + normalized = self._normalize_preview_response(response) + if normalized is None: + raise RuntimeError( + "Superset preview compilation response could not be normalized" + ) + return normalized + + errors: List[str] = [] + for endpoint in ( + f"/dataset/{payload.dataset_id}/preview", + f"/dataset/{payload.dataset_id}/sql", + ): + try: + response = self.client.network.request( + method="POST", + endpoint=endpoint, + data=self._dump_json( + { + "template_params": payload.template_params, + "effective_filters": payload.effective_filters, + } + ), + headers={"Content-Type": "application/json"}, + ) + normalized = self._normalize_preview_response(response) + if normalized is not None: + return normalized + errors.append(f"{endpoint}:unrecognized_response") + except Exception as exc: + errors.append(f"{endpoint}:{exc}") + + raise RuntimeError( + "; ".join(errors) or "Superset preview compilation failed" ) except Exception as exc: logger.explore( @@ -254,10 +395,6 @@ class SupersetCompilationAdapter: ) raise RuntimeError(str(exc)) from exc - normalized = self._normalize_preview_response(response) - if normalized is None: - raise RuntimeError("Superset preview compilation response could not be normalized") - return normalized # [/DEF:SupersetCompilationAdapter._request_superset_preview:Function] # [DEF:SupersetCompilationAdapter._request_sql_lab_session:Function] @@ -270,10 +407,20 @@ class SupersetCompilationAdapter: # @DATA_CONTRACT: Input[SqlLabLaunchPayload] -> Output[Dict[str,Any]] def _request_sql_lab_session(self, payload: SqlLabLaunchPayload) -> Dict[str, Any]: dataset_raw = self.client.get_dataset(payload.dataset_id) - dataset_record = dataset_raw.get("result", dataset_raw) if isinstance(dataset_raw, dict) else {} - database_id = dataset_record.get("database", {}).get("id") if isinstance(dataset_record.get("database"), dict) else dataset_record.get("database_id") + dataset_record = ( + dataset_raw.get("result", dataset_raw) + if isinstance(dataset_raw, dict) + else {} + ) + database_id = ( + dataset_record.get("database", {}).get("id") + if isinstance(dataset_record.get("database"), dict) + else dataset_record.get("database_id") + ) if database_id is None: - raise RuntimeError("Superset dataset does not expose a database identifier for SQL Lab launch") + raise RuntimeError( + "Superset dataset does not expose a database identifier for SQL Lab launch" + ) request_payload = { "database_id": database_id, @@ -305,7 +452,10 @@ class SupersetCompilationAdapter: extra={"target": candidate["target"], "error": str(exc)}, ) - raise RuntimeError("; ".join(errors) or "No Superset SQL Lab surface accepted the request") + raise RuntimeError( + "; ".join(errors) or "No Superset SQL Lab surface accepted the request" + ) + # [/DEF:SupersetCompilationAdapter._request_sql_lab_session:Function] # [DEF:SupersetCompilationAdapter._normalize_preview_response:Function] @@ -339,6 +489,7 @@ class SupersetCompilationAdapter: "raw_response": response, } return None + # [/DEF:SupersetCompilationAdapter._normalize_preview_response:Function] # [DEF:SupersetCompilationAdapter._dump_json:Function] @@ -348,7 +499,10 @@ class SupersetCompilationAdapter: import json return json.dumps(payload, sort_keys=True, default=str) + # [/DEF:SupersetCompilationAdapter._dump_json:Function] + + # [/DEF:SupersetCompilationAdapter:Class] -# [/DEF:SupersetCompilationAdapter:Module] \ No newline at end of file +# [/DEF:SupersetCompilationAdapter:Module] diff --git a/backend/src/dependencies.py b/backend/src/dependencies.py index 687c0992..4e78dd1e 100755 --- a/backend/src/dependencies.py +++ b/backend/src/dependencies.py @@ -15,6 +15,7 @@ # @RELATION: CALLS ->[init_db] from pathlib import Path +from typing import Optional from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError @@ -25,10 +26,16 @@ from .core.scheduler import SchedulerService from .services.resource_service import ResourceService from .services.mapping_service import MappingService from .services.clean_release.repositories import ( - CandidateRepository, ArtifactRepository, ManifestRepository, - PolicyRepository, ComplianceRepository, ReportRepository, - ApprovalRepository, PublicationRepository, AuditRepository, - CleanReleaseAuditLog + CandidateRepository, + ArtifactRepository, + ManifestRepository, + PolicyRepository, + ComplianceRepository, + ReportRepository, + ApprovalRepository, + PublicationRepository, + AuditRepository, + CleanReleaseAuditLog, ) from .services.clean_release.repository import CleanReleaseRepository from .services.clean_release.facade import CleanReleaseFacade @@ -39,14 +46,17 @@ from .core.auth.jwt import decode_token from .core.auth.repository import AuthRepository from .models.auth import User -# Initialize singletons +# Initialize singletons lazily to avoid import-time DB side effects during test collection. # Use absolute path relative to this file to ensure plugins are found regardless of CWD project_root = Path(__file__).parent.parent.parent config_path = project_root / "config.json" -# Initialize database before services that use persisted configuration. -init_db() -config_manager = ConfigManager(config_path=str(config_path)) +config_manager: Optional[ConfigManager] = None +plugin_loader: Optional[PluginLoader] = None +task_manager: Optional[TaskManager] = None +scheduler_service: Optional[SchedulerService] = None +resource_service: Optional[ResourceService] = None + # [DEF:get_config_manager:Function] # @COMPLEXITY: 1 @@ -56,29 +66,23 @@ config_manager = ConfigManager(config_path=str(config_path)) # @RETURN: ConfigManager - The shared config manager instance. def get_config_manager() -> ConfigManager: """Dependency injector for ConfigManager.""" + global config_manager + if config_manager is None: + init_db() + config_manager = ConfigManager(config_path=str(config_path)) return config_manager + + # [/DEF:get_config_manager:Function] plugin_dir = Path(__file__).parent / "plugins" -plugin_loader = PluginLoader(plugin_dir=str(plugin_dir)) -logger.info(f"PluginLoader initialized with directory: {plugin_dir}") -logger.info(f"Available plugins: {[config.name for config in plugin_loader.get_all_plugin_configs()]}") - -task_manager = TaskManager(plugin_loader) -logger.info("TaskManager initialized") - -scheduler_service = SchedulerService(task_manager, config_manager) -logger.info("SchedulerService initialized") - -resource_service = ResourceService() -logger.info("ResourceService initialized") - # Clean Release Redesign Singletons # Note: These use get_db() which is a generator, so we need a way to provide a session. # For singletons in dependencies.py, we might need a different approach or # initialize them inside the dependency functions. + # [DEF:get_plugin_loader:Function] # @COMPLEXITY: 1 # @PURPOSE: Dependency injector for PluginLoader. @@ -87,9 +91,19 @@ logger.info("ResourceService initialized") # @RETURN: PluginLoader - The shared plugin loader instance. def get_plugin_loader() -> PluginLoader: """Dependency injector for PluginLoader.""" + global plugin_loader + if plugin_loader is None: + plugin_loader = PluginLoader(plugin_dir=str(plugin_dir)) + logger.info(f"PluginLoader initialized with directory: {plugin_dir}") + logger.info( + f"Available plugins: {[config.name for config in plugin_loader.get_all_plugin_configs()]}" + ) return plugin_loader + + # [/DEF:get_plugin_loader:Function] + # [DEF:get_task_manager:Function] # @COMPLEXITY: 1 # @PURPOSE: Dependency injector for TaskManager. @@ -98,9 +112,16 @@ def get_plugin_loader() -> PluginLoader: # @RETURN: TaskManager - The shared task manager instance. def get_task_manager() -> TaskManager: """Dependency injector for TaskManager.""" + global task_manager + if task_manager is None: + task_manager = TaskManager(get_plugin_loader()) + logger.info("TaskManager initialized") return task_manager + + # [/DEF:get_task_manager:Function] + # [DEF:get_scheduler_service:Function] # @COMPLEXITY: 1 # @PURPOSE: Dependency injector for SchedulerService. @@ -109,9 +130,16 @@ def get_task_manager() -> TaskManager: # @RETURN: SchedulerService - The shared scheduler service instance. def get_scheduler_service() -> SchedulerService: """Dependency injector for SchedulerService.""" + global scheduler_service + if scheduler_service is None: + scheduler_service = SchedulerService(get_task_manager(), get_config_manager()) + logger.info("SchedulerService initialized") return scheduler_service + + # [/DEF:get_scheduler_service:Function] + # [DEF:get_resource_service:Function] # @COMPLEXITY: 1 # @PURPOSE: Dependency injector for ResourceService. @@ -120,9 +148,16 @@ def get_scheduler_service() -> SchedulerService: # @RETURN: ResourceService - The shared resource service instance. def get_resource_service() -> ResourceService: """Dependency injector for ResourceService.""" + global resource_service + if resource_service is None: + resource_service = ResourceService() + logger.info("ResourceService initialized") return resource_service + + # [/DEF:get_resource_service:Function] + # [DEF:get_mapping_service:Function] # @COMPLEXITY: 1 # @PURPOSE: Dependency injector for MappingService. @@ -131,12 +166,15 @@ def get_resource_service() -> ResourceService: # @RETURN: MappingService - A new mapping service instance. def get_mapping_service() -> MappingService: """Dependency injector for MappingService.""" - return MappingService(config_manager) + return MappingService(get_config_manager()) + + # [/DEF:get_mapping_service:Function] _clean_release_repository = CleanReleaseRepository() + # [DEF:get_clean_release_repository:Function] # @COMPLEXITY: 1 # @PURPOSE: Legacy compatibility shim for CleanReleaseRepository. @@ -144,6 +182,8 @@ _clean_release_repository = CleanReleaseRepository() def get_clean_release_repository() -> CleanReleaseRepository: """Legacy compatibility shim for CleanReleaseRepository.""" return _clean_release_repository + + # [/DEF:get_clean_release_repository:Function] @@ -151,7 +191,7 @@ def get_clean_release_repository() -> CleanReleaseRepository: # @COMPLEXITY: 1 # @PURPOSE: Dependency injector for CleanReleaseFacade. # @POST: Returns a facade instance with a fresh DB session. -def get_clean_release_facade(db = Depends(get_db)) -> CleanReleaseFacade: +def get_clean_release_facade(db=Depends(get_db)) -> CleanReleaseFacade: candidate_repo = CandidateRepository(db) artifact_repo = ArtifactRepository(db) manifest_repo = ManifestRepository(db) @@ -161,7 +201,7 @@ def get_clean_release_facade(db = Depends(get_db)) -> CleanReleaseFacade: approval_repo = ApprovalRepository(db) publication_repo = PublicationRepository(db) audit_repo = AuditRepository(db) - + return CleanReleaseFacade( candidate_repo=candidate_repo, artifact_repo=artifact_repo, @@ -172,17 +212,22 @@ def get_clean_release_facade(db = Depends(get_db)) -> CleanReleaseFacade: approval_repo=approval_repo, publication_repo=publication_repo, audit_repo=audit_repo, - config_manager=config_manager + config_manager=get_config_manager(), ) + + # [/DEF:get_clean_release_facade:Function] # [DEF:oauth2_scheme:Variable] +# @RELATION: DEPENDS_ON -> OAuth2PasswordBearer # @COMPLEXITY: 1 # @PURPOSE: OAuth2 password bearer scheme for token extraction. oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") # [/DEF:oauth2_scheme:Variable] + # [DEF:get_current_user:Function] +# @RELATION: CALLS -> AuthRepository # @COMPLEXITY: 3 # @PURPOSE: Dependency for retrieving currently authenticated user from a JWT. # @PRE: JWT token provided in Authorization header. @@ -191,7 +236,7 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") # @PARAM: token (str) - Extracted JWT token. # @PARAM: db (Session) - Auth database session. # @RETURN: User - The authenticated user. -def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_auth_db)): +def get_current_user(token: str = Depends(oauth2_scheme), db=Depends(get_auth_db)): credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -199,20 +244,25 @@ def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_auth_ ) try: payload = decode_token(token) - username: str = payload.get("sub") - if username is None: + username_value = payload.get("sub") + if not isinstance(username_value, str) or not username_value: raise credentials_exception + username = username_value except JWTError: raise credentials_exception - + repo = AuthRepository(db) user = repo.get_user_by_username(username) if user is None: raise credentials_exception return user + + # [/DEF:get_current_user:Function] + # [DEF:has_permission:Function] +# @RELATION: CALLS -> AuthRepository # @COMPLEXITY: 3 # @PURPOSE: Dependency for checking if the current user has a specific permission. # @PRE: User is authenticated. @@ -228,19 +278,27 @@ def has_permission(resource: str, action: str): for perm in role.permissions: if perm.resource == resource and perm.action == action: return current_user - + # Special case for Admin role (full access) if any(role.name == "Admin" for role in current_user.roles): return current_user - + from .core.auth.logger import log_security_event - log_security_event("PERMISSION_DENIED", current_user.username, {"resource": resource, "action": action}) - + + log_security_event( + "PERMISSION_DENIED", + str(getattr(current_user, "username", "unknown")), + {"resource": resource, "action": action}, + ) + raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Permission denied for {resource}:{action}" + detail=f"Permission denied for {resource}:{action}", ) + return permission_checker + + # [/DEF:has_permission:Function] # [/DEF:AppDependencies:Module] diff --git a/backend/src/models/__tests__/test_clean_release.py b/backend/src/models/__tests__/test_clean_release.py index ae7a6b2e..13b7b815 100644 --- a/backend/src/models/__tests__/test_clean_release.py +++ b/backend/src/models/__tests__/test_clean_release.py @@ -36,17 +36,27 @@ def valid_candidate_data(): "source_snapshot_ref": "v1.0.0-snapshot" } +# [DEF:test_release_candidate_valid:Function] +# @RELATION: BINDS_TO -> __tests__/test_clean_release +# @PURPOSE: Verify that a valid release candidate can be instantiated. def test_release_candidate_valid(valid_candidate_data): rc = ReleaseCandidate(**valid_candidate_data) assert rc.candidate_id == "RC-001" assert rc.status == ReleaseCandidateStatus.DRAFT +# [/DEF:test_release_candidate_valid:Function] + +# [DEF:test_release_candidate_empty_id:Function] +# @RELATION: BINDS_TO -> __tests__/test_clean_release +# @PURPOSE: Verify that a release candidate with an empty ID is rejected. def test_release_candidate_empty_id(valid_candidate_data): valid_candidate_data["candidate_id"] = " " with pytest.raises(ValueError, match="candidate_id must be non-empty"): ReleaseCandidate(**valid_candidate_data) # @TEST_FIXTURE: valid_enterprise_policy +# [/DEF:test_release_candidate_empty_id:Function] + @pytest.fixture def valid_policy_data(): return { @@ -61,17 +71,30 @@ def valid_policy_data(): } # @TEST_INVARIANT: policy_purity +# [DEF:test_enterprise_policy_valid:Function] +# @RELATION: BINDS_TO -> __tests__/test_clean_release +# @PURPOSE: Verify that a valid enterprise policy is accepted. def test_enterprise_policy_valid(valid_policy_data): policy = CleanProfilePolicy(**valid_policy_data) assert policy.external_source_forbidden is True # @TEST_EDGE: enterprise_policy_missing_prohibited +# [/DEF:test_enterprise_policy_valid:Function] + +# [DEF:test_enterprise_policy_missing_prohibited:Function] +# @RELATION: BINDS_TO -> __tests__/test_clean_release +# @PURPOSE: Verify that an enterprise policy without prohibited categories is rejected. def test_enterprise_policy_missing_prohibited(valid_policy_data): valid_policy_data["prohibited_artifact_categories"] = [] with pytest.raises(ValueError, match="enterprise-clean policy requires prohibited_artifact_categories"): CleanProfilePolicy(**valid_policy_data) # @TEST_EDGE: enterprise_policy_external_allowed +# [/DEF:test_enterprise_policy_missing_prohibited:Function] + +# [DEF:test_enterprise_policy_external_allowed:Function] +# @RELATION: BINDS_TO -> __tests__/test_clean_release +# @PURPOSE: Verify that an enterprise policy allowing external sources is rejected. def test_enterprise_policy_external_allowed(valid_policy_data): valid_policy_data["external_source_forbidden"] = False with pytest.raises(ValueError, match="enterprise-clean policy requires external_source_forbidden=true"): @@ -79,6 +102,11 @@ def test_enterprise_policy_external_allowed(valid_policy_data): # @TEST_INVARIANT: manifest_consistency # @TEST_EDGE: manifest_count_mismatch +# [/DEF:test_enterprise_policy_external_allowed:Function] + +# [DEF:test_manifest_count_mismatch:Function] +# @RELATION: BINDS_TO -> __tests__/test_clean_release +# @PURPOSE: Verify that a manifest with count mismatches is rejected. def test_manifest_count_mismatch(): summary = ManifestSummary(included_count=1, excluded_count=0, prohibited_detected_count=0) item = ManifestItem(path="p", category="c", classification=ClassificationType.ALLOWED, reason="r") @@ -101,6 +129,11 @@ def test_manifest_count_mismatch(): # @TEST_INVARIANT: run_integrity # @TEST_EDGE: compliant_run_stage_fail +# [/DEF:test_manifest_count_mismatch:Function] + +# [DEF:test_compliant_run_validation:Function] +# @RELATION: BINDS_TO -> __tests__/test_clean_release +# @PURPOSE: Verify compliant run validation logic and mandatory stage checks. def test_compliant_run_validation(): base_run = { "check_run_id": "run1", @@ -130,6 +163,11 @@ def test_compliant_run_validation(): with pytest.raises(ValueError, match="compliant run requires all mandatory stages"): ComplianceCheckRun(**base_run) +# [/DEF:test_compliant_run_validation:Function] + +# [DEF:test_report_validation:Function] +# @RELATION: BINDS_TO -> __tests__/test_clean_release +# @PURPOSE: Verify compliance report validation based on status and violation counts. def test_report_validation(): # Valid blocked report ComplianceReport( @@ -147,3 +185,4 @@ def test_report_validation(): operator_summary="Blocked", structured_payload_ref="ref", violations_count=2, blocking_violations_count=0 ) +# [/DEF:test_report_validation:Function] diff --git a/backend/src/models/__tests__/test_models.py b/backend/src/models/__tests__/test_models.py index c3abb811..68d57849 100644 --- a/backend/src/models/__tests__/test_models.py +++ b/backend/src/models/__tests__/test_models.py @@ -15,6 +15,7 @@ from src.core.logger import belief_scope # [DEF:test_environment_model:Function] +# @RELATION: BINDS_TO -> test_models # @PURPOSE: Tests that Environment model correctly stores values. # @PRE: Environment class is available. # @POST: Values are verified. diff --git a/backend/src/models/__tests__/test_report_models.py b/backend/src/models/__tests__/test_report_models.py index 05be2630..b39a9892 100644 --- a/backend/src/models/__tests__/test_report_models.py +++ b/backend/src/models/__tests__/test_report_models.py @@ -1,8 +1,8 @@ # [DEF:test_report_models:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @PURPOSE: Unit tests for report Pydantic models and their validators # @LAYER: Domain -# @RELATION: TESTS -> backend.src.models.report import sys from pathlib import Path diff --git a/backend/src/models/assistant.py b/backend/src/models/assistant.py index 17e9d128..62dda5b1 100644 --- a/backend/src/models/assistant.py +++ b/backend/src/models/assistant.py @@ -1,9 +1,9 @@ -# [DEF:backend.src.models.assistant:Module] +# [DEF:AssistantModels:Module] # @COMPLEXITY: 3 # @SEMANTICS: assistant, audit, confirmation, chat # @PURPOSE: SQLAlchemy models for assistant audit trail and confirmation tokens. # @LAYER: Domain -# @RELATION: DEPENDS_ON -> backend.src.models.mapping +# @RELATION: DEPENDS_ON -> MappingModels # @INVARIANT: Assistant records preserve immutable ids and creation timestamps. from datetime import datetime @@ -16,6 +16,7 @@ from .mapping import Base # [DEF:AssistantAuditRecord:Class] # @COMPLEXITY: 3 # @PURPOSE: Store audit decisions and outcomes produced by assistant command handling. +# @RELATION: INHERITS -> MappingModels # @PRE: user_id must identify the actor for every record. # @POST: Audit payload remains available for compliance and debugging. class AssistantAuditRecord(Base): @@ -29,12 +30,15 @@ class AssistantAuditRecord(Base): message = Column(Text, nullable=True) payload = Column(JSON, nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # [/DEF:AssistantAuditRecord:Class] # [DEF:AssistantMessageRecord:Class] # @COMPLEXITY: 3 # @PURPOSE: Persist chat history entries for assistant conversations. +# @RELATION: INHERITS -> MappingModels # @PRE: user_id, conversation_id, role and text must be present. # @POST: Message row can be queried in chronological order. class AssistantMessageRecord(Base): @@ -50,12 +54,15 @@ class AssistantMessageRecord(Base): confirmation_id = Column(String, nullable=True) payload = Column(JSON, nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # [/DEF:AssistantMessageRecord:Class] # [DEF:AssistantConfirmationRecord:Class] # @COMPLEXITY: 3 # @PURPOSE: Persist risky operation confirmation tokens with lifecycle state. +# @RELATION: INHERITS -> MappingModels # @PRE: intent/dispatch and expiry timestamp must be provided. # @POST: State transitions can be tracked and audited. class AssistantConfirmationRecord(Base): @@ -70,5 +77,7 @@ class AssistantConfirmationRecord(Base): expires_at = Column(DateTime, nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) consumed_at = Column(DateTime, nullable=True) + + # [/DEF:AssistantConfirmationRecord:Class] -# [/DEF:backend.src.models.assistant:Module] +# [/DEF:AssistantModels:Module] diff --git a/backend/src/models/auth.py b/backend/src/models/auth.py index ed3c86f9..5e4484f9 100644 --- a/backend/src/models/auth.py +++ b/backend/src/models/auth.py @@ -5,7 +5,7 @@ # @SEMANTICS: auth, models, user, role, permission, sqlalchemy # @PURPOSE: SQLAlchemy models for multi-user authentication and authorization. # @LAYER: Domain -# @RELATION: INHERITS_FROM -> [Base] +# @RELATION: INHERITS_FROM -> [MappingModels:Base] # # @INVARIANT: Usernames and emails must be unique. @@ -20,12 +20,16 @@ from .mapping import Base # [DEF:generate_uuid:Function] # @PURPOSE: Generates a unique UUID string. # @POST: Returns a string representation of a new UUID. +# @RELATION: DEPENDS_ON -> uuid def generate_uuid(): return str(uuid.uuid4()) # [/DEF:generate_uuid:Function] # [DEF:user_roles:Table] # @PURPOSE: Association table for many-to-many relationship between Users and Roles. +# @RELATION: DEPENDS_ON -> Base.metadata +# @RELATION: DEPENDS_ON -> User +# @RELATION: DEPENDS_ON -> Role user_roles = Table( "user_roles", Base.metadata, @@ -36,6 +40,9 @@ user_roles = Table( # [DEF:role_permissions:Table] # @PURPOSE: Association table for many-to-many relationship between Roles and Permissions. +# @RELATION: DEPENDS_ON -> Base.metadata +# @RELATION: DEPENDS_ON -> Role +# @RELATION: DEPENDS_ON -> Permission role_permissions = Table( "role_permissions", Base.metadata, diff --git a/backend/src/models/clean_release.py b/backend/src/models/clean_release.py index 22516c6f..b9c458c2 100644 --- a/backend/src/models/clean_release.py +++ b/backend/src/models/clean_release.py @@ -1,8 +1,9 @@ -# [DEF:backend.src.models.clean_release:Module] -# @COMPLEXITY: 5 +# [DEF:CleanReleaseModels:Module] +# @COMPLEXITY: 3 # @SEMANTICS: clean-release, models, lifecycle, compliance, evidence, immutability # @PURPOSE: Define canonical clean release domain entities and lifecycle guards. # @LAYER: Domain +# @RELATION: DEPENDS_ON -> MappingModels # @PRE: Base mapping model and release enums are available. # @POST: Provides SQLAlchemy and dataclass definitions for governance domain. # @SIDE_EFFECT: None (schema definition). @@ -695,4 +696,4 @@ class CleanReleaseAuditLog(Base): details_json = Column(JSON, default=dict) # [/DEF:CleanReleaseAuditLog:Class] -# [/DEF:backend.src.models.clean_release:Module] \ No newline at end of file +# [/DEF:CleanReleaseModels:Module] \ No newline at end of file diff --git a/backend/src/models/config.py b/backend/src/models/config.py index 7a604fb5..06677e34 100644 --- a/backend/src/models/config.py +++ b/backend/src/models/config.py @@ -1,11 +1,11 @@ -# [DEF:backend.src.models.config:Module] +# [DEF:ConfigModels:Module] # -# @COMPLEXITY: 5 +# @COMPLEXITY: 3 # @SEMANTICS: database, config, settings, sqlalchemy, notification # @PURPOSE: Defines SQLAlchemy persistence models for application and notification configuration records. # @LAYER: Domain -# @RELATION: [DEPENDS_ON] ->[sqlalchemy] -# @RELATION: [DEPENDS_ON] ->[backend.src.models.mapping:Base] + +# @RELATION: [DEPENDS_ON] -> [MappingModels:Base] # @INVARIANT: Configuration payload and notification credentials must remain persisted as non-null JSON documents. from sqlalchemy import Column, String, DateTime, JSON, Boolean @@ -50,4 +50,4 @@ class NotificationConfig(Base): import uuid -# [/DEF:backend.src.models.config:Module] +# [/DEF:ConfigModels:Module] diff --git a/backend/src/models/connection.py b/backend/src/models/connection.py index ef0daf09..bb3f45d6 100644 --- a/backend/src/models/connection.py +++ b/backend/src/models/connection.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.models.connection:Module] +# [DEF:ConnectionModels:Module] # # @COMPLEXITY: 1 # @SEMANTICS: database, connection, configuration, sqlalchemy, sqlite @@ -33,4 +33,4 @@ class ConnectionConfig(Base): updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) # [/DEF:ConnectionConfig:Class] -# [/DEF:backend.src.models.connection:Module] \ No newline at end of file +# [/DEF:ConnectionModels:Module] \ No newline at end of file diff --git a/backend/src/models/dashboard.py b/backend/src/models/dashboard.py index c47067ac..72d12105 100644 --- a/backend/src/models/dashboard.py +++ b/backend/src/models/dashboard.py @@ -1,9 +1,9 @@ -# [DEF:backend.src.models.dashboard:Module] +# [DEF:DashboardModels:Module] # @COMPLEXITY: 3 # @SEMANTICS: dashboard, model, metadata, migration # @PURPOSE: Defines data models for dashboard metadata and selection. # @LAYER: Model -# @RELATION: USED_BY -> backend.src.api.routes.migration +# @RELATION: USED_BY -> MigrationApi from pydantic import BaseModel from typing import List @@ -29,4 +29,4 @@ class DashboardSelection(BaseModel): fix_cross_filters: bool = True # [/DEF:DashboardSelection:Class] -# [/DEF:backend.src.models.dashboard:Module] \ No newline at end of file +# [/DEF:DashboardModels:Module] \ No newline at end of file diff --git a/backend/src/models/llm.py b/backend/src/models/llm.py index 906dbdc6..c93d56c6 100644 --- a/backend/src/models/llm.py +++ b/backend/src/models/llm.py @@ -1,9 +1,9 @@ -# [DEF:backend.src.models.llm:Module] +# [DEF:LlmModels:Module] # @COMPLEXITY: 3 # @SEMANTICS: llm, models, sqlalchemy, persistence # @PURPOSE: SQLAlchemy models for LLM provider configuration and validation results. # @LAYER: Domain -# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base +# @RELATION: INHERITS_FROM -> MappingModels:Base from sqlalchemy import Column, String, Boolean, DateTime, JSON, Text, Time, ForeignKey from datetime import datetime @@ -65,4 +65,4 @@ class ValidationRecord(Base): raw_response = Column(Text, nullable=True) # [/DEF:ValidationRecord:Class] -# [/DEF:backend.src.models.llm:Module] \ No newline at end of file +# [/DEF:LlmModels:Module] \ No newline at end of file diff --git a/backend/src/models/mapping.py b/backend/src/models/mapping.py index 6e9b2051..7f112ee8 100644 --- a/backend/src/models/mapping.py +++ b/backend/src/models/mapping.py @@ -5,7 +5,8 @@ # @SEMANTICS: database, mapping, environment, migration, sqlalchemy, sqlite # @PURPOSE: Defines the database schema for environment metadata and database mappings using SQLAlchemy. # @LAYER: Domain -# @RELATION: DEPENDS_ON -> [sqlalchemy] +# @RELATION: DEPENDS_ON -> sqlalchemy + # # @INVARIANT: All primary keys are UUID strings. # @CONSTRAINT: source_env_id and target_env_id must be valid environment IDs. @@ -44,6 +45,7 @@ class MigrationStatus(enum.Enum): # [DEF:Environment:Class] # @COMPLEXITY: 3 # @PURPOSE: Represents a Superset instance environment. +# @RELATION: DEPENDS_ON -> MappingModels class Environment(Base): __tablename__ = "environments" @@ -87,6 +89,7 @@ class MigrationJob(Base): # @COMPLEXITY: 3 # @PURPOSE: Maps a universal UUID for a resource to its actual ID on a specific environment. # @TEST_DATA: resource_mapping_record -> {'environment_id': 'prod-env-1', 'resource_type': 'chart', 'uuid': '123e4567-e89b-12d3-a456-426614174000', 'remote_integer_id': '42'} +# @RELATION: DEPENDS_ON -> MappingModels class ResourceMapping(Base): __tablename__ = "resource_mappings" diff --git a/backend/src/models/profile.py b/backend/src/models/profile.py index f7a0347c..5c899bd5 100644 --- a/backend/src/models/profile.py +++ b/backend/src/models/profile.py @@ -6,7 +6,7 @@ # @PURPOSE: Defines persistent per-user profile settings for dashboard filter, Git identity/token, and UX preferences. # @LAYER: Domain # @RELATION: DEPENDS_ON -> [AuthModels] -# @RELATION: INHERITS_FROM -> [Base] +# @RELATION: INHERITS_FROM -> [MappingModels:Base] # # @INVARIANT: Exactly one preference row exists per user_id. # @INVARIANT: Sensitive Git token is stored encrypted and never returned in plaintext. @@ -23,6 +23,7 @@ from .mapping import Base # [DEF:UserDashboardPreference:Class] # @COMPLEXITY: 3 # @PURPOSE: Stores Superset username binding and default "my dashboards" toggle for one authenticated user. +# @RELATION: INHERITS -> MappingModels:Base class UserDashboardPreference(Base): __tablename__ = "user_dashboard_preferences" diff --git a/backend/src/models/report.py b/backend/src/models/report.py index f7d9b448..866e7290 100644 --- a/backend/src/models/report.py +++ b/backend/src/models/report.py @@ -1,5 +1,5 @@ -# [DEF:backend.src.models.report:Module] -# @COMPLEXITY: 5 +# [DEF:ReportModels:Module] +# @COMPLEXITY: 3 # @SEMANTICS: reports, models, pydantic, normalization, pagination # @PURPOSE: Canonical report schemas for unified task reporting across heterogeneous task types. # @LAYER: Domain @@ -7,7 +7,7 @@ # @POST: Provides validated schemas for cross-plugin reporting and UI consumption. # @SIDE_EFFECT: None (schema definition). # @DATA_CONTRACT: Model[TaskReport, ReportCollection, ReportDetailView] -# @RELATION: [DEPENDS_ON] ->[backend.src.core.task_manager.models] +# @RELATION: [DEPENDS_ON] -> [TaskModels] # @INVARIANT: Canonical report fields are always present for every report item. # [SECTION: IMPORTS] @@ -20,8 +20,9 @@ from pydantic import BaseModel, Field, field_validator, model_validator # [DEF:TaskType:Class] -# @COMPLEXITY: 5 +# @COMPLEXITY: 3 # @INVARIANT: Must contain valid generic task type mappings. +# @RELATION: DEPENDS_ON -> ReportModels # @SEMANTICS: enum, type, task # @PURPOSE: Supported normalized task report types. class TaskType(str, Enum): @@ -31,11 +32,13 @@ class TaskType(str, Enum): DOCUMENTATION = "documentation" CLEAN_RELEASE = "clean_release" UNKNOWN = "unknown" + + # [/DEF:TaskType:Class] # [DEF:ReportStatus:Class] -# @COMPLEXITY: 5 +# @COMPLEXITY: 3 # @INVARIANT: TaskStatus enum mapping logic holds. # @SEMANTICS: enum, status, task # @PURPOSE: Supported normalized report status values. @@ -44,11 +47,13 @@ class ReportStatus(str, Enum): FAILED = "failed" IN_PROGRESS = "in_progress" PARTIAL = "partial" + + # [/DEF:ReportStatus:Class] # [DEF:ErrorContext:Class] -# @COMPLEXITY: 5 +# @COMPLEXITY: 3 # @INVARIANT: The properties accurately describe error state. # @SEMANTICS: error, context, payload # @PURPOSE: Error and recovery context for failed/partial reports. @@ -69,11 +74,13 @@ class ErrorContext(BaseModel): code: Optional[str] = None message: str next_actions: List[str] = Field(default_factory=list) + + # [/DEF:ErrorContext:Class] # [DEF:TaskReport:Class] -# @COMPLEXITY: 5 +# @COMPLEXITY: 3 # @INVARIANT: Must represent canonical task record attributes. # @SEMANTICS: report, model, summary # @PURPOSE: Canonical normalized report envelope for one task execution. @@ -116,7 +123,7 @@ class TaskReport(BaseModel): updated_at: datetime summary: str details: Optional[Dict[str, Any]] = None - validation_record: Optional[Dict[str, Any]] = None # Extended for US2 + validation_record: Optional[Dict[str, Any]] = None # Extended for US2 error_context: Optional[ErrorContext] = None source_ref: Optional[Dict[str, Any]] = None @@ -126,11 +133,13 @@ class TaskReport(BaseModel): if not isinstance(value, str) or not value.strip(): raise ValueError("Value must be a non-empty string") return value.strip() + + # [/DEF:TaskReport:Class] # [DEF:ReportQuery:Class] -# @COMPLEXITY: 5 +# @COMPLEXITY: 3 # @INVARIANT: Time and pagination queries are mutually consistent. # @SEMANTICS: query, filter, search # @PURPOSE: Query object for server-side report filtering, sorting, and pagination. @@ -184,11 +193,13 @@ class ReportQuery(BaseModel): if self.time_from and self.time_to and self.time_from > self.time_to: raise ValueError("time_from must be less than or equal to time_to") return self + + # [/DEF:ReportQuery:Class] # [DEF:ReportCollection:Class] -# @COMPLEXITY: 5 +# @COMPLEXITY: 3 # @INVARIANT: Represents paginated data correctly. # @SEMANTICS: collection, pagination # @PURPOSE: Paginated collection of normalized task reports. @@ -209,11 +220,13 @@ class ReportCollection(BaseModel): page_size: int = Field(ge=1) has_next: bool applied_filters: ReportQuery + + # [/DEF:ReportCollection:Class] # [DEF:ReportDetailView:Class] -# @COMPLEXITY: 5 +# @COMPLEXITY: 3 # @INVARIANT: Incorporates a report and logs correctly. # @SEMANTICS: view, detail, logs # @PURPOSE: Detailed report representation including diagnostics and recovery actions. @@ -230,6 +243,8 @@ class ReportDetailView(BaseModel): timeline: List[Dict[str, Any]] = Field(default_factory=list) diagnostics: Optional[Dict[str, Any]] = None next_actions: List[str] = Field(default_factory=list) + + # [/DEF:ReportDetailView:Class] -# [/DEF:backend.src.models.report:Module] \ No newline at end of file +# [/DEF:ReportModels:Module] diff --git a/backend/src/models/storage.py b/backend/src/models/storage.py index 3283db44..c67f1b1d 100644 --- a/backend/src/models/storage.py +++ b/backend/src/models/storage.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.models.storage:Module] +# [DEF:StorageModels:Module] # @COMPLEXITY: 1 # @SEMANTICS: storage, file, model, pydantic # @PURPOSE: Data models for the storage system. @@ -41,4 +41,4 @@ class StoredFile(BaseModel): mime_type: Optional[str] = Field(None, description="MIME type of the file.") # [/DEF:StoredFile:Class] -# [/DEF:backend.src.models.storage:Module] \ No newline at end of file +# [/DEF:StorageModels:Module] \ No newline at end of file diff --git a/backend/src/models/task.py b/backend/src/models/task.py index c532dde0..c436c69f 100644 --- a/backend/src/models/task.py +++ b/backend/src/models/task.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.models.task:Module] +# [DEF:TaskModels:Module] # # @COMPLEXITY: 1 # @SEMANTICS: database, task, record, sqlalchemy, sqlite @@ -36,7 +36,7 @@ class TaskRecord(Base): # [DEF:TaskLogRecord:Class] # @PURPOSE: Represents a single persistent log entry for a task. -# @COMPLEXITY: 5 +# @COMPLEXITY: 3 # @RELATION: DEPENDS_ON -> TaskRecord # @INVARIANT: Each log entry belongs to exactly one task. # @@ -113,4 +113,4 @@ class TaskLogRecord(Base): ) # [/DEF:TaskLogRecord:Class] -# [/DEF:backend.src.models.task:Module] \ No newline at end of file +# [/DEF:TaskModels:Module] \ No newline at end of file diff --git a/backend/src/plugins/llm_analysis/__tests__/test_client_headers.py b/backend/src/plugins/llm_analysis/__tests__/test_client_headers.py index 9eb163b1..6e38c85c 100644 --- a/backend/src/plugins/llm_analysis/__tests__/test_client_headers.py +++ b/backend/src/plugins/llm_analysis/__tests__/test_client_headers.py @@ -1,4 +1,5 @@ -# [DEF:backend.src.plugins.llm_analysis.__tests__.test_client_headers:Module] +# [DEF:TestClientHeaders:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, llm-client, openrouter, headers # @PURPOSE: Verify OpenRouter client initialization includes provider-specific headers. @@ -8,6 +9,7 @@ from src.plugins.llm_analysis.service import LLMClient # [DEF:test_openrouter_client_includes_referer_and_title_headers:Function] +# @RELATION: BINDS_TO -> TestClientHeaders # @PURPOSE: OpenRouter requests should carry site/app attribution headers for compatibility. # @PRE: Client is initialized for OPENROUTER provider. # @POST: Async client headers include Authorization, HTTP-Referer, and X-Title. @@ -27,4 +29,4 @@ def test_openrouter_client_includes_referer_and_title_headers(monkeypatch): assert headers["HTTP-Referer"] == "http://localhost:8000" assert headers["X-Title"] == "ss-tools-test" # [/DEF:test_openrouter_client_includes_referer_and_title_headers:Function] -# [/DEF:backend.src.plugins.llm_analysis.__tests__.test_client_headers:Module] +# [/DEF:TestClientHeaders:Module] diff --git a/backend/src/plugins/llm_analysis/__tests__/test_service.py b/backend/src/plugins/llm_analysis/__tests__/test_service.py index 4c3d70f3..c975b5cd 100644 --- a/backend/src/plugins/llm_analysis/__tests__/test_service.py +++ b/backend/src/plugins/llm_analysis/__tests__/test_service.py @@ -1,4 +1,5 @@ -# [DEF:backend.src.plugins.llm_analysis.__tests__.test_service:Module] +# [DEF:TestService:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, llm-analysis, fallback, provider-error, unknown-status # @PURPOSE: Verify LLM analysis transport/provider failures do not masquerade as dashboard FAIL results. @@ -10,6 +11,7 @@ from src.plugins.llm_analysis.service import LLMClient # [DEF:test_test_runtime_connection_uses_json_completion_transport:Function] +# @RELATION: BINDS_TO -> TestService # @PURPOSE: Provider self-test must exercise the same chat completion transport as runtime analysis. # @PRE: get_json_completion is available on initialized client. # @POST: Self-test forwards a lightweight user message into get_json_completion and returns its payload. @@ -38,6 +40,7 @@ async def test_test_runtime_connection_uses_json_completion_transport(monkeypatc # [DEF:test_analyze_dashboard_provider_error_maps_to_unknown:Function] +# @RELATION: BINDS_TO -> TestService # @PURPOSE: Infrastructure/provider failures must produce UNKNOWN analysis status rather than FAIL. # @PRE: LLMClient.get_json_completion raises provider/auth exception. # @POST: Returned payload uses status=UNKNOWN and issue severity UNKNOWN. @@ -64,4 +67,4 @@ async def test_analyze_dashboard_provider_error_maps_to_unknown(monkeypatch, tmp assert "Failed to get response from LLM" in result["summary"] assert result["issues"][0]["severity"] == "UNKNOWN" # [/DEF:test_analyze_dashboard_provider_error_maps_to_unknown:Function] -# [/DEF:backend.src.plugins.llm_analysis.__tests__.test_service:Module] +# [/DEF:TestService:Module] diff --git a/backend/src/schemas/__tests__/test_settings_and_health_schemas.py b/backend/src/schemas/__tests__/test_settings_and_health_schemas.py index 312d510a..5aeff6aa 100644 --- a/backend/src/schemas/__tests__/test_settings_and_health_schemas.py +++ b/backend/src/schemas/__tests__/test_settings_and_health_schemas.py @@ -1,4 +1,5 @@ -# [DEF:backend.src.schemas.__tests__.test_settings_and_health_schemas:Module] +# [DEF:TestSettingsAndHealthSchemas:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @PURPOSE: Regression tests for settings and health schema contracts updated in 026 fix batch. @@ -10,6 +11,7 @@ from src.schemas.settings import ValidationPolicyCreate # [DEF:test_validation_policy_create_accepts_structured_custom_channels:Function] +# @RELATION: BINDS_TO -> TestSettingsAndHealthSchemas # @PURPOSE: Ensure policy schema accepts structured custom channel objects with type/target fields. def test_validation_policy_create_accepts_structured_custom_channels(): payload = { @@ -34,6 +36,7 @@ def test_validation_policy_create_accepts_structured_custom_channels(): # [DEF:test_validation_policy_create_rejects_legacy_string_custom_channels:Function] +# @RELATION: BINDS_TO -> TestSettingsAndHealthSchemas # @PURPOSE: Ensure legacy list[str] custom channel payload is rejected by typed channel contract. def test_validation_policy_create_rejects_legacy_string_custom_channels(): payload = { @@ -53,6 +56,7 @@ def test_validation_policy_create_rejects_legacy_string_custom_channels(): # [DEF:test_dashboard_health_item_status_accepts_only_whitelisted_values:Function] +# @RELATION: BINDS_TO -> TestSettingsAndHealthSchemas # @PURPOSE: Verify strict grouped regex only accepts PASS/WARN/FAIL/UNKNOWN exact statuses. def test_dashboard_health_item_status_accepts_only_whitelisted_values(): valid = DashboardHealthItem( @@ -81,4 +85,4 @@ def test_dashboard_health_item_status_accepts_only_whitelisted_values(): # [/DEF:test_dashboard_health_item_status_accepts_only_whitelisted_values:Function] -# [/DEF:backend.src.schemas.__tests__.test_settings_and_health_schemas:Module] \ No newline at end of file +# [/DEF:TestSettingsAndHealthSchemas:Module] \ No newline at end of file diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py index 3af445b0..7b9ce14c 100644 --- a/backend/src/services/__init__.py +++ b/backend/src/services/__init__.py @@ -1,5 +1,5 @@ -# [DEF:backend.src.services:Module] -# @COMPLEXITY: 3 +# [DEF:services:Module] +# @COMPLEXITY: 2 # @SEMANTICS: services, package, init # @PURPOSE: Package initialization for services module # @LAYER: Core @@ -18,4 +18,4 @@ def __getattr__(name): from .resource_service import ResourceService return ResourceService raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -# [/DEF:backend.src.services:Module] +# [/DEF:services:Module] diff --git a/backend/src/services/__tests__/test_encryption_manager.py b/backend/src/services/__tests__/test_encryption_manager.py index a9cf6b76..8fc7ea41 100644 --- a/backend/src/services/__tests__/test_encryption_manager.py +++ b/backend/src/services/__tests__/test_encryption_manager.py @@ -1,9 +1,9 @@ # [DEF:test_encryption_manager:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: encryption, security, fernet, api-keys, tests # @PURPOSE: Unit tests for EncryptionManager encrypt/decrypt functionality. # @LAYER: Domain -# @RELATION: TESTS -> backend.src.services.llm_provider.EncryptionManager # @INVARIANT: Encrypt+decrypt roundtrip always returns original plaintext. import sys @@ -16,6 +16,7 @@ from cryptography.fernet import Fernet, InvalidToken # [DEF:TestEncryptionManager:Class] +# @RELATION: BINDS_TO -> test_encryption_manager # @PURPOSE: Validate EncryptionManager encrypt/decrypt roundtrip, uniqueness, and error handling. # @PRE: cryptography package installed. # @POST: All encrypt/decrypt invariants verified. diff --git a/backend/src/services/__tests__/test_llm_plugin_persistence.py b/backend/src/services/__tests__/test_llm_plugin_persistence.py index 0946130c..5bfeef8a 100644 --- a/backend/src/services/__tests__/test_llm_plugin_persistence.py +++ b/backend/src/services/__tests__/test_llm_plugin_persistence.py @@ -11,7 +11,9 @@ from src.plugins.llm_analysis import plugin as plugin_module # [DEF:_DummyLogger:Class] # @RELATION: BINDS_TO ->[test_llm_plugin_persistence] +# @COMPLEXITY: 1 # @PURPOSE: Minimal logger shim for TaskContext-like objects used in tests. +# @INVARIANT: Logging methods are no-ops and must not mutate test state. class _DummyLogger: def with_source(self, _source: str): return self @@ -34,7 +36,9 @@ class _DummyLogger: # [DEF:_FakeDBSession:Class] # @RELATION: BINDS_TO ->[test_llm_plugin_persistence] +# @COMPLEXITY: 2 # @PURPOSE: Captures persisted records for assertion and mimics SQLAlchemy session methods used by plugin. +# @INVARIANT: add/commit/close provide only persistence signals asserted by this test. class _FakeDBSession: def __init__(self): self.added = None @@ -90,6 +94,11 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids( async def capture_dashboard(self, _dashboard_id, _screenshot_path): return None + # [DEF:_FakeLLMClient:Class] + # @RELATION: BINDS_TO ->[test_dashboard_validation_plugin_persists_task_and_environment_ids] + # @COMPLEXITY: 2 + # @PURPOSE: Deterministic LLM client double returning canonical analysis payload for persistence-path assertions. + # @INVARIANT: analyze_dashboard is side-effect free and returns schema-compatible PASS result. class _FakeLLMClient: def __init__(self, **_kwargs): return None @@ -101,6 +110,8 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids( "issues": [], } + # [/DEF:_FakeLLMClient:Class] + class _FakeNotificationService: def __init__(self, *_args, **_kwargs): return None diff --git a/backend/src/services/__tests__/test_llm_prompt_templates.py b/backend/src/services/__tests__/test_llm_prompt_templates.py index f74598b0..c2bdf586 100644 --- a/backend/src/services/__tests__/test_llm_prompt_templates.py +++ b/backend/src/services/__tests__/test_llm_prompt_templates.py @@ -1,9 +1,9 @@ -# [DEF:backend.src.services.__tests__.test_llm_prompt_templates:Module] +# [DEF:test_llm_prompt_templates:Module] # @COMPLEXITY: 3 # @SEMANTICS: tests, llm, prompts, templates, settings # @PURPOSE: Validate normalization and rendering behavior for configurable LLM prompt templates. # @LAYER: Domain Tests -# @RELATION: DEPENDS_ON -> backend.src.services.llm_prompt_templates +# @RELATION: DEPENDS_ON ->[backend.src.services.llm_prompt_templates:Function] # @INVARIANT: All required prompt keys remain available after normalization. from src.services.llm_prompt_templates import ( @@ -18,10 +18,13 @@ from src.services.llm_prompt_templates import ( # [DEF:test_normalize_llm_settings_adds_default_prompts:Function] +# @RELATION: BINDS_TO -> test_llm_prompt_templates # @COMPLEXITY: 3 # @PURPOSE: Ensure legacy/partial llm settings are expanded with all prompt defaults. # @PRE: Input llm settings do not contain complete prompts object. # @POST: Returned structure includes required prompt templates with fallback defaults. +# [DEF:test_normalize_llm_settings_adds_default_prompts:Function] +# @RELATION: BINDS_TO -> test_llm_prompt_templates def test_normalize_llm_settings_adds_default_prompts(): normalized = normalize_llm_settings({"default_provider": "x"}) @@ -39,10 +42,15 @@ def test_normalize_llm_settings_adds_default_prompts(): # [DEF:test_normalize_llm_settings_keeps_custom_prompt_values:Function] +# @RELATION: BINDS_TO -> test_llm_prompt_templates # @COMPLEXITY: 3 # @PURPOSE: Ensure user-customized prompt values are preserved during normalization. # @PRE: Input llm settings contain custom prompt override. # @POST: Custom prompt value remains unchanged in normalized output. +# [/DEF:test_normalize_llm_settings_adds_default_prompts:Function] + +# [DEF:test_normalize_llm_settings_keeps_custom_prompt_values:Function] +# @RELATION: BINDS_TO -> test_llm_prompt_templates def test_normalize_llm_settings_keeps_custom_prompt_values(): custom = "Doc for {dataset_name} using {columns_json}" normalized = normalize_llm_settings( @@ -54,10 +62,15 @@ def test_normalize_llm_settings_keeps_custom_prompt_values(): # [DEF:test_render_prompt_replaces_known_placeholders:Function] +# @RELATION: BINDS_TO -> test_llm_prompt_templates # @COMPLEXITY: 3 # @PURPOSE: Ensure template placeholders are deterministically replaced. # @PRE: Template contains placeholders matching provided variables. # @POST: Rendered prompt string contains substituted values. +# [/DEF:test_normalize_llm_settings_keeps_custom_prompt_values:Function] + +# [DEF:test_render_prompt_replaces_known_placeholders:Function] +# @RELATION: BINDS_TO -> test_llm_prompt_templates def test_render_prompt_replaces_known_placeholders(): rendered = render_prompt( "Hello {name}, diff={diff}", @@ -69,8 +82,11 @@ def test_render_prompt_replaces_known_placeholders(): # [DEF:test_is_multimodal_model_detects_known_vision_models:Function] -# @COMPLEXITY: 3 +# @RELATION: BINDS_TO -> test_llm_prompt_templates +# @COMPLEXITY: 2 # @PURPOSE: Ensure multimodal model detection recognizes common vision-capable model names. +# [/DEF:test_render_prompt_replaces_known_placeholders:Function] + def test_is_multimodal_model_detects_known_vision_models(): assert is_multimodal_model("gpt-4o") is True assert is_multimodal_model("claude-3-5-sonnet") is True @@ -80,7 +96,8 @@ def test_is_multimodal_model_detects_known_vision_models(): # [DEF:test_resolve_bound_provider_id_prefers_binding_then_default:Function] -# @COMPLEXITY: 3 +# @RELATION: BINDS_TO -> test_llm_prompt_templates +# @COMPLEXITY: 2 # @PURPOSE: Verify provider binding resolution priority. def test_resolve_bound_provider_id_prefers_binding_then_default(): settings = { @@ -93,7 +110,8 @@ def test_resolve_bound_provider_id_prefers_binding_then_default(): # [DEF:test_normalize_llm_settings_keeps_assistant_planner_settings:Function] -# @COMPLEXITY: 3 +# @RELATION: BINDS_TO -> test_llm_prompt_templates +# @COMPLEXITY: 2 # @PURPOSE: Ensure assistant planner provider/model fields are preserved and normalized. def test_normalize_llm_settings_keeps_assistant_planner_settings(): normalized = normalize_llm_settings( @@ -107,4 +125,4 @@ def test_normalize_llm_settings_keeps_assistant_planner_settings(): # [/DEF:test_normalize_llm_settings_keeps_assistant_planner_settings:Function] -# [/DEF:backend.src.services.__tests__.test_llm_prompt_templates:Module] +# [/DEF:test_llm_prompt_templates:Module] diff --git a/backend/src/services/__tests__/test_llm_provider.py b/backend/src/services/__tests__/test_llm_provider.py index 847a1ff4..c7b811d4 100644 --- a/backend/src/services/__tests__/test_llm_provider.py +++ b/backend/src/services/__tests__/test_llm_provider.py @@ -14,11 +14,14 @@ from src.plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType # [DEF:_test_encryption_key_fixture:Global] # @PURPOSE: Ensure encryption-dependent provider tests run with a valid Fernet key. +# @RELATION: DEPENDS_ON ->[pytest:Module] os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode()) # [/DEF:_test_encryption_key_fixture:Global] # @TEST_CONTRACT: EncryptionManagerModel -> Invariants # @TEST_INVARIANT: symmetric_encryption +# [DEF:test_encryption_cycle:Function] +# @RELATION: BINDS_TO -> __tests__/test_llm_provider def test_encryption_cycle(): """Verify encrypted data can be decrypted back to original string.""" manager = EncryptionManager() @@ -28,6 +31,10 @@ def test_encryption_cycle(): assert manager.decrypt(encrypted) == original # @TEST_EDGE: empty_string_encryption +# [/DEF:test_encryption_cycle:Function] + +# [DEF:test_empty_string_encryption:Function] +# @RELATION: BINDS_TO -> __tests__/test_llm_provider def test_empty_string_encryption(): manager = EncryptionManager() original = "" @@ -35,12 +42,18 @@ def test_empty_string_encryption(): assert manager.decrypt(encrypted) == "" # @TEST_EDGE: decrypt_invalid_data +# [/DEF:test_empty_string_encryption:Function] + +# [DEF:test_decrypt_invalid_data:Function] +# @RELATION: BINDS_TO -> __tests__/test_llm_provider def test_decrypt_invalid_data(): manager = EncryptionManager() with pytest.raises(Exception): manager.decrypt("not-encrypted-string") # @TEST_FIXTURE: mock_db_session +# [/DEF:test_decrypt_invalid_data:Function] + @pytest.fixture def mock_db(): return MagicMock(spec=Session) @@ -49,11 +62,17 @@ def mock_db(): def service(mock_db): return LLMProviderService(db=mock_db) +# [DEF:test_get_all_providers:Function] +# @RELATION: BINDS_TO -> __tests__/test_llm_provider def test_get_all_providers(service, mock_db): service.get_all_providers() mock_db.query.assert_called() mock_db.query().all.assert_called() +# [/DEF:test_get_all_providers:Function] + +# [DEF:test_create_provider:Function] +# @RELATION: BINDS_TO -> __tests__/test_llm_provider def test_create_provider(service, mock_db): config = LLMProviderConfig( provider_type=LLMProviderType.OPENAI, @@ -73,6 +92,10 @@ def test_create_provider(service, mock_db): # Decrypt to verify it matches assert EncryptionManager().decrypt(provider.api_key) == "sk-test" +# [/DEF:test_create_provider:Function] + +# [DEF:test_get_decrypted_api_key:Function] +# @RELATION: BINDS_TO -> __tests__/test_llm_provider def test_get_decrypted_api_key(service, mock_db): # Setup mock provider encrypted_key = EncryptionManager().encrypt("secret-value") @@ -82,10 +105,18 @@ def test_get_decrypted_api_key(service, mock_db): key = service.get_decrypted_api_key("p1") assert key == "secret-value" +# [/DEF:test_get_decrypted_api_key:Function] + +# [DEF:test_get_decrypted_api_key_not_found:Function] +# @RELATION: BINDS_TO -> __tests__/test_llm_provider def test_get_decrypted_api_key_not_found(service, mock_db): mock_db.query().filter().first.return_value = None assert service.get_decrypted_api_key("missing") is None +# [/DEF:test_get_decrypted_api_key_not_found:Function] + +# [DEF:test_update_provider_ignores_masked_placeholder_api_key:Function] +# @RELATION: BINDS_TO -> __tests__/test_llm_provider def test_update_provider_ignores_masked_placeholder_api_key(service, mock_db): existing_encrypted = EncryptionManager().encrypt("secret-value") mock_provider = LLMProvider( @@ -114,3 +145,4 @@ def test_update_provider_ignores_masked_placeholder_api_key(service, mock_db): assert updated.api_key == existing_encrypted assert EncryptionManager().decrypt(updated.api_key) == "secret-value" assert updated.is_active is False +# [/DEF:test_update_provider_ignores_masked_placeholder_api_key:Function] diff --git a/backend/src/services/__tests__/test_rbac_permission_catalog.py b/backend/src/services/__tests__/test_rbac_permission_catalog.py index 822755ca..7f2e2c92 100644 --- a/backend/src/services/__tests__/test_rbac_permission_catalog.py +++ b/backend/src/services/__tests__/test_rbac_permission_catalog.py @@ -1,9 +1,9 @@ -# [DEF:backend.src.services.__tests__.test_rbac_permission_catalog:Module] +# [DEF:test_rbac_permission_catalog:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, rbac, permissions, catalog, discovery, sync # @PURPOSE: Verifies RBAC permission catalog discovery and idempotent synchronization behavior. # @LAYER: Service Tests -# @RELATION: TESTS -> backend.src.services.rbac_permission_catalog # @INVARIANT: Synchronization adds only missing normalized permission pairs. # [SECTION: IMPORTS] @@ -15,6 +15,7 @@ import src.services.rbac_permission_catalog as catalog # [DEF:test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests:Function] +# @RELATION: BINDS_TO -> test_rbac_permission_catalog # @PURPOSE: Ensures route-scanner extracts has_permission pairs from route files and skips __tests__. # @PRE: Temporary route directory contains route and test files. # @POST: Returned set includes production route permissions and excludes test-only declarations. @@ -52,6 +53,7 @@ def test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests(tm # [DEF:test_discover_declared_permissions_unions_route_and_plugin_permissions:Function] +# @RELATION: BINDS_TO -> test_rbac_permission_catalog # @PURPOSE: Ensures full catalog includes route-level permissions plus dynamic plugin EXECUTE rights. # @PRE: Route discovery and plugin loader both return permission sources. # @POST: Result set contains union of both sources. @@ -78,6 +80,7 @@ def test_discover_declared_permissions_unions_route_and_plugin_permissions(monke # [DEF:test_sync_permission_catalog_inserts_only_missing_normalized_pairs:Function] +# @RELATION: BINDS_TO -> test_rbac_permission_catalog # @PURPOSE: Ensures synchronization inserts only missing pairs and normalizes action/resource tokens. # @PRE: DB already contains subset of permissions. # @POST: Only missing normalized pairs are inserted and commit is executed once. @@ -111,6 +114,7 @@ def test_sync_permission_catalog_inserts_only_missing_normalized_pairs(): # [DEF:test_sync_permission_catalog_is_noop_when_all_permissions_exist:Function] +# @RELATION: BINDS_TO -> test_rbac_permission_catalog # @PURPOSE: Ensures synchronization is idempotent when all declared pairs already exist. # @PRE: DB contains full declared permission set. # @POST: No inserts are added and commit is not called. @@ -137,4 +141,4 @@ def test_sync_permission_catalog_is_noop_when_all_permissions_exist(): # [/DEF:test_sync_permission_catalog_is_noop_when_all_permissions_exist:Function] -# [/DEF:backend.src.services.__tests__.test_rbac_permission_catalog:Module] \ No newline at end of file +# [/DEF:test_rbac_permission_catalog:Module] \ No newline at end of file diff --git a/backend/src/services/auth_service.py b/backend/src/services/auth_service.py index 4070465b..06c00143 100644 --- a/backend/src/services/auth_service.py +++ b/backend/src/services/auth_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.auth_service:Module] +# [DEF:auth_service:Module] # @COMPLEXITY: 5 # @SEMANTICS: auth, service, business-logic, login, jwt, adfs, jit-provisioning # @PURPOSE: Orchestrates credential authentication and ADFS JIT user provisioning. @@ -30,7 +30,7 @@ from ..core.logger import belief_scope # @COMPLEXITY: 3 # @PURPOSE: Provides high-level authentication services. class AuthService: - # [DEF:AuthService.__init__:Function] + # [DEF:AuthService_init:Function] # @COMPLEXITY: 1 # @PURPOSE: Initializes the authentication service with repository access over an active DB session. # @PRE: db is a valid SQLAlchemy Session instance bound to the auth persistence context. @@ -41,9 +41,9 @@ class AuthService: def __init__(self, db: Session): self.db = db self.repo = AuthRepository(db) - # [/DEF:AuthService.__init__:Function] + # [/DEF:AuthService_init:Function] - # [DEF:AuthService.authenticate_user:Function] + # [DEF:authenticate_user:Function] # @COMPLEXITY: 3 # @PURPOSE: Validates credentials and account state for local username/password authentication. # @PRE: username and password are non-empty credential inputs. @@ -68,9 +68,9 @@ class AuthService: self.db.refresh(user) return user - # [/DEF:AuthService.authenticate_user:Function] + # [/DEF:authenticate_user:Function] - # [DEF:AuthService.create_session:Function] + # [DEF:create_session:Function] # @COMPLEXITY: 3 # @PURPOSE: Issues an access token payload for an already authenticated user. # @PRE: user is a valid User entity containing username and iterable roles with role.name values. @@ -86,9 +86,9 @@ class AuthService: data={"sub": user.username, "scopes": roles} ) return {"access_token": access_token, "token_type": "bearer"} - # [/DEF:AuthService.create_session:Function] + # [/DEF:create_session:Function] - # [DEF:AuthService.provision_adfs_user:Function] + # [DEF:provision_adfs_user:Function] # @COMPLEXITY: 3 # @PURPOSE: Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings. # @PRE: user_info contains identity claims where at least one of 'upn' or 'email' is present; 'groups' may be absent. @@ -125,7 +125,7 @@ class AuthService: self.db.refresh(user) return user - # [/DEF:AuthService.provision_adfs_user:Function] + # [/DEF:provision_adfs_user:Function] # [/DEF:AuthService:Class] -# [/DEF:backend.src.services.auth_service:Module] \ No newline at end of file +# [/DEF:auth_service:Module] \ No newline at end of file diff --git a/backend/src/services/clean_release/__tests__/test_audit_service.py b/backend/src/services/clean_release/__tests__/test_audit_service.py index 2889670e..89dded35 100644 --- a/backend/src/services/clean_release/__tests__/test_audit_service.py +++ b/backend/src/services/clean_release/__tests__/test_audit_service.py @@ -1,27 +1,53 @@ -# [DEF:backend.tests.services.clean_release.test_audit_service:Module] +# [DEF:TestAuditService:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, clean-release, audit, logging # @PURPOSE: Validate audit hooks emit expected log patterns for clean release lifecycle. # @LAYER: Infra -# @RELATION: TESTS -> backend.src.services.clean_release.audit_service from unittest.mock import patch -from src.services.clean_release.audit_service import audit_preparation, audit_check_run, audit_report +from src.services.clean_release.audit_service import ( + audit_preparation, + audit_check_run, + audit_report, +) + @patch("src.services.clean_release.audit_service.logger") +# [DEF:test_audit_preparation:Function] +# @RELATION: BINDS_TO -> TestAuditService def test_audit_preparation(mock_logger): audit_preparation("cand-1", "PREPARED") - mock_logger.info.assert_called_with("[REASON] clean-release preparation candidate=cand-1 status=PREPARED") + mock_logger.info.assert_called_with( + "[REASON] clean-release preparation candidate=cand-1 status=PREPARED" + ) + + +# [/DEF:test_audit_preparation:Function] + @patch("src.services.clean_release.audit_service.logger") +# [DEF:test_audit_check_run:Function] +# @RELATION: BINDS_TO -> TestAuditService def test_audit_check_run(mock_logger): audit_check_run("check-1", "COMPLIANT") - mock_logger.info.assert_called_with("[REFLECT] clean-release check_run=check-1 final_status=COMPLIANT") + mock_logger.info.assert_called_with( + "[REFLECT] clean-release check_run=check-1 final_status=COMPLIANT" + ) + + +# [/DEF:test_audit_check_run:Function] + @patch("src.services.clean_release.audit_service.logger") +# [DEF:test_audit_report:Function] +# @RELATION: BINDS_TO -> TestAuditService def test_audit_report(mock_logger): audit_report("rep-1", "cand-1") - mock_logger.info.assert_called_with("[EXPLORE] clean-release report_id=rep-1 candidate=cand-1") + mock_logger.info.assert_called_with( + "[EXPLORE] clean-release report_id=rep-1 candidate=cand-1" + ) -# [/DEF:backend.tests.services.clean_release.test_audit_service:Module] +# [/DEF:test_audit_report:Function] +# [/DEF:TestAuditService:Module] diff --git a/backend/src/services/clean_release/__tests__/test_compliance_orchestrator.py b/backend/src/services/clean_release/__tests__/test_compliance_orchestrator.py index 595fb375..70b203cb 100644 --- a/backend/src/services/clean_release/__tests__/test_compliance_orchestrator.py +++ b/backend/src/services/clean_release/__tests__/test_compliance_orchestrator.py @@ -1,9 +1,9 @@ -# [DEF:backend.tests.services.clean_release.test_compliance_orchestrator:Module] +# [DEF:TestComplianceOrchestrator:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, clean-release, orchestrator, stage-state-machine # @PURPOSE: Validate compliance orchestrator stage transitions and final status derivation. # @LAYER: Domain -# @RELATION: TESTS -> backend.src.services.clean_release.compliance_orchestrator # @INVARIANT: Failed mandatory stage forces BLOCKED terminal status. from unittest.mock import patch @@ -22,6 +22,7 @@ from src.services.clean_release.repository import CleanReleaseRepository # [DEF:test_orchestrator_stage_failure_blocks_release:Function] +# @RELATION: BINDS_TO -> TestComplianceOrchestrator # @PURPOSE: Verify mandatory stage failure forces BLOCKED final status. def test_orchestrator_stage_failure_blocks_release(): repository = CleanReleaseRepository() @@ -49,6 +50,7 @@ def test_orchestrator_stage_failure_blocks_release(): # [DEF:test_orchestrator_compliant_candidate:Function] +# @RELATION: BINDS_TO -> TestComplianceOrchestrator # @PURPOSE: Verify happy path where all mandatory stages pass yields COMPLIANT. def test_orchestrator_compliant_candidate(): repository = CleanReleaseRepository() @@ -76,6 +78,7 @@ def test_orchestrator_compliant_candidate(): # [DEF:test_orchestrator_missing_stage_result:Function] +# @RELATION: BINDS_TO -> TestComplianceOrchestrator # @PURPOSE: Verify incomplete mandatory stage set cannot end as COMPLIANT and results in FAILED. def test_orchestrator_missing_stage_result(): repository = CleanReleaseRepository() @@ -93,6 +96,7 @@ def test_orchestrator_missing_stage_result(): # [DEF:test_orchestrator_report_generation_error:Function] +# @RELATION: BINDS_TO -> TestComplianceOrchestrator # @PURPOSE: Verify downstream report errors do not mutate orchestrator final status. def test_orchestrator_report_generation_error(): repository = CleanReleaseRepository() @@ -109,4 +113,4 @@ def test_orchestrator_report_generation_error(): assert run.final_status == CheckFinalStatus.FAILED # [/DEF:test_orchestrator_report_generation_error:Function] -# [/DEF:backend.tests.services.clean_release.test_compliance_orchestrator:Module] +# [/DEF:TestComplianceOrchestrator:Module] diff --git a/backend/src/services/clean_release/__tests__/test_manifest_builder.py b/backend/src/services/clean_release/__tests__/test_manifest_builder.py index b3dab71b..ba2311fc 100644 --- a/backend/src/services/clean_release/__tests__/test_manifest_builder.py +++ b/backend/src/services/clean_release/__tests__/test_manifest_builder.py @@ -1,4 +1,4 @@ -# [DEF:backend.tests.services.clean_release.test_manifest_builder:Module] +# [DEF:TestManifestBuilder:Module] # @COMPLEXITY: 5 # @SEMANTICS: tests, clean-release, manifest, deterministic # @PURPOSE: Validate deterministic manifest generation behavior for US1. @@ -10,6 +10,7 @@ from src.services.clean_release.manifest_builder import build_distribution_manif # [DEF:test_manifest_deterministic_hash_for_same_input:Function] +# @RELATION: BINDS_TO -> TestManifestBuilder # @PURPOSE: Ensure hash is stable for same candidate/policy/artifact input. # @PRE: Same input lists are passed twice. # @POST: Hash and summary remain identical. @@ -38,4 +39,4 @@ def test_manifest_deterministic_hash_for_same_input(): assert manifest1.summary.included_count == manifest2.summary.included_count assert manifest1.summary.excluded_count == manifest2.summary.excluded_count # [/DEF:test_manifest_deterministic_hash_for_same_input:Function] -# [/DEF:backend.tests.services.clean_release.test_manifest_builder:Module] \ No newline at end of file +# [/DEF:TestManifestBuilder:Module] \ No newline at end of file diff --git a/backend/src/services/clean_release/__tests__/test_policy_engine.py b/backend/src/services/clean_release/__tests__/test_policy_engine.py index 7ca58aa1..374d174a 100644 --- a/backend/src/services/clean_release/__tests__/test_policy_engine.py +++ b/backend/src/services/clean_release/__tests__/test_policy_engine.py @@ -40,6 +40,8 @@ def enterprise_clean_setup(): return policy, registry # @TEST_SCENARIO: policy_valid +# [DEF:test_policy_valid:Function] +# @RELATION: BINDS_TO -> __tests__/test_policy_engine def test_policy_valid(enterprise_clean_setup): policy, registry = enterprise_clean_setup engine = CleanPolicyEngine(policy, registry) @@ -48,6 +50,10 @@ def test_policy_valid(enterprise_clean_setup): assert not result.blocking_reasons # @TEST_EDGE: missing_registry_ref +# [/DEF:test_policy_valid:Function] + +# [DEF:test_missing_registry_ref:Function] +# @RELATION: BINDS_TO -> __tests__/test_policy_engine def test_missing_registry_ref(enterprise_clean_setup): policy, registry = enterprise_clean_setup policy.internal_source_registry_ref = " " @@ -57,6 +63,10 @@ def test_missing_registry_ref(enterprise_clean_setup): assert "Policy missing internal_source_registry_ref" in result.blocking_reasons # @TEST_EDGE: conflicting_registry +# [/DEF:test_missing_registry_ref:Function] + +# [DEF:test_conflicting_registry:Function] +# @RELATION: BINDS_TO -> __tests__/test_policy_engine def test_conflicting_registry(enterprise_clean_setup): policy, registry = enterprise_clean_setup registry.registry_id = "WRONG-REG" @@ -66,6 +76,10 @@ def test_conflicting_registry(enterprise_clean_setup): assert "Policy registry ref does not match provided registry" in result.blocking_reasons # @TEST_INVARIANT: deterministic_classification +# [/DEF:test_conflicting_registry:Function] + +# [DEF:test_classify_artifact:Function] +# @RELATION: BINDS_TO -> __tests__/test_policy_engine def test_classify_artifact(enterprise_clean_setup): policy, registry = enterprise_clean_setup engine = CleanPolicyEngine(policy, registry) @@ -78,6 +92,10 @@ def test_classify_artifact(enterprise_clean_setup): assert engine.classify_artifact({"category": "others", "path": "p3"}) == "allowed" # @TEST_EDGE: external_endpoint +# [/DEF:test_classify_artifact:Function] + +# [DEF:test_validate_resource_source:Function] +# @RELATION: BINDS_TO -> __tests__/test_policy_engine def test_validate_resource_source(enterprise_clean_setup): policy, registry = enterprise_clean_setup engine = CleanPolicyEngine(policy, registry) @@ -92,6 +110,10 @@ def test_validate_resource_source(enterprise_clean_setup): assert res_fail.violation["category"] == "external-source" assert res_fail.violation["blocked_release"] is True +# [/DEF:test_validate_resource_source:Function] + +# [DEF:test_evaluate_candidate:Function] +# @RELATION: BINDS_TO -> __tests__/test_policy_engine def test_evaluate_candidate(enterprise_clean_setup): policy, registry = enterprise_clean_setup engine = CleanPolicyEngine(policy, registry) @@ -112,3 +134,4 @@ def test_evaluate_candidate(enterprise_clean_setup): assert len(violations) == 2 assert violations[0]["category"] == "data-purity" assert violations[1]["category"] == "external-source" +# [/DEF:test_evaluate_candidate:Function] diff --git a/backend/src/services/clean_release/__tests__/test_preparation_service.py b/backend/src/services/clean_release/__tests__/test_preparation_service.py index 188e8b80..f5b08678 100644 --- a/backend/src/services/clean_release/__tests__/test_preparation_service.py +++ b/backend/src/services/clean_release/__tests__/test_preparation_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.tests.services.clean_release.test_preparation_service:Module] +# [DEF:TestPreparationService:Module] # @COMPLEXITY: 3 # @SEMANTICS: tests, clean-release, preparation, flow # @PURPOSE: Validate release candidate preparation flow, including policy evaluation and manifest persisting. @@ -17,11 +17,13 @@ from src.models.clean_release import ( ReleaseCandidate, ReleaseCandidateStatus, ProfileType, - DistributionManifest + DistributionManifest, ) from src.services.clean_release.preparation_service import prepare_candidate -# [DEF:backend.tests.services.clean_release.test_preparation_service._mock_policy:Function] + +# [DEF:_mock_policy:Function] +# @RELATION: BINDS_TO -> TestPreparationService # @PURPOSE: Build a valid clean profile policy fixture for preparation tests. def _mock_policy() -> CleanProfilePolicy: return CleanProfilePolicy( @@ -35,21 +37,37 @@ def _mock_policy() -> CleanProfilePolicy: effective_from=datetime.now(timezone.utc), profile=ProfileType.ENTERPRISE_CLEAN, ) -# [/DEF:backend.tests.services.clean_release.test_preparation_service._mock_policy:Function] -# [DEF:backend.tests.services.clean_release.test_preparation_service._mock_registry:Function] + +# [/DEF:_mock_policy:Function] + + +# [DEF:_mock_registry:Function] +# @RELATION: BINDS_TO -> TestPreparationService # @PURPOSE: Build an internal-only source registry fixture for preparation tests. def _mock_registry() -> ResourceSourceRegistry: return ResourceSourceRegistry( registry_id="reg-1", name="Reg", - entries=[ResourceSourceEntry(source_id="s1", host="nexus.internal", protocol="https", purpose="pkg", enabled=True)], + entries=[ + ResourceSourceEntry( + source_id="s1", + host="nexus.internal", + protocol="https", + purpose="pkg", + enabled=True, + ) + ], updated_at=datetime.now(timezone.utc), - updated_by="tester" + updated_by="tester", ) -# [/DEF:backend.tests.services.clean_release.test_preparation_service._mock_registry:Function] -# [DEF:backend.tests.services.clean_release.test_preparation_service._mock_candidate:Function] + +# [/DEF:_mock_registry:Function] + + +# [DEF:_mock_candidate:Function] +# @RELATION: BINDS_TO -> TestPreparationService # @PURPOSE: Build a draft release candidate fixture with provided identifier. def _mock_candidate(candidate_id: str) -> ReleaseCandidate: return ReleaseCandidate( @@ -59,11 +77,15 @@ def _mock_candidate(candidate_id: str) -> ReleaseCandidate: created_at=datetime.now(timezone.utc), status=ReleaseCandidateStatus.DRAFT, created_by="tester", - source_snapshot_ref="v1.0.0-snapshot" + source_snapshot_ref="v1.0.0-snapshot", ) -# [/DEF:backend.tests.services.clean_release.test_preparation_service._mock_candidate:Function] -# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_success:Function] + +# [/DEF:_mock_candidate:Function] + + +# [DEF:test_prepare_candidate_success:Function] +# @RELATION: BINDS_TO -> TestPreparationService # @PURPOSE: Verify candidate transitions to PREPARED when evaluation returns no violations. # @TEST_CONTRACT: [valid_candidate + active_policy + internal_sources + no_violations] -> [status=PREPARED, manifest_persisted, candidate_saved] # @TEST_SCENARIO: [prepare_success] -> [prepared status and persistence side effects are produced] @@ -78,29 +100,44 @@ def test_prepare_candidate_success(): repository.get_candidate.return_value = candidate repository.get_active_policy.return_value = _mock_policy() repository.get_registry.return_value = _mock_registry() - + artifacts = [{"path": "file1.txt", "category": "system"}] sources = ["nexus.internal"] - + # Execute - with patch("src.services.clean_release.preparation_service.CleanPolicyEngine") as MockEngine: + with patch( + "src.services.clean_release.preparation_service.CleanPolicyEngine" + ) as MockEngine: mock_engine_instance = MockEngine.return_value mock_engine_instance.validate_policy.return_value.ok = True mock_engine_instance.evaluate_candidate.return_value = ( - [{"path": "file1.txt", "category": "system", "classification": "required-system", "reason": "system-core"}], - [] + [ + { + "path": "file1.txt", + "category": "system", + "classification": "required-system", + "reason": "system-core", + } + ], + [], ) - - result = prepare_candidate(repository, candidate_id, artifacts, sources, "operator-1") - + + result = prepare_candidate( + repository, candidate_id, artifacts, sources, "operator-1" + ) + # Verify assert result["status"] == ReleaseCandidateStatus.PREPARED.value assert candidate.status == ReleaseCandidateStatus.PREPARED repository.save_manifest.assert_called_once() repository.save_candidate.assert_called_with(candidate) -# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_success:Function] -# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_with_violations:Function] + +# [/DEF:test_prepare_candidate_success:Function] + + +# [DEF:test_prepare_candidate_with_violations:Function] +# @RELATION: BINDS_TO -> TestPreparationService # @PURPOSE: Verify candidate transitions to BLOCKED when evaluation returns blocking violations. # @TEST_CONTRACT: [valid_candidate + active_policy + evaluation_with_violations] -> [status=BLOCKED, violations_exposed] # @TEST_SCENARIO: [prepare_blocked_due_to_policy] -> [blocked status and violation list are produced] @@ -115,28 +152,43 @@ def test_prepare_candidate_with_violations(): repository.get_candidate.return_value = candidate repository.get_active_policy.return_value = _mock_policy() repository.get_registry.return_value = _mock_registry() - + artifacts = [{"path": "bad.txt", "category": "prohibited"}] sources = [] - + # Execute - with patch("src.services.clean_release.preparation_service.CleanPolicyEngine") as MockEngine: + with patch( + "src.services.clean_release.preparation_service.CleanPolicyEngine" + ) as MockEngine: mock_engine_instance = MockEngine.return_value mock_engine_instance.validate_policy.return_value.ok = True mock_engine_instance.evaluate_candidate.return_value = ( - [{"path": "bad.txt", "category": "prohibited", "classification": "excluded-prohibited", "reason": "test-data"}], - [{"category": "data-purity", "blocked_release": True}] + [ + { + "path": "bad.txt", + "category": "prohibited", + "classification": "excluded-prohibited", + "reason": "test-data", + } + ], + [{"category": "data-purity", "blocked_release": True}], ) - - result = prepare_candidate(repository, candidate_id, artifacts, sources, "operator-1") - + + result = prepare_candidate( + repository, candidate_id, artifacts, sources, "operator-1" + ) + # Verify assert result["status"] == ReleaseCandidateStatus.BLOCKED.value assert candidate.status == ReleaseCandidateStatus.BLOCKED assert len(result["violations"]) == 1 -# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_with_violations:Function] -# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_not_found:Function] + +# [/DEF:test_prepare_candidate_with_violations:Function] + + +# [DEF:test_prepare_candidate_not_found:Function] +# @RELATION: BINDS_TO -> TestPreparationService # @PURPOSE: Verify preparation raises ValueError when candidate does not exist. # @TEST_CONTRACT: [missing_candidate] -> [ValueError('Candidate not found')] # @TEST_SCENARIO: [prepare_missing_candidate] -> [raises candidate not found error] @@ -146,12 +198,16 @@ def test_prepare_candidate_with_violations(): def test_prepare_candidate_not_found(): repository = MagicMock() repository.get_candidate.return_value = None - + with pytest.raises(ValueError, match="Candidate not found"): prepare_candidate(repository, "non-existent", [], [], "op") -# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_not_found:Function] -# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_no_active_policy:Function] + +# [/DEF:test_prepare_candidate_not_found:Function] + + +# [DEF:test_prepare_candidate_no_active_policy:Function] +# @RELATION: BINDS_TO -> TestPreparationService # @PURPOSE: Verify preparation raises ValueError when no active policy is available. # @TEST_CONTRACT: [candidate_present + missing_active_policy] -> [ValueError('Active clean policy not found')] # @TEST_SCENARIO: [prepare_missing_policy] -> [raises active policy missing error] @@ -162,10 +218,12 @@ def test_prepare_candidate_no_active_policy(): repository = MagicMock() repository.get_candidate.return_value = _mock_candidate("cand-1") repository.get_active_policy.return_value = None - + with pytest.raises(ValueError, match="Active clean policy not found"): prepare_candidate(repository, "cand-1", [], [], "op") -# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_no_active_policy:Function] -# [/DEF:backend.tests.services.clean_release.test_preparation_service:Module] +# [/DEF:test_prepare_candidate_no_active_policy:Function] + + +# [/DEF:TestPreparationService:Module] diff --git a/backend/src/services/clean_release/__tests__/test_report_builder.py b/backend/src/services/clean_release/__tests__/test_report_builder.py index 2c210ae5..cf0ceb53 100644 --- a/backend/src/services/clean_release/__tests__/test_report_builder.py +++ b/backend/src/services/clean_release/__tests__/test_report_builder.py @@ -1,9 +1,9 @@ -# [DEF:backend.tests.services.clean_release.test_report_builder:Module] +# [DEF:TestReportBuilder:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, clean-release, report-builder, counters # @PURPOSE: Validate compliance report builder counter integrity and blocked-run constraints. # @LAYER: Domain -# @RELATION: TESTS -> backend.src.services.clean_release.report_builder # @INVARIANT: blocked run requires at least one blocking violation. from datetime import datetime, timezone @@ -23,6 +23,7 @@ from src.services.clean_release.repository import CleanReleaseRepository # [DEF:_terminal_run:Function] +# @RELATION: BINDS_TO -> TestReportBuilder # @PURPOSE: Build terminal/non-terminal run fixtures for report builder tests. def _terminal_run(status: CheckFinalStatus) -> ComplianceCheckRun: return ComplianceCheckRun( @@ -40,6 +41,7 @@ def _terminal_run(status: CheckFinalStatus) -> ComplianceCheckRun: # [DEF:_blocking_violation:Function] +# @RELATION: BINDS_TO -> TestReportBuilder # @PURPOSE: Build a blocking violation fixture for blocked report scenarios. def _blocking_violation() -> ComplianceViolation: return ComplianceViolation( @@ -56,6 +58,7 @@ def _blocking_violation() -> ComplianceViolation: # [DEF:test_report_builder_blocked_requires_blocking_violations:Function] +# @RELATION: BINDS_TO -> TestReportBuilder # @PURPOSE: Verify BLOCKED run requires at least one blocking violation. def test_report_builder_blocked_requires_blocking_violations(): builder = ComplianceReportBuilder(CleanReleaseRepository()) @@ -67,6 +70,7 @@ def test_report_builder_blocked_requires_blocking_violations(): # [DEF:test_report_builder_blocked_with_two_violations:Function] +# @RELATION: BINDS_TO -> TestReportBuilder # @PURPOSE: Verify report builder generates conformant payload for a BLOCKED run with violations. def test_report_builder_blocked_with_two_violations(): builder = ComplianceReportBuilder(CleanReleaseRepository()) @@ -87,6 +91,7 @@ def test_report_builder_blocked_with_two_violations(): # [DEF:test_report_builder_counter_consistency:Function] +# @RELATION: BINDS_TO -> TestReportBuilder # @PURPOSE: Verify violations counters remain consistent for blocking payload. def test_report_builder_counter_consistency(): builder = ComplianceReportBuilder(CleanReleaseRepository()) @@ -99,6 +104,7 @@ def test_report_builder_counter_consistency(): # [DEF:test_missing_operator_summary:Function] +# @RELATION: BINDS_TO -> TestReportBuilder # @PURPOSE: Validate non-terminal run prevents operator summary/report generation. def test_missing_operator_summary(): builder = ComplianceReportBuilder(CleanReleaseRepository()) @@ -109,4 +115,4 @@ def test_missing_operator_summary(): assert "Cannot build report for non-terminal run" in str(exc.value) # [/DEF:test_missing_operator_summary:Function] -# [/DEF:backend.tests.services.clean_release.test_report_builder:Module] +# [/DEF:TestReportBuilder:Module] diff --git a/backend/src/services/clean_release/__tests__/test_source_isolation.py b/backend/src/services/clean_release/__tests__/test_source_isolation.py index c7b8deb2..9e6f2669 100644 --- a/backend/src/services/clean_release/__tests__/test_source_isolation.py +++ b/backend/src/services/clean_release/__tests__/test_source_isolation.py @@ -1,9 +1,9 @@ -# [DEF:backend.tests.services.clean_release.test_source_isolation:Module] +# [DEF:TestSourceIsolation:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, clean-release, source-isolation, internal-only # @PURPOSE: Verify internal source registry validation behavior. # @LAYER: Domain -# @RELATION: TESTS -> backend.src.services.clean_release.source_isolation # @INVARIANT: External endpoints always produce blocking violations. from datetime import datetime, timezone @@ -12,6 +12,8 @@ from src.models.clean_release import ResourceSourceEntry, ResourceSourceRegistry from src.services.clean_release.source_isolation import validate_internal_sources +# [DEF:_registry:Function] +# @RELATION: BINDS_TO -> TestSourceIsolation def _registry() -> ResourceSourceRegistry: return ResourceSourceRegistry( registry_id="registry-internal-v1", @@ -38,6 +40,11 @@ def _registry() -> ResourceSourceRegistry: ) +# [/DEF:_registry:Function] + + +# [DEF:test_validate_internal_sources_all_internal_ok:Function] +# @RELATION: BINDS_TO -> TestSourceIsolation def test_validate_internal_sources_all_internal_ok(): result = validate_internal_sources( registry=_registry(), @@ -47,6 +54,11 @@ def test_validate_internal_sources_all_internal_ok(): assert result["violations"] == [] +# [/DEF:test_validate_internal_sources_all_internal_ok:Function] + + +# [DEF:test_validate_internal_sources_external_blocked:Function] +# @RELATION: BINDS_TO -> TestSourceIsolation def test_validate_internal_sources_external_blocked(): result = validate_internal_sources( registry=_registry(), @@ -57,4 +69,6 @@ def test_validate_internal_sources_external_blocked(): assert result["violations"][0]["category"] == "external-source" assert result["violations"][0]["blocked_release"] is True -# [/DEF:backend.tests.services.clean_release.test_source_isolation:Module] \ No newline at end of file + +# [/DEF:test_validate_internal_sources_external_blocked:Function] +# [/DEF:TestSourceIsolation:Module] diff --git a/backend/src/services/clean_release/__tests__/test_stages.py b/backend/src/services/clean_release/__tests__/test_stages.py index c2c90564..6b5fbc46 100644 --- a/backend/src/services/clean_release/__tests__/test_stages.py +++ b/backend/src/services/clean_release/__tests__/test_stages.py @@ -1,30 +1,70 @@ -# [DEF:backend.tests.services.clean_release.test_stages:Module] +# [DEF:TestStages:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, clean-release, compliance, stages # @PURPOSE: Validate final status derivation logic from stage results. # @LAYER: Domain -# @RELATION: TESTS -> backend.src.services.clean_release.stages -from src.models.clean_release import CheckFinalStatus, CheckStageName, CheckStageResult, CheckStageStatus +from src.models.clean_release import ( + CheckFinalStatus, + CheckStageName, + CheckStageResult, + CheckStageStatus, +) from src.services.clean_release.stages import derive_final_status, MANDATORY_STAGE_ORDER + +# [DEF:test_derive_final_status_compliant:Function] +# @RELATION: BINDS_TO -> TestStages def test_derive_final_status_compliant(): - results = [CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok") for s in MANDATORY_STAGE_ORDER] + results = [ + CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok") + for s in MANDATORY_STAGE_ORDER + ] assert derive_final_status(results) == CheckFinalStatus.COMPLIANT + +# [/DEF:test_derive_final_status_compliant:Function] + + +# [DEF:test_derive_final_status_blocked:Function] +# @RELATION: BINDS_TO -> TestStages def test_derive_final_status_blocked(): - results = [CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok") for s in MANDATORY_STAGE_ORDER] + results = [ + CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok") + for s in MANDATORY_STAGE_ORDER + ] results[1].status = CheckStageStatus.FAIL assert derive_final_status(results) == CheckFinalStatus.BLOCKED + +# [/DEF:test_derive_final_status_blocked:Function] + + +# [DEF:test_derive_final_status_failed_missing:Function] +# @RELATION: BINDS_TO -> TestStages def test_derive_final_status_failed_missing(): - results = [CheckStageResult(stage=MANDATORY_STAGE_ORDER[0], status=CheckStageStatus.PASS, details="ok")] + results = [ + CheckStageResult( + stage=MANDATORY_STAGE_ORDER[0], status=CheckStageStatus.PASS, details="ok" + ) + ] assert derive_final_status(results) == CheckFinalStatus.FAILED + +# [/DEF:test_derive_final_status_failed_missing:Function] + + +# [DEF:test_derive_final_status_failed_skipped:Function] +# @RELATION: BINDS_TO -> TestStages def test_derive_final_status_failed_skipped(): - results = [CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok") for s in MANDATORY_STAGE_ORDER] + results = [ + CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok") + for s in MANDATORY_STAGE_ORDER + ] results[2].status = CheckStageStatus.SKIPPED assert derive_final_status(results) == CheckFinalStatus.FAILED -# [/DEF:backend.tests.services.clean_release.test_stages:Module] +# [/DEF:test_derive_final_status_failed_skipped:Function] +# [/DEF:TestStages:Module] diff --git a/backend/src/services/clean_release/approval_service.py b/backend/src/services/clean_release/approval_service.py index e42cdbc7..ac7f6c58 100644 --- a/backend/src/services/clean_release/approval_service.py +++ b/backend/src/services/clean_release/approval_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.approval_service:Module] +# [DEF:approval_service:Module] # @COMPLEXITY: 5 # @SEMANTICS: clean-release, approval, decision, lifecycle, gate # @PURPOSE: Enforce approval/rejection gates over immutable compliance reports. diff --git a/backend/src/services/clean_release/artifact_catalog_loader.py b/backend/src/services/clean_release/artifact_catalog_loader.py index e1b7abb1..ecbc33e3 100644 --- a/backend/src/services/clean_release/artifact_catalog_loader.py +++ b/backend/src/services/clean_release/artifact_catalog_loader.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.artifact_catalog_loader:Module] +# [DEF:artifact_catalog_loader:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, artifacts, bootstrap, json, tui # @PURPOSE: Load bootstrap artifact catalogs for clean release real-mode flows. diff --git a/backend/src/services/clean_release/audit_service.py b/backend/src/services/clean_release/audit_service.py index 30536f10..457df27b 100644 --- a/backend/src/services/clean_release/audit_service.py +++ b/backend/src/services/clean_release/audit_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.audit_service:Module] +# [DEF:audit_service:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, audit, lifecycle, logging # @PURPOSE: Provide lightweight audit hooks for clean release preparation/check/report lifecycle. diff --git a/backend/src/services/clean_release/candidate_service.py b/backend/src/services/clean_release/candidate_service.py index 7d3d8db6..3d4b883b 100644 --- a/backend/src/services/clean_release/candidate_service.py +++ b/backend/src/services/clean_release/candidate_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.candidate_service:Module] +# [DEF:candidate_service:Module] # @COMPLEXITY: 5 # @SEMANTICS: clean-release, candidate, artifacts, lifecycle, validation # @PURPOSE: Register release candidates with validated artifacts and advance lifecycle through legal transitions. diff --git a/backend/src/services/clean_release/compliance_execution_service.py b/backend/src/services/clean_release/compliance_execution_service.py index 7fb7dad2..f66a3cde 100644 --- a/backend/src/services/clean_release/compliance_execution_service.py +++ b/backend/src/services/clean_release/compliance_execution_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.compliance_execution_service:Module] +# [DEF:compliance_execution_service:Module] # @COMPLEXITY: 5 # @SEMANTICS: clean-release, compliance, execution, stages, immutable-evidence # @PURPOSE: Create and execute compliance runs with trusted snapshots, deterministic stages, violations and immutable report persistence. diff --git a/backend/src/services/clean_release/compliance_orchestrator.py b/backend/src/services/clean_release/compliance_orchestrator.py index fbabbfb8..e7b7f7be 100644 --- a/backend/src/services/clean_release/compliance_orchestrator.py +++ b/backend/src/services/clean_release/compliance_orchestrator.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.compliance_orchestrator:Module] +# [DEF:compliance_orchestrator:Module] # @COMPLEXITY: 5 # @SEMANTICS: clean-release, orchestrator, compliance-gate, stages # @PURPOSE: Execute mandatory clean compliance stages and produce final COMPLIANT/BLOCKED/FAILED outcome. @@ -42,7 +42,7 @@ from ...core.logger import belief_scope, logger # [DEF:CleanComplianceOrchestrator:Class] # @PURPOSE: Coordinate clean-release compliance verification stages. class CleanComplianceOrchestrator: - # [DEF:CleanComplianceOrchestrator.__init__:Function] + # [DEF:__init__:Function] # @PURPOSE: Bind repository dependency used for orchestrator persistence and lookups. # @PRE: repository is a valid CleanReleaseRepository instance with required methods. # @POST: self.repository is assigned and used by all orchestration steps. diff --git a/backend/src/services/clean_release/demo_data_service.py b/backend/src/services/clean_release/demo_data_service.py index adaf6e8d..b7946f76 100644 --- a/backend/src/services/clean_release/demo_data_service.py +++ b/backend/src/services/clean_release/demo_data_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.demo_data_service:Module] +# [DEF:demo_data_service:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, demo-mode, namespace, isolation, repository # @PURPOSE: Provide deterministic namespace helpers and isolated in-memory repository creation for demo and real modes. diff --git a/backend/src/services/clean_release/manifest_builder.py b/backend/src/services/clean_release/manifest_builder.py index 6eae11ab..d7e013f1 100644 --- a/backend/src/services/clean_release/manifest_builder.py +++ b/backend/src/services/clean_release/manifest_builder.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.manifest_builder:Module] +# [DEF:manifest_builder:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, manifest, deterministic-hash, summary # @PURPOSE: Build deterministic distribution manifest from classified artifact input. diff --git a/backend/src/services/clean_release/manifest_service.py b/backend/src/services/clean_release/manifest_service.py index ecd91272..692cab6a 100644 --- a/backend/src/services/clean_release/manifest_service.py +++ b/backend/src/services/clean_release/manifest_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.manifest_service:Module] +# [DEF:manifest_service:Module] # @COMPLEXITY: 5 # @SEMANTICS: clean-release, manifest, versioning, immutability, lifecycle # @PURPOSE: Build immutable distribution manifests with deterministic digest and version increment. diff --git a/backend/src/services/clean_release/policy_engine.py b/backend/src/services/clean_release/policy_engine.py index 05ed9bda..ae00e141 100644 --- a/backend/src/services/clean_release/policy_engine.py +++ b/backend/src/services/clean_release/policy_engine.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.policy_engine:Module] +# [DEF:policy_engine:Module] # @COMPLEXITY: 5 # @SEMANTICS: clean-release, policy, classification, source-isolation # @PURPOSE: Evaluate artifact/source policies for enterprise clean profile with deterministic outcomes. diff --git a/backend/src/services/clean_release/policy_resolution_service.py b/backend/src/services/clean_release/policy_resolution_service.py index 5ed44a94..9bae70fe 100644 --- a/backend/src/services/clean_release/policy_resolution_service.py +++ b/backend/src/services/clean_release/policy_resolution_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.policy_resolution_service:Module] +# [DEF:policy_resolution_service:Module] # @COMPLEXITY: 5 # @SEMANTICS: clean-release, policy, registry, trusted-resolution, immutable-snapshots # @PURPOSE: Resolve trusted policy and registry snapshots from ConfigManager without runtime overrides. diff --git a/backend/src/services/clean_release/preparation_service.py b/backend/src/services/clean_release/preparation_service.py index 15e26caf..553e281e 100644 --- a/backend/src/services/clean_release/preparation_service.py +++ b/backend/src/services/clean_release/preparation_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.preparation_service:Module] +# [DEF:preparation_service:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, preparation, manifest, policy-evaluation # @PURPOSE: Prepare release candidate by policy evaluation and deterministic manifest creation. diff --git a/backend/src/services/clean_release/publication_service.py b/backend/src/services/clean_release/publication_service.py index 163500ec..8b9497b6 100644 --- a/backend/src/services/clean_release/publication_service.py +++ b/backend/src/services/clean_release/publication_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.publication_service:Module] +# [DEF:publication_service:Module] # @COMPLEXITY: 5 # @SEMANTICS: clean-release, publication, revoke, gate, lifecycle # @PURPOSE: Enforce publication and revocation gates with append-only publication records. diff --git a/backend/src/services/clean_release/report_builder.py b/backend/src/services/clean_release/report_builder.py index 37981b9a..91dc27a2 100644 --- a/backend/src/services/clean_release/report_builder.py +++ b/backend/src/services/clean_release/report_builder.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.report_builder:Module] +# [DEF:report_builder:Module] # @COMPLEXITY: 5 # @SEMANTICS: clean-release, report, audit, counters, violations # @PURPOSE: Build and persist compliance reports with consistent counter invariants. diff --git a/backend/src/services/clean_release/repository.py b/backend/src/services/clean_release/repository.py index f92a6a7b..a4986075 100644 --- a/backend/src/services/clean_release/repository.py +++ b/backend/src/services/clean_release/repository.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.repository:Module] +# [DEF:repository:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, repository, persistence, in-memory # @PURPOSE: Provide repository adapter for clean release entities with deterministic access methods. diff --git a/backend/src/services/clean_release/source_isolation.py b/backend/src/services/clean_release/source_isolation.py index 915b5da2..31045c8f 100644 --- a/backend/src/services/clean_release/source_isolation.py +++ b/backend/src/services/clean_release/source_isolation.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.source_isolation:Module] +# [DEF:source_isolation:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, source-isolation, internal-only, validation # @PURPOSE: Validate that all resource endpoints belong to the approved internal source registry. diff --git a/backend/src/services/clean_release/stages/__init__.py b/backend/src/services/clean_release/stages/__init__.py index 12e0f74f..b466be28 100644 --- a/backend/src/services/clean_release/stages/__init__.py +++ b/backend/src/services/clean_release/stages/__init__.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.stages:Module] +# [DEF:stages:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, compliance, stages, state-machine # @PURPOSE: Define compliance stage order and helper functions for deterministic run-state evaluation. diff --git a/backend/src/services/clean_release/stages/base.py b/backend/src/services/clean_release/stages/base.py index abcc3c94..e495b299 100644 --- a/backend/src/services/clean_release/stages/base.py +++ b/backend/src/services/clean_release/stages/base.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.stages.base:Module] +# [DEF:base:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, compliance, stages, contracts, base # @PURPOSE: Define shared contracts and helpers for pluggable clean-release compliance stages. diff --git a/backend/src/services/clean_release/stages/data_purity.py b/backend/src/services/clean_release/stages/data_purity.py index b3cc52d2..6f5508c7 100644 --- a/backend/src/services/clean_release/stages/data_purity.py +++ b/backend/src/services/clean_release/stages/data_purity.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.stages.data_purity:Module] +# [DEF:data_purity:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, compliance-stage, data-purity # @PURPOSE: Evaluate manifest purity counters and emit blocking violations for prohibited artifacts. diff --git a/backend/src/services/clean_release/stages/internal_sources_only.py b/backend/src/services/clean_release/stages/internal_sources_only.py index 8c19a89e..5ab2254f 100644 --- a/backend/src/services/clean_release/stages/internal_sources_only.py +++ b/backend/src/services/clean_release/stages/internal_sources_only.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.stages.internal_sources_only:Module] +# [DEF:internal_sources_only:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, compliance-stage, source-isolation, registry # @PURPOSE: Verify manifest-declared sources belong to trusted internal registry allowlist. diff --git a/backend/src/services/clean_release/stages/manifest_consistency.py b/backend/src/services/clean_release/stages/manifest_consistency.py index 643cdeae..3ae53b86 100644 --- a/backend/src/services/clean_release/stages/manifest_consistency.py +++ b/backend/src/services/clean_release/stages/manifest_consistency.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.stages.manifest_consistency:Module] +# [DEF:manifest_consistency:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, compliance-stage, manifest, consistency, digest # @PURPOSE: Ensure run is bound to the exact manifest snapshot and digest used at run creation time. diff --git a/backend/src/services/clean_release/stages/no_external_endpoints.py b/backend/src/services/clean_release/stages/no_external_endpoints.py index 88cb1dcf..4698b05b 100644 --- a/backend/src/services/clean_release/stages/no_external_endpoints.py +++ b/backend/src/services/clean_release/stages/no_external_endpoints.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.clean_release.stages.no_external_endpoints:Module] +# [DEF:no_external_endpoints:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, compliance-stage, endpoints, network # @PURPOSE: Block manifest payloads that expose external endpoints outside trusted schemes and hosts. diff --git a/backend/src/services/dataset_review/__init__.py b/backend/src/services/dataset_review/__init__.py index 058c6f38..3228395c 100644 --- a/backend/src/services/dataset_review/__init__.py +++ b/backend/src/services/dataset_review/__init__.py @@ -1,7 +1,8 @@ -# [DEF:backend.src.services.dataset_review:Module] +# [DEF:dataset_review:Module] # # @SEMANTICS: dataset, review, orchestration # @PURPOSE: Provides services for dataset-centered orchestration flow. +# @RELATION: EXPORTS ->[DatasetReviewOrchestrator:Class] # @LAYER: Services # -# [/DEF:backend.src.services.dataset_review:Module] \ No newline at end of file +# [/DEF:dataset_review:Module] \ No newline at end of file diff --git a/backend/src/services/dataset_review/clarification_engine.py b/backend/src/services/dataset_review/clarification_engine.py index 39f6de64..4c70e881 100644 --- a/backend/src/services/dataset_review/clarification_engine.py +++ b/backend/src/services/dataset_review/clarification_engine.py @@ -15,7 +15,7 @@ # @INVARIANT: Only one active clarification question may exist at a time; skipped and expert-review items remain unresolved and visible. from __future__ import annotations -# [DEF:ClarificationEngine.imports:Block] +# [DEF:imports:Block] import uuid from dataclasses import dataclass, field from datetime import datetime @@ -42,7 +42,7 @@ from src.models.dataset_review import ( from src.services.dataset_review.repositories.session_repository import ( DatasetReviewSessionRepository, ) -# [/DEF:ClarificationEngine.imports:Block] +# [/DEF:imports:Block] # [DEF:ClarificationQuestionPayload:Class] @@ -97,14 +97,14 @@ class ClarificationAnswerCommand: # @POST: Returned clarification state is persistence-backed and aligned with session readiness/recommended action. # @SIDE_EFFECT: Mutates clarification answers, session flags, and related clarification findings. class ClarificationEngine: - # [DEF:ClarificationEngine.__init__:Function] + # [DEF:ClarificationEngine_init:Function] # @COMPLEXITY: 2 # @PURPOSE: Bind repository dependency for clarification persistence operations. def __init__(self, repository: DatasetReviewSessionRepository) -> None: self.repository = repository - # [/DEF:ClarificationEngine.__init__:Function] + # [/DEF:ClarificationEngine_init:Function] - # [DEF:ClarificationEngine.build_question_payload:Function] + # [DEF:build_question_payload:Function] # @COMPLEXITY: 4 # @PURPOSE: Return the one active highest-priority clarification question payload with why-it-matters, current guess, and options. # @RELATION: [DEPENDS_ON] ->[ClarificationQuestion] @@ -197,9 +197,9 @@ class ClarificationEngine: }, ) return payload - # [/DEF:ClarificationEngine.build_question_payload:Function] + # [/DEF:build_question_payload:Function] - # [DEF:ClarificationEngine.record_answer:Function] + # [DEF:record_answer:Function] # @COMPLEXITY: 4 # @PURPOSE: Persist one clarification answer before any pointer/readiness mutation and compute deterministic state impact. # @RELATION: [DEPENDS_ON] ->[ClarificationAnswer] @@ -326,19 +326,19 @@ class ClarificationEngine: session=session, changed_findings=[changed_finding] if changed_finding else [], ) - # [/DEF:ClarificationEngine.record_answer:Function] + # [/DEF:record_answer:Function] - # [DEF:ClarificationEngine.summarize_progress:Function] - # @COMPLEXITY: 3 + # [DEF:summarize_progress:Function] + # @COMPLEXITY: 2 # @PURPOSE: Produce a compact progress summary for pause/resume and completion UX. # @RELATION: [DEPENDS_ON] ->[ClarificationSession] def summarize_progress(self, clarification_session: ClarificationSession) -> str: resolved = self._count_resolved_questions(clarification_session) remaining = self._count_remaining_questions(clarification_session) return f"{resolved} resolved, {remaining} unresolved" - # [/DEF:ClarificationEngine.summarize_progress:Function] + # [/DEF:summarize_progress:Function] - # [DEF:ClarificationEngine._get_latest_clarification_session:Function] + # [DEF:_get_latest_clarification_session:Function] # @COMPLEXITY: 2 # @PURPOSE: Select the latest clarification session for the current dataset review aggregate. def _get_latest_clarification_session( @@ -353,10 +353,10 @@ class ClarificationEngine: reverse=True, ) return ordered_sessions[0] - # [/DEF:ClarificationEngine._get_latest_clarification_session:Function] + # [/DEF:_get_latest_clarification_session:Function] - # [DEF:ClarificationEngine._find_question:Function] - # @COMPLEXITY: 1 + # [DEF:_find_question:Function] + # @COMPLEXITY: 2 # @PURPOSE: Resolve a clarification question from the active clarification aggregate. def _find_question( self, @@ -367,9 +367,9 @@ class ClarificationEngine: if question.question_id == question_id: return question return None - # [/DEF:ClarificationEngine._find_question:Function] + # [/DEF:_find_question:Function] - # [DEF:ClarificationEngine._select_next_open_question:Function] + # [DEF:_select_next_open_question:Function] # @COMPLEXITY: 2 # @PURPOSE: Select the next unresolved question in deterministic priority order. def _select_next_open_question( @@ -384,10 +384,10 @@ class ClarificationEngine: return None open_questions.sort(key=lambda item: (-int(item.priority), item.created_at, item.question_id)) return open_questions[0] - # [/DEF:ClarificationEngine._select_next_open_question:Function] + # [/DEF:_select_next_open_question:Function] - # [DEF:ClarificationEngine._count_resolved_questions:Function] - # @COMPLEXITY: 1 + # [DEF:_count_resolved_questions:Function] + # @COMPLEXITY: 2 # @PURPOSE: Count questions whose answers fully resolved the ambiguity. def _count_resolved_questions(self, clarification_session: ClarificationSession) -> int: return sum( @@ -395,10 +395,10 @@ class ClarificationEngine: for question in clarification_session.questions if question.state == QuestionState.ANSWERED ) - # [/DEF:ClarificationEngine._count_resolved_questions:Function] + # [/DEF:_count_resolved_questions:Function] - # [DEF:ClarificationEngine._count_remaining_questions:Function] - # @COMPLEXITY: 1 + # [DEF:_count_remaining_questions:Function] + # @COMPLEXITY: 2 # @PURPOSE: Count questions still unresolved or deferred after clarification interaction. def _count_remaining_questions(self, clarification_session: ClarificationSession) -> int: return sum( @@ -406,9 +406,9 @@ class ClarificationEngine: for question in clarification_session.questions if question.state in {QuestionState.OPEN, QuestionState.SKIPPED, QuestionState.EXPERT_REVIEW} ) - # [/DEF:ClarificationEngine._count_remaining_questions:Function] + # [/DEF:_count_remaining_questions:Function] - # [DEF:ClarificationEngine._normalize_answer_value:Function] + # [DEF:_normalize_answer_value:Function] # @COMPLEXITY: 2 # @PURPOSE: Validate and normalize answer payload based on answer kind and active question options. def _normalize_answer_value( @@ -429,9 +429,9 @@ class ClarificationEngine: if answer_kind == AnswerKind.EXPERT_REVIEW: return normalized_answer_value or "expert_review" return normalized_answer_value - # [/DEF:ClarificationEngine._normalize_answer_value:Function] + # [/DEF:_normalize_answer_value:Function] - # [DEF:ClarificationEngine._build_impact_summary:Function] + # [DEF:_build_impact_summary:Function] # @COMPLEXITY: 2 # @PURPOSE: Build a compact audit note describing how the clarification answer affects session state. def _build_impact_summary( @@ -445,10 +445,10 @@ class ClarificationEngine: if answer_kind == AnswerKind.EXPERT_REVIEW: return f"Clarification for {question.topic_ref} was deferred for expert review." return f"Clarification for {question.topic_ref} recorded as '{answer_value}'." - # [/DEF:ClarificationEngine._build_impact_summary:Function] + # [/DEF:_build_impact_summary:Function] - # [DEF:ClarificationEngine._upsert_clarification_finding:Function] - # @COMPLEXITY: 3 + # [DEF:_upsert_clarification_finding:Function] + # @COMPLEXITY: 2 # @PURPOSE: Keep one finding per clarification topic aligned with answer outcome and unresolved visibility rules. # @RELATION: [DEPENDS_ON] ->[ValidationFinding] def _upsert_clarification_finding( @@ -513,10 +513,10 @@ class ClarificationEngine: existing.title = "Clarification requires expert review" return existing - # [/DEF:ClarificationEngine._upsert_clarification_finding:Function] + # [/DEF:_upsert_clarification_finding:Function] - # [DEF:ClarificationEngine._derive_readiness_state:Function] - # @COMPLEXITY: 3 + # [DEF:_derive_readiness_state:Function] + # @COMPLEXITY: 2 # @PURPOSE: Recompute readiness after clarification mutation while preserving unresolved visibility semantics. # @RELATION: [DEPENDS_ON] ->[ClarificationSession] # @RELATION: [DEPENDS_ON] ->[DatasetReviewSession] @@ -532,9 +532,9 @@ class ClarificationEngine: return ReadinessState.CLARIFICATION_NEEDED return ReadinessState.REVIEW_READY - # [/DEF:ClarificationEngine._derive_readiness_state:Function] + # [/DEF:_derive_readiness_state:Function] - # [DEF:ClarificationEngine._derive_recommended_action:Function] + # [DEF:_derive_recommended_action:Function] # @COMPLEXITY: 2 # @PURPOSE: Recompute next-action guidance after clarification mutations. def _derive_recommended_action(self, session: DatasetReviewSession) -> RecommendedAction: @@ -546,7 +546,7 @@ class ClarificationEngine: if clarification_session.remaining_count > 0: return RecommendedAction.START_CLARIFICATION return RecommendedAction.REVIEW_DOCUMENTATION - # [/DEF:ClarificationEngine._derive_recommended_action:Function] + # [/DEF:_derive_recommended_action:Function] # [/DEF:ClarificationEngine:Class] # [/DEF:ClarificationEngine:Module] \ No newline at end of file diff --git a/backend/src/services/dataset_review/event_logger.py b/backend/src/services/dataset_review/event_logger.py index d221bbda..de4face4 100644 --- a/backend/src/services/dataset_review/event_logger.py +++ b/backend/src/services/dataset_review/event_logger.py @@ -48,14 +48,14 @@ class SessionEventPayload: # @SIDE_EFFECT: Writes one audit row to persistence and emits logger.reason/logger.reflect traces. # @DATA_CONTRACT: Input[SessionEventPayload] -> Output[SessionEvent] class SessionEventLogger: - # [DEF:SessionEventLogger.__init__:Function] + # [DEF:SessionEventLogger_init:Function] # @COMPLEXITY: 2 # @PURPOSE: Bind a live SQLAlchemy session to the session-event logger. def __init__(self, db: Session) -> None: self.db = db - # [/DEF:SessionEventLogger.__init__:Function] + # [/DEF:SessionEventLogger_init:Function] - # [DEF:SessionEventLogger.log_event:Function] + # [DEF:log_event:Function] # @COMPLEXITY: 4 # @PURPOSE: Persist one explicit session event row for an owned dataset-review mutation. # @RELATION: [DEPENDS_ON] ->[SessionEvent] @@ -126,10 +126,10 @@ class SessionEventLogger: }, ) return event - # [/DEF:SessionEventLogger.log_event:Function] + # [/DEF:log_event:Function] - # [DEF:SessionEventLogger.log_for_session:Function] - # @COMPLEXITY: 3 + # [DEF:log_for_session:Function] + # @COMPLEXITY: 2 # @PURPOSE: Convenience wrapper for logging an event directly from a session aggregate root. # @RELATION: [CALLS] ->[SessionEventLogger.log_event] def log_for_session( @@ -152,7 +152,7 @@ class SessionEventLogger: event_details=dict(event_details or {}), ) ) - # [/DEF:SessionEventLogger.log_for_session:Function] + # [/DEF:log_for_session:Function] # [/DEF:SessionEventLogger:Class] # [/DEF:SessionEventLoggerModule:Module] \ No newline at end of file diff --git a/backend/src/services/dataset_review/orchestrator.py b/backend/src/services/dataset_review/orchestrator.py index 14b23953..fa5598df 100644 --- a/backend/src/services/dataset_review/orchestrator.py +++ b/backend/src/services/dataset_review/orchestrator.py @@ -17,7 +17,7 @@ from __future__ import annotations -# [DEF:DatasetReviewOrchestrator.imports:Block] +# [DEF:imports:Block] from dataclasses import dataclass, field from datetime import datetime import hashlib @@ -70,7 +70,7 @@ from src.services.dataset_review.repositories.session_repository import ( ) from src.services.dataset_review.semantic_resolver import SemanticSourceResolver from src.services.dataset_review.event_logger import SessionEventPayload -# [/DEF:DatasetReviewOrchestrator.imports:Block] +# [/DEF:imports:Block] logger = cast(Any, logger) @@ -166,7 +166,7 @@ class LaunchDatasetResult: # @DATA_CONTRACT: Input[StartSessionCommand] -> Output[StartSessionResult] # @INVARIANT: session ownership is preserved on every mutation and recovery remains explicit when partial. class DatasetReviewOrchestrator: - # [DEF:DatasetReviewOrchestrator.__init__:Function] + # [DEF:DatasetReviewOrchestrator_init:Function] # @COMPLEXITY: 3 # @PURPOSE: Bind repository, config, and task dependencies required by the orchestration boundary. # @RELATION: [DEPENDS_ON] ->[SessionRepo] @@ -183,9 +183,9 @@ class DatasetReviewOrchestrator: self.task_manager = task_manager self.semantic_resolver = semantic_resolver or SemanticSourceResolver() - # [/DEF:DatasetReviewOrchestrator.__init__:Function] + # [/DEF:DatasetReviewOrchestrator_init:Function] - # [DEF:DatasetReviewOrchestrator.start_session:Function] + # [DEF:start_session:Function] # @COMPLEXITY: 5 # @PURPOSE: Initialize a new session from a Superset link or dataset selection and trigger context recovery. # @RELATION: [DEPENDS_ON] ->[SessionRepo] @@ -378,9 +378,9 @@ class DatasetReviewOrchestrator: findings=findings, ) - # [/DEF:DatasetReviewOrchestrator.start_session:Function] + # [/DEF:start_session:Function] - # [DEF:DatasetReviewOrchestrator.prepare_launch_preview:Function] + # [DEF:prepare_launch_preview:Function] # @COMPLEXITY: 4 # @PURPOSE: Assemble effective execution inputs and trigger Superset-side preview compilation. # @RELATION: [CALLS] ->[SupersetCompilationAdapter.compile_preview] @@ -490,9 +490,9 @@ class DatasetReviewOrchestrator: blocked_reasons=[], ) - # [/DEF:DatasetReviewOrchestrator.prepare_launch_preview:Function] + # [/DEF:prepare_launch_preview:Function] - # [DEF:DatasetReviewOrchestrator.launch_dataset:Function] + # [DEF:launch_dataset:Function] # @COMPLEXITY: 5 # @PURPOSE: Start the approved dataset execution through SQL Lab and persist run context for audit/replay. # @RELATION: [CALLS] ->[SupersetCompilationAdapter.create_sql_lab_session] @@ -624,10 +624,10 @@ class DatasetReviewOrchestrator: blocked_reasons=[], ) - # [/DEF:DatasetReviewOrchestrator.launch_dataset:Function] + # [/DEF:launch_dataset:Function] - # [DEF:DatasetReviewOrchestrator._parse_dataset_selection:Function] - # @COMPLEXITY: 3 + # [DEF:_parse_dataset_selection:Function] + # @COMPLEXITY: 2 # @PURPOSE: Normalize dataset-selection payload into canonical session references. # @RELATION: [DEPENDS_ON] ->[DatasetReviewSession] def _parse_dataset_selection(self, source_input: str) -> tuple[str, Optional[int]]: @@ -647,10 +647,10 @@ class DatasetReviewOrchestrator: return normalized, None - # [/DEF:DatasetReviewOrchestrator._parse_dataset_selection:Function] + # [/DEF:_parse_dataset_selection:Function] - # [DEF:DatasetReviewOrchestrator._build_initial_profile:Function] - # @COMPLEXITY: 3 + # [DEF:_build_initial_profile:Function] + # @COMPLEXITY: 2 # @PURPOSE: Create the first profile snapshot so exports and detail views remain usable immediately after intake. # @RELATION: [DEPENDS_ON] ->[DatasetProfile] def _build_initial_profile( @@ -690,9 +690,9 @@ class DatasetReviewOrchestrator: manual_summary_locked=False, ) - # [/DEF:DatasetReviewOrchestrator._build_initial_profile:Function] + # [/DEF:_build_initial_profile:Function] - # [DEF:DatasetReviewOrchestrator._build_partial_recovery_findings:Function] + # [DEF:_build_partial_recovery_findings:Function] # @COMPLEXITY: 4 # @PURPOSE: Project partial Superset intake recovery into explicit findings without blocking session usability. # @RELATION: [DEPENDS_ON] ->[ValidationFinding] @@ -722,9 +722,9 @@ class DatasetReviewOrchestrator: ) return findings - # [/DEF:DatasetReviewOrchestrator._build_partial_recovery_findings:Function] + # [/DEF:_build_partial_recovery_findings:Function] - # [DEF:DatasetReviewOrchestrator._build_recovery_bootstrap:Function] + # [DEF:_build_recovery_bootstrap:Function] # @COMPLEXITY: 4 # @PURPOSE: Recover and materialize initial imported filters, template variables, and draft execution mappings after session creation. # @RELATION: [CALLS] ->[SupersetContextExtractor.recover_imported_filters] @@ -881,9 +881,24 @@ class DatasetReviewOrchestrator: return imported_filters, template_variables, execution_mappings, findings - # [/DEF:DatasetReviewOrchestrator._build_recovery_bootstrap:Function] + # [/DEF:_build_recovery_bootstrap:Function] - # [DEF:DatasetReviewOrchestrator._build_execution_snapshot:Function] + # [DEF:_extract_effective_filter_value:Function] + # @COMPLEXITY: 2 + # @PURPOSE: Separate normalized filter payload metadata from the user-facing effective filter value. + def _extract_effective_filter_value( + self, normalized_value: Any, raw_value: Any + ) -> Any: + if isinstance(normalized_value, dict) and ( + "filter_clauses" in normalized_value + or "extra_form_data" in normalized_value + ): + return raw_value + return normalized_value if normalized_value is not None else raw_value + + # [/DEF:_extract_effective_filter_value:Function] + + # [DEF:_build_execution_snapshot:Function] # @COMPLEXITY: 4 # @PURPOSE: Build effective filters, template params, approvals, and fingerprint for preview and launch gating. # @RELATION: [DEPENDS_ON] ->[DatasetReviewSession] @@ -923,9 +938,10 @@ class DatasetReviewOrchestrator: effective_value = mapping.effective_value if effective_value is None: - effective_value = imported_filter.normalized_value - if effective_value is None: - effective_value = imported_filter.raw_value + effective_value = self._extract_effective_filter_value( + imported_filter.normalized_value, + imported_filter.raw_value, + ) if effective_value is None: effective_value = template_variable.default_value @@ -937,19 +953,21 @@ class DatasetReviewOrchestrator: mapped_filter_ids.add(imported_filter.filter_id) if effective_value is not None: - effective_filters.append( - { - "mapping_id": mapping.mapping_id, - "filter_id": imported_filter.filter_id, - "filter_name": imported_filter.filter_name, - "display_name": imported_filter.display_name, - "variable_id": template_variable.variable_id, - "variable_name": template_variable.variable_name, - "effective_value": effective_value, - "raw_input_value": mapping.raw_input_value, - "normalized_filter_payload": imported_filter.normalized_value, - } - ) + mapped_filter_payload = { + "mapping_id": mapping.mapping_id, + "filter_id": imported_filter.filter_id, + "filter_name": imported_filter.filter_name, + "variable_id": template_variable.variable_id, + "variable_name": template_variable.variable_name, + "effective_value": effective_value, + "raw_input_value": mapping.raw_input_value, + } + if isinstance(imported_filter.normalized_value, dict): + mapped_filter_payload["display_name"] = imported_filter.display_name + mapped_filter_payload["normalized_filter_payload"] = ( + imported_filter.normalized_value + ) + effective_filters.append(mapped_filter_payload) template_params[template_variable.variable_name] = effective_value if mapping.approval_state == ApprovalState.APPROVED: approved_mapping_ids.append(mapping.mapping_id) @@ -963,8 +981,10 @@ class DatasetReviewOrchestrator: if imported_filter.filter_id in mapped_filter_ids: continue effective_value = imported_filter.normalized_value - if effective_value is None: - effective_value = imported_filter.raw_value + effective_value = self._extract_effective_filter_value( + imported_filter.normalized_value, + imported_filter.raw_value, + ) if effective_value is None: continue effective_filters.append( @@ -1014,9 +1034,9 @@ class DatasetReviewOrchestrator: "preview_fingerprint": preview_fingerprint, } - # [/DEF:DatasetReviewOrchestrator._build_execution_snapshot:Function] + # [/DEF:_build_execution_snapshot:Function] - # [DEF:DatasetReviewOrchestrator._build_launch_blockers:Function] + # [DEF:_build_launch_blockers:Function] # @COMPLEXITY: 4 # @PURPOSE: Enforce launch gates from findings, approvals, and current preview truth. # @RELATION: [DEPENDS_ON] ->[CompiledPreview] @@ -1057,9 +1077,9 @@ class DatasetReviewOrchestrator: return sorted(set(blockers)) - # [/DEF:DatasetReviewOrchestrator._build_launch_blockers:Function] + # [/DEF:_build_launch_blockers:Function] - # [DEF:DatasetReviewOrchestrator._get_latest_preview:Function] + # [DEF:_get_latest_preview:Function] # @COMPLEXITY: 2 # @PURPOSE: Resolve the current latest preview snapshot for one session aggregate. def _get_latest_preview( @@ -1078,18 +1098,18 @@ class DatasetReviewOrchestrator: reverse=True, )[0] - # [/DEF:DatasetReviewOrchestrator._get_latest_preview:Function] + # [/DEF:_get_latest_preview:Function] - # [DEF:DatasetReviewOrchestrator._compute_preview_fingerprint:Function] + # [DEF:_compute_preview_fingerprint:Function] # @COMPLEXITY: 2 # @PURPOSE: Produce deterministic execution fingerprint for preview truth and staleness checks. def _compute_preview_fingerprint(self, payload: Dict[str, Any]) -> str: serialized = json.dumps(payload, sort_keys=True, default=str) return hashlib.sha256(serialized.encode("utf-8")).hexdigest() - # [/DEF:DatasetReviewOrchestrator._compute_preview_fingerprint:Function] + # [/DEF:_compute_preview_fingerprint:Function] - # [DEF:DatasetReviewOrchestrator._enqueue_recovery_task:Function] + # [DEF:_enqueue_recovery_task:Function] # @COMPLEXITY: 4 # @PURPOSE: Link session start to observable async recovery when task infrastructure is available. # @RELATION: [CALLS] ->[create_task] @@ -1147,7 +1167,7 @@ class DatasetReviewOrchestrator: task_id = getattr(task_object, "id", None) return str(task_id) if task_id else None - # [/DEF:DatasetReviewOrchestrator._enqueue_recovery_task:Function] + # [/DEF:_enqueue_recovery_task:Function] # [/DEF:DatasetReviewOrchestrator:Class] diff --git a/backend/src/services/dataset_review/repositories/__tests__/test_session_repository.py b/backend/src/services/dataset_review/repositories/__tests__/test_session_repository.py index 77d968ea..c6f29ddc 100644 --- a/backend/src/services/dataset_review/repositories/__tests__/test_session_repository.py +++ b/backend/src/services/dataset_review/repositories/__tests__/test_session_repository.py @@ -21,14 +21,14 @@ from src.models.dataset_review import ( from src.services.dataset_review.repositories.session_repository import DatasetReviewSessionRepository # [DEF:SessionRepositoryTests:Module] -# @COMPLEXITY: 3 +# @RELATION: BELONGS_TO -> SrcRoot +# @COMPLEXITY: 2 # @PURPOSE: Unit tests for DatasetReviewSessionRepository. -# @RELATION: TESTS -> [DatasetReviewSessionRepository] @pytest.fixture def db_session(): # [DEF:db_session:Function] - # @COMPLEXITY: 1 + # @COMPLEXITY: 2 # @RELATION: BINDS_TO -> [SessionRepositoryTests] engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) @@ -44,6 +44,8 @@ def db_session(): yield session session.close() +# [DEF:test_create_session:Function] +# @RELATION: BINDS_TO -> SessionRepositoryTests def test_create_session(db_session): # @PURPOSE: Verify session creation and persistence. repo = DatasetReviewSessionRepository(db_session) @@ -60,6 +62,10 @@ def test_create_session(db_session): loaded = db_session.query(DatasetReviewSession).filter_by(session_id=session.session_id).first() assert loaded.user_id == "user1" +# [/DEF:test_create_session:Function] + +# [DEF:test_load_session_detail_ownership:Function] +# @RELATION: BINDS_TO -> SessionRepositoryTests def test_load_session_detail_ownership(db_session): # @PURPOSE: Verify ownership enforcement in detail loading. repo = DatasetReviewSessionRepository(db_session) @@ -77,6 +83,10 @@ def test_load_session_detail_ownership(db_session): loaded_wrong = repo.load_session_detail(session.session_id, "wrong_user") assert loaded_wrong is None +# [/DEF:test_load_session_detail_ownership:Function] + +# [DEF:test_load_session_detail_collaborator:Function] +# @RELATION: BINDS_TO -> SessionRepositoryTests def test_load_session_detail_collaborator(db_session): # @PURPOSE: Verify collaborator access in detail loading. repo = DatasetReviewSessionRepository(db_session) @@ -103,6 +113,10 @@ def test_load_session_detail_collaborator(db_session): assert loaded is not None assert loaded.session_id == session.session_id +# [/DEF:test_load_session_detail_collaborator:Function] + +# [DEF:test_save_preview_marks_stale:Function] +# @RELATION: BINDS_TO -> SessionRepositoryTests def test_save_preview_marks_stale(db_session): # @PURPOSE: Verify that saving a new preview marks old ones as stale. repo = DatasetReviewSessionRepository(db_session) @@ -123,6 +137,10 @@ def test_save_preview_marks_stale(db_session): assert p2.preview_status == "ready" assert session.last_preview_id == p2.preview_id +# [/DEF:test_save_preview_marks_stale:Function] + +# [DEF:test_save_profile_and_findings:Function] +# @RELATION: BINDS_TO -> SessionRepositoryTests def test_save_profile_and_findings(db_session): # @PURPOSE: Verify persistence of profile and findings. repo = DatasetReviewSessionRepository(db_session) @@ -173,6 +191,10 @@ def test_save_profile_and_findings(db_session): assert len(final_session.findings) == 1 assert final_session.findings[0].code == "WARN1" +# [/DEF:test_save_profile_and_findings:Function] + +# [DEF:test_save_run_context:Function] +# @RELATION: BINDS_TO -> SessionRepositoryTests def test_save_run_context(db_session): # @PURPOSE: Verify saving of run context. repo = DatasetReviewSessionRepository(db_session) @@ -199,6 +221,10 @@ def test_save_run_context(db_session): assert session.last_run_context_id == rc.run_context_id +# [/DEF:test_save_run_context:Function] + +# [DEF:test_list_sessions_for_user:Function] +# @RELATION: BINDS_TO -> SessionRepositoryTests def test_list_sessions_for_user(db_session): # @PURPOSE: Verify listing of sessions by user. repo = DatasetReviewSessionRepository(db_session) @@ -213,4 +239,4 @@ def test_list_sessions_for_user(db_session): assert len(sessions) == 2 assert all(s.user_id == "user1" for s in sessions) -# [/DEF:SessionRepositoryTests:Module] \ No newline at end of file +# [/DEF:SessionRepositoryTests:Module]# [/DEF:test_list_sessions_for_user:Function] diff --git a/backend/src/services/dataset_review/repositories/session_repository.py b/backend/src/services/dataset_review/repositories/session_repository.py index 46d844f8..dab7377d 100644 --- a/backend/src/services/dataset_review/repositories/session_repository.py +++ b/backend/src/services/dataset_review/repositories/session_repository.py @@ -33,6 +33,7 @@ from src.models.dataset_review import ( from src.core.logger import belief_scope, logger from src.services.dataset_review.event_logger import SessionEventLogger + # [DEF:SessionRepo:Class] # @COMPLEXITY: 4 # @PURPOSE: Enforce ownership-scoped persistence and retrieval for dataset review session aggregates. @@ -45,13 +46,13 @@ from src.services.dataset_review.event_logger import SessionEventLogger # @SIDE_EFFECT: mutates and queries the persistence layer through the injected database session. # @DATA_CONTRACT: Input[OwnedSessionQuery|SessionMutation] -> Output[PersistedSessionAggregate|PersistedChildRecord] class DatasetReviewSessionRepository: - # [DEF:init_repo:Function] - # @COMPLEXITY: 2 + # @COMPLEXITY: 4 # @PURPOSE: Bind one live SQLAlchemy session to the repository instance. def __init__(self, db: Session): self.db = db self.event_logger = SessionEventLogger(db) + # [/DEF:init_repo:Function] # [DEF:get_owned_session:Function] @@ -68,10 +69,14 @@ class DatasetReviewSessionRepository: "Resolving owner-scoped dataset review session for mutation path", extra={"session_id": session_id, "user_id": user_id}, ) - session = self.db.query(DatasetReviewSession).filter( - DatasetReviewSession.session_id == session_id, - DatasetReviewSession.user_id == user_id, - ).first() + session = ( + self.db.query(DatasetReviewSession) + .filter( + DatasetReviewSession.session_id == session_id, + DatasetReviewSession.user_id == user_id, + ) + .first() + ) if not session: logger.explore( "Owner-scoped dataset review session lookup failed", @@ -83,6 +88,7 @@ class DatasetReviewSessionRepository: extra={"session_id": session.session_id, "user_id": session.user_id}, ) return session + # [/DEF:get_owned_session:Function] # [DEF:create_sess:Function] @@ -97,7 +103,10 @@ class DatasetReviewSessionRepository: with belief_scope("DatasetReviewSessionRepository.create_session"): logger.reason( "Persisting dataset review session shell", - extra={"user_id": session.user_id, "environment_id": session.environment_id}, + extra={ + "user_id": session.user_id, + "environment_id": session.environment_id, + }, ) self.db.add(session) self.db.commit() @@ -107,44 +116,58 @@ class DatasetReviewSessionRepository: extra={"session_id": session.session_id, "user_id": session.user_id}, ) return session + # [/DEF:create_sess:Function] # [DEF:load_detail:Function] - # @COMPLEXITY: 3 + # @COMPLEXITY: 4 # @PURPOSE: Return the full session aggregate for API and frontend resume flows. # @RELATION: [DEPENDS_ON] -> [DatasetReviewSession] # @RELATION: [DEPENDS_ON] -> [SessionCollaborator] - def load_session_detail(self, session_id: str, user_id: str) -> Optional[DatasetReviewSession]: + def load_session_detail( + self, session_id: str, user_id: str + ) -> Optional[DatasetReviewSession]: with belief_scope("DatasetReviewSessionRepository.load_session_detail"): logger.reason( "Loading dataset review session detail for owner-or-collaborator scope", extra={"session_id": session_id, "user_id": user_id}, ) - session = self.db.query(DatasetReviewSession)\ - .outerjoin(SessionCollaborator, DatasetReviewSession.session_id == SessionCollaborator.session_id)\ + session = ( + self.db.query(DatasetReviewSession) + .outerjoin( + SessionCollaborator, + DatasetReviewSession.session_id == SessionCollaborator.session_id, + ) .options( joinedload(DatasetReviewSession.profile), joinedload(DatasetReviewSession.findings), joinedload(DatasetReviewSession.collaborators), joinedload(DatasetReviewSession.semantic_sources), - joinedload(DatasetReviewSession.semantic_fields).joinedload(SemanticFieldEntry.candidates), + joinedload(DatasetReviewSession.semantic_fields).joinedload( + SemanticFieldEntry.candidates + ), joinedload(DatasetReviewSession.imported_filters), joinedload(DatasetReviewSession.template_variables), joinedload(DatasetReviewSession.execution_mappings), - joinedload(DatasetReviewSession.clarification_sessions).joinedload(ClarificationSession.questions).joinedload(ClarificationQuestion.options), - joinedload(DatasetReviewSession.clarification_sessions).joinedload(ClarificationSession.questions).joinedload(ClarificationQuestion.answer), + joinedload(DatasetReviewSession.clarification_sessions) + .joinedload(ClarificationSession.questions) + .joinedload(ClarificationQuestion.options), + joinedload(DatasetReviewSession.clarification_sessions) + .joinedload(ClarificationSession.questions) + .joinedload(ClarificationQuestion.answer), joinedload(DatasetReviewSession.previews), joinedload(DatasetReviewSession.run_contexts), - joinedload(DatasetReviewSession.events) - )\ - .filter(DatasetReviewSession.session_id == session_id)\ + joinedload(DatasetReviewSession.events), + ) + .filter(DatasetReviewSession.session_id == session_id) .filter( or_( DatasetReviewSession.user_id == user_id, - SessionCollaborator.user_id == user_id + SessionCollaborator.user_id == user_id, ) - )\ + ) .first() + ) logger.reflect( "Dataset review session detail lookup completed", extra={ @@ -154,6 +177,7 @@ class DatasetReviewSessionRepository: }, ) return session + # [/DEF:load_detail:Function] # [DEF:save_prof_find:Function] @@ -166,7 +190,13 @@ class DatasetReviewSessionRepository: # @POST: stored profile matches the current session and findings are replaced by the supplied collection. # @SIDE_EFFECT: updates profile rows, deletes stale findings, inserts current findings, and commits the transaction. # @DATA_CONTRACT: Input[ProfileAndFindingsMutation] -> Output[DatasetReviewSession] - def save_profile_and_findings(self, session_id: str, user_id: str, profile: DatasetProfile, findings: List[ValidationFinding]) -> DatasetReviewSession: + def save_profile_and_findings( + self, + session_id: str, + user_id: str, + profile: DatasetProfile, + findings: List[ValidationFinding], + ) -> DatasetReviewSession: with belief_scope("DatasetReviewSessionRepository.save_profile_and_findings"): session = self._get_owned_session(session_id, user_id) logger.reason( @@ -180,7 +210,11 @@ class DatasetReviewSessionRepository: ) if profile: - existing_profile = self.db.query(DatasetProfile).filter_by(session_id=session_id).first() + existing_profile = ( + self.db.query(DatasetProfile) + .filter_by(session_id=session_id) + .first() + ) if existing_profile: profile.profile_id = existing_profile.profile_id self.db.merge(profile) @@ -203,6 +237,7 @@ class DatasetReviewSessionRepository: }, ) return self.load_session_detail(session_id, user_id) + # [/DEF:save_prof_find:Function] # [DEF:save_recovery_state:Function] @@ -268,6 +303,7 @@ class DatasetReviewSessionRepository: }, ) return self.load_session_detail(session_id, user_id) + # [/DEF:save_recovery_state:Function] # [DEF:save_prev:Function] @@ -279,7 +315,9 @@ class DatasetReviewSessionRepository: # @POST: preview is persisted and the session points to the latest preview identifier. # @SIDE_EFFECT: updates prior preview statuses, inserts a preview row, mutates the parent session, and commits. # @DATA_CONTRACT: Input[PreviewMutation] -> Output[CompiledPreview] - def save_preview(self, session_id: str, user_id: str, preview: CompiledPreview) -> CompiledPreview: + def save_preview( + self, session_id: str, user_id: str, preview: CompiledPreview + ) -> CompiledPreview: with belief_scope("DatasetReviewSessionRepository.save_preview"): session = self._get_owned_session(session_id, user_id) logger.reason( @@ -306,6 +344,7 @@ class DatasetReviewSessionRepository: }, ) return preview + # [/DEF:save_prev:Function] # [DEF:save_run_ctx:Function] @@ -317,7 +356,9 @@ class DatasetReviewSessionRepository: # @POST: run context is persisted and linked as the latest launch snapshot for the session. # @SIDE_EFFECT: inserts a run-context row, mutates the parent session pointer, and commits. # @DATA_CONTRACT: Input[RunContextMutation] -> Output[DatasetRunContext] - def save_run_context(self, session_id: str, user_id: str, run_context: DatasetRunContext) -> DatasetRunContext: + def save_run_context( + self, session_id: str, user_id: str, run_context: DatasetRunContext + ) -> DatasetRunContext: with belief_scope("DatasetReviewSessionRepository.save_run_context"): session = self._get_owned_session(session_id, user_id) logger.reason( @@ -340,10 +381,11 @@ class DatasetReviewSessionRepository: }, ) return run_context + # [/DEF:save_run_ctx:Function] # [DEF:list_user_sess:Function] - # @COMPLEXITY: 3 + # @COMPLEXITY: 2 # @PURPOSE: List review sessions owned by a specific user ordered by most recent update. # @RELATION: [DEPENDS_ON] -> [DatasetReviewSession] def list_sessions_for_user(self, user_id: str) -> List[DatasetReviewSession]: @@ -352,15 +394,21 @@ class DatasetReviewSessionRepository: "Listing dataset review sessions for owner scope", extra={"user_id": user_id}, ) - sessions = self.db.query(DatasetReviewSession).filter( - DatasetReviewSession.user_id == user_id - ).order_by(DatasetReviewSession.updated_at.desc()).all() + sessions = ( + self.db.query(DatasetReviewSession) + .filter(DatasetReviewSession.user_id == user_id) + .order_by(DatasetReviewSession.updated_at.desc()) + .all() + ) logger.reflect( "Dataset review session list assembled", extra={"user_id": user_id, "session_count": len(sessions)}, ) return sessions + # [/DEF:list_user_sess:Function] + + # [/DEF:SessionRepo:Class] -# [/DEF:DatasetReviewSessionRepository:Module] \ No newline at end of file +# [/DEF:DatasetReviewSessionRepository:Module] diff --git a/backend/src/services/dataset_review/semantic_resolver.py b/backend/src/services/dataset_review/semantic_resolver.py index d09ff49e..b8e924ef 100644 --- a/backend/src/services/dataset_review/semantic_resolver.py +++ b/backend/src/services/dataset_review/semantic_resolver.py @@ -14,7 +14,7 @@ from __future__ import annotations -# [DEF:SemanticSourceResolver.imports:Block] +# [DEF:imports:Block] from dataclasses import dataclass, field from difflib import SequenceMatcher from typing import Any, Dict, Iterable, List, Mapping, Optional @@ -26,7 +26,7 @@ from src.models.dataset_review import ( FieldProvenance, SemanticSource, ) -# [/DEF:SemanticSourceResolver.imports:Block] +# [/DEF:imports:Block] # [DEF:DictionaryResolutionResult:Class] @@ -50,14 +50,14 @@ class DictionaryResolutionResult: # @POST: result contains confidence-ranked candidates and does not overwrite manual locks implicitly. # @SIDE_EFFECT: emits semantic trace logs for ranking and fallback decisions. class SemanticSourceResolver: - # [DEF:SemanticSourceResolver.resolve_from_file:Function] + # [DEF:resolve_from_file:Function] # @COMPLEXITY: 2 # @PURPOSE: Normalize uploaded semantic file records into field-level candidates. def resolve_from_file(self, source_payload: Mapping[str, Any], fields: Iterable[Mapping[str, Any]]) -> DictionaryResolutionResult: return DictionaryResolutionResult(source_ref=str(source_payload.get("source_ref") or "uploaded_file")) - # [/DEF:SemanticSourceResolver.resolve_from_file:Function] + # [/DEF:resolve_from_file:Function] - # [DEF:SemanticSourceResolver.resolve_from_dictionary:Function] + # [DEF:resolve_from_dictionary:Function] # @COMPLEXITY: 4 # @PURPOSE: Resolve candidates from connected tabular dictionary sources. # @RELATION: [DEPENDS_ON] ->[SemanticFieldEntry] @@ -213,9 +213,9 @@ class SemanticSourceResolver: }, ) return result - # [/DEF:SemanticSourceResolver.resolve_from_dictionary:Function] + # [/DEF:resolve_from_dictionary:Function] - # [DEF:SemanticSourceResolver.resolve_from_reference_dataset:Function] + # [DEF:resolve_from_reference_dataset:Function] # @COMPLEXITY: 2 # @PURPOSE: Reuse semantic metadata from trusted Superset datasets. def resolve_from_reference_dataset( @@ -224,10 +224,10 @@ class SemanticSourceResolver: fields: Iterable[Mapping[str, Any]], ) -> DictionaryResolutionResult: return DictionaryResolutionResult(source_ref=str(source_payload.get("source_ref") or "reference_dataset")) - # [/DEF:SemanticSourceResolver.resolve_from_reference_dataset:Function] + # [/DEF:resolve_from_reference_dataset:Function] - # [DEF:SemanticSourceResolver.rank_candidates:Function] - # @COMPLEXITY: 3 + # [DEF:rank_candidates:Function] + # @COMPLEXITY: 2 # @PURPOSE: Apply confidence ordering and determine best candidate per field. # @RELATION: [DEPENDS_ON] ->[SemanticCandidate] def rank_candidates(self, candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: @@ -242,25 +242,25 @@ class SemanticSourceResolver: for index, candidate in enumerate(ranked, start=1): candidate["candidate_rank"] = index return ranked - # [/DEF:SemanticSourceResolver.rank_candidates:Function] + # [/DEF:rank_candidates:Function] - # [DEF:SemanticSourceResolver.detect_conflicts:Function] + # [DEF:detect_conflicts:Function] # @COMPLEXITY: 2 # @PURPOSE: Mark competing candidate sets that require explicit user review. def detect_conflicts(self, candidates: List[Dict[str, Any]]) -> bool: return len(candidates) > 1 - # [/DEF:SemanticSourceResolver.detect_conflicts:Function] + # [/DEF:detect_conflicts:Function] - # [DEF:SemanticSourceResolver.apply_field_decision:Function] + # [DEF:apply_field_decision:Function] # @COMPLEXITY: 2 # @PURPOSE: Accept, reject, or manually override a field-level semantic value. def apply_field_decision(self, field_state: Mapping[str, Any], decision: Mapping[str, Any]) -> Dict[str, Any]: merged = dict(field_state) merged.update(decision) return merged - # [/DEF:SemanticSourceResolver.apply_field_decision:Function] + # [/DEF:apply_field_decision:Function] - # [DEF:SemanticSourceResolver.propagate_source_version_update:Function] + # [DEF:propagate_source_version_update:Function] # @COMPLEXITY: 4 # @PURPOSE: Propagate a semantic source version change to unlocked field entries without silently overwriting manual or locked values. # @RELATION: [DEPENDS_ON] ->[SemanticSource] @@ -315,9 +315,9 @@ class SemanticSourceResolver: "preserved_locked": preserved_locked, "untouched": untouched, } - # [/DEF:SemanticSourceResolver.propagate_source_version_update:Function] + # [/DEF:propagate_source_version_update:Function] - # [DEF:SemanticSourceResolver._normalize_dictionary_row:Function] + # [DEF:_normalize_dictionary_row:Function] # @COMPLEXITY: 2 # @PURPOSE: Normalize one dictionary row into a consistent lookup structure. def _normalize_dictionary_row(self, row: Mapping[str, Any]) -> Dict[str, Any]: @@ -335,9 +335,9 @@ class SemanticSourceResolver: "description": row.get("description"), "display_format": row.get("display_format") or row.get("format"), } - # [/DEF:SemanticSourceResolver._normalize_dictionary_row:Function] + # [/DEF:_normalize_dictionary_row:Function] - # [DEF:SemanticSourceResolver._find_fuzzy_matches:Function] + # [DEF:_find_fuzzy_matches:Function] # @COMPLEXITY: 2 # @PURPOSE: Produce confidence-scored fuzzy matches while keeping them reviewable. def _find_fuzzy_matches(self, field_name: str, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: @@ -353,9 +353,9 @@ class SemanticSourceResolver: fuzzy_matches.append({"row": row, "score": round(score, 3)}) fuzzy_matches.sort(key=lambda item: item["score"], reverse=True) return fuzzy_matches[:3] - # [/DEF:SemanticSourceResolver._find_fuzzy_matches:Function] + # [/DEF:_find_fuzzy_matches:Function] - # [DEF:SemanticSourceResolver._build_candidate_payload:Function] + # [DEF:_build_candidate_payload:Function] # @COMPLEXITY: 2 # @PURPOSE: Project normalized dictionary rows into semantic candidate payloads. def _build_candidate_payload( @@ -374,9 +374,9 @@ class SemanticSourceResolver: "proposed_display_format": row.get("display_format"), "status": CandidateStatus.PROPOSED.value, } - # [/DEF:SemanticSourceResolver._build_candidate_payload:Function] + # [/DEF:_build_candidate_payload:Function] - # [DEF:SemanticSourceResolver._match_priority:Function] + # [DEF:_match_priority:Function] # @COMPLEXITY: 2 # @PURPOSE: Encode trusted-confidence ordering so exact dictionary reuse beats fuzzy invention. def _match_priority(self, match_type: Optional[str]) -> int: @@ -387,14 +387,14 @@ class SemanticSourceResolver: CandidateMatchType.GENERATED.value: 3, } return priority.get(str(match_type or ""), 99) - # [/DEF:SemanticSourceResolver._match_priority:Function] + # [/DEF:_match_priority:Function] - # [DEF:SemanticSourceResolver._normalize_key:Function] - # @COMPLEXITY: 1 + # [DEF:_normalize_key:Function] + # @COMPLEXITY: 2 # @PURPOSE: Normalize field identifiers for stable exact/fuzzy comparisons. def _normalize_key(self, value: str) -> str: return "".join(ch for ch in str(value or "").strip().lower() if ch.isalnum() or ch == "_") - # [/DEF:SemanticSourceResolver._normalize_key:Function] + # [/DEF:_normalize_key:Function] # [/DEF:SemanticSourceResolver:Class] # [/DEF:SemanticSourceResolver:Module] \ No newline at end of file diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py index c16b13d6..c97946e4 100644 --- a/backend/src/services/git_service.py +++ b/backend/src/services/git_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.git_service:Module] +# [DEF:git_service:Module] # # @COMPLEXITY: 3 # @SEMANTICS: git, service, gitpython, repository, version_control @@ -31,7 +31,7 @@ from src.models.git import GitRepository, GitServerConfig from src.models.config import AppConfigRecord from src.core.database import SessionLocal -# [DEF:backend.src.services.git_service.GitService:Class] +# [DEF:GitService:Class] # @COMPLEXITY: 3 # @PURPOSE: Wrapper for GitPython operations with semantic logging and error handling. class GitService: @@ -39,7 +39,7 @@ class GitService: Wrapper for GitPython operations. """ - # [DEF:backend.src.services.git_service.GitService.__init__:Function] + # [DEF:GitService_init:Function] # @PURPOSE: Initializes the GitService with a base path for repositories. # @PARAM: base_path (str) - Root directory for all Git clones. # @PRE: base_path is a valid string path. @@ -53,9 +53,9 @@ class GitService: self._uses_default_base_path = base_path == "git_repos" self.base_path = self._resolve_base_path(base_path) self._ensure_base_path_exists() - # [/DEF:backend.src.services.git_service.GitService.__init__:Function] + # [/DEF:GitService_init:Function] - # [DEF:backend.src.services.git_service.GitService._ensure_base_path_exists:Function] + # [DEF:_ensure_base_path_exists:Function] # @PURPOSE: Ensure the repositories root directory exists and is a directory. # @PRE: self.base_path is resolved to filesystem path. # @POST: self.base_path exists as directory or raises ValueError. @@ -72,9 +72,9 @@ class GitService: f"[_ensure_base_path_exists][Coherence:Failed] Cannot create Git repositories base path: {self.base_path}. Error: {e}" ) raise ValueError(f"Cannot create Git repositories base path: {self.base_path}. {e}") - # [/DEF:backend.src.services.git_service.GitService._ensure_base_path_exists:Function] + # [/DEF:_ensure_base_path_exists:Function] - # [DEF:backend.src.services.git_service.GitService._resolve_base_path:Function] + # [DEF:_resolve_base_path:Function] # @PURPOSE: Resolve base repository directory from explicit argument or global storage settings. # @PRE: base_path is a string path. # @POST: Returns absolute path for Git repositories root. @@ -115,9 +115,9 @@ class GitService: except Exception as e: logger.warning(f"[_resolve_base_path][Coherence:Failed] Falling back to default path: {e}") return fallback_path - # [/DEF:backend.src.services.git_service.GitService._resolve_base_path:Function] + # [/DEF:_resolve_base_path:Function] - # [DEF:backend.src.services.git_service.GitService._normalize_repo_key:Function] + # [DEF:_normalize_repo_key:Function] # @PURPOSE: Convert user/dashboard-provided key to safe filesystem directory name. # @PRE: repo_key can be None/empty. # @POST: Returns normalized non-empty key. @@ -127,9 +127,9 @@ class GitService: raw_key = str(repo_key or "").strip().lower() normalized = re.sub(r"[^a-z0-9._-]+", "-", raw_key).strip("._-") return normalized or "dashboard" - # [/DEF:backend.src.services.git_service.GitService._normalize_repo_key:Function] + # [/DEF:_normalize_repo_key:Function] - # [DEF:backend.src.services.git_service.GitService._update_repo_local_path:Function] + # [DEF:_update_repo_local_path:Function] # @PURPOSE: Persist repository local_path in GitRepository table when record exists. # @PRE: dashboard_id is valid integer. # @POST: local_path is updated for existing record. @@ -151,9 +151,9 @@ class GitService: session.close() except Exception as e: logger.warning(f"[_update_repo_local_path][Coherence:Failed] {e}") - # [/DEF:backend.src.services.git_service.GitService._update_repo_local_path:Function] + # [/DEF:_update_repo_local_path:Function] - # [DEF:backend.src.services.git_service.GitService._migrate_repo_directory:Function] + # [DEF:_migrate_repo_directory:Function] # @PURPOSE: Move legacy repository directory to target path and sync DB metadata. # @PRE: source_path exists. # @POST: Repository content available at target_path. @@ -182,9 +182,9 @@ class GitService: f"[_migrate_repo_directory][Coherence:OK] Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}" ) return target_abs - # [/DEF:backend.src.services.git_service.GitService._migrate_repo_directory:Function] + # [/DEF:_migrate_repo_directory:Function] - # [DEF:backend.src.services.git_service.GitService._ensure_gitflow_branches:Function] + # [DEF:_ensure_gitflow_branches:Function] # @PURPOSE: Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin. # @PRE: repo is a valid GitPython Repo instance. # @POST: main, dev, preprod are available in local repository and pushed to origin when available. @@ -267,9 +267,9 @@ class GitService: logger.warning( f"[_ensure_gitflow_branches][Action] Could not checkout dev branch for dashboard {dashboard_id}: {e}" ) - # [/DEF:backend.src.services.git_service.GitService._ensure_gitflow_branches:Function] + # [/DEF:_ensure_gitflow_branches:Function] - # [DEF:backend.src.services.git_service.GitService._get_repo_path:Function] + # [DEF:_get_repo_path:Function] # @PURPOSE: Resolves the local filesystem path for a dashboard's repository. # @PARAM: dashboard_id (int) # @PARAM: repo_key (Optional[str]) - Slug-like key used when DB local_path is absent. @@ -321,9 +321,9 @@ class GitService: self._update_repo_local_path(dashboard_id, target_path) return target_path - # [/DEF:backend.src.services.git_service.GitService._get_repo_path:Function] + # [/DEF:_get_repo_path:Function] - # [DEF:backend.src.services.git_service.GitService.init_repo:Function] + # [DEF:init_repo:Function] # @PURPOSE: Initialize or clone a repository for a dashboard. # @PARAM: dashboard_id (int) # @PARAM: remote_url (str) @@ -371,9 +371,9 @@ class GitService: repo = Repo.clone_from(auth_url, repo_path) self._ensure_gitflow_branches(repo, dashboard_id) return repo - # [/DEF:backend.src.services.git_service.GitService.init_repo:Function] + # [/DEF:init_repo:Function] - # [DEF:backend.src.services.git_service.GitService.delete_repo:Function] + # [DEF:delete_repo:Function] # @PURPOSE: Remove local repository and DB binding for a dashboard. # @PRE: dashboard_id is a valid integer. # @POST: Local path is deleted when present and GitRepository row is removed. @@ -420,9 +420,9 @@ class GitService: raise HTTPException(status_code=500, detail=f"Failed to delete repository: {str(e)}") finally: session.close() - # [/DEF:backend.src.services.git_service.GitService.delete_repo:Function] + # [/DEF:delete_repo:Function] - # [DEF:backend.src.services.git_service.GitService.get_repo:Function] + # [DEF:get_repo:Function] # @PURPOSE: Get Repo object for a dashboard. # @PRE: Repository must exist on disk for the given dashboard_id. # @POST: Returns a GitPython Repo instance for the dashboard. @@ -439,9 +439,9 @@ class GitService: except Exception as e: logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}") raise HTTPException(status_code=500, detail="Failed to open local Git repository") - # [/DEF:backend.src.services.git_service.GitService.get_repo:Function] + # [/DEF:get_repo:Function] - # [DEF:backend.src.services.git_service.GitService.configure_identity:Function] + # [DEF:configure_identity:Function] # @PURPOSE: Configure repository-local Git committer identity for user-scoped operations. # @PRE: dashboard_id repository exists; git_username/git_email may be empty. # @POST: Repository config has user.name and user.email when both identity values are provided. @@ -471,9 +471,9 @@ class GitService: except Exception as e: logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}") raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {str(e)}") - # [/DEF:backend.src.services.git_service.GitService.configure_identity:Function] + # [/DEF:configure_identity:Function] - # [DEF:backend.src.services.git_service.GitService.list_branches:Function] + # [DEF:list_branches:Function] # @PURPOSE: List all branches for a dashboard's repository. # @PRE: Repository for dashboard_id exists. # @POST: Returns a list of branch metadata dictionaries. @@ -526,9 +526,9 @@ class GitService: }) return branches - # [/DEF:backend.src.services.git_service.GitService.list_branches:Function] + # [/DEF:list_branches:Function] - # [DEF:backend.src.services.git_service.GitService.create_branch:Function] + # [DEF:create_branch:Function] # @PURPOSE: Create a new branch from an existing one. # @PARAM: name (str) - New branch name. # @PARAM: from_branch (str) - Source branch. @@ -563,9 +563,9 @@ class GitService: except Exception as e: logger.error(f"[create_branch][Coherence:Failed] {e}") raise - # [/DEF:backend.src.services.git_service.GitService.create_branch:Function] + # [/DEF:create_branch:Function] - # [DEF:backend.src.services.git_service.GitService.checkout_branch:Function] + # [DEF:checkout_branch:Function] # @PURPOSE: Switch to a specific branch. # @PRE: Repository exists and the specified branch name exists. # @POST: The repository working directory is updated to the specified branch. @@ -575,9 +575,9 @@ class GitService: repo = self.get_repo(dashboard_id) logger.info(f"[checkout_branch][Action] Checking out branch {name}") repo.git.checkout(name) - # [/DEF:backend.src.services.git_service.GitService.checkout_branch:Function] + # [/DEF:checkout_branch:Function] - # [DEF:backend.src.services.git_service.GitService.commit_changes:Function] + # [DEF:commit_changes:Function] # @PURPOSE: Stage and commit changes. # @PARAM: message (str) - Commit message. # @PARAM: files (List[str]) - Optional list of specific files to stage. @@ -602,9 +602,9 @@ class GitService: repo.index.commit(message) logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}") - # [/DEF:backend.src.services.git_service.GitService.commit_changes:Function] + # [/DEF:commit_changes:Function] - # [DEF:backend.src.services.git_service.GitService._extract_http_host:Function] + # [DEF:_extract_http_host:Function] # @PURPOSE: Extract normalized host[:port] from HTTP(S) URL. # @PRE: url_value may be empty. # @POST: Returns lowercase host token or None. @@ -626,9 +626,9 @@ class GitService: if parsed.port: return f"{host.lower()}:{parsed.port}" return host.lower() - # [/DEF:backend.src.services.git_service.GitService._extract_http_host:Function] + # [/DEF:_extract_http_host:Function] - # [DEF:backend.src.services.git_service.GitService._strip_url_credentials:Function] + # [DEF:_strip_url_credentials:Function] # @PURPOSE: Remove credentials from URL while preserving scheme/host/path. # @PRE: url_value may contain credentials. # @POST: Returns URL without username/password. @@ -648,9 +648,9 @@ class GitService: if parsed.port: host = f"{host}:{parsed.port}" return parsed._replace(netloc=host).geturl() - # [/DEF:backend.src.services.git_service.GitService._strip_url_credentials:Function] + # [/DEF:_strip_url_credentials:Function] - # [DEF:backend.src.services.git_service.GitService._replace_host_in_url:Function] + # [DEF:_replace_host_in_url:Function] # @PURPOSE: Replace source URL host with host from configured server URL. # @PRE: source_url and config_url are HTTP(S) URLs. # @POST: Returns source URL with updated host (credentials preserved) or None. @@ -687,9 +687,9 @@ class GitService: new_netloc = f"{auth_part}{target_host}" return source_parsed._replace(netloc=new_netloc).geturl() - # [/DEF:backend.src.services.git_service.GitService._replace_host_in_url:Function] + # [/DEF:_replace_host_in_url:Function] - # [DEF:backend.src.services.git_service.GitService._align_origin_host_with_config:Function] + # [DEF:_align_origin_host_with_config:Function] # @PURPOSE: Auto-align local origin host to configured Git server host when they drift. # @PRE: origin remote exists. # @POST: origin URL host updated and DB binding normalized when mismatch detected. @@ -756,9 +756,9 @@ class GitService: ) return aligned_url - # [/DEF:backend.src.services.git_service.GitService._align_origin_host_with_config:Function] + # [/DEF:_align_origin_host_with_config:Function] - # [DEF:backend.src.services.git_service.GitService.push_changes:Function] + # [DEF:push_changes:Function] # @PURPOSE: Push local commits to remote. # @PRE: Repository exists and has an 'origin' remote. # @POST: Local branch commits are pushed to origin. @@ -871,9 +871,9 @@ class GitService: except Exception as e: logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}") raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}") - # [/DEF:backend.src.services.git_service.GitService.push_changes:Function] + # [/DEF:push_changes:Function] - # [DEF:backend.src.services.git_service.GitService._read_blob_text:Function] + # [DEF:_read_blob_text:Function] # @PURPOSE: Read text from a Git blob. # @RELATION: USES -> [Blob] def _read_blob_text(self, blob: Blob) -> str: @@ -884,9 +884,9 @@ class GitService: return blob.data_stream.read().decode("utf-8", errors="replace") except Exception: return "" - # [/DEF:backend.src.services.git_service.GitService._read_blob_text:Function] + # [/DEF:_read_blob_text:Function] - # [DEF:backend.src.services.git_service.GitService._get_unmerged_file_paths:Function] + # [DEF:_get_unmerged_file_paths:Function] # @PURPOSE: List files with merge conflicts. # @RELATION: USES -> [Repo] def _get_unmerged_file_paths(self, repo: Repo) -> List[str]: @@ -895,9 +895,9 @@ class GitService: return sorted(list(repo.index.unmerged_blobs().keys())) except Exception: return [] - # [/DEF:backend.src.services.git_service.GitService._get_unmerged_file_paths:Function] + # [/DEF:_get_unmerged_file_paths:Function] - # [DEF:backend.src.services.git_service.GitService._build_unfinished_merge_payload:Function] + # [DEF:_build_unfinished_merge_payload:Function] # @PURPOSE: Build payload for unfinished merge state. # @RELATION: CALLS -> [GitService._get_unmerged_file_paths] def _build_unfinished_merge_payload(self, repo: Repo) -> Dict[str, Any]: @@ -949,9 +949,9 @@ class GitService: "git merge --abort", ], } - # [/DEF:backend.src.services.git_service.GitService._build_unfinished_merge_payload:Function] + # [/DEF:_build_unfinished_merge_payload:Function] - # [DEF:backend.src.services.git_service.GitService.get_merge_status:Function] + # [DEF:get_merge_status:Function] # @PURPOSE: Get current merge status for a dashboard repository. # @RELATION: CALLS -> [GitService.get_repo] # @RELATION: CALLS -> [GitService._build_unfinished_merge_payload] @@ -984,9 +984,9 @@ class GitService: "merge_message_preview": payload["merge_message_preview"], "conflicts_count": int(payload.get("conflicts_count") or 0), } - # [/DEF:backend.src.services.git_service.GitService.get_merge_status:Function] + # [/DEF:get_merge_status:Function] - # [DEF:backend.src.services.git_service.GitService.get_merge_conflicts:Function] + # [DEF:get_merge_conflicts:Function] # @PURPOSE: List all files with conflicts and their contents. # @RELATION: CALLS -> [GitService.get_repo] # @RELATION: CALLS -> [GitService._read_blob_text] @@ -1011,9 +1011,9 @@ class GitService: } ) return sorted(conflicts, key=lambda item: item["file_path"]) - # [/DEF:backend.src.services.git_service.GitService.get_merge_conflicts:Function] + # [/DEF:get_merge_conflicts:Function] - # [DEF:backend.src.services.git_service.GitService.resolve_merge_conflicts:Function] + # [DEF:resolve_merge_conflicts:Function] # @PURPOSE: Resolve conflicts using specified strategy. # @RELATION: CALLS -> [GitService.get_repo] def resolve_merge_conflicts(self, dashboard_id: int, resolutions: List[Dict[str, Any]]) -> List[str]: @@ -1049,9 +1049,9 @@ class GitService: resolved_files.append(file_path) return resolved_files - # [/DEF:backend.src.services.git_service.GitService.resolve_merge_conflicts:Function] + # [/DEF:resolve_merge_conflicts:Function] - # [DEF:backend.src.services.git_service.GitService.abort_merge:Function] + # [DEF:abort_merge:Function] # @PURPOSE: Abort ongoing merge. # @RELATION: CALLS -> [GitService.get_repo] def abort_merge(self, dashboard_id: int) -> Dict[str, Any]: @@ -1066,9 +1066,9 @@ class GitService: return {"status": "no_merge_in_progress"} raise HTTPException(status_code=409, detail=f"Cannot abort merge: {details}") return {"status": "aborted"} - # [/DEF:backend.src.services.git_service.GitService.abort_merge:Function] + # [/DEF:abort_merge:Function] - # [DEF:backend.src.services.git_service.GitService.continue_merge:Function] + # [DEF:continue_merge:Function] # @PURPOSE: Finalize merge after conflict resolution. # @RELATION: CALLS -> [GitService.get_repo] # @RELATION: CALLS -> [GitService._get_unmerged_file_paths] @@ -1104,9 +1104,9 @@ class GitService: except Exception: commit_hash = "" return {"status": "committed", "commit_hash": commit_hash} - # [/DEF:backend.src.services.git_service.GitService.continue_merge:Function] + # [/DEF:continue_merge:Function] - # [DEF:backend.src.services.git_service.GitService.pull_changes:Function] + # [DEF:pull_changes:Function] # @PURPOSE: Pull changes from remote. # @PRE: Repository exists and has an 'origin' remote. # @POST: Changes from origin are pulled and merged into the active branch. @@ -1189,9 +1189,9 @@ class GitService: except Exception as e: logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}") raise HTTPException(status_code=500, detail=f"Git pull failed: {str(e)}") - # [/DEF:backend.src.services.git_service.GitService.pull_changes:Function] + # [/DEF:pull_changes:Function] - # [DEF:backend.src.services.git_service.GitService.get_status:Function] + # [DEF:get_status:Function] # @PURPOSE: Get current repository status (dirty files, untracked, etc.) # @PRE: Repository for dashboard_id exists. # @POST: Returns a dictionary representing the Git status. @@ -1266,9 +1266,9 @@ class GitService: "is_diverged": is_diverged, "sync_state": sync_state, } - # [/DEF:backend.src.services.git_service.GitService.get_status:Function] + # [/DEF:get_status:Function] - # [DEF:backend.src.services.git_service.GitService.get_diff:Function] + # [DEF:get_diff:Function] # @PURPOSE: Generate diff for a file or the whole repository. # @PARAM: file_path (str) - Optional specific file. # @PARAM: staged (bool) - Whether to show staged changes. @@ -1286,9 +1286,9 @@ class GitService: if file_path: return repo.git.diff(*diff_args, "--", file_path) return repo.git.diff(*diff_args) - # [/DEF:backend.src.services.git_service.GitService.get_diff:Function] + # [/DEF:get_diff:Function] - # [DEF:backend.src.services.git_service.GitService.get_commit_history:Function] + # [DEF:get_commit_history:Function] # @PURPOSE: Retrieve commit history for a repository. # @PARAM: limit (int) - Max number of commits to return. # @PRE: Repository for dashboard_id exists. @@ -1317,9 +1317,9 @@ class GitService: logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}") return [] return commits - # [/DEF:backend.src.services.git_service.GitService.get_commit_history:Function] + # [/DEF:get_commit_history:Function] - # [DEF:backend.src.services.git_service.GitService.test_connection:Function] + # [DEF:test_connection:Function] # @PURPOSE: Test connection to Git provider using PAT. # @PARAM: provider (GitProvider) # @PARAM: url (str) @@ -1368,9 +1368,9 @@ class GitService: except Exception as e: logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}") return False - # [/DEF:backend.src.services.git_service.GitService.test_connection:Function] + # [/DEF:test_connection:Function] - # [DEF:backend.src.services.git_service.GitService._normalize_git_server_url:Function] + # [DEF:_normalize_git_server_url:Function] # @PURPOSE: Normalize Git server URL for provider API calls. # @PRE: raw_url is non-empty. # @POST: Returns URL without trailing slash. @@ -1380,9 +1380,9 @@ class GitService: if not normalized: raise HTTPException(status_code=400, detail="Git server URL is required") return normalized.rstrip("/") - # [/DEF:backend.src.services.git_service.GitService._normalize_git_server_url:Function] + # [/DEF:_normalize_git_server_url:Function] - # [DEF:backend.src.services.git_service.GitService._gitea_headers:Function] + # [DEF:_gitea_headers:Function] # @PURPOSE: Build Gitea API authorization headers. # @PRE: pat is provided. # @POST: Returns headers with token auth. @@ -1396,9 +1396,9 @@ class GitService: "Content-Type": "application/json", "Accept": "application/json", } - # [/DEF:backend.src.services.git_service.GitService._gitea_headers:Function] + # [/DEF:_gitea_headers:Function] - # [DEF:backend.src.services.git_service.GitService._gitea_request:Function] + # [DEF:_gitea_request:Function] # @PURPOSE: Execute HTTP request against Gitea API with stable error mapping. # @PRE: method and endpoint are valid. # @POST: Returns decoded JSON payload. @@ -1446,9 +1446,9 @@ class GitService: if response.status_code == 204: return None return response.json() - # [/DEF:backend.src.services.git_service.GitService._gitea_request:Function] + # [/DEF:_gitea_request:Function] - # [DEF:backend.src.services.git_service.GitService.get_gitea_current_user:Function] + # [DEF:get_gitea_current_user:Function] # @PURPOSE: Resolve current Gitea user for PAT. # @PRE: server_url and pat are valid. # @POST: Returns current username. @@ -1460,9 +1460,9 @@ class GitService: if not username: raise HTTPException(status_code=500, detail="Failed to resolve Gitea username") return str(username) - # [/DEF:backend.src.services.git_service.GitService.get_gitea_current_user:Function] + # [/DEF:get_gitea_current_user:Function] - # [DEF:backend.src.services.git_service.GitService.list_gitea_repositories:Function] + # [DEF:list_gitea_repositories:Function] # @PURPOSE: List repositories visible to authenticated Gitea user. # @PRE: server_url and pat are valid. # @POST: Returns repository list from Gitea. @@ -1478,9 +1478,9 @@ class GitService: if not isinstance(payload, list): return [] return payload - # [/DEF:backend.src.services.git_service.GitService.list_gitea_repositories:Function] + # [/DEF:list_gitea_repositories:Function] - # [DEF:backend.src.services.git_service.GitService.create_gitea_repository:Function] + # [DEF:create_gitea_repository:Function] # @PURPOSE: Create repository in Gitea for authenticated user. # @PRE: name is non-empty and PAT has repo creation permission. # @POST: Returns created repository payload. @@ -1515,9 +1515,9 @@ class GitService: if not isinstance(created, dict): raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository") return created - # [/DEF:backend.src.services.git_service.GitService.create_gitea_repository:Function] + # [/DEF:create_gitea_repository:Function] - # [DEF:backend.src.services.git_service.GitService.delete_gitea_repository:Function] + # [DEF:delete_gitea_repository:Function] # @PURPOSE: Delete repository in Gitea. # @PRE: owner and repo_name are non-empty. # @POST: Repository deleted on Gitea server. @@ -1537,9 +1537,9 @@ class GitService: pat, f"/repos/{owner}/{repo_name}", ) - # [/DEF:backend.src.services.git_service.GitService.delete_gitea_repository:Function] + # [/DEF:delete_gitea_repository:Function] - # [DEF:backend.src.services.git_service.GitService._gitea_branch_exists:Function] + # [DEF:_gitea_branch_exists:Function] # @PURPOSE: Check whether a branch exists in Gitea repository. # @PRE: owner/repo/branch are non-empty. # @POST: Returns True when branch exists, False when 404. @@ -1563,9 +1563,9 @@ class GitService: if exc.status_code == 404: return False raise - # [/DEF:backend.src.services.git_service.GitService._gitea_branch_exists:Function] + # [/DEF:_gitea_branch_exists:Function] - # [DEF:backend.src.services.git_service.GitService._build_gitea_pr_404_detail:Function] + # [DEF:_build_gitea_pr_404_detail:Function] # @PURPOSE: Build actionable error detail for Gitea PR 404 responses. # @PRE: owner/repo/from_branch/to_branch are provided. # @POST: Returns specific branch-missing message when detected. @@ -1599,9 +1599,9 @@ class GitService: if not target_exists: return f"Gitea branch not found: target branch '{to_branch}' in {owner}/{repo}" return None - # [/DEF:backend.src.services.git_service.GitService._build_gitea_pr_404_detail:Function] + # [/DEF:_build_gitea_pr_404_detail:Function] - # [DEF:backend.src.services.git_service.GitService.create_github_repository:Function] + # [DEF:create_github_repository:Function] # @PURPOSE: Create repository in GitHub or GitHub Enterprise. # @PRE: PAT has repository create permission. # @POST: Returns created repository payload. @@ -1652,9 +1652,9 @@ class GitService: pass raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}") return response.json() - # [/DEF:backend.src.services.git_service.GitService.create_github_repository:Function] + # [/DEF:create_github_repository:Function] - # [DEF:backend.src.services.git_service.GitService.create_gitlab_repository:Function] + # [DEF:create_gitlab_repository:Function] # @PURPOSE: Create repository(project) in GitLab. # @PRE: PAT has api scope. # @POST: Returns created repository payload. @@ -1713,9 +1713,9 @@ class GitService: if "full_name" not in data: data["full_name"] = data.get("path_with_namespace") or data.get("name") return data - # [/DEF:backend.src.services.git_service.GitService.create_gitlab_repository:Function] + # [/DEF:create_gitlab_repository:Function] - # [DEF:backend.src.services.git_service.GitService._parse_remote_repo_identity:Function] + # [DEF:_parse_remote_repo_identity:Function] # @PURPOSE: Parse owner/repo from remote URL for Git server API operations. # @PRE: remote_url is a valid git URL. # @POST: Returns owner/repo tokens. @@ -1749,9 +1749,9 @@ class GitService: "namespace": namespace, "full_name": f"{namespace}/{repo}", } - # [/DEF:backend.src.services.git_service.GitService._parse_remote_repo_identity:Function] + # [/DEF:_parse_remote_repo_identity:Function] - # [DEF:backend.src.services.git_service.GitService._derive_server_url_from_remote:Function] + # [DEF:_derive_server_url_from_remote:Function] # @PURPOSE: Build API base URL from remote repository URL without credentials. # @PRE: remote_url may be any git URL. # @POST: Returns normalized http(s) base URL or None when derivation is impossible. @@ -1772,9 +1772,9 @@ class GitService: if parsed.port: netloc = f"{netloc}:{parsed.port}" return f"{parsed.scheme}://{netloc}".rstrip("/") - # [/DEF:backend.src.services.git_service.GitService._derive_server_url_from_remote:Function] + # [/DEF:_derive_server_url_from_remote:Function] - # [DEF:backend.src.services.git_service.GitService.promote_direct_merge:Function] + # [DEF:promote_direct_merge:Function] # @PURPOSE: Perform direct merge between branches in local repo and push target branch. # @PRE: Repository exists and both branches are valid. # @POST: Target branch contains merged changes from source branch. @@ -1838,9 +1838,9 @@ class GitService: "to_branch": target, "status": "merged", } - # [/DEF:backend.src.services.git_service.GitService.promote_direct_merge:Function] + # [/DEF:promote_direct_merge:Function] - # [DEF:backend.src.services.git_service.GitService.create_gitea_pull_request:Function] + # [DEF:create_gitea_pull_request:Function] # @PURPOSE: Create pull request in Gitea. # @PRE: Config and remote URL are valid. # @POST: Returns normalized PR metadata. @@ -1931,9 +1931,9 @@ class GitService: "url": data.get("html_url") or data.get("url"), "status": data.get("state") or "open", } - # [/DEF:backend.src.services.git_service.GitService.create_gitea_pull_request:Function] + # [/DEF:create_gitea_pull_request:Function] - # [DEF:backend.src.services.git_service.GitService.create_github_pull_request:Function] + # [DEF:create_github_pull_request:Function] # @PURPOSE: Create pull request in GitHub or GitHub Enterprise. # @PRE: Config and remote URL are valid. # @POST: Returns normalized PR metadata. @@ -1987,9 +1987,9 @@ class GitService: "url": data.get("html_url") or data.get("url"), "status": data.get("state") or "open", } - # [/DEF:backend.src.services.git_service.GitService.create_github_pull_request:Function] + # [/DEF:create_github_pull_request:Function] - # [DEF:backend.src.services.git_service.GitService.create_gitlab_merge_request:Function] + # [DEF:create_gitlab_merge_request:Function] # @PURPOSE: Create merge request in GitLab. # @PRE: Config and remote URL are valid. # @POST: Returns normalized MR metadata. @@ -2043,7 +2043,7 @@ class GitService: "url": data.get("web_url") or data.get("url"), "status": data.get("state") or "opened", } - # [/DEF:backend.src.services.git_service.GitService.create_gitlab_merge_request:Function] + # [/DEF:create_gitlab_merge_request:Function] -# [/DEF:backend.src.services.git_service.GitService:Class] -# [/DEF:backend.src.services.git_service:Module] +# [/DEF:GitService:Class] +# [/DEF:git_service:Module] diff --git a/backend/src/services/health_service.py b/backend/src/services/health_service.py index 68478803..b549c0fa 100644 --- a/backend/src/services/health_service.py +++ b/backend/src/services/health_service.py @@ -34,7 +34,7 @@ class HealthService: """ @PURPOSE: Service for managing and querying dashboard health data. """ - # [DEF:HealthService.__init__:Function] + # [DEF:HealthService_init:Function] # @COMPLEXITY: 3 # @PURPOSE: Initialize health service with DB session and optional config access for dashboard metadata resolution. # @PRE: db is a valid SQLAlchemy session. @@ -43,9 +43,9 @@ class HealthService: self.db = db self.config_manager = config_manager self._dashboard_meta_cache: Dict[Tuple[str, str], Dict[str, Optional[str]]] = {} - # [/DEF:HealthService.__init__:Function] + # [/DEF:HealthService_init:Function] - # [DEF:HealthService._prime_dashboard_meta_cache:Function] + # [DEF:_prime_dashboard_meta_cache:Function] # @COMPLEXITY: 3 # @PURPOSE: Warm dashboard slug/title cache with one Superset list fetch per environment. # @PRE: records may contain mixed numeric and slug dashboard identifiers. @@ -124,9 +124,9 @@ class HealthService: "slug": None, "title": None, } - # [/DEF:HealthService._prime_dashboard_meta_cache:Function] + # [/DEF:_prime_dashboard_meta_cache:Function] - # [DEF:HealthService._resolve_dashboard_meta:Function] + # [DEF:_resolve_dashboard_meta:Function] # @COMPLEXITY: 1 # @PURPOSE: Resolve slug/title for a dashboard referenced by persisted validation record. # @PRE: dashboard_id may be numeric or slug-like; environment_id may be empty. @@ -151,9 +151,9 @@ class HealthService: meta = {"slug": None, "title": None} self._dashboard_meta_cache[cache_key] = meta return meta - # [/DEF:HealthService._resolve_dashboard_meta:Function] + # [/DEF:_resolve_dashboard_meta:Function] - # [DEF:HealthService.get_health_summary:Function] + # [DEF:get_health_summary:Function] # @COMPLEXITY: 3 # @PURPOSE: Aggregate latest validation status per dashboard and enrich rows with dashboard slug/title. # @PRE: environment_id may be omitted to aggregate across all environments. @@ -229,9 +229,9 @@ class HealthService: fail_count=fail_count, unknown_count=unknown_count ) - # [/DEF:HealthService.get_health_summary:Function] + # [/DEF:get_health_summary:Function] - # [DEF:HealthService.delete_validation_report:Function] + # [DEF:delete_validation_report:Function] # @COMPLEXITY: 3 # @PURPOSE: Delete one persisted health report and optionally clean linked task/log artifacts. # @PRE: record_id is a validation record identifier. @@ -306,7 +306,7 @@ class HealthService: ) return True - # [/DEF:HealthService.delete_validation_report:Function] + # [/DEF:delete_validation_report:Function] # [/DEF:HealthService:Class] diff --git a/backend/src/services/llm_prompt_templates.py b/backend/src/services/llm_prompt_templates.py index 8d368835..0d7ff5f1 100644 --- a/backend/src/services/llm_prompt_templates.py +++ b/backend/src/services/llm_prompt_templates.py @@ -1,9 +1,9 @@ -# [DEF:backend.src.services.llm_prompt_templates:Module] -# @COMPLEXITY: 3 +# [DEF:llm_prompt_templates:Module] +# @COMPLEXITY: 2 # @SEMANTICS: llm, prompts, templates, settings # @PURPOSE: Provide default LLM prompt templates and normalization helpers for runtime usage. # @LAYER: Domain -# @RELATION: DEPENDS_ON -> backend.src.core.config_manager +# @RELATION: DEPENDS_ON ->[backend.src.core.config_manager:Function] # @INVARIANT: All required prompt template keys are always present after normalization. from __future__ import annotations @@ -13,7 +13,7 @@ from typing import Dict, Any, Optional # [DEF:DEFAULT_LLM_PROMPTS:Constant] -# @COMPLEXITY: 3 +# @COMPLEXITY: 2 # @PURPOSE: Default prompt templates used by documentation, dashboard validation, and git commit generation. DEFAULT_LLM_PROMPTS: Dict[str, str] = { "dashboard_validation_prompt": ( @@ -62,7 +62,7 @@ DEFAULT_LLM_PROMPTS: Dict[str, str] = { # [DEF:DEFAULT_LLM_PROVIDER_BINDINGS:Constant] -# @COMPLEXITY: 3 +# @COMPLEXITY: 2 # @PURPOSE: Default provider binding per task domain. DEFAULT_LLM_PROVIDER_BINDINGS: Dict[str, str] = { "dashboard_validation": "", @@ -73,7 +73,7 @@ DEFAULT_LLM_PROVIDER_BINDINGS: Dict[str, str] = { # [DEF:DEFAULT_LLM_ASSISTANT_SETTINGS:Constant] -# @COMPLEXITY: 3 +# @COMPLEXITY: 2 # @PURPOSE: Default planner settings for assistant chat intent model/provider resolution. DEFAULT_LLM_ASSISTANT_SETTINGS: Dict[str, str] = { "assistant_planner_provider": "", @@ -197,4 +197,4 @@ def render_prompt(template: str, variables: Dict[str, Any]) -> str: # [/DEF:render_prompt:Function] -# [/DEF:backend.src.services.llm_prompt_templates:Module] +# [/DEF:llm_prompt_templates:Module] diff --git a/backend/src/services/llm_provider.py b/backend/src/services/llm_provider.py index c75a739e..e0553c22 100644 --- a/backend/src/services/llm_provider.py +++ b/backend/src/services/llm_provider.py @@ -1,11 +1,10 @@ -# [DEF:backend.src.services.llm_provider:Module] +# [DEF:llm_provider:Module] # @COMPLEXITY: 3 # @SEMANTICS: service, llm, provider, encryption # @PURPOSE: Service for managing LLM provider configurations with encrypted API keys. # @LAYER: Domain -# @RELATION: DEPENDS_ON -> backend.src.core.database -# @RELATION: DEPENDS_ON -> backend.src.models.llm - +# @RELATION: DEPENDS_ON ->[backend.src.core.database:Function] +# @RELATION: DEPENDS_ON ->[backend.src.models.llm:Function] from typing import List, Optional, TYPE_CHECKING from sqlalchemy.orm import Session from ..models.llm import LLMProvider @@ -23,7 +22,7 @@ MASKED_API_KEY_PLACEHOLDER = "********" # @PURPOSE: Load and validate the Fernet key used for secret encryption. # @PRE: ENCRYPTION_KEY environment variable must be set to a valid Fernet key. # @POST: Returns validated key bytes ready for Fernet initialization. -# @RELATION: DEPENDS_ON -> backend.src.core.logger +# @RELATION: DEPENDS_ON ->[backend.src.core.logger:Function] # @SIDE_EFFECT: Emits belief-state logs for missing or invalid encryption configuration. # @INVARIANT: Encryption initialization never falls back to a hardcoded secret. def _require_fernet_key() -> bytes: @@ -61,46 +60,46 @@ def _require_fernet_key() -> bytes: # @TEST_EDGE: empty_string_encryption -> {"data": ""} # @TEST_INVARIANT: symmetric_encryption -> verifies: [basic_encryption_cycle, empty_string_encryption] class EncryptionManager: - # [DEF:EncryptionManager.__init__:Function] + # [DEF:EncryptionManager_init:Function] # @PURPOSE: Initialize the encryption manager with a Fernet key. # @PRE: ENCRYPTION_KEY env var must be set to a valid Fernet key. # @POST: Fernet instance ready for encryption/decryption. def __init__(self): self.key = _require_fernet_key() self.fernet = Fernet(self.key) - # [/DEF:EncryptionManager.__init__:Function] + # [/DEF:EncryptionManager_init:Function] - # [DEF:EncryptionManager.encrypt:Function] + # [DEF:encrypt:Function] # @PURPOSE: Encrypt a plaintext string. # @PRE: data must be a non-empty string. # @POST: Returns encrypted string. def encrypt(self, data: str) -> str: with belief_scope("encrypt"): return self.fernet.encrypt(data.encode()).decode() - # [/DEF:EncryptionManager.encrypt:Function] + # [/DEF:encrypt:Function] - # [DEF:EncryptionManager.decrypt:Function] + # [DEF:decrypt:Function] # @PURPOSE: Decrypt an encrypted string. # @PRE: encrypted_data must be a valid Fernet-encrypted string. # @POST: Returns original plaintext string. def decrypt(self, encrypted_data: str) -> str: with belief_scope("decrypt"): return self.fernet.decrypt(encrypted_data.encode()).decode() - # [/DEF:EncryptionManager.decrypt:Function] + # [/DEF:decrypt:Function] # [/DEF:EncryptionManager:Class] # [DEF:LLMProviderService:Class] # @COMPLEXITY: 3 # @PURPOSE: Service to manage LLM provider lifecycle. class LLMProviderService: - # [DEF:LLMProviderService.__init__:Function] + # [DEF:LLMProviderService_init:Function] # @PURPOSE: Initialize the service with database session. # @PRE: db must be a valid SQLAlchemy Session. # @POST: Service ready for provider operations. def __init__(self, db: Session): self.db = db self.encryption = EncryptionManager() - # [/DEF:LLMProviderService.__init__:Function] + # [/DEF:LLMProviderService_init:Function] # [DEF:get_all_providers:Function] # @COMPLEXITY: 3 @@ -214,4 +213,4 @@ class LLMProviderService: # [/DEF:LLMProviderService:Class] -# [/DEF:backend.src.services.llm_provider:Module] +# [/DEF:llm_provider:Module] diff --git a/backend/src/services/mapping_service.py b/backend/src/services/mapping_service.py index 67341f72..8e9a10a4 100644 --- a/backend/src/services/mapping_service.py +++ b/backend/src/services/mapping_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.mapping_service:Module] +# [DEF:mapping_service:Module] # # @SEMANTICS: service, mapping, fuzzy-matching, superset # @PURPOSE: Orchestrates database fetching and fuzzy matching suggestions. @@ -19,7 +19,7 @@ from ..core.utils.matching import suggest_mappings # @PURPOSE: Service for handling database mapping logic. class MappingService: - # [DEF:__init__:Function] + # [DEF:init:Function] # @PURPOSE: Initializes the mapping service with a config manager. # @PRE: config_manager is provided. # @PARAM: config_manager (ConfigManager) - The configuration manager. @@ -27,7 +27,7 @@ class MappingService: def __init__(self, config_manager): with belief_scope("MappingService.__init__"): self.config_manager = config_manager - # [/DEF:__init__:Function] + # [/DEF:init:Function] # [DEF:_get_client:Function] # @PURPOSE: Helper to get an initialized SupersetClient for an environment. @@ -68,4 +68,4 @@ class MappingService: # [/DEF:MappingService:Class] -# [/DEF:backend.src.services.mapping_service:Module] +# [/DEF:mapping_service:Module] diff --git a/backend/src/services/notifications/__init__.py b/backend/src/services/notifications/__init__.py index 6f79d663..a45791ee 100644 --- a/backend/src/services/notifications/__init__.py +++ b/backend/src/services/notifications/__init__.py @@ -1,3 +1,4 @@ -# [DEF:src.services.notifications:Package] +# [DEF:notifications:Package] # @PURPOSE: Notification service package root. -# [/DEF:src.services.notifications:Package] +# @RELATION: EXPORTS ->[NotificationService:Class] +# [/DEF:notifications:Package] diff --git a/backend/src/services/notifications/__tests__/test_notification_service.py b/backend/src/services/notifications/__tests__/test_notification_service.py index e7ae0fd2..e4be5663 100644 --- a/backend/src/services/notifications/__tests__/test_notification_service.py +++ b/backend/src/services/notifications/__tests__/test_notification_service.py @@ -1,6 +1,7 @@ -# [DEF:backend.src.services.notifications.__tests__.test_notification_service:Module] -# @COMPLEXITY: 3 +# [DEF:test_notification_service:Module] +# @COMPLEXITY: 2 # @PURPOSE: Unit tests for NotificationService routing and dispatch logic. +# @RELATION: TESTS ->[NotificationService:Class] import pytest from unittest.mock import MagicMock, AsyncMock, patch @@ -117,4 +118,4 @@ async def test_dispatch_report_calls_providers(service, mock_db): service._providers["TELEGRAM"].send.assert_called_once() service._providers["SMTP"].send.assert_called_once() -# [/DEF:backend.src.services.notifications.__tests__.test_notification_service:Module] \ No newline at end of file +# [/DEF:test_notification_service:Module] \ No newline at end of file diff --git a/backend/src/services/notifications/providers.py b/backend/src/services/notifications/providers.py index 84b17e20..06c58635 100644 --- a/backend/src/services/notifications/providers.py +++ b/backend/src/services/notifications/providers.py @@ -1,8 +1,9 @@ -# [DEF:backend.src.services.notifications.providers:Module] +# [DEF:providers:Module] # # @COMPLEXITY: 5 # @SEMANTICS: notifications, providers, smtp, slack, telegram, abstraction # @PURPOSE: Defines abstract base and concrete implementations for external notification delivery. +# @RELATION: IMPLEMENTS ->[NotificationService:Class] # @LAYER: Infra # # @INVARIANT: Providers must be stateless and resilient to network failures. @@ -22,7 +23,13 @@ from ...core.logger import logger # @PURPOSE: Abstract base class for all notification providers. class NotificationProvider(ABC): @abstractmethod - async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool: + async def send( + self, + target: str, + subject: str, + body: str, + context: Optional[Dict[str, Any]] = None, + ) -> bool: """ Send a notification to a specific target. :param target: Recipient identifier (email, channel ID, user ID). @@ -32,6 +39,8 @@ class NotificationProvider(ABC): :return: True if successfully dispatched. """ pass + + # [/DEF:NotificationProvider:Class] @@ -46,7 +55,13 @@ class SMTPProvider(NotificationProvider): self.from_email = config.get("from_email") self.use_tls = config.get("use_tls", True) - async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool: + async def send( + self, + target: str, + subject: str, + body: str, + context: Optional[Dict[str, Any]] = None, + ) -> bool: try: msg = MIMEMultipart() msg["From"] = self.from_email @@ -63,8 +78,12 @@ class SMTPProvider(NotificationProvider): server.quit() return True except Exception as e: - logger.error(f"[SMTPProvider][FAILED] Failed to send email to {target}: {e}") + logger.error( + f"[SMTPProvider][FAILED] Failed to send email to {target}: {e}" + ) return False + + # [/DEF:SMTPProvider:Class] @@ -74,25 +93,35 @@ class TelegramProvider(NotificationProvider): def __init__(self, config: Dict[str, Any]): self.bot_token = config.get("bot_token") - async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool: + async def send( + self, + target: str, + subject: str, + body: str, + context: Optional[Dict[str, Any]] = None, + ) -> bool: if not self.bot_token: logger.error("[TelegramProvider][FAILED] Bot token not configured") return False - + url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage" payload = { "chat_id": target, "text": f"*{subject}*\n\n{body}", - "parse_mode": "Markdown" + "parse_mode": "Markdown", } - + try: response = requests.post(url, json=payload, timeout=10) response.raise_for_status() return True except Exception as e: - logger.error(f"[TelegramProvider][FAILED] Failed to send Telegram message to {target}: {e}") + logger.error( + f"[TelegramProvider][FAILED] Failed to send Telegram message to {target}: {e}" + ) return False + + # [/DEF:TelegramProvider:Class] @@ -102,15 +131,19 @@ class SlackProvider(NotificationProvider): def __init__(self, config: Dict[str, Any]): self.webhook_url = config.get("webhook_url") - async def send(self, target: str, subject: str, body: str, context: Optional[Dict[str, Any]] = None) -> bool: + async def send( + self, + target: str, + subject: str, + body: str, + context: Optional[Dict[str, Any]] = None, + ) -> bool: if not self.webhook_url: logger.error("[SlackProvider][FAILED] Webhook URL not configured") return False - - payload = { - "text": f"*{subject}*\n{body}" - } - + + payload = {"text": f"*{subject}*\n{body}"} + try: response = requests.post(self.webhook_url, json=payload, timeout=10) response.raise_for_status() @@ -118,6 +151,8 @@ class SlackProvider(NotificationProvider): except Exception as e: logger.error(f"[SlackProvider][FAILED] Failed to send Slack message: {e}") return False + + # [/DEF:SlackProvider:Class] -# [/DEF:backend.src.services.notifications.providers:Module] \ No newline at end of file +# [/DEF:providers:Module] diff --git a/backend/src/services/notifications/service.py b/backend/src/services/notifications/service.py index 0aceab7c..6b96529f 100644 --- a/backend/src/services/notifications/service.py +++ b/backend/src/services/notifications/service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.notifications.service:Module] +# [DEF:service:Module] # # @COMPLEXITY: 5 # @SEMANTICS: notifications, service, routing, dispatch, background-tasks @@ -143,4 +143,4 @@ class NotificationService: ) # [/DEF:NotificationService:Class] -# [/DEF:backend.src.services.notifications.service:Module] \ No newline at end of file +# [/DEF:service:Module] \ No newline at end of file diff --git a/backend/src/services/profile_service.py b/backend/src/services/profile_service.py index 9aae19f1..15c99741 100644 --- a/backend/src/services/profile_service.py +++ b/backend/src/services/profile_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.services.profile_service:Module] +# [DEF:profile_service:Module] # # @COMPLEXITY: 5 # @SEMANTICS: profile, service, validation, ownership, filtering, superset, preferences @@ -51,36 +51,47 @@ SUPPORTED_DENSITIES = {"compact", "comfortable"} # [DEF:ProfileValidationError:Class] -# @COMPLEXITY: 3 +# @RELATION: INHERITS -> Exception +# @COMPLEXITY: 2 # @PURPOSE: Domain validation error for profile preference update requests. class ProfileValidationError(Exception): def __init__(self, errors: Sequence[str]): self.errors = list(errors) super().__init__("Profile preference validation failed") + + # [/DEF:ProfileValidationError:Class] # [DEF:EnvironmentNotFoundError:Class] -# @COMPLEXITY: 3 +# @RELATION: INHERITS -> Exception +# @COMPLEXITY: 2 # @PURPOSE: Raised when environment_id from lookup request is unknown in app configuration. class EnvironmentNotFoundError(Exception): pass + + # [/DEF:EnvironmentNotFoundError:Class] # [DEF:ProfileAuthorizationError:Class] -# @COMPLEXITY: 3 +# @RELATION: INHERITS -> Exception +# @COMPLEXITY: 2 # @PURPOSE: Raised when caller attempts cross-user preference mutation. class ProfileAuthorizationError(Exception): pass + + # [/DEF:ProfileAuthorizationError:Class] # [DEF:ProfileService:Class] +# @RELATION: DEPENDS_ON -> sqlalchemy.orm.Session # @COMPLEXITY: 5 # @PURPOSE: Implements profile preference read/update flow and Superset account lookup degradation strategy. class ProfileService: - # [DEF:__init__:Function] + # [DEF:init:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Initialize service with DB session and config manager. # @PRE: db session is active and config_manager supports get_environments(). # @POST: Service is ready for preference persistence and lookup operations. @@ -90,14 +101,18 @@ class ProfileService: self.plugin_loader = plugin_loader self.auth_repository = AuthRepository(db) self.encryption = EncryptionManager() - # [/DEF:__init__:Function] + + # [/DEF:init:Function] # [DEF:get_my_preference:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Return current user's persisted preference or default non-configured view. # @PRE: current_user is authenticated. # @POST: Returned payload belongs to current_user only. def get_my_preference(self, current_user: User) -> ProfilePreferenceResponse: - with belief_scope("ProfileService.get_my_preference", f"user_id={current_user.id}"): + with belief_scope( + "ProfileService.get_my_preference", f"user_id={current_user.id}" + ): logger.reflect("[REFLECT] Loading current user's dashboard preference") preference = self._get_preference_row(current_user.id) security_summary = self._build_security_summary(current_user) @@ -112,17 +127,23 @@ class ProfileService: return ProfilePreferenceResponse( status="success", message="Preference loaded", - preference=self._to_preference_payload(preference, str(current_user.id)), + preference=self._to_preference_payload( + preference, str(current_user.id) + ), security=security_summary, ) + # [/DEF:get_my_preference:Function] # [DEF:get_dashboard_filter_binding:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Return only dashboard-filter fields required by dashboards listing hot path. # @PRE: current_user is authenticated. # @POST: Returns normalized username and profile-default filter toggles without security summary expansion. def get_dashboard_filter_binding(self, current_user: User) -> dict: - with belief_scope("ProfileService.get_dashboard_filter_binding", f"user_id={current_user.id}"): + with belief_scope( + "ProfileService.get_dashboard_filter_binding", f"user_id={current_user.id}" + ): preference = self._get_preference_row(current_user.id) if preference is None: return { @@ -133,8 +154,12 @@ class ProfileService: } return { - "superset_username": self._sanitize_username(preference.superset_username), - "superset_username_normalized": self._normalize_username(preference.superset_username), + "superset_username": self._sanitize_username( + preference.superset_username + ), + "superset_username_normalized": self._normalize_username( + preference.superset_username + ), "show_only_my_dashboards": bool(preference.show_only_my_dashboards), "show_only_slug_dashboards": bool( preference.show_only_slug_dashboards @@ -142,9 +167,11 @@ class ProfileService: else True ), } + # [/DEF:get_dashboard_filter_binding:Function] # [DEF:update_my_preference:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Validate and persist current user's profile preference in self-scoped mode. # @PRE: current_user is authenticated and payload is provided. # @POST: Preference row for current_user is created/updated when validation passes. @@ -154,19 +181,29 @@ class ProfileService: payload: ProfilePreferenceUpdateRequest, target_user_id: Optional[str] = None, ) -> ProfilePreferenceResponse: - with belief_scope("ProfileService.update_my_preference", f"user_id={current_user.id}"): - logger.reason("[REASON] Evaluating self-scope guard before preference mutation") + with belief_scope( + "ProfileService.update_my_preference", f"user_id={current_user.id}" + ): + logger.reason( + "[REASON] Evaluating self-scope guard before preference mutation" + ) requested_user_id = str(target_user_id or current_user.id) if requested_user_id != str(current_user.id): logger.explore("[EXPLORE] Cross-user mutation attempt blocked") - raise ProfileAuthorizationError("Cross-user preference mutation is forbidden") + raise ProfileAuthorizationError( + "Cross-user preference mutation is forbidden" + ) preference = self._get_or_create_preference_row(current_user.id) provided_fields = set(getattr(payload, "model_fields_set", set())) - effective_superset_username = self._sanitize_username(preference.superset_username) + effective_superset_username = self._sanitize_username( + preference.superset_username + ) if "superset_username" in provided_fields: - effective_superset_username = self._sanitize_username(payload.superset_username) + effective_superset_username = self._sanitize_username( + payload.superset_username + ) effective_show_only = bool(preference.show_only_my_dashboards) if "show_only_my_dashboards" in provided_fields: @@ -247,12 +284,14 @@ class ProfileService: preference.git_email = effective_git_email if "git_personal_access_token" in provided_fields: - sanitized_token = self._sanitize_secret(payload.git_personal_access_token) + sanitized_token = self._sanitize_secret( + payload.git_personal_access_token + ) if sanitized_token is None: preference.git_personal_access_token_encrypted = None else: - preference.git_personal_access_token_encrypted = self.encryption.encrypt( - sanitized_token + preference.git_personal_access_token_encrypted = ( + self.encryption.encrypt(sanitized_token) ) preference.start_page = effective_start_page @@ -263,7 +302,9 @@ class ProfileService: preference.notify_on_fail = effective_notify_on_fail preference.updated_at = datetime.utcnow() - persisted_preference = self.auth_repository.save_user_dashboard_preference(preference) + persisted_preference = self.auth_repository.save_user_dashboard_preference( + preference + ) logger.reason("[REASON] Preference persisted successfully") return ProfilePreferenceResponse( @@ -275,9 +316,11 @@ class ProfileService: ), security=self._build_security_summary(current_user), ) + # [/DEF:update_my_preference:Function] # [DEF:lookup_superset_accounts:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Query Superset users in selected environment and project canonical account candidates. # @PRE: current_user is authenticated and environment_id exists. # @POST: Returns success payload or degraded payload with warning while preserving manual fallback. @@ -293,7 +336,9 @@ class ProfileService: environment = self._resolve_environment(request.environment_id) if environment is None: logger.explore("[EXPLORE] Lookup aborted: environment not found") - raise EnvironmentNotFoundError(f"Environment '{request.environment_id}' not found") + raise EnvironmentNotFoundError( + f"Environment '{request.environment_id}' not found" + ) sort_column = str(request.sort_column or "username").strip().lower() sort_order = str(request.sort_order or "desc").strip().lower() @@ -338,7 +383,9 @@ class ProfileService: items=items, ) except Exception as exc: - logger.explore(f"[EXPLORE] Lookup degraded due to upstream error: {exc}") + logger.explore( + f"[EXPLORE] Lookup degraded due to upstream error: {exc}" + ) return SupersetAccountLookupResponse( status="degraded", environment_id=request.environment_id, @@ -351,9 +398,11 @@ class ProfileService: ), items=[], ) + # [/DEF:lookup_superset_accounts:Function] # [DEF:matches_dashboard_actor:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Apply trim+case-insensitive actor match across owners OR modified_by. # @PRE: bound_username can be empty; owners may contain mixed payload. # @POST: Returns True when normalized username matches owners or modified_by. @@ -375,9 +424,11 @@ class ProfileService: if modified_token and normalized_actor == modified_token: return True return False + # [/DEF:matches_dashboard_actor:Function] # [DEF:_build_security_summary:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Build read-only security snapshot with role and permission badges. # @PRE: current_user is authenticated. # @POST: Returns deterministic security projection for profile UI. @@ -402,7 +453,9 @@ class ProfileService: normalized_resource = self._sanitize_text(resource) normalized_action = str(action or "").strip().upper() if normalized_resource and normalized_action: - declared_permission_pairs.add((normalized_resource, normalized_action)) + declared_permission_pairs.add( + (normalized_resource, normalized_action) + ) except Exception as discovery_error: logger.warning( "[ProfileService][EXPLORE] Failed to build declared permission catalog: %s", @@ -435,13 +488,17 @@ class ProfileService: roles=role_names, permissions=permission_states, ) + # [/DEF:_build_security_summary:Function] # [DEF:_collect_user_permission_pairs:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Collect effective permission tuples from current user's roles. # @PRE: current_user can include role/permission graph. # @POST: Returns unique normalized (resource, ACTION) tuples. - def _collect_user_permission_pairs(self, current_user: User) -> Set[Tuple[str, str]]: + def _collect_user_permission_pairs( + self, current_user: User + ) -> Set[Tuple[str, str]]: collected: Set[Tuple[str, str]] = set() roles = getattr(current_user, "roles", []) or [] for role in roles: @@ -452,9 +509,11 @@ class ProfileService: if resource and action: collected.add((resource, action)) return collected + # [/DEF:_collect_user_permission_pairs:Function] # [DEF:_format_permission_key:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Convert normalized permission pair to compact UI key. # @PRE: resource and action are normalized. # @POST: Returns user-facing badge key. @@ -464,9 +523,11 @@ class ProfileService: if normalized_action == "READ": return normalized_resource return f"{normalized_resource}:{normalized_action.lower()}" + # [/DEF:_format_permission_key:Function] # [DEF:_to_preference_payload:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Map ORM preference row to API DTO with token metadata. # @PRE: preference row can contain nullable optional fields. # @POST: Returns normalized ProfilePreference object. @@ -516,13 +577,17 @@ class ProfileService: ), telegram_id=self._sanitize_text(preference.telegram_id), email_address=self._sanitize_text(preference.email_address), - notify_on_fail=bool(preference.notify_on_fail) if preference.notify_on_fail is not None else True, + notify_on_fail=bool(preference.notify_on_fail) + if preference.notify_on_fail is not None + else True, created_at=created_at, updated_at=updated_at, ) + # [/DEF:_to_preference_payload:Function] # [DEF:_mask_secret_value:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Build a safe display value for sensitive secrets. # @PRE: secret may be None or plaintext. # @POST: Returns masked representation or None. @@ -533,9 +598,11 @@ class ProfileService: if len(sanitized_secret) <= 4: return "***" return f"{sanitized_secret[:2]}***{sanitized_secret[-2:]}" + # [/DEF:_mask_secret_value:Function] # [DEF:_sanitize_text:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Normalize optional text into trimmed form or None. # @PRE: value may be empty or None. # @POST: Returns trimmed value or None. @@ -544,9 +611,11 @@ class ProfileService: if not normalized: return None return normalized + # [/DEF:_sanitize_text:Function] # [DEF:_sanitize_secret:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Normalize secret input into trimmed form or None. # @PRE: value may be None or blank. # @POST: Returns trimmed secret or None. @@ -557,9 +626,11 @@ class ProfileService: if not normalized: return None return normalized + # [/DEF:_sanitize_secret:Function] # [DEF:_normalize_start_page:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Normalize supported start page aliases to canonical values. # @PRE: value may be None or alias. # @POST: Returns one of SUPPORTED_START_PAGES. @@ -570,9 +641,11 @@ class ProfileService: if normalized in SUPPORTED_START_PAGES: return normalized return "dashboards" + # [/DEF:_normalize_start_page:Function] # [DEF:_normalize_density:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Normalize supported density aliases to canonical values. # @PRE: value may be None or alias. # @POST: Returns one of SUPPORTED_DENSITIES. @@ -583,9 +656,11 @@ class ProfileService: if normalized in SUPPORTED_DENSITIES: return normalized return "comfortable" + # [/DEF:_normalize_density:Function] # [DEF:_resolve_environment:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Resolve environment model from configured environments by id. # @PRE: environment_id is provided. # @POST: Returns environment object when found else None. @@ -595,17 +670,21 @@ class ProfileService: if str(getattr(env, "id", "")) == str(environment_id): return env return None + # [/DEF:_resolve_environment:Function] # [DEF:_get_preference_row:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Return persisted preference row for user or None. # @PRE: user_id is provided. # @POST: Returns matching row or None. def _get_preference_row(self, user_id: str) -> Optional[UserDashboardPreference]: return self.auth_repository.get_user_dashboard_preference(str(user_id)) + # [/DEF:_get_preference_row:Function] # [DEF:_get_or_create_preference_row:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Return existing preference row or create new unsaved row. # @PRE: user_id is provided. # @POST: Returned row always contains user_id. @@ -614,9 +693,11 @@ class ProfileService: if existing is not None: return existing return UserDashboardPreference(user_id=str(user_id)) + # [/DEF:_get_or_create_preference_row:Function] # [DEF:_build_default_preference:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Build non-persisted default preference DTO for unconfigured users. # @PRE: user_id is provided. # @POST: Returns ProfilePreference with disabled toggle and empty username. @@ -641,9 +722,11 @@ class ProfileService: created_at=now, updated_at=now, ) + # [/DEF:_build_default_preference:Function] # [DEF:_validate_update_payload:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Validate username/toggle constraints for preference mutation. # @PRE: payload is provided. # @POST: Returns validation errors list; empty list means valid. @@ -664,7 +747,9 @@ class ProfileService: "Username should not contain spaces. Please enter a valid Apache Superset username." ) if show_only_my_dashboards and not sanitized_username: - errors.append("Superset username is required when default filter is enabled.") + errors.append( + "Superset username is required when default filter is enabled." + ) sanitized_git_email = self._sanitize_text(git_email) if sanitized_git_email: @@ -693,17 +778,21 @@ class ProfileService: errors.append("Notification email should be a valid email address.") return errors + # [/DEF:_validate_update_payload:Function] # [DEF:_sanitize_username:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Normalize raw username into trimmed form or None for empty input. # @PRE: value can be empty or None. # @POST: Returns trimmed username or None. def _sanitize_username(self, value: Optional[str]) -> Optional[str]: return self._sanitize_text(value) + # [/DEF:_sanitize_username:Function] # [DEF:_normalize_username:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Apply deterministic trim+lower normalization for actor matching. # @PRE: value can be empty or None. # @POST: Returns lowercase normalized token or None. @@ -712,9 +801,11 @@ class ProfileService: if sanitized is None: return None return sanitized.lower() + # [/DEF:_normalize_username:Function] # [DEF:_normalize_owner_tokens:Function] + # @RELATION: BINDS_TO -> ProfileService # @PURPOSE: Normalize owners payload into deduplicated lower-cased tokens. # @PRE: owners can be iterable of scalars or dict-like values. # @POST: Returns list of unique normalized owner tokens. @@ -727,8 +818,12 @@ class ProfileService: if isinstance(owner, dict): first_name = self._sanitize_username(str(owner.get("first_name") or "")) last_name = self._sanitize_username(str(owner.get("last_name") or "")) - full_name = " ".join(part for part in [first_name, last_name] if part).strip() - snake_name = "_".join(part for part in [first_name, last_name] if part).strip("_") + full_name = " ".join( + part for part in [first_name, last_name] if part + ).strip() + snake_name = "_".join( + part for part in [first_name, last_name] if part + ).strip("_") owner_candidates = [ owner.get("username"), owner.get("user_name"), @@ -748,7 +843,10 @@ class ProfileService: if token and token not in normalized: normalized.append(token) return normalized + # [/DEF:_normalize_owner_tokens:Function] + + # [/DEF:ProfileService:Class] -# [/DEF:backend.src.services.profile_service:Module] +# [/DEF:profile_service:Module] diff --git a/backend/src/services/rbac_permission_catalog.py b/backend/src/services/rbac_permission_catalog.py index d401b2a0..6736e292 100644 --- a/backend/src/services/rbac_permission_catalog.py +++ b/backend/src/services/rbac_permission_catalog.py @@ -1,6 +1,6 @@ -# [DEF:backend.src.services.rbac_permission_catalog:Module] +# [DEF:rbac_permission_catalog:Module] # -# @COMPLEXITY: 3 +# @COMPLEXITY: 2 # @SEMANTICS: rbac, permissions, catalog, sync, discovery # @PURPOSE: Discovers declared RBAC permissions from API routes/plugins and synchronizes them into auth database. # @LAYER: Service @@ -187,4 +187,4 @@ def sync_permission_catalog( return len(missing_pairs) # [/DEF:sync_permission_catalog:Function] -# [/DEF:backend.src.services.rbac_permission_catalog:Module] +# [/DEF:rbac_permission_catalog:Module] diff --git a/backend/src/services/reports/__init__.py b/backend/src/services/reports/__init__.py index 02ff7137..612ec71c 100644 --- a/backend/src/services/reports/__init__.py +++ b/backend/src/services/reports/__init__.py @@ -1,3 +1,3 @@ -# [DEF:src.services.reports:Package] +# [DEF:reports:Package] # @PURPOSE: Report service package root. -# [/DEF:src.services.reports:Package] +# [/DEF:reports:Package] diff --git a/backend/src/services/reports/__tests__/test_report_normalizer.py b/backend/src/services/reports/__tests__/test_report_normalizer.py index c0acb893..2e38cea2 100644 --- a/backend/src/services/reports/__tests__/test_report_normalizer.py +++ b/backend/src/services/reports/__tests__/test_report_normalizer.py @@ -1,9 +1,9 @@ -# [DEF:backend.tests.test_report_normalizer:Module] -# @COMPLEXITY: 3 +# [DEF:test_report_normalizer:Module] +# @COMPLEXITY: 2 # @SEMANTICS: tests, reports, normalizer, fallback # @PURPOSE: Validate unknown task type fallback and partial payload normalization behavior. +# @RELATION: TESTS ->[normalize_report:Function] # @LAYER: Domain (Tests) -# @RELATION: TESTS -> backend.src.services.reports.normalizer # @INVARIANT: Unknown plugin types are mapped to canonical unknown task type. from datetime import datetime @@ -12,6 +12,8 @@ from src.core.task_manager.models import Task, TaskStatus from src.services.reports.normalizer import normalize_task_report +# [DEF:test_unknown_type_maps_to_unknown_profile:Function] +# @RELATION: BINDS_TO -> test_report_normalizer def test_unknown_type_maps_to_unknown_profile(): task = Task( id="unknown-1", @@ -30,6 +32,10 @@ def test_unknown_type_maps_to_unknown_profile(): assert report.error_context is not None +# [/DEF:test_unknown_type_maps_to_unknown_profile:Function] + +# [DEF:test_partial_payload_keeps_report_visible_with_placeholders:Function] +# @RELATION: BINDS_TO -> test_report_normalizer def test_partial_payload_keeps_report_visible_with_placeholders(): task = Task( id="partial-1", @@ -48,6 +54,10 @@ def test_partial_payload_keeps_report_visible_with_placeholders(): assert "result" in report.details +# [/DEF:test_partial_payload_keeps_report_visible_with_placeholders:Function] + +# [DEF:test_clean_release_plugin_maps_to_clean_release_task_type:Function] +# @RELATION: BINDS_TO -> test_report_normalizer def test_clean_release_plugin_maps_to_clean_release_task_type(): task = Task( id="clean-release-1", @@ -65,4 +75,4 @@ def test_clean_release_plugin_maps_to_clean_release_task_type(): assert report.summary == "Clean release compliance passed" -# [/DEF:backend.tests.test_report_normalizer:Module] \ No newline at end of file +# [/DEF:test_report_normalizer:Module]# [/DEF:test_clean_release_plugin_maps_to_clean_release_task_type:Function] diff --git a/backend/src/services/reports/__tests__/test_report_service.py b/backend/src/services/reports/__tests__/test_report_service.py index dd14d8af..49560848 100644 --- a/backend/src/services/reports/__tests__/test_report_service.py +++ b/backend/src/services/reports/__tests__/test_report_service.py @@ -1,8 +1,8 @@ # [DEF:test_report_service:Module] -# @COMPLEXITY: 3 +# @COMPLEXITY: 2 # @PURPOSE: Unit tests for ReportsService list/detail operations +# @RELATION: TESTS ->[ReportsService:Class] # @LAYER: Domain -# @RELATION: TESTS -> backend.src.services.reports.report_service.ReportsService import sys from pathlib import Path @@ -13,6 +13,8 @@ from unittest.mock import MagicMock, patch from datetime import datetime, timezone, timedelta +# [DEF:_make_task:Function] +# @RELATION: BINDS_TO -> test_report_service def _make_task(task_id="task-1", plugin_id="superset-backup", status_value="SUCCESS", started_at=None, finished_at=None, result=None, params=None, logs=None): """Create a mock Task object matching the Task model interface.""" @@ -28,6 +30,8 @@ def _make_task(task_id="task-1", plugin_id="superset-backup", status_value="SUCC return task +# [/DEF:_make_task:Function] + class TestReportsServiceList: """Tests for ReportsService.list_reports.""" diff --git a/backend/src/services/reports/__tests__/test_type_profiles.py b/backend/src/services/reports/__tests__/test_type_profiles.py index e8be8b79..78ff25ce 100644 --- a/backend/src/services/reports/__tests__/test_type_profiles.py +++ b/backend/src/services/reports/__tests__/test_type_profiles.py @@ -9,6 +9,8 @@ from src.services.reports.type_profiles import resolve_task_type, get_type_profi # @TEST_CONTRACT: ResolveTaskType -> Invariants # @TEST_INVARIANT: fallback_to_unknown +# [DEF:test_resolve_task_type_fallbacks:Function] +# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles def test_resolve_task_type_fallbacks(): """Verify missing/unmapped plugin_id returns TaskType.UNKNOWN.""" assert resolve_task_type(None) == TaskType.UNKNOWN @@ -17,6 +19,10 @@ def test_resolve_task_type_fallbacks(): assert resolve_task_type("invalid_plugin") == TaskType.UNKNOWN # @TEST_FIXTURE: valid_plugin +# [/DEF:test_resolve_task_type_fallbacks:Function] + +# [DEF:test_resolve_task_type_valid:Function] +# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles def test_resolve_task_type_valid(): """Verify known plugin IDs map correctly.""" assert resolve_task_type("superset-migration") == TaskType.MIGRATION @@ -25,6 +31,10 @@ def test_resolve_task_type_valid(): assert resolve_task_type("documentation") == TaskType.DOCUMENTATION # @TEST_FIXTURE: valid_profile +# [/DEF:test_resolve_task_type_valid:Function] + +# [DEF:test_get_type_profile_valid:Function] +# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles def test_get_type_profile_valid(): """Verify known task types return correct profile metadata.""" profile = get_type_profile(TaskType.MIGRATION) @@ -34,6 +44,10 @@ def test_get_type_profile_valid(): # @TEST_INVARIANT: always_returns_dict # @TEST_EDGE: missing_profile +# [/DEF:test_get_type_profile_valid:Function] + +# [DEF:test_get_type_profile_fallback:Function] +# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles def test_get_type_profile_fallback(): """Verify unknown task type returns fallback profile.""" # Assuming TaskType.UNKNOWN or any non-mapped value @@ -45,3 +59,4 @@ def test_get_type_profile_fallback(): profile_fallback = get_type_profile("non-enum-value") assert profile_fallback["display_label"] == "Other / Unknown" assert profile_fallback["fallback"] is True +# [/DEF:test_get_type_profile_fallback:Function] diff --git a/backend/src/services/reports/normalizer.py b/backend/src/services/reports/normalizer.py index 9a9c01f2..5fef1f95 100644 --- a/backend/src/services/reports/normalizer.py +++ b/backend/src/services/reports/normalizer.py @@ -1,11 +1,11 @@ -# [DEF:backend.src.services.reports.normalizer:Module] +# [DEF:normalizer:Module] # @COMPLEXITY: 5 # @SEMANTICS: reports, normalization, tasks, fallback # @PURPOSE: Convert task manager task objects into canonical unified TaskReport entities with deterministic fallback behavior. # @LAYER: Domain -# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.models.Task -# @RELATION: DEPENDS_ON -> backend.src.models.report -# @RELATION: DEPENDS_ON -> backend.src.services.reports.type_profiles +# @RELATION: DEPENDS_ON ->[backend.src.core.task_manager.models.Task:Function] +# @RELATION: DEPENDS_ON ->[backend.src.models.report:Function] +# @RELATION: DEPENDS_ON ->[backend.src.services.reports.type_profiles:Function] # @INVARIANT: Unknown task types and partial payloads remain visible via fallback mapping. # [SECTION: IMPORTS] @@ -168,4 +168,4 @@ def normalize_task_report(task: Task) -> TaskReport: ) # [/DEF:normalize_task_report:Function] -# [/DEF:backend.src.services.reports.normalizer:Module] \ No newline at end of file +# [/DEF:normalizer:Module] \ No newline at end of file diff --git a/backend/src/services/reports/report_service.py b/backend/src/services/reports/report_service.py index b81a7f63..1d9a22f2 100644 --- a/backend/src/services/reports/report_service.py +++ b/backend/src/services/reports/report_service.py @@ -1,11 +1,11 @@ -# [DEF:backend.src.services.reports.report_service:Module] +# [DEF:report_service:Module] # @COMPLEXITY: 5 # @SEMANTICS: reports, service, aggregation, filtering, pagination, detail # @PURPOSE: Aggregate, normalize, filter, and paginate task reports for unified list/detail API use cases. # @LAYER: Domain -# @RELATION: DEPENDS_ON -> backend.src.core.task_manager.manager.TaskManager -# @RELATION: DEPENDS_ON -> backend.src.models.report -# @RELATION: DEPENDS_ON -> backend.src.services.reports.normalizer +# @RELATION: DEPENDS_ON ->[backend.src.core.task_manager.manager.TaskManager:Function] +# @RELATION: DEPENDS_ON ->[backend.src.models.report:Function] +# @RELATION: DEPENDS_ON ->[backend.src.services.reports.normalizer:Function] # @INVARIANT: List responses are deterministic and include applied filter echo metadata. # [SECTION: IMPORTS] @@ -41,7 +41,7 @@ from .normalizer import normalize_task_report # @TEST_EDGE: report_not_found -> get_report_detail returns None # @TEST_INVARIANT: consistent_pagination -> verifies: [valid_service] class ReportsService: - # [DEF:__init__:Function] + # [DEF:init:Function] # @COMPLEXITY: 5 # @PURPOSE: Initialize service with TaskManager dependency. # @PRE: task_manager is a live TaskManager instance. @@ -52,7 +52,7 @@ class ReportsService: with belief_scope("__init__"): self.task_manager = task_manager self.clean_release_repository = clean_release_repository - # [/DEF:__init__:Function] + # [/DEF:init:Function] # [DEF:_load_normalized_reports:Function] # @PURPOSE: Build normalized reports from all available tasks. @@ -243,4 +243,4 @@ class ReportsService: # [/DEF:get_report_detail:Function] # [/DEF:ReportsService:Class] -# [/DEF:backend.src.services.reports.report_service:Module] \ No newline at end of file +# [/DEF:report_service:Module] \ No newline at end of file diff --git a/backend/src/services/reports/type_profiles.py b/backend/src/services/reports/type_profiles.py index b80acb26..2f053b56 100644 --- a/backend/src/services/reports/type_profiles.py +++ b/backend/src/services/reports/type_profiles.py @@ -1,9 +1,9 @@ -# [DEF:backend.src.services.reports.type_profiles:Module] -# @COMPLEXITY: 5 +# [DEF:type_profiles:Module] +# @COMPLEXITY: 2 # @SEMANTICS: reports, type_profiles, normalization, fallback # @PURPOSE: Deterministic mapping of plugin/task identifiers to canonical report task types and fallback profile metadata. # @LAYER: Domain -# @RELATION: DEPENDS_ON -> backend.src.models.report.TaskType +# @RELATION: DEPENDS_ON ->[backend.src.models.report.TaskType:Function] # @INVARIANT: Unknown input always resolves to TaskType.UNKNOWN with a single fallback profile. # [SECTION: IMPORTS] @@ -120,4 +120,4 @@ def get_type_profile(task_type: TaskType) -> Dict[str, Any]: return TASK_TYPE_PROFILES.get(task_type, TASK_TYPE_PROFILES[TaskType.UNKNOWN]) # [/DEF:get_type_profile:Function] -# [/DEF:backend.src.services.reports.type_profiles:Module] \ No newline at end of file +# [/DEF:type_profiles:Module] \ No newline at end of file diff --git a/backend/src/services/resource_service.py b/backend/src/services/resource_service.py index b66cb9cf..31c73ec8 100644 --- a/backend/src/services/resource_service.py +++ b/backend/src/services/resource_service.py @@ -1,12 +1,12 @@ -# [DEF:backend.src.services.resource_service:Module] +# [DEF:ResourceServiceModule:Module] # @COMPLEXITY: 3 # @SEMANTICS: service, resources, dashboards, datasets, tasks, git # @PURPOSE: Shared service for fetching resource data with Git status and task status # @LAYER: Service -# @RELATION: DEPENDS_ON ->[backend.src.core.superset_client.SupersetClient] +# @RELATION: DEPENDS_ON ->[SupersetClient] # @RELATION: DEPENDS_ON ->[TaskManagerPackage] # @RELATION: DEPENDS_ON ->[TaskManagerModels] -# @RELATION: DEPENDS_ON ->[backend.src.services.git_service.GitService] +# @RELATION: DEPENDS_ON ->[GitService] # @INVARIANT: All resources include metadata about their current state # [SECTION: IMPORTS] @@ -18,12 +18,14 @@ from ..services.git_service import GitService from ..core.logger import logger, belief_scope # [/SECTION] -# [DEF:backend.src.services.resource_service.ResourceService:Class] +# [DEF:ResourceService:Class] # @COMPLEXITY: 3 # @PURPOSE: Provides centralized access to resource data with enhanced metadata +# @RELATION: DEPENDS_ON ->[SupersetClient] +# @RELATION: DEPENDS_ON ->[GitService] class ResourceService: - # [DEF:backend.src.services.resource_service.ResourceService.__init__:Function] + # [DEF:ResourceService_init:Function] # @COMPLEXITY: 1 # @PURPOSE: Initialize the resource service with dependencies # @PRE: None @@ -32,9 +34,9 @@ class ResourceService: with belief_scope("ResourceService.__init__"): self.git_service = GitService() logger.info("[ResourceService][Action] Initialized ResourceService") - # [/DEF:backend.src.services.resource_service.ResourceService.__init__:Function] + # [/DEF:ResourceService_init:Function] - # [DEF:backend.src.services.resource_service.ResourceService.get_dashboards_with_status:Function] + # [DEF:get_dashboards_with_status:Function] # @COMPLEXITY: 3 # @PURPOSE: Fetch dashboards from environment with Git status and last task status # @PRE: env is a valid Environment object @@ -42,9 +44,9 @@ class ResourceService: # @PARAM: env (Environment) - The environment to fetch from # @PARAM: tasks (List[Task]) - List of tasks to check for status # @RETURN: List[Dict] - Dashboards with git_status and last_task fields - # @RELATION: CALLS ->[backend.src.core.superset_client.SupersetClient.get_dashboards_summary] - # @RELATION: CALLS ->[backend.src.services.resource_service.ResourceService._get_git_status_for_dashboard] - # @RELATION: CALLS ->[backend.src.services.resource_service.ResourceService._get_last_llm_task_for_dashboard] + # @RELATION: CALLS ->[SupersetClient.get_dashboards_summary] + # @RELATION: CALLS ->[_get_git_status_for_dashboard] + # @RELATION: CALLS ->[_get_last_llm_task_for_dashboard] async def get_dashboards_with_status( self, env: Any, @@ -94,6 +96,9 @@ class ResourceService: # @PARAM: page (int) - 1-based page number. # @PARAM: page_size (int) - Page size. # @RETURN: Dict[str, Any] - {"dashboards": List[Dict], "total": int, "total_pages": int} + # @RELATION: CALLS ->[SupersetClient.get_dashboards_summary_page] + # @RELATION: CALLS ->[_get_git_status_for_dashboard] + # @RELATION: CALLS ->[_get_last_llm_task_for_dashboard] async def get_dashboards_page_with_status( self, env: Any, @@ -157,6 +162,9 @@ class ResourceService: # @PARAM: env_id (Optional[str]) - Environment ID to match task params # @PARAM: tasks (Optional[List[Task]]) - List of tasks to search # @RETURN: Optional[Dict] - Task summary with task_id and status + # @RELATION: CALLS ->[_normalize_datetime_for_compare] + # @RELATION: CALLS ->[_normalize_validation_status] + # @RELATION: CALLS ->[_normalize_task_status] def _get_last_llm_task_for_dashboard( self, dashboard_id: int, @@ -236,6 +244,7 @@ class ResourceService: # @POST: Returns uppercase status without enum class prefix # @PARAM: raw_status (Any) - Raw task status object/value # @RETURN: str - Normalized status token + # @RELATION: USED_BY ->[_get_last_llm_task_for_dashboard] def _normalize_task_status(self, raw_status: Any) -> str: if raw_status is None: return "" @@ -253,6 +262,7 @@ class ResourceService: # @POST: Returns normalized validation status token or None # @PARAM: raw_status (Any) - Raw validation status from task result # @RETURN: Optional[str] - PASS|FAIL|WARN|UNKNOWN + # @RELATION: USED_BY ->[_get_last_llm_task_for_dashboard] def _normalize_validation_status(self, raw_status: Any) -> Optional[str]: if raw_status is None: return None @@ -269,6 +279,8 @@ class ResourceService: # @POST: Returns UTC-aware datetime; non-datetime values map to minimal UTC datetime. # @PARAM: value (Any) - Candidate datetime-like value. # @RETURN: datetime - UTC-aware comparable datetime. + # @RELATION: USED_BY ->[_get_last_llm_task_for_dashboard] + # @RELATION: USED_BY ->[_get_last_task_for_resource] def _normalize_datetime_for_compare(self, value: Any) -> datetime: if isinstance(value, datetime): if value.tzinfo is None: @@ -285,8 +297,8 @@ class ResourceService: # @PARAM: env (Environment) - The environment to fetch from # @PARAM: tasks (List[Task]) - List of tasks to check for status # @RETURN: List[Dict] - Datasets with mapped_fields and last_task fields - # @RELATION: CALLS ->[SupersetClient:get_datasets_summary] - # @RELATION: CALLS ->[self:_get_last_task_for_resource] + # @RELATION: CALLS ->[SupersetClient.get_datasets_summary] + # @RELATION: CALLS ->[_get_last_task_for_resource] async def get_datasets_with_status( self, env: Any, @@ -323,6 +335,8 @@ class ResourceService: # @POST: Returns summary with active_count and recent_tasks # @PARAM: tasks (List[Task]) - List of tasks to summarize # @RETURN: Dict - Activity summary + # @RELATION: CALLS ->[_extract_resource_name_from_task] + # @RELATION: CALLS ->[_extract_resource_type_from_task] def get_activity_summary(self, tasks: List[Task]) -> Dict[str, Any]: with belief_scope("get_activity_summary"): # Count active (RUNNING, WAITING_INPUT) tasks @@ -363,7 +377,7 @@ class ResourceService: # @POST: Returns git status or None if no repo exists # @PARAM: dashboard_id (int) - The dashboard ID # @RETURN: Optional[Dict] - Git status with branch and sync_status - # @RELATION: CALLS ->[GitService:get_repo] + # @RELATION: CALLS ->[get_repo] def _get_git_status_for_dashboard(self, dashboard_id: int) -> Optional[Dict[str, Any]]: try: repo = self.git_service.get_repo(dashboard_id) @@ -424,6 +438,7 @@ class ResourceService: # @PARAM: resource_id (str) - The resource identifier (e.g., "dashboard-123") # @PARAM: tasks (Optional[List[Task]]) - List of tasks to search # @RETURN: Optional[Dict] - Task summary with task_id and status + # @RELATION: CALLS ->[_normalize_datetime_for_compare] def _get_last_task_for_resource( self, resource_id: str, @@ -461,6 +476,7 @@ class ResourceService: # @POST: Returns resource name or task ID # @PARAM: task (Task) - The task to extract from # @RETURN: str - Resource name or fallback + # @RELATION: USED_BY ->[get_activity_summary] def _extract_resource_name_from_task(self, task: Task) -> str: params = task.params or {} return params.get('resource_name', f"Task {task.id}") @@ -473,9 +489,10 @@ class ResourceService: # @POST: Returns resource type or 'unknown' # @PARAM: task (Task) - The task to extract from # @RETURN: str - Resource type + # @RELATION: USED_BY ->[get_activity_summary] def _extract_resource_type_from_task(self, task: Task) -> str: params = task.params or {} return params.get('resource_type', 'unknown') # [/DEF:_extract_resource_type_from_task:Function] # [/DEF:ResourceService:Class] -# [/DEF:backend.src.services.resource_service:Module] +# [/DEF:ResourceServiceModule:Module] diff --git a/backend/tests/core/migration/test_archive_parser.py b/backend/tests/core/migration/test_archive_parser.py index 11dd8277..613362d9 100644 --- a/backend/tests/core/migration/test_archive_parser.py +++ b/backend/tests/core/migration/test_archive_parser.py @@ -1,4 +1,4 @@ -# [DEF:backend.tests.core.migration.test_archive_parser:Module] +# [DEF:TestArchiveParser:Module] # # @COMPLEXITY: 3 # @PURPOSE: Unit tests for MigrationArchiveParser ZIP extraction contract. @@ -20,6 +20,8 @@ if backend_dir not in sys.path: from src.core.migration.archive_parser import MigrationArchiveParser +# [DEF:test_extract_objects_from_zip_collects_all_types:Function] +# @RELATION: BINDS_TO -> TestArchiveParser def test_extract_objects_from_zip_collects_all_types(): parser = MigrationArchiveParser() with tempfile.TemporaryDirectory() as td: @@ -59,4 +61,5 @@ def test_extract_objects_from_zip_collects_all_types(): raise AssertionError("dataset uuid mismatch") -# [/DEF:backend.tests.core.migration.test_archive_parser:Module] +# [/DEF:TestArchiveParser:Module] +# [/DEF:test_extract_objects_from_zip_collects_all_types:Function] diff --git a/backend/tests/core/migration/test_dry_run_orchestrator.py b/backend/tests/core/migration/test_dry_run_orchestrator.py index 6f26113f..70f7167b 100644 --- a/backend/tests/core/migration/test_dry_run_orchestrator.py +++ b/backend/tests/core/migration/test_dry_run_orchestrator.py @@ -1,4 +1,4 @@ -# [DEF:backend.tests.core.migration.test_dry_run_orchestrator:Module] +# [DEF:TestDryRunOrchestrator:Module] # # @COMPLEXITY: 3 # @PURPOSE: Unit tests for MigrationDryRunService diff and risk computation contracts. @@ -23,11 +23,17 @@ from src.models.dashboard import DashboardSelection from src.models.mapping import Base +# [DEF:_load_fixture:Function] +# @RELATION: BINDS_TO -> TestDryRunOrchestrator def _load_fixture() -> dict: fixture_path = Path(__file__).parents[2] / "fixtures" / "migration_dry_run_fixture.json" return json.loads(fixture_path.read_text()) +# [/DEF:_load_fixture:Function] + +# [DEF:_make_session:Function] +# @RELATION: BINDS_TO -> TestDryRunOrchestrator def _make_session(): engine = create_engine( "sqlite:///:memory:", @@ -39,6 +45,10 @@ def _make_session(): return Session() +# [/DEF:_make_session:Function] + +# [DEF:test_migration_dry_run_service_builds_diff_and_risk:Function] +# @RELATION: BINDS_TO -> TestDryRunOrchestrator def test_migration_dry_run_service_builds_diff_and_risk(): # @TEST_CONTRACT: dry_run_result_contract -> { # required_fields: {diff: object, summary: object, risk: object}, @@ -107,4 +117,5 @@ def test_migration_dry_run_service_builds_diff_and_risk(): raise AssertionError("breaking_reference risk is not detected") -# [/DEF:backend.tests.core.migration.test_dry_run_orchestrator:Module] +# [/DEF:TestDryRunOrchestrator:Module] +# [/DEF:test_migration_dry_run_service_builds_diff_and_risk:Function] diff --git a/backend/tests/core/test_defensive_guards.py b/backend/tests/core/test_defensive_guards.py index d831a05c..a9f4257f 100644 --- a/backend/tests/core/test_defensive_guards.py +++ b/backend/tests/core/test_defensive_guards.py @@ -11,6 +11,8 @@ from src.services.git_service import GitService from src.core.superset_client import SupersetClient from src.core.config_models import Environment +# [DEF:test_git_service_get_repo_path_guard:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_git_service_get_repo_path_guard(): """Verify that _get_repo_path raises ValueError if dashboard_id is None.""" service = GitService(base_path="test_repos") @@ -18,6 +20,10 @@ def test_git_service_get_repo_path_guard(): service._get_repo_path(None) +# [/DEF:test_git_service_get_repo_path_guard:Function] + +# [DEF:test_git_service_get_repo_path_recreates_base_dir:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_git_service_get_repo_path_recreates_base_dir(): """Verify _get_repo_path recreates missing base directory before returning repo path.""" service = GitService(base_path="test_repos_runtime_recreate") @@ -28,6 +34,10 @@ def test_git_service_get_repo_path_recreates_base_dir(): assert Path(service.base_path).is_dir() assert repo_path == str(Path(service.base_path) / "42") +# [/DEF:test_git_service_get_repo_path_recreates_base_dir:Function] + +# [DEF:test_superset_client_import_dashboard_guard:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_superset_client_import_dashboard_guard(): """Verify that import_dashboard raises ValueError if file_name is None.""" mock_env = Environment( @@ -42,6 +52,10 @@ def test_superset_client_import_dashboard_guard(): client.import_dashboard(None) +# [/DEF:test_superset_client_import_dashboard_guard:Function] + +# [DEF:test_git_service_init_repo_reclones_when_path_is_not_a_git_repo:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_git_service_init_repo_reclones_when_path_is_not_a_git_repo(): """Verify init_repo reclones when target path exists but is not a valid Git repository.""" service = GitService(base_path="test_repos_invalid_repo") @@ -61,6 +75,10 @@ def test_git_service_init_repo_reclones_when_path_is_not_a_git_repo(): assert not target_path.exists() +# [/DEF:test_git_service_init_repo_reclones_when_path_is_not_a_git_repo:Function] + +# [DEF:test_git_service_ensure_gitflow_branches_creates_and_pushes_missing_defaults:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_git_service_ensure_gitflow_branches_creates_and_pushes_missing_defaults(): """Verify _ensure_gitflow_branches creates dev/preprod locally and pushes them to origin.""" service = GitService(base_path="test_repos_gitflow_defaults") @@ -115,6 +133,10 @@ def test_git_service_ensure_gitflow_branches_creates_and_pushes_missing_defaults assert "preprod:preprod" in repo.origin.pushed +# [/DEF:test_git_service_ensure_gitflow_branches_creates_and_pushes_missing_defaults:Function] + +# [DEF:test_git_service_configure_identity_updates_repo_local_config:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_git_service_configure_identity_updates_repo_local_config(): """Verify configure_identity writes repository-local user.name/user.email.""" service = GitService(base_path="test_repos_identity") @@ -130,3 +152,4 @@ def test_git_service_configure_identity_updates_repo_local_config(): fake_repo.config_writer.assert_called_once_with(config_level="repository") config_writer.set_value.assert_any_call("user", "name", "user_1") config_writer.set_value.assert_any_call("user", "email", "user1@mail.ru") +# [/DEF:test_git_service_configure_identity_updates_repo_local_config:Function] diff --git a/backend/tests/core/test_git_service_gitea_pr.py b/backend/tests/core/test_git_service_gitea_pr.py index 8a826e51..1e38883c 100644 --- a/backend/tests/core/test_git_service_gitea_pr.py +++ b/backend/tests/core/test_git_service_gitea_pr.py @@ -1,9 +1,9 @@ -# [DEF:backend.tests.core.test_git_service_gitea_pr:Module] +# [DEF:TestGitServiceGiteaPr:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, git, gitea, pull_request, fallback # @PURPOSE: Validate Gitea PR creation fallback behavior when configured server URL is stale. # @LAYER: Domain -# @RELATION: TESTS -> backend.src.services.git_service.create_gitea_pull_request # @INVARIANT: A 404 from primary Gitea URL retries once against remote-url host when different. import asyncio @@ -19,6 +19,7 @@ from src.services.git_service import GitService # [DEF:test_derive_server_url_from_remote_strips_credentials:Function] +# @RELATION: BINDS_TO -> TestGitServiceGiteaPr # @PURPOSE: Ensure helper returns host base URL and removes embedded credentials. # @PRE: remote_url is an https URL with username/token. # @POST: Result is scheme+host only. @@ -32,6 +33,7 @@ def test_derive_server_url_from_remote_strips_credentials(): # [DEF:test_create_gitea_pull_request_retries_with_remote_host_on_404:Function] +# @RELATION: BINDS_TO -> TestGitServiceGiteaPr # @PURPOSE: Verify create_gitea_pull_request retries with remote URL host after primary 404. # @PRE: primary server_url differs from remote_url host. # @POST: Method returns success payload from fallback request. @@ -67,6 +69,7 @@ def test_create_gitea_pull_request_retries_with_remote_host_on_404(monkeypatch): # [DEF:test_create_gitea_pull_request_returns_branch_error_when_target_missing:Function] +# @RELATION: BINDS_TO -> TestGitServiceGiteaPr # @PURPOSE: Ensure Gitea 404 on PR creation is mapped to actionable target-branch validation error. # @PRE: PR create call returns 404 and target branch is absent. # @POST: Service raises HTTPException 400 with explicit missing target branch message. @@ -101,4 +104,4 @@ def test_create_gitea_pull_request_returns_branch_error_when_target_missing(monk assert "target branch 'preprod'" in str(exc_info.value.detail) # [/DEF:test_create_gitea_pull_request_returns_branch_error_when_target_missing:Function] -# [/DEF:backend.tests.core.test_git_service_gitea_pr:Module] +# [/DEF:TestGitServiceGiteaPr:Module] diff --git a/backend/tests/core/test_migration_engine.py b/backend/tests/core/test_migration_engine.py index 8a0005c5..e63f0cf0 100644 --- a/backend/tests/core/test_migration_engine.py +++ b/backend/tests/core/test_migration_engine.py @@ -1,4 +1,4 @@ -# [DEF:backend.tests.core.test_migration_engine:Module] +# [DEF:TestMigrationEngine:Module] # # @COMPLEXITY: 3 # @PURPOSE: Unit tests for MigrationEngine's cross-filter patching algorithms. @@ -26,8 +26,15 @@ from src.models.mapping import ResourceType # --- Fixtures --- + +# [DEF:MockMappingService:Class] +# @RELATION: BINDS_TO ->[TestMigrationEngine] +# @COMPLEXITY: 2 +# @PURPOSE: Deterministic mapping service double for native filter ID remapping scenarios. +# @INVARIANT: Returns mappings only for requested UUID keys present in seeded map. class MockMappingService: """Mock that simulates IdMappingService.get_remote_ids_batch.""" + def __init__(self, mappings: dict): self.mappings = mappings @@ -39,26 +46,32 @@ class MockMappingService: return result +# [/DEF:MockMappingService:Class] + + +# [DEF:_write_dashboard_yaml:Function] +# @RELATION: BINDS_TO -> TestMigrationEngine def _write_dashboard_yaml(dir_path: Path, metadata: dict) -> Path: """Helper: writes a dashboard YAML file with json_metadata.""" file_path = dir_path / "dash.yaml" - with open(file_path, 'w') as f: + with open(file_path, "w") as f: yaml.dump({"json_metadata": json.dumps(metadata)}, f) return file_path # --- _patch_dashboard_metadata tests --- +# [/DEF:_write_dashboard_yaml:Function] + + +# [DEF:test_patch_dashboard_metadata_replaces_chart_ids:Function] +# @RELATION: BINDS_TO -> TestMigrationEngine def test_patch_dashboard_metadata_replaces_chart_ids(): """Verifies that chartId values are replaced using the mapping service.""" mock_service = MockMappingService({"uuid-chart-A": 999}) engine = MigrationEngine(mock_service) - metadata = { - "native_filter_configuration": [ - {"targets": [{"chartId": 42}]} - ] - } + metadata = {"native_filter_configuration": [{"targets": [{"chartId": 42}]}]} with tempfile.TemporaryDirectory() as td: fp = _write_dashboard_yaml(Path(td), metadata) @@ -66,22 +79,25 @@ def test_patch_dashboard_metadata_replaces_chart_ids(): engine._patch_dashboard_metadata(fp, "target-env", source_map) - with open(fp, 'r') as f: + with open(fp, "r") as f: data = yaml.safe_load(f) result = json.loads(data["json_metadata"]) - assert result["native_filter_configuration"][0]["targets"][0]["chartId"] == 999 + assert ( + result["native_filter_configuration"][0]["targets"][0]["chartId"] == 999 + ) +# [/DEF:test_patch_dashboard_metadata_replaces_chart_ids:Function] + + +# [DEF:test_patch_dashboard_metadata_replaces_dataset_ids:Function] +# @RELATION: BINDS_TO -> TestMigrationEngine def test_patch_dashboard_metadata_replaces_dataset_ids(): """Verifies that datasetId values are replaced using the mapping service.""" mock_service = MockMappingService({"uuid-ds-B": 500}) engine = MigrationEngine(mock_service) - metadata = { - "native_filter_configuration": [ - {"targets": [{"datasetId": 10}]} - ] - } + metadata = {"native_filter_configuration": [{"targets": [{"datasetId": 10}]}]} with tempfile.TemporaryDirectory() as td: fp = _write_dashboard_yaml(Path(td), metadata) @@ -89,12 +105,20 @@ def test_patch_dashboard_metadata_replaces_dataset_ids(): engine._patch_dashboard_metadata(fp, "target-env", source_map) - with open(fp, 'r') as f: + with open(fp, "r") as f: data = yaml.safe_load(f) result = json.loads(data["json_metadata"]) - assert result["native_filter_configuration"][0]["targets"][0]["datasetId"] == 500 + assert ( + result["native_filter_configuration"][0]["targets"][0]["datasetId"] + == 500 + ) +# [/DEF:test_patch_dashboard_metadata_replaces_dataset_ids:Function] + + +# [DEF:test_patch_dashboard_metadata_skips_when_no_metadata:Function] +# @RELATION: BINDS_TO -> TestMigrationEngine def test_patch_dashboard_metadata_skips_when_no_metadata(): """Verifies early return when json_metadata key is absent.""" mock_service = MockMappingService({}) @@ -102,16 +126,21 @@ def test_patch_dashboard_metadata_skips_when_no_metadata(): with tempfile.TemporaryDirectory() as td: fp = Path(td) / "dash.yaml" - with open(fp, 'w') as f: + with open(fp, "w") as f: yaml.dump({"title": "No metadata here"}, f) engine._patch_dashboard_metadata(fp, "target-env", {}) - with open(fp, 'r') as f: + with open(fp, "r") as f: data = yaml.safe_load(f) assert "json_metadata" not in data +# [/DEF:test_patch_dashboard_metadata_skips_when_no_metadata:Function] + + +# [DEF:test_patch_dashboard_metadata_handles_missing_targets:Function] +# @RELATION: BINDS_TO -> TestMigrationEngine def test_patch_dashboard_metadata_handles_missing_targets(): """When some source IDs have no target mapping, patches what it can and leaves the rest.""" mock_service = MockMappingService({"uuid-A": 100}) # Only uuid-A maps @@ -129,7 +158,7 @@ def test_patch_dashboard_metadata_handles_missing_targets(): engine._patch_dashboard_metadata(fp, "target-env", source_map) - with open(fp, 'r') as f: + with open(fp, "r") as f: data = yaml.safe_load(f) result = json.loads(data["json_metadata"]) targets = result["native_filter_configuration"][0]["targets"] @@ -140,6 +169,11 @@ def test_patch_dashboard_metadata_handles_missing_targets(): # --- _extract_chart_uuids_from_archive tests --- +# [/DEF:test_patch_dashboard_metadata_handles_missing_targets:Function] + + +# [DEF:test_extract_chart_uuids_from_archive:Function] +# @RELATION: BINDS_TO -> TestMigrationEngine def test_extract_chart_uuids_from_archive(): """Verifies that chart YAML files are parsed for id->uuid mappings.""" engine = MigrationEngine() @@ -151,9 +185,9 @@ def test_extract_chart_uuids_from_archive(): chart1 = {"id": 42, "uuid": "uuid-42", "slice_name": "Chart One"} chart2 = {"id": 99, "uuid": "uuid-99", "slice_name": "Chart Two"} - with open(charts_dir / "chart1.yaml", 'w') as f: + with open(charts_dir / "chart1.yaml", "w") as f: yaml.dump(chart1, f) - with open(charts_dir / "chart2.yaml", 'w') as f: + with open(charts_dir / "chart2.yaml", "w") as f: yaml.dump(chart2, f) result = engine._extract_chart_uuids_from_archive(Path(td)) @@ -163,23 +197,33 @@ def test_extract_chart_uuids_from_archive(): # --- _transform_yaml tests --- +# [/DEF:test_extract_chart_uuids_from_archive:Function] + + +# [DEF:test_transform_yaml_replaces_database_uuid:Function] +# @RELATION: BINDS_TO -> TestMigrationEngine def test_transform_yaml_replaces_database_uuid(): """Verifies that database_uuid in a dataset YAML is replaced.""" engine = MigrationEngine() with tempfile.TemporaryDirectory() as td: fp = Path(td) / "dataset.yaml" - with open(fp, 'w') as f: + with open(fp, "w") as f: yaml.dump({"database_uuid": "source-uuid-abc", "table_name": "my_table"}, f) engine._transform_yaml(fp, {"source-uuid-abc": "target-uuid-xyz"}) - with open(fp, 'r') as f: + with open(fp, "r") as f: data = yaml.safe_load(f) assert data["database_uuid"] == "target-uuid-xyz" assert data["table_name"] == "my_table" +# [/DEF:test_transform_yaml_replaces_database_uuid:Function] + + +# [DEF:test_transform_yaml_ignores_unmapped_uuid:Function] +# @RELATION: BINDS_TO -> TestMigrationEngine def test_transform_yaml_ignores_unmapped_uuid(): """Verifies no changes when UUID is not in the mapping.""" engine = MigrationEngine() @@ -187,18 +231,23 @@ def test_transform_yaml_ignores_unmapped_uuid(): with tempfile.TemporaryDirectory() as td: fp = Path(td) / "dataset.yaml" original = {"database_uuid": "unknown-uuid", "table_name": "test"} - with open(fp, 'w') as f: + with open(fp, "w") as f: yaml.dump(original, f) engine._transform_yaml(fp, {"other-uuid": "replacement"}) - with open(fp, 'r') as f: + with open(fp, "r") as f: data = yaml.safe_load(f) assert data["database_uuid"] == "unknown-uuid" # --- [NEW] transform_zip E2E tests --- +# [/DEF:test_transform_yaml_ignores_unmapped_uuid:Function] + + +# [DEF:test_transform_zip_end_to_end:Function] +# @RELATION: BINDS_TO -> TestMigrationEngine def test_transform_zip_end_to_end(): """Verifies full orchestration: extraction, transformation, patching, and re-packaging.""" mock_service = MockMappingService({"char-uuid": 101, "ds-uuid": 202}) @@ -212,41 +261,41 @@ def test_transform_zip_end_to_end(): # Create source ZIP structure with tempfile.TemporaryDirectory() as src_dir: src_path = Path(src_dir) - + # 1. Dataset ds_dir = src_path / "datasets" ds_dir.mkdir() - with open(ds_dir / "ds.yaml", 'w') as f: + with open(ds_dir / "ds.yaml", "w") as f: yaml.dump({"database_uuid": "source-db-uuid", "table_name": "users"}, f) - + # 2. Chart ch_dir = src_path / "charts" ch_dir.mkdir() - with open(ch_dir / "ch.yaml", 'w') as f: + with open(ch_dir / "ch.yaml", "w") as f: yaml.dump({"id": 10, "uuid": "char-uuid"}, f) - + # 3. Dashboard db_dir = src_path / "dashboards" db_dir.mkdir() metadata = {"native_filter_configuration": [{"targets": [{"chartId": 10}]}]} - with open(db_dir / "db.yaml", 'w') as f: + with open(db_dir / "db.yaml", "w") as f: yaml.dump({"json_metadata": json.dumps(metadata)}, f) - with zipfile.ZipFile(zip_path, 'w') as zf: + with zipfile.ZipFile(zip_path, "w") as zf: for root, _, files in os.walk(src_path): for file in files: p = Path(root) / file zf.write(p, p.relative_to(src_path)) db_mapping = {"source-db-uuid": "target-db-uuid"} - + # Execute transform success = engine.transform_zip( - str(zip_path), - str(output_path), - db_mapping, - target_env_id="test-target", - fix_cross_filters=True + str(zip_path), + str(output_path), + db_mapping, + target_env_id="test-target", + fix_cross_filters=True, ) assert success is True @@ -254,23 +303,31 @@ def test_transform_zip_end_to_end(): # Verify contents with tempfile.TemporaryDirectory() as out_dir: - with zipfile.ZipFile(output_path, 'r') as zf: + with zipfile.ZipFile(output_path, "r") as zf: zf.extractall(out_dir) - + out_path = Path(out_dir) - + # Verify dataset transformation - with open(out_path / "datasets" / "ds.yaml", 'r') as f: + with open(out_path / "datasets" / "ds.yaml", "r") as f: ds_data = yaml.safe_load(f) assert ds_data["database_uuid"] == "target-db-uuid" - + # Verify dashboard patching - with open(out_path / "dashboards" / "db.yaml", 'r') as f: + with open(out_path / "dashboards" / "db.yaml", "r") as f: db_data = yaml.safe_load(f) meta = json.loads(db_data["json_metadata"]) - assert meta["native_filter_configuration"][0]["targets"][0]["chartId"] == 101 + assert ( + meta["native_filter_configuration"][0]["targets"][0]["chartId"] + == 101 + ) +# [/DEF:test_transform_zip_end_to_end:Function] + + +# [DEF:test_transform_zip_invalid_path:Function] +# @RELATION: BINDS_TO -> TestMigrationEngine def test_transform_zip_invalid_path(): """@PRE: Verify behavior (False) on invalid ZIP path.""" engine = MigrationEngine() @@ -278,13 +335,19 @@ def test_transform_zip_invalid_path(): assert success is False +# [/DEF:test_transform_zip_invalid_path:Function] + + +# [DEF:test_transform_yaml_nonexistent_file:Function] +# @RELATION: BINDS_TO -> TestMigrationEngine def test_transform_yaml_nonexistent_file(): """@PRE: Verify behavior on non-existent YAML file.""" engine = MigrationEngine() - # Should log error and not crash (implemented via try-except if wrapped, + # Should log error and not crash (implemented via try-except if wrapped, # but _transform_yaml itself might raise FileNotFoundError if not guarded) with pytest.raises(FileNotFoundError): engine._transform_yaml(Path("non_existent.yaml"), {}) -# [/DEF:backend.tests.core.test_migration_engine:Module] +# [/DEF:TestMigrationEngine:Module] +# [/DEF:test_transform_yaml_nonexistent_file:Function] diff --git a/backend/tests/scripts/test_clean_release_cli.py b/backend/tests/scripts/test_clean_release_cli.py index 5868dced..113f551c 100644 --- a/backend/tests/scripts/test_clean_release_cli.py +++ b/backend/tests/scripts/test_clean_release_cli.py @@ -1,4 +1,5 @@ # [DEF:test_clean_release_cli:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @PURPOSE: Smoke tests for the redesigned clean release CLI. # @LAYER: Domain @@ -17,6 +18,8 @@ from src.services.clean_release.enums import CandidateStatus, ComplianceDecision from src.scripts.clean_release_cli import main as cli_main +# [DEF:test_cli_candidate_register_scaffold:Function] +# @RELATION: BINDS_TO -> test_clean_release_cli def test_cli_candidate_register_scaffold() -> None: """Candidate register CLI command smoke test.""" exit_code = cli_main( @@ -35,6 +38,10 @@ def test_cli_candidate_register_scaffold() -> None: assert exit_code == 0 +# [/DEF:test_cli_candidate_register_scaffold:Function] + +# [DEF:test_cli_manifest_build_scaffold:Function] +# @RELATION: BINDS_TO -> test_clean_release_cli def test_cli_manifest_build_scaffold() -> None: """Manifest build CLI command smoke test.""" register_exit = cli_main( @@ -81,6 +88,10 @@ def test_cli_manifest_build_scaffold() -> None: assert manifest_exit == 0 +# [/DEF:test_cli_manifest_build_scaffold:Function] + +# [DEF:test_cli_compliance_run_scaffold:Function] +# @RELATION: BINDS_TO -> test_clean_release_cli def test_cli_compliance_run_scaffold() -> None: """Compliance CLI command smoke test for run/status/report/violations.""" repository = get_clean_release_repository() @@ -181,6 +192,10 @@ def test_cli_compliance_run_scaffold() -> None: assert report_exit == 0 +# [/DEF:test_cli_compliance_run_scaffold:Function] + +# [DEF:test_cli_release_gate_commands_scaffold:Function] +# @RELATION: BINDS_TO -> test_clean_release_cli def test_cli_release_gate_commands_scaffold() -> None: """Release gate CLI smoke test for approve/reject/publish/revoke commands.""" repository = get_clean_release_repository() @@ -303,3 +318,4 @@ def test_cli_release_gate_commands_scaffold() -> None: # [/DEF:test_clean_release_cli:Module] +# [/DEF:test_cli_release_gate_commands_scaffold:Function] diff --git a/backend/tests/scripts/test_clean_release_tui.py b/backend/tests/scripts/test_clean_release_tui.py index a25ac2ce..59ed57ef 100644 --- a/backend/tests/scripts/test_clean_release_tui.py +++ b/backend/tests/scripts/test_clean_release_tui.py @@ -1,9 +1,9 @@ -# [DEF:backend.tests.scripts.test_clean_release_tui:Module] +# [DEF:TestCleanReleaseTui:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @SEMANTICS: tests, tui, clean-release, curses # @PURPOSE: Unit tests for the interactive curses TUI of the clean release process. # @LAYER: Scripts -# @RELATION: TESTS -> backend.src.scripts.clean_release_tui # @INVARIANT: TUI initializes, handles hotkeys (F5, F10) and safely falls back without TTY. import os @@ -27,6 +27,8 @@ def mock_stdscr() -> MagicMock: return stdscr +# [DEF:test_headless_fallback:Function] +# @RELATION: BINDS_TO -> TestCleanReleaseTui def test_headless_fallback(capsys): """ @TEST_EDGE: stdout_unavailable @@ -44,7 +46,11 @@ def test_headless_fallback(capsys): assert "Use CLI/API workflow instead" in captured.err +# [/DEF:test_headless_fallback:Function] + @patch("src.scripts.clean_release_tui.curses") +# [DEF:test_tui_initial_render:Function] +# @RELATION: BINDS_TO -> TestCleanReleaseTui def test_tui_initial_render(mock_curses_module, mock_stdscr: MagicMock): """ Simulates the initial rendering cycle of the TUI application to ensure @@ -77,7 +83,11 @@ def test_tui_initial_render(mock_curses_module, mock_stdscr: MagicMock): assert any("F5 Run" in str(call) for call in addstr_calls) +# [/DEF:test_tui_initial_render:Function] + @patch("src.scripts.clean_release_tui.curses") +# [DEF:test_tui_run_checks_f5:Function] +# @RELATION: BINDS_TO -> TestCleanReleaseTui def test_tui_run_checks_f5(mock_curses_module, mock_stdscr: MagicMock): """ Simulates pressing F5 to transition into the RUNNING checks flow. @@ -112,7 +122,11 @@ def test_tui_run_checks_f5(mock_curses_module, mock_stdscr: MagicMock): assert len(app.violations_list) > 0 +# [/DEF:test_tui_run_checks_f5:Function] + @patch("src.scripts.clean_release_tui.curses") +# [DEF:test_tui_exit_f10:Function] +# @RELATION: BINDS_TO -> TestCleanReleaseTui def test_tui_exit_f10(mock_curses_module, mock_stdscr: MagicMock): """ Simulates pressing F10 to exit the application immediately without running checks. @@ -129,7 +143,11 @@ def test_tui_exit_f10(mock_curses_module, mock_stdscr: MagicMock): assert app.status == "READY" +# [/DEF:test_tui_exit_f10:Function] + @patch("src.scripts.clean_release_tui.curses") +# [DEF:test_tui_clear_history_f7:Function] +# @RELATION: BINDS_TO -> TestCleanReleaseTui def test_tui_clear_history_f7(mock_curses_module, mock_stdscr: MagicMock): """ Simulates pressing F7 to clear history. @@ -153,11 +171,17 @@ def test_tui_clear_history_f7(mock_curses_module, mock_stdscr: MagicMock): assert len(app.checks_progress) == 0 +# [/DEF:test_tui_clear_history_f7:Function] + @patch("src.scripts.clean_release_tui.curses") +# [DEF:test_tui_real_mode_bootstrap_imports_artifacts_catalog:Function] +# @RELATION: BINDS_TO -> TestCleanReleaseTui def test_tui_real_mode_bootstrap_imports_artifacts_catalog( mock_curses_module, mock_stdscr: MagicMock, tmp_path, +# [/DEF:test_tui_real_mode_bootstrap_imports_artifacts_catalog:Function] + ): """ @TEST_CONTRACT: bootstrap.json + artifacts.json -> candidate PREPARED with imported artifacts @@ -220,4 +244,4 @@ def test_tui_real_mode_bootstrap_imports_artifacts_catalog( assert artifacts[0].detected_category == "core" -# [/DEF:backend.tests.scripts.test_clean_release_tui:Module] +# [/DEF:TestCleanReleaseTui:Module] diff --git a/backend/tests/scripts/test_clean_release_tui_v2.py b/backend/tests/scripts/test_clean_release_tui_v2.py index 02e30e46..3faba721 100644 --- a/backend/tests/scripts/test_clean_release_tui_v2.py +++ b/backend/tests/scripts/test_clean_release_tui_v2.py @@ -1,8 +1,8 @@ # [DEF:test_clean_release_tui_v2:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @PURPOSE: Smoke tests for thin-client TUI action dispatch and blocked transition behavior. # @LAYER: Domain -# @RELATION: TESTS -> backend.src.scripts.clean_release_tui """Smoke tests for the redesigned clean release TUI.""" @@ -15,6 +15,8 @@ from src.models.clean_release import CheckFinalStatus from src.scripts.clean_release_tui import CleanReleaseTUI, main +# [DEF:_build_mock_stdscr:Function] +# @RELATION: BINDS_TO -> test_clean_release_tui_v2 def _build_mock_stdscr() -> MagicMock: stdscr = MagicMock() stdscr.getmaxyx.return_value = (40, 120) @@ -22,7 +24,11 @@ def _build_mock_stdscr() -> MagicMock: return stdscr +# [/DEF:_build_mock_stdscr:Function] + @patch("src.scripts.clean_release_tui.curses") +# [DEF:test_tui_f5_dispatches_run_action:Function] +# @RELATION: BINDS_TO -> test_clean_release_tui_v2 def test_tui_f5_dispatches_run_action(mock_curses_module: MagicMock) -> None: """F5 should dispatch run action from TUI loop.""" mock_curses_module.KEY_F10 = curses.KEY_F10 @@ -40,7 +46,11 @@ def test_tui_f5_dispatches_run_action(mock_curses_module: MagicMock) -> None: run_checks_mock.assert_called_once_with() +# [/DEF:test_tui_f5_dispatches_run_action:Function] + @patch("src.scripts.clean_release_tui.curses") +# [DEF:test_tui_f5_run_smoke_reports_blocked_state:Function] +# @RELATION: BINDS_TO -> test_clean_release_tui_v2 def test_tui_f5_run_smoke_reports_blocked_state(mock_curses_module: MagicMock) -> None: """F5 smoke test should expose blocked outcome state after run action.""" mock_curses_module.KEY_F10 = curses.KEY_F10 @@ -65,6 +75,10 @@ def test_tui_f5_run_smoke_reports_blocked_state(mock_curses_module: MagicMock) - assert app.violations_list +# [/DEF:test_tui_f5_run_smoke_reports_blocked_state:Function] + +# [DEF:test_tui_non_tty_refuses_startup:Function] +# @RELATION: BINDS_TO -> test_clean_release_tui_v2 def test_tui_non_tty_refuses_startup(capsys) -> None: """Non-TTY startup must refuse TUI mode and redirect operator to CLI/API flow.""" with patch("sys.stdout.isatty", return_value=False): @@ -76,7 +90,11 @@ def test_tui_non_tty_refuses_startup(capsys) -> None: assert "Use CLI/API workflow instead" in captured.err +# [/DEF:test_tui_non_tty_refuses_startup:Function] + @patch("src.scripts.clean_release_tui.curses") +# [DEF:test_tui_f8_blocked_without_facade_binding:Function] +# @RELATION: BINDS_TO -> test_clean_release_tui_v2 def test_tui_f8_blocked_without_facade_binding(mock_curses_module: MagicMock) -> None: """F8 should not perform hidden state mutation when facade action is not bound.""" mock_curses_module.KEY_F10 = curses.KEY_F10 @@ -95,3 +113,4 @@ def test_tui_f8_blocked_without_facade_binding(mock_curses_module: MagicMock) -> # [/DEF:test_clean_release_tui_v2:Module] +# [/DEF:test_tui_f8_blocked_without_facade_binding:Function] diff --git a/backend/tests/services/clean_release/test_approval_service.py b/backend/tests/services/clean_release/test_approval_service.py index dd0ee41b..2ff1d048 100644 --- a/backend/tests/services/clean_release/test_approval_service.py +++ b/backend/tests/services/clean_release/test_approval_service.py @@ -1,11 +1,9 @@ -# [DEF:backend.tests.services.clean_release.test_approval_service:Module] +# [DEF:TestApprovalService:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 5 # @SEMANTICS: tests, clean-release, approval, lifecycle, gate # @PURPOSE: Define approval gate contracts for approve/reject operations over immutable compliance evidence. # @LAYER: Tests -# @RELATION: TESTS -> src.services.clean_release.approval_service -# @RELATION: TESTS -> src.services.clean_release.enums -# @RELATION: TESTS -> src.services.clean_release.repository # @INVARIANT: Approval is allowed only for PASSED report bound to candidate; duplicate approve and foreign report must be rejected. from __future__ import annotations @@ -21,6 +19,7 @@ from src.services.clean_release.repository import CleanReleaseRepository # [DEF:_seed_candidate_with_report:Function] +# @RELATION: BINDS_TO -> TestApprovalService # @PURPOSE: Seed candidate and report fixtures for approval gate tests. # @PRE: candidate_id and report_id are non-empty. # @POST: Repository contains candidate and report linked by candidate_id. @@ -61,6 +60,7 @@ def _seed_candidate_with_report( # [DEF:test_approve_rejects_blocked_report:Function] +# @RELATION: BINDS_TO -> TestApprovalService # @PURPOSE: Ensure approve is rejected when latest report final status is not PASSED. # @PRE: Candidate has BLOCKED report. # @POST: approve_candidate raises ApprovalGateError. @@ -83,6 +83,7 @@ def test_approve_rejects_blocked_report(): # [DEF:test_approve_rejects_foreign_report:Function] +# @RELATION: BINDS_TO -> TestApprovalService # @PURPOSE: Ensure approve is rejected when report belongs to another candidate. # @PRE: Candidate exists, report candidate_id differs. # @POST: approve_candidate raises ApprovalGateError. @@ -113,6 +114,7 @@ def test_approve_rejects_foreign_report(): # [DEF:test_approve_rejects_duplicate_approve:Function] +# @RELATION: BINDS_TO -> TestApprovalService # @PURPOSE: Ensure repeated approve decision for same candidate is blocked. # @PRE: Candidate has already been approved once. # @POST: Second approve_candidate call raises ApprovalGateError. @@ -143,6 +145,7 @@ def test_approve_rejects_duplicate_approve(): # [DEF:test_reject_persists_decision_without_promoting_candidate_state:Function] +# @RELATION: BINDS_TO -> TestApprovalService # @PURPOSE: Ensure reject decision is immutable and does not promote candidate to APPROVED. # @PRE: Candidate has PASSED report and CHECK_PASSED lifecycle state. # @POST: reject_candidate persists REJECTED decision; candidate status remains unchanged. @@ -167,6 +170,7 @@ def test_reject_persists_decision_without_promoting_candidate_state(): # [DEF:test_reject_then_publish_is_blocked:Function] +# @RELATION: BINDS_TO -> TestApprovalService # @PURPOSE: Ensure latest REJECTED decision blocks publication gate. # @PRE: Candidate is rejected for passed report. # @POST: publish_candidate raises PublicationGateError. @@ -196,4 +200,4 @@ def test_reject_then_publish_is_blocked(): ) # [/DEF:test_reject_then_publish_is_blocked:Function] -# [/DEF:backend.tests.services.clean_release.test_approval_service:Module] \ No newline at end of file +# [/DEF:TestApprovalService:Module] \ No newline at end of file diff --git a/backend/tests/services/clean_release/test_candidate_manifest_services.py b/backend/tests/services/clean_release/test_candidate_manifest_services.py index 27db45eb..e544725a 100644 --- a/backend/tests/services/clean_release/test_candidate_manifest_services.py +++ b/backend/tests/services/clean_release/test_candidate_manifest_services.py @@ -1,4 +1,5 @@ # [DEF:test_candidate_manifest_services:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 3 # @PURPOSE: Test lifecycle and manifest versioning for release candidates. # @LAYER: Tests @@ -23,6 +24,8 @@ def db_session(): yield session session.close() +# [DEF:test_candidate_lifecycle_transitions:Function] +# @RELATION: BINDS_TO -> test_candidate_manifest_services def test_candidate_lifecycle_transitions(db_session): """ @PURPOSE: Verify legal state transitions for ReleaseCandidate. @@ -47,6 +50,10 @@ def test_candidate_lifecycle_transitions(db_session): with pytest.raises(IllegalTransitionError, match="Forbidden transition"): candidate.transition_to(CandidateStatus.DRAFT) +# [/DEF:test_candidate_lifecycle_transitions:Function] + +# [DEF:test_manifest_versioning_and_immutability:Function] +# @RELATION: BINDS_TO -> test_candidate_manifest_services def test_manifest_versioning_and_immutability(db_session): """ @PURPOSE: Verify manifest versioning and immutability invariants. @@ -90,6 +97,10 @@ def test_manifest_versioning_and_immutability(db_session): assert len(all_manifests) == 2 +# [/DEF:test_manifest_versioning_and_immutability:Function] + +# [DEF:_valid_artifacts:Function] +# @RELATION: BINDS_TO -> test_candidate_manifest_services def _valid_artifacts(): return [ { @@ -101,6 +112,10 @@ def _valid_artifacts(): ] +# [/DEF:_valid_artifacts:Function] + +# [DEF:test_register_candidate_rejects_duplicate_candidate_id:Function] +# @RELATION: BINDS_TO -> test_candidate_manifest_services def test_register_candidate_rejects_duplicate_candidate_id(): repository = CleanReleaseRepository() register_candidate( @@ -123,6 +138,10 @@ def test_register_candidate_rejects_duplicate_candidate_id(): ) +# [/DEF:test_register_candidate_rejects_duplicate_candidate_id:Function] + +# [DEF:test_register_candidate_rejects_malformed_artifact_input:Function] +# @RELATION: BINDS_TO -> test_candidate_manifest_services def test_register_candidate_rejects_malformed_artifact_input(): repository = CleanReleaseRepository() bad_artifacts = [{"id": "art-1", "path": "bin/app", "size": 42}] # missing sha256 @@ -138,6 +157,10 @@ def test_register_candidate_rejects_malformed_artifact_input(): ) +# [/DEF:test_register_candidate_rejects_malformed_artifact_input:Function] + +# [DEF:test_register_candidate_rejects_empty_artifact_set:Function] +# @RELATION: BINDS_TO -> test_candidate_manifest_services def test_register_candidate_rejects_empty_artifact_set(): repository = CleanReleaseRepository() @@ -152,6 +175,10 @@ def test_register_candidate_rejects_empty_artifact_set(): ) +# [/DEF:test_register_candidate_rejects_empty_artifact_set:Function] + +# [DEF:test_manifest_service_rebuild_creates_new_version:Function] +# @RELATION: BINDS_TO -> test_candidate_manifest_services def test_manifest_service_rebuild_creates_new_version(): repository = CleanReleaseRepository() register_candidate( @@ -171,6 +198,10 @@ def test_manifest_service_rebuild_creates_new_version(): assert first.id != second.id +# [/DEF:test_manifest_service_rebuild_creates_new_version:Function] + +# [DEF:test_manifest_service_existing_manifest_cannot_be_mutated:Function] +# @RELATION: BINDS_TO -> test_candidate_manifest_services def test_manifest_service_existing_manifest_cannot_be_mutated(): repository = CleanReleaseRepository() register_candidate( @@ -194,6 +225,10 @@ def test_manifest_service_existing_manifest_cannot_be_mutated(): assert rebuilt.id != created.id +# [/DEF:test_manifest_service_existing_manifest_cannot_be_mutated:Function] + +# [DEF:test_manifest_service_rejects_missing_candidate:Function] +# @RELATION: BINDS_TO -> test_candidate_manifest_services def test_manifest_service_rejects_missing_candidate(): repository = CleanReleaseRepository() @@ -201,3 +236,4 @@ def test_manifest_service_rejects_missing_candidate(): build_manifest_snapshot(repository=repository, candidate_id="missing-candidate", created_by="operator") # [/DEF:test_candidate_manifest_services:Module] +# [/DEF:test_manifest_service_rejects_missing_candidate:Function] diff --git a/backend/tests/services/clean_release/test_compliance_execution_service.py b/backend/tests/services/clean_release/test_compliance_execution_service.py index a6a6b03e..1a9a8f49 100644 --- a/backend/tests/services/clean_release/test_compliance_execution_service.py +++ b/backend/tests/services/clean_release/test_compliance_execution_service.py @@ -1,10 +1,9 @@ -# [DEF:backend.tests.services.clean_release.test_compliance_execution_service:Module] +# [DEF:TestComplianceExecutionService:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 5 # @SEMANTICS: tests, clean-release, compliance, pipeline, run-finalization # @PURPOSE: Validate stage pipeline and run finalization contracts for compliance execution. # @LAYER: Tests -# @RELATION: TESTS -> backend.src.services.clean_release.compliance_orchestrator -# @RELATION: TESTS -> backend.src.services.clean_release.report_builder # @INVARIANT: Missing manifest prevents run startup; failed execution cannot finalize as PASSED. from __future__ import annotations @@ -27,6 +26,7 @@ from src.services.clean_release.repository import CleanReleaseRepository # [DEF:_seed_with_candidate_policy_registry:Function] +# @RELATION: BINDS_TO -> TestComplianceExecutionService # @PURPOSE: Build deterministic repository state for run startup tests. # @PRE: candidate_id and snapshot ids are non-empty. # @POST: Returns repository with candidate, policy and registry; manifest is optional. @@ -100,6 +100,7 @@ def _seed_with_candidate_policy_registry( # [DEF:test_run_without_manifest_rejected:Function] +# @RELATION: BINDS_TO -> TestComplianceExecutionService # @PURPOSE: Ensure compliance run cannot start when manifest is unresolved. # @PRE: Candidate/policy exist but manifest is missing. # @POST: start_check_run raises ValueError and no run is persisted. @@ -120,6 +121,7 @@ def test_run_without_manifest_rejected(): # [DEF:test_task_crash_mid_run_marks_failed:Function] +# @RELATION: BINDS_TO -> TestComplianceExecutionService # @PURPOSE: Ensure execution crash conditions force FAILED run status. # @PRE: Run exists, then required dependency becomes unavailable before execute_stages. # @POST: execute_stages persists run with FAILED status. @@ -143,6 +145,7 @@ def test_task_crash_mid_run_marks_failed(): # [DEF:test_blocked_run_finalization_blocks_report_builder:Function] +# @RELATION: BINDS_TO -> TestComplianceExecutionService # @PURPOSE: Ensure blocked runs require blocking violations before report creation. # @PRE: Manifest contains prohibited artifacts leading to BLOCKED decision. # @POST: finalize keeps BLOCKED and report_builder rejects zero blocking violations. @@ -170,4 +173,4 @@ def test_blocked_run_finalization_blocks_report_builder(): builder.build_report_payload(run, []) # [/DEF:test_blocked_run_finalization_blocks_report_builder:Function] -# [/DEF:backend.tests.services.clean_release.test_compliance_execution_service:Module] +# [/DEF:TestComplianceExecutionService:Module] diff --git a/backend/tests/services/clean_release/test_compliance_task_integration.py b/backend/tests/services/clean_release/test_compliance_task_integration.py index b70ddf02..903f154b 100644 --- a/backend/tests/services/clean_release/test_compliance_task_integration.py +++ b/backend/tests/services/clean_release/test_compliance_task_integration.py @@ -1,10 +1,9 @@ -# [DEF:backend.tests.services.clean_release.test_compliance_task_integration:Module] +# [DEF:TestComplianceTaskIntegration:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 5 # @SEMANTICS: tests, clean-release, compliance, task-manager, integration # @PURPOSE: Verify clean release compliance runs execute through TaskManager lifecycle with observable success/failure outcomes. # @LAYER: Tests -# @RELATION: TESTS -> backend.src.core.task_manager.manager.TaskManager -# @RELATION: TESTS -> backend.src.services.clean_release.compliance_orchestrator.CleanComplianceOrchestrator # @INVARIANT: Compliance execution triggered as task produces terminal task status and persists run evidence. from __future__ import annotations @@ -24,16 +23,21 @@ from src.models.clean_release import ( ReleaseCandidate, SourceRegistrySnapshot, ) -from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator +from src.services.clean_release.compliance_orchestrator import ( + CleanComplianceOrchestrator, +) from src.services.clean_release.enums import CandidateStatus, RunStatus from src.services.clean_release.repository import CleanReleaseRepository # [DEF:_seed_repository:Function] +# @RELATION: BINDS_TO -> TestComplianceTaskIntegration # @PURPOSE: Prepare deterministic candidate/policy/registry/manifest fixtures for task integration tests. # @PRE: with_manifest controls manifest availability. # @POST: Returns initialized repository and identifiers for compliance run startup. -def _seed_repository(*, with_manifest: bool) -> tuple[CleanReleaseRepository, str, str, str]: +def _seed_repository( + *, with_manifest: bool +) -> tuple[CleanReleaseRepository, str, str, str]: repository = CleanReleaseRepository() candidate_id = "cand-task-int-1" policy_id = "policy-task-int-1" @@ -94,10 +98,13 @@ def _seed_repository(*, with_manifest: bool) -> tuple[CleanReleaseRepository, st ) return repository, candidate_id, policy_id, manifest_id + + # [/DEF:_seed_repository:Function] # [DEF:CleanReleaseCompliancePlugin:Class] +# @RELATION: BINDS_TO -> TestComplianceTaskIntegration # @PURPOSE: TaskManager plugin shim that executes clean release compliance orchestration. class CleanReleaseCompliancePlugin: @property @@ -125,12 +132,21 @@ class CleanReleaseCompliancePlugin: if context is not None: context.logger.info("Compliance run completed via TaskManager plugin") - return {"run_id": run.id, "run_status": run.status, "final_status": run.final_status} + return { + "run_id": run.id, + "run_status": run.status, + "final_status": run.final_status, + } + + # [/DEF:CleanReleaseCompliancePlugin:Class] # [DEF:_PluginLoaderStub:Class] +# @RELATION: BINDS_TO -> TestComplianceTaskIntegration +# @COMPLEXITY: 2 # @PURPOSE: Provide minimal plugin loader contract used by TaskManager in integration tests. +# @INVARIANT: has_plugin/get_plugin only acknowledge the seeded compliance plugin id. class _PluginLoaderStub: def __init__(self, plugin: CleanReleaseCompliancePlugin): self._plugin = plugin @@ -142,18 +158,26 @@ class _PluginLoaderStub: if plugin_id != self._plugin.id: raise ValueError("Plugin not found") return self._plugin + + # [/DEF:_PluginLoaderStub:Class] # [DEF:_make_task_manager:Function] +# @RELATION: BINDS_TO -> TestComplianceTaskIntegration # @PURPOSE: Build TaskManager with mocked persistence services for isolated integration tests. # @POST: Returns TaskManager ready for async task execution. def _make_task_manager() -> TaskManager: plugin_loader = _PluginLoaderStub(CleanReleaseCompliancePlugin()) - with patch("src.core.task_manager.manager.TaskPersistenceService") as mock_persistence, patch( - "src.core.task_manager.manager.TaskLogPersistenceService" - ) as mock_log_persistence: + with ( + patch( + "src.core.task_manager.manager.TaskPersistenceService" + ) as mock_persistence, + patch( + "src.core.task_manager.manager.TaskLogPersistenceService" + ) as mock_log_persistence, + ): mock_persistence.return_value.load_tasks.return_value = [] mock_persistence.return_value.persist_task = MagicMock() mock_log_persistence.return_value.add_logs = MagicMock() @@ -162,14 +186,19 @@ def _make_task_manager() -> TaskManager: mock_log_persistence.return_value.get_sources = MagicMock(return_value=[]) return TaskManager(plugin_loader) + + # [/DEF:_make_task_manager:Function] # [DEF:_wait_for_terminal_task:Function] +# @RELATION: BINDS_TO -> TestComplianceTaskIntegration # @PURPOSE: Poll task registry until target task reaches terminal status. # @PRE: task_id exists in manager registry. # @POST: Returns task with SUCCESS or FAILED status, otherwise raises TimeoutError. -async def _wait_for_terminal_task(manager: TaskManager, task_id: str, timeout_seconds: float = 3.0): +async def _wait_for_terminal_task( + manager: TaskManager, task_id: str, timeout_seconds: float = 3.0 +): started = asyncio.get_running_loop().time() while True: task = manager.get_task(task_id) @@ -178,16 +207,21 @@ async def _wait_for_terminal_task(manager: TaskManager, task_id: str, timeout_se if asyncio.get_running_loop().time() - started > timeout_seconds: raise TimeoutError(f"Task {task_id} did not reach terminal status") await asyncio.sleep(0.05) + + # [/DEF:_wait_for_terminal_task:Function] # [DEF:test_compliance_run_executes_as_task_manager_task:Function] +# @RELATION: BINDS_TO -> TestComplianceTaskIntegration # @PURPOSE: Verify successful compliance execution is observable as TaskManager SUCCESS task. # @PRE: Candidate, policy and manifest are available in repository. # @POST: Task ends with SUCCESS; run is persisted with SUCCEEDED status and task binding. @pytest.mark.asyncio async def test_compliance_run_executes_as_task_manager_task(): - repository, candidate_id, policy_id, manifest_id = _seed_repository(with_manifest=True) + repository, candidate_id, policy_id, manifest_id = _seed_repository( + with_manifest=True + ) manager = _make_task_manager() try: @@ -214,16 +248,21 @@ async def test_compliance_run_executes_as_task_manager_task(): finally: manager._flusher_stop_event.set() manager._flusher_thread.join(timeout=2) + + # [/DEF:test_compliance_run_executes_as_task_manager_task:Function] # [DEF:test_compliance_run_missing_manifest_marks_task_failed:Function] +# @RELATION: BINDS_TO -> TestComplianceTaskIntegration # @PURPOSE: Verify missing manifest startup failure is surfaced as TaskManager FAILED task. # @PRE: Candidate/policy exist but manifest is absent. # @POST: Task ends with FAILED and run history remains empty. @pytest.mark.asyncio async def test_compliance_run_missing_manifest_marks_task_failed(): - repository, candidate_id, policy_id, manifest_id = _seed_repository(with_manifest=False) + repository, candidate_id, policy_id, manifest_id = _seed_repository( + with_manifest=False + ) manager = _make_task_manager() try: @@ -241,10 +280,14 @@ async def test_compliance_run_missing_manifest_marks_task_failed(): assert finished.status == TaskStatus.FAILED assert len(repository.check_runs) == 0 - assert any("Manifest or Policy not found" in log.message for log in finished.logs) + assert any( + "Manifest or Policy not found" in log.message for log in finished.logs + ) finally: manager._flusher_stop_event.set() manager._flusher_thread.join(timeout=2) + + # [/DEF:test_compliance_run_missing_manifest_marks_task_failed:Function] -# [/DEF:backend.tests.services.clean_release.test_compliance_task_integration:Module] +# [/DEF:TestComplianceTaskIntegration:Module] diff --git a/backend/tests/services/clean_release/test_demo_mode_isolation.py b/backend/tests/services/clean_release/test_demo_mode_isolation.py index 3a32cd20..47ca617a 100644 --- a/backend/tests/services/clean_release/test_demo_mode_isolation.py +++ b/backend/tests/services/clean_release/test_demo_mode_isolation.py @@ -1,4 +1,4 @@ -# [DEF:backend.tests.services.clean_release.test_demo_mode_isolation:Module] +# [DEF:TestDemoModeIsolation:Module] # @COMPLEXITY: 3 # @SEMANTICS: clean-release, demo-mode, isolation, namespace, repository # @PURPOSE: Verify demo and real mode namespace isolation contracts before TUI integration. @@ -18,6 +18,7 @@ from src.services.clean_release.demo_data_service import ( # [DEF:test_resolve_namespace_separates_demo_and_real:Function] +# @RELATION: BINDS_TO -> TestDemoModeIsolation # @PURPOSE: Ensure namespace resolver returns deterministic and distinct namespaces. # @PRE: Mode names are provided as user/runtime strings. # @POST: Demo and real namespaces are different and stable. @@ -32,6 +33,7 @@ def test_resolve_namespace_separates_demo_and_real() -> None: # [DEF:test_build_namespaced_id_prevents_cross_mode_collisions:Function] +# @RELATION: BINDS_TO -> TestDemoModeIsolation # @PURPOSE: Ensure ID generation prevents demo/real collisions for identical logical IDs. # @PRE: Same logical candidate id is used in two different namespaces. # @POST: Produced physical IDs differ by namespace prefix. @@ -47,6 +49,7 @@ def test_build_namespaced_id_prevents_cross_mode_collisions() -> None: # [DEF:test_create_isolated_repository_keeps_mode_data_separate:Function] +# @RELATION: BINDS_TO -> TestDemoModeIsolation # @PURPOSE: Verify demo and real repositories do not leak state across mode boundaries. # @PRE: Two repositories are created for distinct modes. # @POST: Candidate mutations in one mode are not visible in the other mode. @@ -84,4 +87,4 @@ def test_create_isolated_repository_keeps_mode_data_separate() -> None: assert real_repo.get_candidate(demo_candidate_id) is None # [/DEF:test_create_isolated_repository_keeps_mode_data_separate:Function] -# [/DEF:backend.tests.services.clean_release.test_demo_mode_isolation:Module] +# [/DEF:TestDemoModeIsolation:Module] diff --git a/backend/tests/services/clean_release/test_policy_resolution_service.py b/backend/tests/services/clean_release/test_policy_resolution_service.py index f59f4c1a..353428ec 100644 --- a/backend/tests/services/clean_release/test_policy_resolution_service.py +++ b/backend/tests/services/clean_release/test_policy_resolution_service.py @@ -1,4 +1,4 @@ -# [DEF:backend.tests.services.clean_release.test_policy_resolution_service:Module] +# [DEF:TestPolicyResolutionService:Module] # @COMPLEXITY: 5 # @SEMANTICS: clean-release, policy-resolution, trusted-snapshots, contracts # @PURPOSE: Verify trusted policy snapshot resolution contract and error guards. @@ -21,6 +21,7 @@ from src.services.clean_release.repository import CleanReleaseRepository # [DEF:_config_manager:Function] +# @RELATION: BINDS_TO -> TestPolicyResolutionService # @PURPOSE: Build deterministic ConfigManager-like stub for tests. # @PRE: policy_id and registry_id may be None or non-empty strings. # @POST: Returns object exposing get_config().settings.clean_release active IDs. @@ -33,6 +34,7 @@ def _config_manager(policy_id, registry_id): # [DEF:test_resolve_trusted_policy_snapshots_missing_profile:Function] +# @RELATION: BINDS_TO -> TestPolicyResolutionService # @PURPOSE: Ensure resolution fails when trusted profile is not configured. # @PRE: active_policy_id is None. # @POST: Raises PolicyResolutionError with missing trusted profile reason. @@ -49,6 +51,7 @@ def test_resolve_trusted_policy_snapshots_missing_profile(): # [DEF:test_resolve_trusted_policy_snapshots_missing_registry:Function] +# @RELATION: BINDS_TO -> TestPolicyResolutionService # @PURPOSE: Ensure resolution fails when trusted registry is not configured. # @PRE: active_registry_id is None and active_policy_id is set. # @POST: Raises PolicyResolutionError with missing trusted registry reason. @@ -65,6 +68,7 @@ def test_resolve_trusted_policy_snapshots_missing_registry(): # [DEF:test_resolve_trusted_policy_snapshots_rejects_override_attempt:Function] +# @RELATION: BINDS_TO -> TestPolicyResolutionService # @PURPOSE: Ensure runtime override attempt is rejected even if snapshots exist. # @PRE: valid trusted snapshots exist in repository and override is provided. # @POST: Raises PolicyResolutionError with override forbidden reason. @@ -102,4 +106,4 @@ def test_resolve_trusted_policy_snapshots_rejects_override_attempt(): ) # [/DEF:test_resolve_trusted_policy_snapshots_rejects_override_attempt:Function] -# [/DEF:backend.tests.services.clean_release.test_policy_resolution_service:Module] +# [/DEF:TestPolicyResolutionService:Module] diff --git a/backend/tests/services/clean_release/test_publication_service.py b/backend/tests/services/clean_release/test_publication_service.py index 301ab136..f07e69d1 100644 --- a/backend/tests/services/clean_release/test_publication_service.py +++ b/backend/tests/services/clean_release/test_publication_service.py @@ -1,11 +1,9 @@ -# [DEF:backend.tests.services.clean_release.test_publication_service:Module] +# [DEF:TestPublicationService:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 5 # @SEMANTICS: tests, clean-release, publication, revoke, gate # @PURPOSE: Define publication gate contracts over approved candidates and immutable publication records. # @LAYER: Tests -# @RELATION: TESTS -> src.services.clean_release.publication_service -# @RELATION: TESTS -> src.services.clean_release.approval_service -# @RELATION: TESTS -> src.services.clean_release.repository # @INVARIANT: Publish requires approval; revoke requires existing publication; republish after revoke is allowed as a new record. from __future__ import annotations @@ -21,6 +19,7 @@ from src.services.clean_release.repository import CleanReleaseRepository # [DEF:_seed_candidate_with_passed_report:Function] +# @RELATION: BINDS_TO -> TestPublicationService # @PURPOSE: Seed candidate/report fixtures for publication gate scenarios. # @PRE: candidate_id and report_id are non-empty. # @POST: Repository contains candidate and PASSED report. @@ -57,6 +56,7 @@ def _seed_candidate_with_passed_report( # [DEF:test_publish_without_approval_rejected:Function] +# @RELATION: BINDS_TO -> TestPublicationService # @PURPOSE: Ensure publish action is blocked until candidate is approved. # @PRE: Candidate has PASSED report but status is not APPROVED. # @POST: publish_candidate raises PublicationGateError. @@ -80,6 +80,7 @@ def test_publish_without_approval_rejected(): # [DEF:test_revoke_unknown_publication_rejected:Function] +# @RELATION: BINDS_TO -> TestPublicationService # @PURPOSE: Ensure revocation is rejected for unknown publication id. # @PRE: Repository has no matching publication record. # @POST: revoke_publication raises PublicationGateError. @@ -99,6 +100,7 @@ def test_revoke_unknown_publication_rejected(): # [DEF:test_republish_after_revoke_creates_new_active_record:Function] +# @RELATION: BINDS_TO -> TestPublicationService # @PURPOSE: Ensure republish after revoke is allowed and creates a new ACTIVE record. # @PRE: Candidate is APPROVED and first publication has been revoked. # @POST: New publish call returns distinct publication id with ACTIVE status. @@ -145,4 +147,4 @@ def test_republish_after_revoke_creates_new_active_record(): assert second.status == PublicationStatus.ACTIVE.value # [/DEF:test_republish_after_revoke_creates_new_active_record:Function] -# [/DEF:backend.tests.services.clean_release.test_publication_service:Module] \ No newline at end of file +# [/DEF:TestPublicationService:Module] \ No newline at end of file diff --git a/backend/tests/services/clean_release/test_report_audit_immutability.py b/backend/tests/services/clean_release/test_report_audit_immutability.py index 6e1c817a..f5d0a23d 100644 --- a/backend/tests/services/clean_release/test_report_audit_immutability.py +++ b/backend/tests/services/clean_release/test_report_audit_immutability.py @@ -1,11 +1,9 @@ -# [DEF:backend.tests.services.clean_release.test_report_audit_immutability:Module] +# [DEF:TestReportAuditImmutability:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 5 # @SEMANTICS: tests, clean-release, report, audit, immutability, append-only # @PURPOSE: Validate report snapshot immutability expectations and append-only audit hook behavior for US2. # @LAYER: Tests -# @RELATION: TESTS -> src.services.clean_release.report_builder.ComplianceReportBuilder -# @RELATION: TESTS -> src.services.clean_release.audit_service -# @RELATION: TESTS -> src.services.clean_release.repository.CleanReleaseRepository # @INVARIANT: Built reports are immutable snapshots; audit hooks produce append-only event traces. from __future__ import annotations @@ -15,18 +13,30 @@ from unittest.mock import patch import pytest -from src.models.clean_release import ComplianceReport, ComplianceRun, ComplianceViolation -from src.services.clean_release.audit_service import audit_check_run, audit_preparation, audit_report, audit_violation +from src.models.clean_release import ( + ComplianceReport, + ComplianceRun, + ComplianceViolation, +) +from src.services.clean_release.audit_service import ( + audit_check_run, + audit_preparation, + audit_report, + audit_violation, +) from src.services.clean_release.enums import ComplianceDecision, RunStatus from src.services.clean_release.report_builder import ComplianceReportBuilder from src.services.clean_release.repository import CleanReleaseRepository # [DEF:_terminal_run:Function] +# @RELATION: BINDS_TO -> TestReportAuditImmutability # @PURPOSE: Build deterministic terminal run fixture for report snapshot tests. # @PRE: final_status is a valid ComplianceDecision value. # @POST: Returns a terminal ComplianceRun suitable for report generation. -def _terminal_run(final_status: ComplianceDecision = ComplianceDecision.PASSED) -> ComplianceRun: +def _terminal_run( + final_status: ComplianceDecision = ComplianceDecision.PASSED, +) -> ComplianceRun: return ComplianceRun( id="run-immut-1", candidate_id="cand-immut-1", @@ -41,10 +51,13 @@ def _terminal_run(final_status: ComplianceDecision = ComplianceDecision.PASSED) status=RunStatus.SUCCEEDED, final_status=final_status, ) + + # [/DEF:_terminal_run:Function] # [DEF:test_report_builder_sets_immutable_snapshot_flag:Function] +# @RELATION: BINDS_TO -> TestReportAuditImmutability # @PURPOSE: Ensure generated report payload is marked immutable and persisted as snapshot. # @PRE: Terminal run exists. # @POST: Built report has immutable=True and repository stores same immutable object. @@ -59,10 +72,13 @@ def test_report_builder_sets_immutable_snapshot_flag(): assert report.immutable is True assert persisted.immutable is True assert repository.get_report(report.id) is persisted + + # [/DEF:test_report_builder_sets_immutable_snapshot_flag:Function] # [DEF:test_repository_rejects_report_overwrite_for_same_report_id:Function] +# @RELATION: BINDS_TO -> TestReportAuditImmutability # @PURPOSE: Define immutability contract that report snapshots cannot be overwritten by same identifier. # @PRE: Existing report with id is already persisted. # @POST: Second save for same report id is rejected with explicit immutability error. @@ -73,7 +89,11 @@ def test_repository_rejects_report_overwrite_for_same_report_id(): run_id="run-immut-1", candidate_id="cand-immut-1", final_status=ComplianceDecision.PASSED, - summary_json={"operator_summary": "original", "violations_count": 0, "blocking_violations_count": 0}, + summary_json={ + "operator_summary": "original", + "violations_count": 0, + "blocking_violations_count": 0, + }, generated_at=datetime.now(timezone.utc), immutable=True, ) @@ -82,7 +102,11 @@ def test_repository_rejects_report_overwrite_for_same_report_id(): run_id="run-immut-2", candidate_id="cand-immut-2", final_status=ComplianceDecision.ERROR, - summary_json={"operator_summary": "mutated", "violations_count": 1, "blocking_violations_count": 1}, + summary_json={ + "operator_summary": "mutated", + "violations_count": 1, + "blocking_violations_count": 1, + }, generated_at=datetime.now(timezone.utc), immutable=True, ) @@ -91,10 +115,13 @@ def test_repository_rejects_report_overwrite_for_same_report_id(): with pytest.raises(ValueError, match="immutable"): repository.save_report(mutated) + + # [/DEF:test_repository_rejects_report_overwrite_for_same_report_id:Function] # [DEF:test_audit_hooks_emit_append_only_event_stream:Function] +# @RELATION: BINDS_TO -> TestReportAuditImmutability # @PURPOSE: Verify audit hooks emit one event per action call and preserve call order. # @PRE: Logger backend is patched. # @POST: Three calls produce three ordered info entries with molecular prefixes. @@ -109,6 +136,8 @@ def test_audit_hooks_emit_append_only_event_stream(mock_logger): assert logged_messages[0].startswith("[REASON]") assert logged_messages[1].startswith("[REFLECT]") assert logged_messages[2].startswith("[EXPLORE]") + + # [/DEF:test_audit_hooks_emit_append_only_event_stream:Function] -# [/DEF:backend.tests.services.clean_release.test_report_audit_immutability:Module] \ No newline at end of file +# [/DEF:TestReportAuditImmutability:Module] diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 4469fbcd..5773a62e 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,3 +1,12 @@ +# [DEF:TestAuth:Module] +# @COMPLEXITY: 3 +# @PURPOSE: Covers authentication service/repository behavior and auth bootstrap helpers. +# @LAYER: Test +# @RELATION: TESTS -> AuthService +# @RELATION: TESTS -> AuthRepository +# @RELATION: TESTS -> create_admin +# @RELATION: TESTS -> ensure_encryption_key + import sys from pathlib import Path @@ -19,113 +28,145 @@ from src.scripts.init_auth_db import ensure_encryption_key # Create in-memory SQLite database for testing SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" -engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) # Create all tables Base.metadata.create_all(bind=engine) + @pytest.fixture def db_session(): """Create a new database session with a transaction, rollback after test""" connection = engine.connect() transaction = connection.begin() session = TestingSessionLocal(bind=connection) - + yield session - + session.close() transaction.rollback() connection.close() + @pytest.fixture def auth_service(db_session): return AuthService(db_session) + @pytest.fixture def auth_repo(db_session): return AuthRepository(db_session) + +# [DEF:test_create_user:Function] +# @RELATION: BINDS_TO -> TestAuth def test_create_user(auth_repo): """Test user creation""" user = User( username="testuser", email="test@example.com", password_hash=get_password_hash("testpassword123"), - auth_source="LOCAL" + auth_source="LOCAL", ) - + auth_repo.db.add(user) auth_repo.db.commit() - + retrieved_user = auth_repo.get_user_by_username("testuser") assert retrieved_user is not None assert retrieved_user.username == "testuser" assert retrieved_user.email == "test@example.com" assert verify_password("testpassword123", retrieved_user.password_hash) + +# [/DEF:test_create_user:Function] + + +# [DEF:test_authenticate_user:Function] +# @RELATION: BINDS_TO -> TestAuth def test_authenticate_user(auth_service, auth_repo): """Test user authentication with valid and invalid credentials""" user = User( username="testuser", email="test@example.com", password_hash=get_password_hash("testpassword123"), - auth_source="LOCAL" + auth_source="LOCAL", ) - + auth_repo.db.add(user) auth_repo.db.commit() - + # Test valid credentials authenticated_user = auth_service.authenticate_user("testuser", "testpassword123") assert authenticated_user is not None assert authenticated_user.username == "testuser" - + # Test invalid password invalid_user = auth_service.authenticate_user("testuser", "wrongpassword") assert invalid_user is None - + # Test invalid username invalid_user = auth_service.authenticate_user("nonexistent", "testpassword123") assert invalid_user is None + +# [/DEF:test_authenticate_user:Function] + + +# [DEF:test_create_session:Function] +# @RELATION: BINDS_TO -> TestAuth def test_create_session(auth_service, auth_repo): """Test session token creation""" user = User( username="testuser", email="test@example.com", password_hash=get_password_hash("testpassword123"), - auth_source="LOCAL" + auth_source="LOCAL", ) - + auth_repo.db.add(user) auth_repo.db.commit() - + session = auth_service.create_session(user) assert "access_token" in session assert "token_type" in session assert session["token_type"] == "bearer" assert len(session["access_token"]) > 0 + +# [/DEF:test_create_session:Function] + + +# [DEF:test_role_permission_association:Function] +# @RELATION: BINDS_TO -> TestAuth def test_role_permission_association(auth_repo): """Test role and permission association""" role = Role(name="Admin", description="System administrator") perm1 = Permission(resource="admin:users", action="READ") perm2 = Permission(resource="admin:users", action="WRITE") - + role.permissions.extend([perm1, perm2]) - + auth_repo.db.add(role) auth_repo.db.commit() - + retrieved_role = auth_repo.get_role_by_name("Admin") assert retrieved_role is not None assert len(retrieved_role.permissions) == 2 - + permissions = [f"{p.resource}:{p.action}" for p in retrieved_role.permissions] assert "admin:users:READ" in permissions assert "admin:users:WRITE" in permissions + +# [/DEF:test_role_permission_association:Function] + + +# [DEF:test_user_role_association:Function] +# @RELATION: BINDS_TO -> TestAuth def test_user_role_association(auth_repo): """Test user and role association""" role = Role(name="Admin", description="System administrator") @@ -133,58 +174,84 @@ def test_user_role_association(auth_repo): username="adminuser", email="admin@example.com", password_hash=get_password_hash("adminpass123"), - auth_source="LOCAL" + auth_source="LOCAL", ) - + user.roles.append(role) - + auth_repo.db.add(role) auth_repo.db.add(user) auth_repo.db.commit() - + retrieved_user = auth_repo.get_user_by_username("adminuser") assert retrieved_user is not None assert len(retrieved_user.roles) == 1 assert retrieved_user.roles[0].name == "Admin" + +# [/DEF:test_user_role_association:Function] + + +# [DEF:test_ad_group_mapping:Function] +# @RELATION: BINDS_TO -> TestAuth def test_ad_group_mapping(auth_repo): """Test AD group mapping""" role = Role(name="ADFS_Admin", description="ADFS administrators") - + auth_repo.db.add(role) auth_repo.db.commit() - + mapping = ADGroupMapping(ad_group="DOMAIN\\ADFS_Admins", role_id=role.id) - + auth_repo.db.add(mapping) auth_repo.db.commit() - - retrieved_mapping = auth_repo.db.query(ADGroupMapping).filter_by(ad_group="DOMAIN\\ADFS_Admins").first() + + retrieved_mapping = ( + auth_repo.db.query(ADGroupMapping) + .filter_by(ad_group="DOMAIN\\ADFS_Admins") + .first() + ) assert retrieved_mapping is not None assert retrieved_mapping.role_id == role.id +# [/DEF:test_ad_group_mapping:Function] + + +# [DEF:test_create_admin_creates_user_with_optional_email:Function] +# @RELATION: BINDS_TO -> TestAuth def test_create_admin_creates_user_with_optional_email(monkeypatch, db_session): """Test bootstrap admin creation stores optional email and Admin role""" monkeypatch.setattr("src.scripts.create_admin.AuthSessionLocal", lambda: db_session) result = create_admin("bootstrap-admin", "bootstrap-pass", "admin@example.com") - created_user = db_session.query(User).filter(User.username == "bootstrap-admin").first() + created_user = ( + db_session.query(User).filter(User.username == "bootstrap-admin").first() + ) assert result == "created" assert created_user is not None assert created_user.email == "admin@example.com" assert created_user.roles[0].name == "Admin" +# [/DEF:test_create_admin_creates_user_with_optional_email:Function] + + +# [DEF:test_create_admin_is_idempotent_for_existing_user:Function] +# @RELATION: BINDS_TO -> TestAuth def test_create_admin_is_idempotent_for_existing_user(monkeypatch, db_session): """Test bootstrap admin creation preserves existing user on repeated runs""" monkeypatch.setattr("src.scripts.create_admin.AuthSessionLocal", lambda: db_session) first_result = create_admin("bootstrap-admin-2", "bootstrap-pass") - second_result = create_admin("bootstrap-admin-2", "new-password", "changed@example.com") + second_result = create_admin( + "bootstrap-admin-2", "new-password", "changed@example.com" + ) - created_user = db_session.query(User).filter(User.username == "bootstrap-admin-2").first() + created_user = ( + db_session.query(User).filter(User.username == "bootstrap-admin-2").first() + ) assert first_result == "created" assert second_result == "exists" assert created_user is not None @@ -193,6 +260,11 @@ def test_create_admin_is_idempotent_for_existing_user(monkeypatch, db_session): assert not verify_password("new-password", created_user.password_hash) +# [/DEF:test_create_admin_is_idempotent_for_existing_user:Function] + + +# [DEF:test_ensure_encryption_key_generates_backend_env_file:Function] +# @RELATION: BINDS_TO -> TestAuth def test_ensure_encryption_key_generates_backend_env_file(monkeypatch, tmp_path): """Test first-time initialization generates and persists a Fernet key.""" env_file = tmp_path / ".env" @@ -202,23 +274,41 @@ def test_ensure_encryption_key_generates_backend_env_file(monkeypatch, tmp_path) assert generated_key assert env_file.exists() - assert env_file.read_text(encoding="utf-8").strip() == f"ENCRYPTION_KEY={generated_key}" + assert ( + env_file.read_text(encoding="utf-8").strip() + == f"ENCRYPTION_KEY={generated_key}" + ) assert verify_fernet_key(generated_key) +# [/DEF:test_ensure_encryption_key_generates_backend_env_file:Function] + + +# [DEF:test_ensure_encryption_key_reuses_existing_env_file_value:Function] +# @RELATION: BINDS_TO -> TestAuth def test_ensure_encryption_key_reuses_existing_env_file_value(monkeypatch, tmp_path): """Test persisted key is reused without rewriting file contents.""" env_file = tmp_path / ".env" existing_key = Fernet.generate_key().decode() - env_file.write_text(f"ENCRYPTION_KEY={existing_key}\nOTHER=value\n", encoding="utf-8") + env_file.write_text( + f"ENCRYPTION_KEY={existing_key}\nOTHER=value\n", encoding="utf-8" + ) monkeypatch.delenv("ENCRYPTION_KEY", raising=False) reused_key = ensure_encryption_key(env_file) assert reused_key == existing_key - assert env_file.read_text(encoding="utf-8") == f"ENCRYPTION_KEY={existing_key}\nOTHER=value\n" + assert ( + env_file.read_text(encoding="utf-8") + == f"ENCRYPTION_KEY={existing_key}\nOTHER=value\n" + ) +# [/DEF:test_ensure_encryption_key_reuses_existing_env_file_value:Function] + + +# [DEF:test_ensure_encryption_key_prefers_process_environment:Function] +# @RELATION: BINDS_TO -> TestAuth def test_ensure_encryption_key_prefers_process_environment(monkeypatch, tmp_path): """Test explicit process environment has priority over file generation.""" env_file = tmp_path / ".env" @@ -231,6 +321,12 @@ def test_ensure_encryption_key_prefers_process_environment(monkeypatch, tmp_path assert not env_file.exists() +# [/DEF:test_ensure_encryption_key_prefers_process_environment:Function] + + def verify_fernet_key(value: str) -> bool: Fernet(value.encode()) return True + + +# [/DEF:TestAuth:Module] diff --git a/backend/tests/test_log_persistence.py b/backend/tests/test_log_persistence.py index 1378ca3c..557dbe00 100644 --- a/backend/tests/test_log_persistence.py +++ b/backend/tests/test_log_persistence.py @@ -1,8 +1,8 @@ # [DEF:test_log_persistence:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @SEMANTICS: test, log, persistence, unit_test # @PURPOSE: Unit tests for TaskLogPersistenceService. # @LAYER: Test -# @RELATION: TESTS -> TaskLogPersistenceService # @COMPLEXITY: 5 # [SECTION: IMPORTS] @@ -18,6 +18,7 @@ from src.core.task_manager.models import LogEntry, LogFilter # [/SECTION] # [DEF:TestLogPersistence:Class] +# @RELATION: BINDS_TO -> test_log_persistence # @PURPOSE: Test suite for TaskLogPersistenceService. # @COMPLEXITY: 5 # @TEST_DATA: log_entry -> {"task_id": "test-task-1", "level": "INFO", "source": "test_source", "message": "Test message"} diff --git a/backend/tests/test_logger.py b/backend/tests/test_logger.py index 36ea13b9..be85a5b3 100644 --- a/backend/tests/test_logger.py +++ b/backend/tests/test_logger.py @@ -1,4 +1,4 @@ -# [DEF:tests.test_logger:Module] +# [DEF:TestLogger:Module] # @COMPLEXITY: 3 # @SEMANTICS: logging, tests, belief_state # @PURPOSE: Unit tests for the custom logger formatters and configuration context manager. @@ -18,6 +18,7 @@ from src.core.config_models import LoggingConfig # [DEF:test_belief_scope_logs_entry_action_exit_at_debug:Function] +# @RELATION: BINDS_TO -> TestLogger # @PURPOSE: Test that belief_scope generates [ID][Entry], [ID][Action], and [ID][Exit] logs at DEBUG level. # @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG. # @POST: Logs are verified to contain Entry, Action, and Exit tags at DEBUG level. @@ -50,6 +51,7 @@ def test_belief_scope_logs_entry_action_exit_at_debug(caplog): # [DEF:test_belief_scope_error_handling:Function] +# @RELATION: BINDS_TO -> TestLogger # @PURPOSE: Test that belief_scope logs Coherence:Failed on exception. # @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG. # @POST: Logs are verified to contain Coherence:Failed tag. @@ -82,6 +84,7 @@ def test_belief_scope_error_handling(caplog): # [DEF:test_belief_scope_success_coherence:Function] +# @RELATION: BINDS_TO -> TestLogger # @PURPOSE: Test that belief_scope logs Coherence:OK on success. # @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG. # @POST: Logs are verified to contain Coherence:OK tag. @@ -111,6 +114,7 @@ def test_belief_scope_success_coherence(caplog): # [DEF:test_belief_scope_not_visible_at_info:Function] +# @RELATION: BINDS_TO -> TestLogger # @PURPOSE: Test that belief_scope Entry/Exit/Coherence logs are NOT visible at INFO level. # @PRE: belief_scope is available. caplog fixture is used. # @POST: Entry/Exit/Coherence logs are not captured at INFO level. @@ -133,6 +137,7 @@ def test_belief_scope_not_visible_at_info(caplog): # [DEF:test_task_log_level_default:Function] +# @RELATION: BINDS_TO -> TestLogger # @PURPOSE: Test that default task log level is INFO. # @PRE: None. # @POST: Default level is INFO. @@ -144,6 +149,7 @@ def test_task_log_level_default(): # [DEF:test_should_log_task_level:Function] +# @RELATION: BINDS_TO -> TestLogger # @PURPOSE: Test that should_log_task_level correctly filters log levels. # @PRE: None. # @POST: Filtering works correctly for all level combinations. @@ -158,6 +164,7 @@ def test_should_log_task_level(): # [DEF:test_configure_logger_task_log_level:Function] +# @RELATION: BINDS_TO -> TestLogger # @PURPOSE: Test that configure_logger updates task_log_level. # @PRE: LoggingConfig is available. # @POST: task_log_level is updated correctly. @@ -185,6 +192,7 @@ def test_configure_logger_task_log_level(): # [DEF:test_enable_belief_state_flag:Function] +# @RELATION: BINDS_TO -> TestLogger # @PURPOSE: Test that enable_belief_state flag controls belief_scope logging. # @PRE: LoggingConfig is available. caplog fixture is used. # @POST: belief_scope logs are controlled by the flag. @@ -219,4 +227,4 @@ def test_enable_belief_state_flag(caplog): ) configure_logger(config) # [/DEF:test_enable_belief_state_flag:Function] -# [/DEF:tests.test_logger:Module] \ No newline at end of file +# [/DEF:TestLogger:Module] \ No newline at end of file diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index eaf23271..82494419 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -2,6 +2,7 @@ from src.core.config_models import Environment from src.core.logger import belief_scope # [DEF:test_environment_model:Function] +# @RELATION: TESTS -> Environment # @PURPOSE: Tests that Environment model correctly stores values. # @PRE: Environment class is available. # @POST: Values are verified. diff --git a/backend/tests/test_resource_hubs.py b/backend/tests/test_resource_hubs.py index fb3899d2..0d8610be 100644 --- a/backend/tests/test_resource_hubs.py +++ b/backend/tests/test_resource_hubs.py @@ -7,6 +7,7 @@ from src.dependencies import get_config_manager, get_task_manager, get_resource_ client = TestClient(app) # [DEF:test_dashboards_api:Test] +# @RELATION: BINDS_TO -> SrcRoot # @PURPOSE: Verify GET /api/dashboards contract compliance # @TEST: Valid env_id returns 200 and dashboard list # @TEST: Invalid env_id returns 404 @@ -59,6 +60,8 @@ def mock_deps(): app.dependency_overrides.clear() +# [DEF:test_get_dashboards_success:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_get_dashboards_success(mock_deps): response = client.get("/api/dashboards?env_id=env1") assert response.status_code == 200 @@ -68,10 +71,18 @@ def test_get_dashboards_success(mock_deps): assert data["dashboards"][0]["title"] == "Sales" assert data["dashboards"][0]["git_status"]["sync_status"] == "OK" +# [/DEF:test_get_dashboards_success:Function] + +# [DEF:test_get_dashboards_not_found:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_get_dashboards_not_found(mock_deps): response = client.get("/api/dashboards?env_id=invalid") assert response.status_code == 404 +# [/DEF:test_get_dashboards_not_found:Function] + +# [DEF:test_get_dashboards_search:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_get_dashboards_search(mock_deps): response = client.get("/api/dashboards?env_id=env1&search=Sales") assert response.status_code == 200 @@ -82,12 +93,17 @@ def test_get_dashboards_search(mock_deps): # [/DEF:test_dashboards_api:Test] # [DEF:test_datasets_api:Test] +# @RELATION: BINDS_TO -> SrcRoot # @PURPOSE: Verify GET /api/datasets contract compliance # @TEST: Valid env_id returns 200 and dataset list # @TEST: Invalid env_id returns 404 # @TEST: Search filter works # @TEST: Negative - Service failure returns 503 +# [/DEF:test_get_dashboards_search:Function] + +# [DEF:test_get_datasets_success:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_get_datasets_success(mock_deps): mock_deps["resource"].get_datasets_with_status = AsyncMock(return_value=[ {"id": 1, "table_name": "orders", "schema": "public", "database": "db1", "mapped_fields": {"total": 10, "mapped": 5}, "last_task": None} @@ -101,10 +117,18 @@ def test_get_datasets_success(mock_deps): assert data["datasets"][0]["table_name"] == "orders" assert data["datasets"][0]["mapped_fields"]["mapped"] == 5 +# [/DEF:test_get_datasets_success:Function] + +# [DEF:test_get_datasets_not_found:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_get_datasets_not_found(mock_deps): response = client.get("/api/datasets?env_id=invalid") assert response.status_code == 404 +# [/DEF:test_get_datasets_not_found:Function] + +# [DEF:test_get_datasets_search:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_get_datasets_search(mock_deps): mock_deps["resource"].get_datasets_with_status = AsyncMock(return_value=[ {"id": 1, "table_name": "orders", "schema": "public", "database": "db1", "mapped_fields": {"total": 10, "mapped": 5}, "last_task": None}, @@ -117,6 +141,10 @@ def test_get_datasets_search(mock_deps): assert len(data["datasets"]) == 1 assert data["datasets"][0]["table_name"] == "orders" +# [/DEF:test_get_datasets_search:Function] + +# [DEF:test_get_datasets_service_failure:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_get_datasets_service_failure(mock_deps): mock_deps["resource"].get_datasets_with_status = AsyncMock(side_effect=Exception("Superset down")) @@ -128,29 +156,47 @@ def test_get_datasets_service_failure(mock_deps): # [DEF:test_pagination_boundaries:Test] +# @RELATION: BINDS_TO -> SrcRoot # @PURPOSE: Verify pagination validation for GET endpoints # @TEST: page<1 and page_size>100 return 400 +# [/DEF:test_get_datasets_service_failure:Function] + +# [DEF:test_get_dashboards_pagination_zero_page:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_get_dashboards_pagination_zero_page(mock_deps): """@TEST_EDGE: pagination_zero_page -> {page:0, status:400}""" response = client.get("/api/dashboards?env_id=env1&page=0") assert response.status_code == 400 assert "Page must be >= 1" in response.json()["detail"] +# [/DEF:test_get_dashboards_pagination_zero_page:Function] + +# [DEF:test_get_dashboards_pagination_oversize:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_get_dashboards_pagination_oversize(mock_deps): """@TEST_EDGE: pagination_oversize -> {page_size:101, status:400}""" response = client.get("/api/dashboards?env_id=env1&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_pagination_oversize:Function] + +# [DEF:test_get_datasets_pagination_zero_page:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_get_datasets_pagination_zero_page(mock_deps): """@TEST_EDGE: pagination_zero_page on datasets""" response = client.get("/api/datasets?env_id=env1&page=0") assert response.status_code == 400 +# [/DEF:test_get_datasets_pagination_zero_page:Function] + +# [DEF:test_get_datasets_pagination_oversize:Function] +# @RELATION: BINDS_TO -> UnknownModule def test_get_datasets_pagination_oversize(mock_deps): """@TEST_EDGE: pagination_oversize on datasets""" response = client.get("/api/datasets?env_id=env1&page_size=101") assert response.status_code == 400 # [/DEF:test_pagination_boundaries:Test] +# [/DEF:test_get_datasets_pagination_oversize:Function] diff --git a/backend/tests/test_task_manager.py b/backend/tests/test_task_manager.py index 44459d01..0da2f243 100644 --- a/backend/tests/test_task_manager.py +++ b/backend/tests/test_task_manager.py @@ -1,9 +1,9 @@ # [DEF:test_task_manager:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @COMPLEXITY: 5 # @SEMANTICS: task-manager, lifecycle, CRUD, log-buffer, filtering, tests # @PURPOSE: Unit tests for TaskManager lifecycle, CRUD, log buffering, and filtering. # @LAYER: Core -# @RELATION: TESTS -> backend.src.core.task_manager.manager.TaskManager # @INVARIANT: TaskManager state changes are deterministic and testable with mocked dependencies. import sys @@ -17,6 +17,8 @@ from datetime import datetime # Helper to create a TaskManager with mocked dependencies +# [DEF:_make_manager:Function] +# @RELATION: BINDS_TO -> test_task_manager def _make_manager(): """Create TaskManager with mocked plugin_loader and persistence services.""" mock_plugin_loader = MagicMock() @@ -50,12 +52,18 @@ def _make_manager(): return manager, mock_plugin_loader, MockPersistence.return_value, MockLogPersistence.return_value +# [/DEF:_make_manager:Function] + +# [DEF:_cleanup_manager:Function] +# @RELATION: BINDS_TO -> test_task_manager def _cleanup_manager(manager): """Stop the flusher thread.""" manager._flusher_stop_event.set() manager._flusher_thread.join(timeout=2) +# [/DEF:_cleanup_manager:Function] + class TestTaskManagerInit: """Tests for TaskManager initialization.""" diff --git a/backend/tests/test_task_persistence.py b/backend/tests/test_task_persistence.py index e3c83b75..9a3fbee9 100644 --- a/backend/tests/test_task_persistence.py +++ b/backend/tests/test_task_persistence.py @@ -1,8 +1,8 @@ # [DEF:test_task_persistence:Module] +# @RELATION: BELONGS_TO -> SrcRoot # @SEMANTICS: test, task, persistence, unit_test # @PURPOSE: Unit tests for TaskPersistenceService. # @LAYER: Test -# @RELATION: TESTS -> TaskPersistenceService # @COMPLEXITY: 5 # @TEST_DATA: valid_task -> {"id": "test-uuid-1", "plugin_id": "backup", "status": "PENDING"} @@ -21,6 +21,7 @@ from src.core.task_manager.models import Task, TaskStatus, LogEntry # [DEF:TestTaskPersistenceHelpers:Class] +# @RELATION: BINDS_TO -> test_task_persistence # @PURPOSE: Test suite for TaskPersistenceService static helper methods. # @COMPLEXITY: 5 class TestTaskPersistenceHelpers: @@ -110,6 +111,7 @@ class TestTaskPersistenceHelpers: # [DEF:TestTaskPersistenceService:Class] +# @RELATION: BINDS_TO -> test_task_persistence # @PURPOSE: Test suite for TaskPersistenceService CRUD operations. # @COMPLEXITY: 5 # @TEST_DATA: valid_task -> {"id": "test-uuid-1", "plugin_id": "backup", "status": "PENDING"} diff --git a/build.sh b/build.sh index 61b0ce5e..4b52de9c 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# [DEF:build:Module] +# @PURPOSE: Utility script for build +# @TIER: TRIVIAL +# @COMPLEXITY: 1 set -euo pipefail @@ -45,6 +49,7 @@ import base64 import os import sys from pathlib import Path +from typing import List, Optional def is_valid_fernet_key(raw_value: str) -> bool: @@ -67,8 +72,8 @@ def generate_fernet_key() -> str: env_path = Path(sys.argv[1]) env_path.parent.mkdir(parents=True, exist_ok=True) -existing_lines: list[str] = [] -existing_key: str | None = None +existing_lines: List[str] = [] +existing_key: Optional[str] = None if env_path.exists(): existing_lines = env_path.read_text(encoding="utf-8").splitlines() @@ -117,3 +122,5 @@ echo "[2/2] Starting Docker services..." echo "Done. Services are running." echo "Use '${COMPOSE_CMD[*]} ${COMPOSE_ARGS[*]} ps' to check status and '${COMPOSE_CMD[*]} ${COMPOSE_ARGS[*]} logs -f' to stream logs." + +# [/DEF:build:Module] diff --git a/frontend/src/components/DashboardGrid.svelte b/frontend/src/components/DashboardGrid.svelte index a63c2ba9..61686ad4 100644 --- a/frontend/src/components/DashboardGrid.svelte +++ b/frontend/src/components/DashboardGrid.svelte @@ -1,5 +1,7 @@ - - -
-

Available Tools

-
- {#each $plugins as plugin} + + + + + +
+

Available Tools

+
+ {#each $plugins as plugin}
selectPlugin(plugin)} @@ -52,13 +52,13 @@ tabindex="0" onkeydown={(e) => e.key === 'Enter' && selectPlugin(plugin)} > -

{plugin.name}

-

{plugin.description}

- v{plugin.version} -
- {/each} -
-
- - - +

{plugin.name}

+

{plugin.description}

+ v{plugin.version} +
+ {/each} +
+ + + + diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte index 9bb57a81..340e31c8 100644 --- a/frontend/src/routes/+error.svelte +++ b/frontend/src/routes/+error.svelte @@ -1,4 +1,7 @@ +