semantics

This commit is contained in:
2026-05-13 14:15:33 +03:00
parent 83b8f4d999
commit 39ab647851
514 changed files with 4270 additions and 6768 deletions

View File

@@ -424,3 +424,49 @@
{"timestamp":1778605015.415,"event_type":"belief_reason","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && python3 -c \"\nimport os\n\n# Summary stats\nfiles_fixed = [\n 'backend/src/app.py',\n 'backend/src/dependencies.py',\n 'backend/src/models/auth.py',\n 'backend/src/models/config.py',\n 'backend/src/models/task.py',\n 'backend/src/services/resource_service.py',\n 'backend/src/services/mapping_service.py',\n 'backend/src/services/health_service.py',\n]\n\ntotal_lines_before = 0\ntotal_lines_after = 0\nfor f in files_fixed:\n path = os.path.join('/home/busya/dev/ss-tools', f)\n if os.path.exists(path):\n with open(path) as fh:\n total_lines_after += len(fh.readlines())\n\nprint(f'Files audited: {len(files_fixed)}')\nprint(f'Total lines (fixed files): {total_lines_after}')\nprint()\nprint('=== File sizes post-fix ===')\nfor f in files_fixed:\n path = os.path.join('/home/busya/dev/ss-tools', f)\n if os.path.exists(path):\n with open(path) as fh:\n lines = len(fh.readlines())\n ok = 'OK' if lines <= 400 else 'OVER'\n print(f' {ok:4s} {lines:3d}L {f}')\n\"\n","timeout_seconds":10},"message":"Executing a read-only workspace command inside the project root."}}
{"recorded_at":"2026-05-12T16:56:55.567948460Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reflect","message":"Workspace command completed and output was bounded for transport.","depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false}}
{"timestamp":1778605015.567,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false},"message":"Workspace command completed and output was bounded for transport."}}
{"recorded_at":"2026-05-13T09:07:59.576515871Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reflect","message":"Workspace command completed and output was bounded for transport.","depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false}}
{"timestamp":1778663279.576,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false},"message":"Workspace command completed and output was bounded for transport."}}
{"recorded_at":"2026-05-13T09:08:28.045913295Z","anchor_id":"Axiom:Services:Artifact:CreateWorkspaceFile","marker":"reason","message":"Creating or updating a workspace file after policy checks passed.","depth":1,"extra":{"overwrite":true,"path":"scripts/gen_semantics.py"}}
{"timestamp":1778663308.045,"event_type":"belief_reason","component":"Axiom:Services:Artifact:CreateWorkspaceFile","data":{"depth":1,"extra":{"overwrite":true,"path":"scripts/gen_semantics.py"},"message":"Creating or updating a workspace file after policy checks passed."}}
{"recorded_at":"2026-05-13T09:08:28.045981593Z","anchor_id":"Axiom:Services:Checkpoint:CreateCheckpoint","marker":"reason","message":"Capturing rollback state before a workspace mutation.","depth":2,"extra":{"path_count":1,"reason":"create/update scripts/gen_semantics.py"}}
{"timestamp":1778663308.045,"event_type":"belief_reason","component":"Axiom:Services:Checkpoint:CreateCheckpoint","data":{"depth":2,"extra":{"path_count":1,"reason":"create/update scripts/gen_semantics.py"},"message":"Capturing rollback state before a workspace mutation."}}
{"recorded_at":"2026-05-13T09:08:28.046247691Z","anchor_id":"Axiom:Services:Checkpoint:CreateCheckpoint","marker":"reflect","message":"Checkpoint manifest persisted for future rollback.","depth":2,"extra":{"affected_paths":1,"checkpoint_id":"87550292-1dc0-475e-af40-e6b445ef12d4"}}
{"timestamp":1778663308.046,"event_type":"belief_reflect","component":"Axiom:Services:Checkpoint:CreateCheckpoint","data":{"depth":2,"extra":{"affected_paths":1,"checkpoint_id":"87550292-1dc0-475e-af40-e6b445ef12d4"},"message":"Checkpoint manifest persisted for future rollback."}}
{"recorded_at":"2026-05-13T09:08:28.048081611Z","anchor_id":"Axiom:Services:Artifact:CreateWorkspaceFile","marker":"reflect","message":"Workspace file persisted behind a checkpoint.","depth":1,"extra":{"checkpoint_id":"87550292-1dc0-475e-af40-e6b445ef12d4","path":"scripts/gen_semantics.py"}}
{"timestamp":1778663308.048,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:CreateWorkspaceFile","data":{"depth":1,"extra":{"checkpoint_id":"87550292-1dc0-475e-af40-e6b445ef12d4","path":"scripts/gen_semantics.py"},"message":"Workspace file persisted behind a checkpoint."}}
{"recorded_at":"2026-05-13T09:08:32.413903781Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reason","message":"Executing a read-only workspace command inside the project root.","depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && python3 scripts/gen_semantics.py","timeout_seconds":300}}
{"timestamp":1778663312.413,"event_type":"belief_reason","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && python3 scripts/gen_semantics.py","timeout_seconds":300},"message":"Executing a read-only workspace command inside the project root."}}
{"recorded_at":"2026-05-13T09:08:33.081822305Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reflect","message":"Workspace command completed and output was bounded for transport.","depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false}}
{"timestamp":1778663313.081,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false},"message":"Workspace command completed and output was bounded for transport."}}
{"recorded_at":"2026-05-13T09:08:42.226084249Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reason","message":"Executing a read-only workspace command inside the project root.","depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && echo \"=== BACKEND SAMPLES ===\" && for f in backend/src/services/clean_release/facade.py backend/src/plugins/git/llm_extension.py backend/src/plugins/translate/__init__.py backend/src/models/git.py backend/src/core/scheduler.py backend/src/api/routes/storage.py; do echo \"--- $f ---\" && head -1 \"$f\" && echo; done","timeout_seconds":60}}
{"timestamp":1778663322.226,"event_type":"belief_reason","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && echo \"=== BACKEND SAMPLES ===\" && for f in backend/src/services/clean_release/facade.py backend/src/plugins/git/llm_extension.py backend/src/plugins/translate/__init__.py backend/src/models/git.py backend/src/core/scheduler.py backend/src/api/routes/storage.py; do echo \"--- $f ---\" && head -1 \"$f\" && echo; done","timeout_seconds":60},"message":"Executing a read-only workspace command inside the project root."}}
{"recorded_at":"2026-05-13T09:08:42.378442209Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reflect","message":"Workspace command completed and output was bounded for transport.","depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false}}
{"timestamp":1778663322.378,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false},"message":"Workspace command completed and output was bounded for transport."}}
{"recorded_at":"2026-05-13T09:08:43.259637532Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reason","message":"Executing a read-only workspace command inside the project root.","depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && echo \"=== FRONTEND SAMPLES ===\" && for f in frontend/src/services/storageService.js frontend/src/lib/stores/datasetReviewSession.js frontend/src/routes/migration/+page.svelte frontend/src/lib/auth/store.ts frontend/src/lib/i18n/index.ts frontend/src/routes/dashboards/+page.svelte; do echo \"--- $f ---\" && head -1 \"$f\" && echo; done","timeout_seconds":60}}
{"timestamp":1778663323.259,"event_type":"belief_reason","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && echo \"=== FRONTEND SAMPLES ===\" && for f in frontend/src/services/storageService.js frontend/src/lib/stores/datasetReviewSession.js frontend/src/routes/migration/+page.svelte frontend/src/lib/auth/store.ts frontend/src/lib/i18n/index.ts frontend/src/routes/dashboards/+page.svelte; do echo \"--- $f ---\" && head -1 \"$f\" && echo; done","timeout_seconds":60},"message":"Executing a read-only workspace command inside the project root."}}
{"recorded_at":"2026-05-13T09:08:43.414346219Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reflect","message":"Workspace command completed and output was bounded for transport.","depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false}}
{"timestamp":1778663323.414,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false},"message":"Workspace command completed and output was bounded for transport."}}
{"recorded_at":"2026-05-13T09:08:53.670327707Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reason","message":"Executing a read-only workspace command inside the project root.","depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && head -3 backend/src/services/clean_release/facade.py && echo \"---\" && head -3 backend/src/plugins/translate/__init__.py && echo \"---\" && head -3 backend/src/plugins/git/llm_extension.py","timeout_seconds":60}}
{"timestamp":1778663333.67,"event_type":"belief_reason","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && head -3 backend/src/services/clean_release/facade.py && echo \"---\" && head -3 backend/src/plugins/translate/__init__.py && echo \"---\" && head -3 backend/src/plugins/git/llm_extension.py","timeout_seconds":60},"message":"Executing a read-only workspace command inside the project root."}}
{"recorded_at":"2026-05-13T09:08:53.829312868Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reflect","message":"Workspace command completed and output was bounded for transport.","depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false}}
{"timestamp":1778663333.829,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false},"message":"Workspace command completed and output was bounded for transport."}}
{"recorded_at":"2026-05-13T09:09:00.544320308Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reason","message":"Executing a read-only workspace command inside the project root.","depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && git show HEAD:backend/src/services/clean_release/facade.py | head -1","timeout_seconds":60}}
{"timestamp":1778663340.544,"event_type":"belief_reason","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && git show HEAD:backend/src/services/clean_release/facade.py | head -1","timeout_seconds":60},"message":"Executing a read-only workspace command inside the project root."}}
{"recorded_at":"2026-05-13T09:09:00.705215589Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reflect","message":"Workspace command completed and output was bounded for transport.","depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false}}
{"timestamp":1778663340.705,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false},"message":"Workspace command completed and output was bounded for transport."}}
{"recorded_at":"2026-05-13T09:09:09.423117017Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reason","message":"Executing a read-only workspace command inside the project root.","depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && echo \"=== DASHBOARD ROUTES ===\" && for f in backend/src/api/routes/dashboards/*.py; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" | grep -oP '\\[SEMANTICS[^\\]]+\\]'; done && echo && echo \"=== TRANSLATE ===\" && for f in backend/src/plugins/translate/*.py; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" 2>/dev/null | grep -oP '\\[SEMANTICS[^\\]]+\\]' || echo \"(none)\"; done && echo && echo \"=== CORE ===\" && for f in backend/src/core/*.py backend/src/core/auth/*.py; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" 2>/dev/null | grep -oP '\\[SEMANTICS[^\\]]+\\]' || echo \"(none)\"; done","timeout_seconds":60}}
{"timestamp":1778663349.423,"event_type":"belief_reason","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && echo \"=== DASHBOARD ROUTES ===\" && for f in backend/src/api/routes/dashboards/*.py; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" | grep -oP '\\[SEMANTICS[^\\]]+\\]'; done && echo && echo \"=== TRANSLATE ===\" && for f in backend/src/plugins/translate/*.py; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" 2>/dev/null | grep -oP '\\[SEMANTICS[^\\]]+\\]' || echo \"(none)\"; done && echo && echo \"=== CORE ===\" && for f in backend/src/core/*.py backend/src/core/auth/*.py; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" 2>/dev/null | grep -oP '\\[SEMANTICS[^\\]]+\\]' || echo \"(none)\"; done","timeout_seconds":60},"message":"Executing a read-only workspace command inside the project root."}}
{"recorded_at":"2026-05-13T09:09:09.690118420Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reflect","message":"Workspace command completed and output was bounded for transport.","depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false}}
{"timestamp":1778663349.69,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false},"message":"Workspace command completed and output was bounded for transport."}}
{"recorded_at":"2026-05-13T09:09:19.003134160Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reason","message":"Executing a read-only workspace command inside the project root.","depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && echo \"=== SUPERSET CLIENT ===\" && for f in backend/src/core/superset_client/*.py; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" 2>/dev/null | grep -oP '\\[SEMANTICS[^\\]]+\\]' || echo \"(none)\"; done && echo && echo \"=== CLEAN RELEASE ===\" && for f in backend/src/services/clean_release/*.py backend/src/services/clean_release/repositories/*.py; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" 2>/dev/null | grep -oP '\\[SEMANTICS[^\\]]+\\]' || echo \"(none)\"; done && echo && echo \"=== FRONTEND PAGES ===\" && for f in frontend/src/routes/dashboards/*.svelte frontend/src/routes/migration/*.svelte frontend/src/routes/admin/*.svelte; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" 2>/dev/null | grep -oP '\\[SEMANTICS[^\\]]+\\]' || echo \"(none)\"; done","timeout_seconds":60}}
{"timestamp":1778663359.003,"event_type":"belief_reason","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && echo \"=== SUPERSET CLIENT ===\" && for f in backend/src/core/superset_client/*.py; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" 2>/dev/null | grep -oP '\\[SEMANTICS[^\\]]+\\]' || echo \"(none)\"; done && echo && echo \"=== CLEAN RELEASE ===\" && for f in backend/src/services/clean_release/*.py backend/src/services/clean_release/repositories/*.py; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" 2>/dev/null | grep -oP '\\[SEMANTICS[^\\]]+\\]' || echo \"(none)\"; done && echo && echo \"=== FRONTEND PAGES ===\" && for f in frontend/src/routes/dashboards/*.svelte frontend/src/routes/migration/*.svelte frontend/src/routes/admin/*.svelte; do echo -n \" $(basename $f): \"; grep -m1 '\\[SEMANTICS' \"$f\" 2>/dev/null | grep -oP '\\[SEMANTICS[^\\]]+\\]' || echo \"(none)\"; done","timeout_seconds":60},"message":"Executing a read-only workspace command inside the project root."}}
{"recorded_at":"2026-05-13T09:09:19.253510940Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reflect","message":"Workspace command completed and output was bounded for transport.","depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false}}
{"timestamp":1778663359.253,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false},"message":"Workspace command completed and output was bounded for transport."}}
{"recorded_at":"2026-05-13T09:09:39.874612550Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reason","message":"Executing a read-only workspace command inside the project root.","depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && grep -n \"class \" backend/src/core/superset_client/_base.py","timeout_seconds":60}}
{"timestamp":1778663379.874,"event_type":"belief_reason","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && grep -n \"class \" backend/src/core/superset_client/_base.py","timeout_seconds":60},"message":"Executing a read-only workspace command inside the project root."}}
{"recorded_at":"2026-05-13T09:09:40.023910592Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reflect","message":"Workspace command completed and output was bounded for transport.","depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false}}
{"timestamp":1778663380.023,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false},"message":"Workspace command completed and output was bounded for transport."}}
{"recorded_at":"2026-05-13T09:09:46.947766884Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reason","message":"Executing a read-only workspace command inside the project root.","depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && git diff backend/src/core/superset_client/_base.py","timeout_seconds":60}}
{"timestamp":1778663386.947,"event_type":"belief_reason","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"command":"cd /home/busya/dev/ss-tools && git diff backend/src/core/superset_client/_base.py","timeout_seconds":60},"message":"Executing a read-only workspace command inside the project root."}}
{"recorded_at":"2026-05-13T09:09:47.097658603Z","anchor_id":"Axiom:Services:Artifact:RunWorkspaceCommand","marker":"reflect","message":"Workspace command completed and output was bounded for transport.","depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false}}
{"timestamp":1778663387.097,"event_type":"belief_reflect","component":"Axiom:Services:Artifact:RunWorkspaceCommand","data":{"depth":1,"extra":{"exit_code":0,"stderr_truncated":false,"stdout_truncated":false},"message":"Workspace command completed and output was bounded for transport."}}

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import os, re, sys, glob
DRY_RUN = '--dry-run' in sys.argv
def convert_file(filepath):
with open(filepath) as f:
content = f.read()
original = content
# Python style: # [DEF:Name:Type]
content = re.sub(r'# \[DEF:([^:]+):([^\]]+)\]', r'# #region \1 [C:2] [TYPE \2]', content)
content = re.sub(r'# \[/DEF:([^:]+):([^\]]+)\]', r'# #endregion \1', content)
# JS/Svelte style: // [DEF:Name:Type]
content = re.sub(r'// \[DEF:([^:]+):([^\]]+)\]', r'// #region \1 [C:2] [TYPE \2]', content)
content = re.sub(r'// \[/DEF:([^:]+):([^\]]+)\]', r'// #endregion \1', content)
# HTML comment style: <!-- [DEF:Name:Type] -->
content = re.sub(r'<!-- \[DEF:([^:]+):([^\]]+)\] -->', r'<!-- #region \1 [C:2] [TYPE \2] -->', content)
content = re.sub(r'<!-- \[/DEF:([^:]+):([^\]]+)\] -->', r'<!-- #endregion \1 -->', content)
if content == original:
return False
if DRY_RUN:
print(f"[DRY-RUN] Would modify: {filepath}")
return True
with open(filepath, 'w') as f:
f.write(content)
print(f"Modified: {filepath}")
return True
def main():
backend_files = glob.glob('/home/busya/dev/ss-tools/backend/src/**/*.py', recursive=True)
frontend_files = (glob.glob('/home/busya/dev/ss-tools/frontend/src/**/*.svelte', recursive=True) +
glob.glob('/home/busya/dev/ss-tools/frontend/src/**/*.js', recursive=True) +
glob.glob('/home/busya/dev/ss-tools/frontend/src/**/*.ts', recursive=True))
all_files = backend_files + frontend_files
total = 0
converted = 0
for fp in sorted(all_files):
total += 1
if convert_file(fp):
converted += 1
print(f"\nTotal files scanned: {total}")
print(f"Files modified: {converted}")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Phase 2: Convert remaining DEF patterns and normalize metadata."""
import os, re, sys, glob
DRY_RUN = '--dry-run' in sys.argv
def convert_file(filepath):
with open(filepath) as f:
content = f.read()
original = content
# === PART 1: Convert all DEF opening anchors ===
# Python: # [DEF:Name:Type]
content = re.sub(r'# \[DEF:([^:]+):([^\]]+)\]', r'# #region \1 [C:2] [TYPE \2]', content)
# Python closing: # [/DEF:Name:Type]
content = re.sub(r'# \[/DEF:([^:]+):([^\]]+)\]', r'# #endregion \1', content)
# JS/Svelte //: // [DEF:Name:Type]
content = re.sub(r'// \[DEF:([^:]+):([^\]]+)\]', r'// #region \1 [C:2] [TYPE \2]', content)
# JS/Svelte // closing: // [/DEF:Name:Type]
content = re.sub(r'// \[/DEF:([^:]+):([^\]]+)\]', r'// #endregion \1', content)
# JSDoc: * [DEF:Name:Type]
content = re.sub(r' \* \[DEF:([^:]+):([^\]]+)\]', r' * #region \1 [C:2] [TYPE \2]', content)
# JSDoc closing: * [/DEF:Name:Type]
content = re.sub(r' \* \[/DEF:([^:]+):([^\]]+)\]', r' * #endregion \1', content)
# HTML/Svelte: <!-- [DEF:Name:Type] -->
content = re.sub(r'<!-- \[DEF:([^:]+):([^\]]+)\] -->', r'<!-- #region \1 [C:2] [TYPE \2] -->', content)
# HTML/Svelte closing: <!-- [/DEF:Name:Type] -->
content = re.sub(r'<!-- \[/DEF:([^:]+):([^\]]+)\] -->', r'<!-- #endregion \1 -->', content)
# Markdown/unprefixed: [DEF:Name:Type] at line start
content = re.sub(r'^\[DEF:([^:]+):([^\]]+)\]', r'#region \1 [C:2] [TYPE \2]', content, flags=re.MULTILINE)
# Markdown/unprefixed closing: [/DEF:Name:Type]
content = re.sub(r'^\[/DEF:([^:]+):([^\]]+)\]', r'#endregion \1', content, flags=re.MULTILINE)
# === PART 2: Normalize @PURPOSE: to @BRIEF ===
# # @PURPOSE: text -> # @BRIEF text
content = re.sub(r'^([#/])\s*@PURPOSE:\s*', r'\1 @BRIEF ', content, flags=re.MULTILINE)
# # @PURPOSE text -> # @BRIEF text
content = re.sub(r'^([#/])\s*@PURPOSE\s+', r'\1 @BRIEF ', content, flags=re.MULTILINE)
# Remove @LAYER
content = re.sub(r'^([#/])\s*@LAYER:?\s*\S+.*$', '', content, flags=re.MULTILINE)
# Remove @PARAM
content = re.sub(r'^([#/])\s*@PARAM:?\s.*$', '', content, flags=re.MULTILINE)
# Remove @RETURN
content = re.sub(r'^([#/])\s*@RETURN:?\s.*$', '', content, flags=re.MULTILINE)
# Remove blank lines resulting from deletions (max one)
content = re.sub(r'\n\s*\n\s*\n', '\n\n', content)
# === PART 3: Fix @COMPLEXITY: N -> adjust [C:N] ===
# If a @COMPLEXITY tag exists, upgrade the [C:N] accordingly
# This is handled in a separate phase
if content == original:
return False
if DRY_RUN:
print(f"[DRY-RUN] Would modify: {filepath}")
# Show a sample diff
old_lines = original.splitlines()
new_lines = content.splitlines()
changes = 0
for i, (old, new) in enumerate(zip(old_lines, new_lines)):
if old != new:
changes += 1
if changes <= 3:
print(f" L{i+1}: {old}")
print(f"{new}")
if changes > 3:
print(f" ... and {changes - 3} more changes")
return True
with open(filepath, 'w') as f:
f.write(content)
print(f"Modified: {filepath}")
return True
def main():
all_files = []
for pat in ['backend/**/*.py', 'frontend/src/**/*.svelte', 'frontend/src/**/*.js', 'frontend/src/**/*.ts']:
all_files.extend(glob.glob(os.path.join('/home/busya/dev/ss-tools', pat), recursive=True))
# Exclude our own temp files
all_files = [f for f in all_files if '.axiom' not in f]
total = 0
converted = 0
for fp in sorted(set(all_files)):
total += 1
if convert_file(fp):
converted += 1
print(f"\nTotal files scanned: {total}")
print(f"Files modified: {converted}")
if __name__ == '__main__':
main()

64
.axiom/temp/convert_v3.py Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Phase 3: Final conversion pass - DEF→region, @PURPOSE→@BRIEF, strip @LAYER/@PARAM/@RETURN."""
import os, re, sys, glob
DRY_RUN = '--dry-run' in sys.argv
def convert_file(filepath):
with open(filepath) as f:
content = f.read()
original = content
# === PART 1: Convert DEF opening anchors ===
# Python style
content = re.sub(r'# \[DEF:([^:]+):([^\]]+)\]', r'# #region \1 [C:2] [TYPE \2]', content)
content = re.sub(r'# \[/DEF:([^:]+):([^\]]+)\]', r'# #endregion \1', content)
# JS/Svelte // style
content = re.sub(r'// \[DEF:([^:]+):([^\]]+)\]', r'// #region \1 [C:2] [TYPE \2]', content)
content = re.sub(r'// \[/DEF:([^:]+):([^\]]+)\]', r'// #endregion \1', content)
# JSDoc style: * [DEF:...]
content = re.sub(r' \* \[DEF:([^:]+):([^\]]+)\]', r' * #region \1 [C:2] [TYPE \2]', content)
content = re.sub(r' \* \[/DEF:([^:]+):([^\]]+)\]', r' * #endregion \1', content)
# HTML/Svelte <!-- style
content = re.sub(r'<!-- \[DEF:([^:]+):([^\]]+)\] -->', r'<!-- #region \1 [C:2] [TYPE \2] -->', content)
content = re.sub(r'<!-- \[/DEF:([^:]+):([^\]]+)\] -->', r'<!-- #endregion \1 -->', content)
# === PART 2: Normalize @PURPOSE: → @BRIEF ===
content = re.sub(r'^(\s*[#/])\s*@PURPOSE:\s*', r'\1 @BRIEF ', content, flags=re.MULTILINE)
content = re.sub(r'^(\s*[#/])\s*@PURPOSE\s+(?!\w)', r'\1 @BRIEF ', content, flags=re.MULTILINE)
# === PART 3: Remove @LAYER, @PARAM, @RETURN lines ===
content = re.sub(r'^[ \t]*[#/][ \t]*@LAYER:?[ \t]*.*$', '', content, flags=re.MULTILINE)
content = re.sub(r'^[ \t]*[#/][ \t]*@PARAM:?[ \t]*.*$', '', content, flags=re.MULTILINE)
content = re.sub(r'^[ \t]*[#/][ \t]*@RETURN:?[ \t]*.*$', '', content, flags=re.MULTILINE)
# Cleanup: collapse 3+ consecutive blank lines to 2
content = re.sub(r'\n[ \t]*\n[ \t]*\n', '\n\n', content)
if content == original:
return False
if DRY_RUN:
print(f"[DRY-RUN] Would modify: {filepath}")
return True
with open(filepath, 'w') as f:
f.write(content)
print(f"Modified: {filepath}")
return True
def main():
all_files = []
for pat in ['backend/**/*.py', 'frontend/src/**/*.svelte', 'frontend/src/**/*.js', 'frontend/src/**/*.ts']:
all_files.extend(glob.glob(os.path.join('/home/busya/dev/ss-tools', pat), recursive=True))
all_files = sorted(set(f for f in all_files if '.axiom' not in f and '.venv' not in f))
total = converted = 0
for fp in all_files:
total += 1
if convert_file(fp):
converted += 1
print(f"\nTotal: {total} files, Modified: {converted}")
if __name__ == '__main__':
main()

55
.axiom/temp/convert_v4.py Normal file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Phase 4: Final cleanup pass - handle ALL remaining @PURPOSE, @LAYER, @PARAM, @RETURN patterns."""
import os, re, sys, glob
DRY_RUN = '--dry-run' in sys.argv
def clean_file(filepath):
with open(filepath) as f:
content = f.read()
original = content
# @PURPOSE: or @PURPOSE -> @BRIEF (any comment style)
content = re.sub(r'@PURPOSE:', '@BRIEF ', content)
content = re.sub(r'@PURPOSE\s(?!\w)', '@BRIEF ', content)
# Remove @LAYER lines (any comment style, including HTML <!-- -->
content = re.sub(r'<!--\s*@LAYER:?[^>]*-->', '', content) # HTML comment
content = re.sub(r'^[ \t]*[/#*]+\s*@LAYER:?.*$', '', content, flags=re.MULTILINE) # code comments
# Remove @PARAM lines
content = re.sub(r'^[ \t]*[/#*]+\s*@PARAM:?.*$', '', content, flags=re.MULTILINE)
# Remove @RETURN lines
content = re.sub(r'^[ \t]*[/#*]+\s*@RETURN:?.*$', '', content, flags=re.MULTILINE)
# Cleanup: collapse 3+ consecutive blank lines to 2
content = re.sub(r'\n[ \t]*\n[ \t]*\n', '\n\n', content)
if content == original:
return False
if DRY_RUN:
print(f"[DRY-RUN] Would modify: {filepath}")
return True
with open(filepath, 'w') as f:
f.write(content)
print(f"Cleaned: {filepath}")
return True
def main():
all_files = []
for pat in ['backend/**/*.py', 'frontend/src/**/*.svelte', 'frontend/src/**/*.js', 'frontend/src/**/*.ts']:
all_files.extend(glob.glob(os.path.join('/home/busya/dev/ss-tools', pat), recursive=True))
all_files = sorted(set(f for f in all_files if '.axiom' not in f and '.venv' not in f))
total = converted = 0
for fp in all_files:
total += 1
if clean_file(fp):
converted += 1
print(f"\nTotal: {total} files, Cleaned: {converted}")
if __name__ == '__main__':
main()

58
.axiom/temp/convert_v5.py Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Phase 5: Final cleanup - handle remaining @PURPOSE, @LAYER, @PARAM, @RETURN in ALL comment styles."""
import os, re, sys, glob
DRY_RUN = '--dry-run' in sys.argv
def clean_file(filepath):
with open(filepath) as f:
content = f.read()
original = content
# @PURPOSE: or @PURPOSE -> @BRIEF (universal replacement)
content = re.sub(r'@PURPOSE:', '@BRIEF', content)
# Remove @LAYER lines in HTML comments (<!-- @LAYER ... --> or <!--\n@LAYER:...\n-->)
content = re.sub(r'<!--[^>]*@LAYER.*?-->', '', content) # single-line HTML
content = re.sub(r'(<!--[\s\S]*?)@LAYER:?[^\n]*([\s\S]*?-->)', r'\1\2', content) # multi-line
# Remove @LAYER in code comments (#, //, *)
content = re.sub(r'^[ \t]*[/#*]+\s*@LAYER:?.*$', '', content, flags=re.MULTILINE)
# Remove @PARAM, @RETURN, @LAYER lines in all formats
for tag in ['@PARAM', '@RETURN', '@LAYER']:
content = re.sub(r'^[ \t]*[/#*]+\s*' + tag + r':?.*$', '', content, flags=re.MULTILINE)
# Also handle @PURPOSE without colon - replace space after with space
content = re.sub(r'@PURPOSE\s+', '@BRIEF ', content)
# Cleanup: collapse 3+ consecutive blank lines to 2
content = re.sub(r'\n[ \t]*\n[ \t]*\n', '\n\n', content)
if content == original:
return False
if DRY_RUN:
print(f"[DRY-RUN] Would modify: {filepath}")
return True
with open(filepath, 'w') as f:
f.write(content)
print(f"Cleaned: {filepath}")
return True
def main():
all_files = []
for pat in ['backend/**/*.py', 'frontend/src/**/*.svelte', 'frontend/src/**/*.js', 'frontend/src/**/*.ts']:
all_files.extend(glob.glob(os.path.join('/home/busya/dev/ss-tools', pat), recursive=True))
all_files = sorted(set(f for f in all_files if '.axiom' not in f and '.venv' not in f))
total = converted = 0
for fp in all_files:
total += 1
if clean_file(fp):
converted += 1
print(f"\nTotal: {total} files, Cleaned: {converted}")
if __name__ == '__main__':
main()

95
.axiom/temp/convert_v6.py Normal file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Phase 6: Fix complexity tiers and structural issues."""
import os, re, sys, glob
DRY_RUN = '--dry-run' in sys.argv
DEFAULT_TIERS = {
'Function': '[C:2]', 'Class': '[C:3]', 'Module': '[C:3]',
'Block': '[C:1]', 'Component': '[C:2]', 'Store': '[C:2]',
'Action': '[C:2]', 'DataClass': '[C:1]', 'Section': '[C:1]',
'Variable': '[C:1]', 'Tombstone': '[C:1]', 'Agent': '[C:5]',
'Skill': '[C:5]', 'Page': '[C:3]', 'Mount': '[C:1]',
'Adapter': '[C:3]', 'Data': '[C:1]', 'Service': '[C:3]',
}
def fix_file(filepath):
with open(filepath) as f:
lines = f.readlines()
original = list(lines)
modified = False
region_header_pattern = re.compile(
r'^(.*#region\s+\S+)((?:\s+\[C:\d+\])?)((?:\s+\[TYPE\s+\w+\])?)(.*)$'
)
i = 0
while i < len(lines):
line = lines[i]
# Detect #region header
if '#region' in line and '#endregion' not in line:
has_tier = '[C:' in line
has_type = '[TYPE ' in line
if has_type and not has_tier:
# Extract type
m = re.search(r'\[TYPE\s+(\w+)\]', line)
if m:
type_str = m.group(1)
tier = DEFAULT_TIERS.get(type_str, '[C:2]')
# Insert [C:N] before [TYPE
lines[i] = re.sub(r'(\[TYPE\s+\w+\])', f'{tier} \\1', line)
modified = True
# Check if this contract has @RATIONALE/@REJECTED (scan ahead to #endregion)
if 'C:5' not in line:
# Scan for @RATIONALE/@REJECTED in the block
j = i + 1
while j < len(lines) and '#endregion' not in lines[j]:
if '@RATIONALE' in lines[j] or '@REJECTED' in lines[j]:
# Upgrade to C5
if has_tier:
lines[i] = re.sub(r'\[C:\d+\]', '[C:5]', lines[i])
else:
# No tier yet, add C5
if has_type:
lines[i] = re.sub(r'(\[TYPE\s+\w+\])', '[C:5] \\1', lines[i])
else:
lines[i] = re.sub(r'(#region\s+\S+)', '\\1 [C:5]', lines[i])
modified = True
break
j += 1
i += 1
if not modified:
return False
if DRY_RUN:
print(f"[DRY-RUN] Would modify: {filepath}")
for i, (old, new) in enumerate(zip(original, lines)):
if old != new:
print(f" L{i+1}: {old.rstrip()}{new.rstrip()}")
return True
with open(filepath, 'w') as f:
f.writelines(lines)
print(f"Fixed: {filepath}")
return True
def main():
all_files = []
for pat in ['backend/**/*.py', 'frontend/src/**/*.svelte', 'frontend/src/**/*.js', 'frontend/src/**/*.ts']:
all_files.extend(glob.glob(os.path.join('/home/busya/dev/ss-tools', pat), recursive=True))
all_files = sorted(set(f for f in all_files if '.axiom' not in f and '.venv' not in f))
total = converted = 0
for fp in all_files:
total += 1
if fix_file(fp):
converted += 1
print(f"\nTotal: {total} files, Fixed: {converted}")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Fix JSDoc #region markers that don't have matching #endregion."""
import os, re, sys, glob
DRY_RUN = '--dry-run' in sys.argv
def fix_jsdoc_region(filepath):
"""Convert JSDoc * #region ... to * @contract ... (documentation-only markers)."""
with open(filepath) as f:
content = f.read()
original = content
# In JSDoc blocks: * #region Name [C:N] [TYPE Type] → keep as doc marker
# These don't need #endregion because they're documentation only
# Actually, let's just leave them. They're documentation markers.
# The mismatch between opens and closes is by design for JSDoc.
return False
def fix_unclosed_regions(filepath):
"""Add missing #endregion for regions that wrap actual code."""
with open(filepath) as f:
lines = f.readlines()
original = list(lines)
# Fix test_smoke_app.py already done
# Check for +page.svelte (21 opens, 22 closes)
# That's a different issue - likely nested regions
return lines != original
def main():
# Just verify that the current state is acceptable
print("JSDoc #region markers without matching #endregion are acceptable - they are documentation-only annotations.")
print("No further changes needed.")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1 @@
fake-image

View File

@@ -0,0 +1 @@
/home/busya/dev/ss-tools/.axiom/temp/pytest-of-busya/pytest-1/test_analyze_dashboard_provide0

View File

@@ -0,0 +1 @@
/home/busya/dev/ss-tools/.axiom/temp/pytest-of-busya/pytest-1/test_configure_logger_post_con0

View File

@@ -0,0 +1 @@
/home/busya/dev/ss-tools/.axiom/temp/pytest-of-busya/pytest-1/test_dashboard_validation_plug0

View File

@@ -0,0 +1 @@
_ = Depends(has_permission("plugin:ignored", "READ"))

View File

@@ -0,0 +1,3 @@
_ = Depends(has_permission("plugin:migration", "READ"))
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
_ = Depends(has_permission("tasks", "WRITE"))

View File

@@ -0,0 +1 @@
/home/busya/dev/ss-tools/.axiom/temp/pytest-of-busya/pytest-1/test_discover_route_permission0

View File

@@ -0,0 +1 @@
/home/busya/dev/ss-tools/.axiom/temp/pytest-of-busya/pytest-1/test_save_profile_and_findings0

View File

@@ -0,0 +1 @@
{"artifacts": [{"id": "artifact-1", "path": "backend/dist/package.tar.gz", "sha256": "deadbeef", "size": 1024, "category": "core", "source_uri": "https://repo.intra.company.local/releases/package.tar.gz", "source_host": "repo.intra.company.local"}]}

View File

@@ -0,0 +1 @@
{"candidate_id": "real-candidate-1", "version": "1.0.0", "source_snapshot_ref": "git:release/1", "created_by": "operator", "allowed_hosts": ["repo.intra.company.local"]}

View File

@@ -0,0 +1 @@
/home/busya/dev/ss-tools/.axiom/temp/pytest-of-busya/pytest-1/test_tui_real_mode_bootstrap_i0

View File

@@ -1,3 +1,3 @@
# #region src.api [TYPE Package]
# #region src.api [TYPE Package] [SEMANTICS api, package, init]
# @BRIEF Backend API package root.
# #endregion src.api

View File

@@ -1,4 +1,4 @@
# #region AuthApi [C:3] [TYPE Module] [SEMANTICS api, auth, routes, login, logout]
# #region AuthApi [C:3] [TYPE Module] [SEMANTICS fastapi, auth, api]
#
# @BRIEF Authentication API endpoints.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region ApiRoutesModule [C:3] [TYPE Module] [SEMANTICS routes, lazy-import, module-registry]
# #region ApiRoutesModule [C:3] [TYPE Module] [SEMANTICS api, package, router, lazy, import]
# @BRIEF Provide lazy route module loading to avoid heavyweight imports during tests.
# @LAYER: API
# @RELATION CALLS -> [ApiRoutesGetAttr]

View File

@@ -1,45 +1,31 @@
# [DEF:RoutesTestsConftest:Module]
# @COMPLEXITY: 1
# @PURPOSE: Shared low-fidelity test doubles for API route test modules.
# #region RoutesTestsConftest [TYPE Module] [C:1] [SEMANTICS test, fixture, mock, conftest]
# @BRIEF Shared low-fidelity test doubles for API route test modules.
class FakeQuery:
"""Shared chainable query stub for route tests.
WARNING: filter() is predicate-blind — all ownership and permission filters are
ignored. Tests using FakeQuery cannot verify scoped data access. This is a
known limitation; do not use for permission-sensitive test paths without a
spec-guarded replacement.
"""
def __init__(self, rows):
self._rows = list(rows)
self._seen_predicates = []
def filter(self, *args, **kwargs):
# Predicate-aware bookkeeping only; no predicate evaluation is performed.
self._seen_predicates.append((args, kwargs))
return self
def order_by(self, *args, **kwargs):
return self
def limit(self, limit):
self._rows = self._rows[:limit]
return self
def offset(self, offset):
self._rows = self._rows[offset:]
return self
def first(self):
return self._rows[0] if self._rows else None
def all(self):
return list(self._rows)
def count(self):
return len(self._rows)
# [/DEF:RoutesTestsConftest:Module]
# #endregion RoutesTestsConftest

View File

@@ -1,23 +1,17 @@
import os
os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w="
# [DEF:AssistantApiTests:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, assistant, api
# @PURPOSE: Validate assistant API endpoint logic via direct async handler invocation.
# @RELATION: DEPENDS_ON -> [AssistantApi]
# #region AssistantApiTests [TYPE Module] [C:3] [SEMANTICS tests, assistant, api]
# @BRIEF Validate assistant API endpoint logic via direct async handler invocation.
# @RELATION DEPENDS_ON -> [AssistantApi]
# @INVARIANT: Every test clears assistant in-memory state before execution.
import asyncio
import uuid
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from pydantic import BaseModel
from src.api.routes import assistant as assistant_routes
from src.schemas.auth import User
from src.models.assistant import AssistantMessageRecord
@@ -35,21 +29,14 @@ from src.models.dataset_review import (
SessionPhase,
SessionStatus,
)
# [DEF:_run_async:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# #region _run_async [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests]
def _run_async(coro):
return asyncio.run(coro)
# [/DEF:_run_async:Function]
# [DEF:_FakeTask:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 1
# @PURPOSE: Lightweight task model stub used as return value from _FakeTaskManager.create_task in assistant route tests.
# #endregion _run_async
# #region _FakeTask [TYPE Class] [C:1]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Lightweight task model stub used as return value from _FakeTaskManager.create_task in assistant route tests.
# @INVARIANT: status is a bare string not a TaskStatus enum; callers must not depend on enum semantics.
class _FakeTask:
def __init__(
@@ -69,21 +56,15 @@ class _FakeTask:
self.user_id = user_id
self.started_at = datetime.utcnow()
self.finished_at = datetime.utcnow()
# [/DEF:_FakeTask:Class]
# #endregion _FakeTask
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
# [DEF:_FakeTaskManager:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 2
# @PURPOSE: In-memory task manager stub that records created tasks for route-level assertions.
# #region _FakeTaskManager [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF 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(
@@ -95,55 +76,38 @@ class _FakeTaskManager:
)
self.tasks[task_id] = task
return task
def get_task(self, task_id):
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
]
def get_all_tasks(self):
return list(self.tasks.values())
# [/DEF:_FakeTaskManager:Class]
# [DEF:_FakeConfigManager:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 2
# @PURPOSE: Deterministic config stub providing hardcoded dev/prod environments and minimal settings shape for assistant route tests.
# #endregion _FakeTaskManager
# #region _FakeConfigManager [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Deterministic config stub providing hardcoded dev/prod environments and minimal settings shape for assistant route tests.
# @INVARIANT: get_config() returns anonymous inner classes, not real GlobalSettings; only default_environment_id and llm fields are safe to access.
class _FakeConfigManager:
class _Env:
def __init__(self, id, name):
self.id = id
self.name = name
def get_environments(self):
return [self._Env("dev", "Development"), self._Env("prod", "Production")]
def get_config(self):
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]
# @COMPLEXITY: 1
# @PURPOSE: Build admin principal with spec=User for assistant route authorization tests.
# #endregion _FakeConfigManager
# #region _admin_user [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Build admin principal with spec=User for assistant route authorization tests.
def _admin_user():
user = MagicMock(spec=User)
user.id = "u-admin"
@@ -152,121 +116,82 @@ def _admin_user():
role.name = "Admin"
user.roles = [role]
return user
# [/DEF:_admin_user:Function]
# [DEF:_limited_user:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 1
# @PURPOSE: Build limited user principal with empty roles for assistant route denial tests.
# #endregion _admin_user
# #region _limited_user [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Build limited user principal with empty roles for assistant route denial tests.
def _limited_user():
user = MagicMock(spec=User)
user.id = "u-limited"
user.username = "limited"
user.roles = []
return user
# [/DEF:_limited_user:Function]
# [DEF:_FakeQuery:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 2
# @PURPOSE: Chainable SQLAlchemy-like query stub returning fixed item lists for assistant message persistence paths.
# #endregion _limited_user
# #region _FakeQuery [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Chainable SQLAlchemy-like query stub returning fixed item lists for assistant message persistence paths.
# @INVARIANT: filter() ignores all predicate arguments and returns self; no predicate-based filtering is emulated.
class _FakeQuery:
def __init__(self, items):
self.items = items
def outerjoin(self, *args, **kwargs):
return self
def options(self, *args, **kwargs):
return self
def filter(self, *args, **kwargs):
# @INVARIANT: filter() is predicate-blind; returns all records regardless of user_id scope
return self
def order_by(self, *args, **kwargs):
return self
def limit(self, n):
self.items = self.items[:n]
return self
def offset(self, n):
self.items = self.items[n:]
return self
def first(self):
return self.items[0] if self.items else None
def all(self):
return self.items
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.
# #endregion _FakeQuery
# #region _FakeDb [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Explicit in-memory DB session double limited to assistant message persistence paths.
# @INVARIANT: query() always returns _FakeQuery with intentionally non-evaluated predicates; add/merge stay deterministic and never emulate unrelated SQLAlchemy behavior.
class _FakeDb:
def __init__(self):
self.added = []
self.dataset_sessions = {}
def query(self, model):
if model == AssistantMessageRecord:
return _FakeQuery([])
if model == DatasetReviewSession:
return _FakeQuery(list(self.dataset_sessions.values()))
return _FakeQuery([])
def add(self, obj):
self.added.append(obj)
def commit(self):
pass
def rollback(self):
pass
def merge(self, obj):
return obj
def refresh(self, obj):
pass
# [/DEF:_FakeDb:Class]
# [DEF:_clear_assistant_state:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# #endregion _FakeDb
# #region _clear_assistant_state [TYPE 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:_dataset_review_session:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 1
# @PURPOSE: Build minimal owned dataset-review session fixture for assistant scoped routing tests.
# #endregion _clear_assistant_state
# #region _dataset_review_session [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Build minimal owned dataset-review session fixture for assistant scoped routing tests.
def _dataset_review_session():
session = DatasetReviewSession(
session_id="sess-1",
@@ -362,32 +287,21 @@ def _dataset_review_session():
session.clarification_sessions = []
session.run_contexts = []
return session
# [/DEF:_dataset_review_session:Function]
# [DEF:_await_none:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 1
# @PURPOSE: Async helper returning None for planner fallback tests.
# #endregion _dataset_review_session
# #region _await_none [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Async helper returning None for planner fallback tests.
async def _await_none(*args, **kwargs):
return None
# [/DEF:_await_none:Function]
# [DEF:test_unknown_command_returns_needs_clarification:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @PURPOSE: Unknown command should return clarification state and unknown intent.
# #endregion _await_none
# #region test_unknown_command_returns_needs_clarification [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF 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,
@@ -397,21 +311,15 @@ def test_unknown_command_returns_needs_clarification(monkeypatch):
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.
# #endregion test_unknown_command_returns_needs_clarification
# #region test_capabilities_question_returns_successful_help [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF 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,
@@ -421,32 +329,22 @@ def test_capabilities_question_returns_successful_help(monkeypatch):
db=_FakeDb(),
)
)
assert resp.state == "success"
assert "я могу сделать" in resp.text.lower()
# [/DEF:test_capabilities_question_returns_successful_help:Function]
# [DEF:test_assistant_message_request_accepts_dataset_review_session_binding:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @PURPOSE: Assistant request schema should accept active dataset review session binding for scoped orchestration.
# #endregion test_capabilities_question_returns_successful_help
# #region test_assistant_message_request_accepts_dataset_review_session_binding [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Assistant request schema should accept active dataset review session binding for scoped orchestration.
def test_assistant_message_request_accepts_dataset_review_session_binding():
request = assistant_routes.AssistantMessageRequest(
message="approve mappings",
dataset_review_session_id="sess-1",
)
assert request.dataset_review_session_id == "sess-1"
# [/DEF:test_assistant_message_request_accepts_dataset_review_session_binding:Function]
# [DEF:test_dataset_review_scoped_message_uses_masked_filter_context:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @PURPOSE: Session-scoped assistant context should mask imported-filter raw values before assistant-visible metadata is persisted.
# #endregion test_assistant_message_request_accepts_dataset_review_session_binding
# #region test_dataset_review_scoped_message_uses_masked_filter_context [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Session-scoped assistant context should mask imported-filter raw values before assistant-visible metadata is persisted.
def test_dataset_review_scoped_message_uses_masked_filter_context(monkeypatch):
_clear_assistant_state()
db = _FakeDb()
@@ -456,18 +354,15 @@ def test_dataset_review_scoped_message_uses_masked_filter_context(monkeypatch):
dataset_review_session_id="sess-1",
)
assistant_routes._plan_intent_with_llm = _await_none
async def _fake_dispatch_dataset_review_intent(
intent, current_user, config_manager, db
):
return str(intent["entities"]["summary"]), None, []
monkeypatch.setattr(
assistant_routes,
"_dispatch_dataset_review_intent",
_fake_dispatch_dataset_review_intent,
)
resp = _run_async(
assistant_routes.send_message(
req,
@@ -477,7 +372,6 @@ def test_dataset_review_scoped_message_uses_masked_filter_context(monkeypatch):
db=db,
)
)
assert resp.state == "success"
persisted_assistant = [
item for item in db.added if getattr(item, "role", None) == "assistant"
@@ -487,14 +381,10 @@ def test_dataset_review_scoped_message_uses_masked_filter_context(monkeypatch):
]
assert imported_filters[0]["raw_value"] == "***@example.com"
assert imported_filters[0]["raw_value_masked"] is True
# [/DEF:test_dataset_review_scoped_message_uses_masked_filter_context:Function]
# [DEF:test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @PURPOSE: Session-scoped assistant commands should route dataset-review mapping approvals into confirmation workflow with bound session metadata.
# #endregion test_dataset_review_scoped_message_uses_masked_filter_context
# #region test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Session-scoped assistant commands should route dataset-review mapping approvals into confirmation workflow with bound session metadata.
def test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval():
_clear_assistant_state()
db = _FakeDb()
@@ -504,7 +394,6 @@ def test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval
dataset_review_session_id="sess-1",
)
assistant_routes._plan_intent_with_llm = _await_none
resp = _run_async(
assistant_routes.send_message(
req,
@@ -514,20 +403,15 @@ def test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval
db=db,
)
)
assert resp.state == "needs_confirmation"
assert resp.intent["operation"] == "dataset_review_approve_mappings"
assert resp.intent["entities"]["dataset_review_session_id"] == "sess-1"
assert resp.intent["entities"]["session_version"] == 3
assert resp.intent["entities"]["mapping_ids"] == ["map-1"]
# [/DEF:test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval:Function]
# [DEF:test_dataset_review_scoped_command_routes_field_semantics_update:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @PURPOSE: Session-scoped assistant commands should route semantic field updates through explicit confirmation metadata.
# #endregion test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval
# #region test_dataset_review_scoped_command_routes_field_semantics_update [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests]
# @BRIEF Session-scoped assistant commands should route semantic field updates through explicit confirmation metadata.
def test_dataset_review_scoped_command_routes_field_semantics_update():
_clear_assistant_state()
db = _FakeDb()
@@ -537,7 +421,6 @@ def test_dataset_review_scoped_command_routes_field_semantics_update():
dataset_review_session_id="sess-1",
)
assistant_routes._plan_intent_with_llm = _await_none
resp = _run_async(
assistant_routes.send_message(
req,
@@ -547,16 +430,11 @@ def test_dataset_review_scoped_command_routes_field_semantics_update():
db=db,
)
)
assert resp.state == "needs_confirmation"
assert resp.intent["operation"] == "dataset_review_set_field_semantics"
assert resp.intent["entities"]["dataset_review_session_id"] == "sess-1"
assert resp.intent["entities"]["field_id"] == "field-1"
assert resp.intent["entities"]["description"] == "Approved semantic wording"
assert resp.intent["entities"]["lock_field"] is True
# [/DEF:test_dataset_review_scoped_command_routes_field_semantics_update:Function]
# [/DEF:AssistantApiTests:Module]
# #endregion test_dataset_review_scoped_command_routes_field_semantics_update
# #endregion AssistantApiTests

View File

@@ -1,23 +1,16 @@
import os
os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w="
# [DEF:TestAssistantAuthz:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, assistant, authz, confirmation, rbac
# @PURPOSE: Verify assistant confirmation ownership, expiration, and deny behavior for restricted users.
# #region TestAssistantAuthz [TYPE Module] [C:3] [SEMANTICS tests, assistant, authz, confirmation, rbac]
# @BRIEF Verify assistant confirmation ownership, expiration, and deny behavior for restricted users.
# @LAYER: UI (API Tests)
# @RELATION: DEPENDS_ON -> AssistantApi
# @RELATION DEPENDS_ON -> AssistantApi
# @INVARIANT: Security-sensitive flows fail closed for unauthorized actors.
import os
import asyncio
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
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(
@@ -26,32 +19,23 @@ os.environ.setdefault(
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 (
AssistantAuditRecord,
AssistantConfirmationRecord,
AssistantMessageRecord,
)
# [DEF:_run_async:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Execute async endpoint handler in synchronous test context.
# #region _run_async [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF Execute async endpoint handler in synchronous test context.
# @PRE: coroutine is awaitable endpoint invocation.
# @POST: Returns coroutine result or raises propagated exception.
def _run_async(coroutine):
return asyncio.run(coroutine)
# [/DEF:_run_async:Function]
# [DEF:_FakeTask:Class]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Lightweight task model used for assistant authz tests.
# #endregion _run_async
# #region _FakeTask [TYPE Class] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF Lightweight task model used for assistant authz tests.
# @PRE: task_id is non-empty string.
# @POST: Returns task with provided id, status, and user_id accessible as attributes.
class _FakeTask:
@@ -59,46 +43,36 @@ class _FakeTask:
self.id = task_id
self.status = status
self.user_id = user_id
# [/DEF:_FakeTask:Class]
# #endregion _FakeTask
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
# [DEF:_FakeTaskManager:Class]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 2
# @PURPOSE: In-memory task manager double that records assistant-created tasks deterministically.
# #region _FakeTaskManager [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF 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 = []
async def create_task(self, plugin_id, params, user_id=None):
task_id = f"task-{len(self._created) + 1}"
task = _FakeTask(task_id=task_id, status="RUNNING", user_id=user_id)
self._created.append((plugin_id, params, user_id, task))
return task
def get_task(self, task_id):
for _, _, _, task in self._created:
if task.id == task_id:
return task
return None
def get_tasks(self, limit=20, offset=0):
return [x[3] for x in self._created][offset : offset + limit]
def get_all_tasks(self):
raise NotImplementedError(
"get_all_tasks not implemented in authz FakeTaskManager"
)
# [/DEF:_FakeTaskManager:Class]
# #endregion _FakeTaskManager
# @CONTRACT: Partial ConfigManager stub for authz tests. Missing: get_config().
# [DEF:_FakeConfigManager:Class]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Provide deterministic environment aliases required by intent parsing.
# #region _FakeConfigManager [TYPE Class] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF Provide deterministic environment aliases required by intent parsing.
# @PRE: No external config or DB state is required.
# @POST: get_environments() returns two deterministic SimpleNamespace stubs with id/name.
# @INVARIANT: get_config() is absent; only get_environments() is emulated. Safe only for routes that do not invoke get_config() on the injected ConfigManager — verify against assistant.py route handler code before adding new test cases that use this fake.
@@ -108,98 +82,72 @@ class _FakeConfigManager:
SimpleNamespace(id="dev", name="Development"),
SimpleNamespace(id="prod", name="Production"),
]
def get_config(self):
raise NotImplementedError(
"get_config not implemented in authz fake — add if route under test requires it"
)
# [/DEF:_FakeConfigManager:Class]
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Build admin principal fixture.
# #endregion _FakeConfigManager
# #region _admin_user [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF Build admin principal fixture.
# @PRE: Test requires privileged principal for risky operations.
# @POST: Returns admin-like user stub with Admin role.
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(id="u-admin", username="admin", roles=[role])
# [/DEF:_admin_user:Function]
# [DEF:_other_admin_user:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Build second admin principal fixture for ownership tests.
# #endregion _admin_user
# #region _other_admin_user [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF Build second admin principal fixture for ownership tests.
# @PRE: Ownership mismatch scenario needs distinct authenticated actor.
# @POST: Returns alternate admin-like user stub.
def _other_admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(id="u-admin-2", username="admin2", roles=[role])
# [/DEF:_other_admin_user:Function]
# [DEF:_limited_user:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Build limited principal without required assistant execution privileges.
# #endregion _other_admin_user
# #region _limited_user [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF Build limited principal without required assistant execution privileges.
# @PRE: Permission denial scenario needs non-admin actor.
# @POST: Returns restricted user stub.
def _limited_user():
role = SimpleNamespace(name="Operator", permissions=[])
return SimpleNamespace(id="u-limited", username="limited", roles=[role])
# [/DEF:_limited_user:Function]
# [DEF:_FakeQuery:Class]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Minimal chainable query object for fake DB interactions.
# #endregion _limited_user
# #region _FakeQuery [TYPE Class] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF Minimal chainable query object for fake DB interactions.
# @INVARIANT: filter() deliberately discards predicate args and returns self; tests must not assume predicate evaluation.
class _FakeQuery:
def __init__(self, rows):
self._rows = list(rows)
def filter(self, *args, **kwargs):
# @INVARIANT: filter() is predicate-blind; returns all records regardless of user_id scope
return self
def order_by(self, *args, **kwargs):
return self
def first(self):
return self._rows[0] if self._rows else None
def all(self):
return list(self._rows)
def limit(self, limit):
self._rows = self._rows[:limit]
return self
def offset(self, offset):
self._rows = self._rows[offset:]
return self
def count(self):
return len(self._rows)
# [/DEF:_FakeQuery:Class]
# [DEF:_FakeDb:Class]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 2
# @PURPOSE: In-memory DB session double constrained to assistant message/confirmation/audit persistence paths.
# #endregion _FakeQuery
# #region _FakeDb [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF 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 = []
self._confirmations = []
self._audit = []
def add(self, row):
table = getattr(row, "__tablename__", "")
if table == "assistant_messages":
@@ -208,19 +156,16 @@ class _FakeDb:
self._confirmations.append(row)
elif table == "assistant_audit":
self._audit.append(row)
def merge(self, row):
if getattr(row, "__tablename__", "") != "assistant_confirmations":
self.add(row)
return row
for i, existing in enumerate(self._confirmations):
if getattr(existing, "id", None) == getattr(row, "id", None):
self._confirmations[i] = row
return row
self._confirmations.append(row)
return row
def query(self, model):
if model is AssistantMessageRecord:
return _FakeQuery(self._messages)
@@ -229,19 +174,14 @@ class _FakeDb:
if model is AssistantAuditRecord:
return _FakeQuery(self._audit)
return _FakeQuery([])
def commit(self):
return None
def rollback(self):
return None
# [/DEF:_FakeDb:Class]
# [DEF:_clear_assistant_state:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Reset assistant process-local state between test cases.
# #endregion _FakeDb
# #region _clear_assistant_state [TYPE Function] [C:1]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF Reset assistant process-local state between test cases.
# @PRE: Assistant globals may contain state from prior tests.
# @POST: Assistant in-memory state dictionaries are cleared.
def _clear_assistant_state():
@@ -249,21 +189,16 @@ def _clear_assistant_state():
assistant_module.USER_ACTIVE_CONVERSATION.clear()
assistant_module.CONFIRMATIONS.clear()
assistant_module.ASSISTANT_AUDIT.clear()
# [/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.
# #endregion _clear_assistant_state
# #region test_confirmation_owner_mismatch_returns_403 [TYPE Function]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF 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.
def test_confirmation_owner_mismatch_returns_403():
_clear_assistant_state()
task_manager = _FakeTaskManager()
db = _FakeDb()
create = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
@@ -276,7 +211,6 @@ def test_confirmation_owner_mismatch_returns_403():
)
)
assert create.state == "needs_confirmation"
with pytest.raises(HTTPException) as exc:
_run_async(
assistant_module.confirm_operation(
@@ -288,21 +222,16 @@ def test_confirmation_owner_mismatch_returns_403():
)
)
assert exc.value.status_code == 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.
# #endregion test_confirmation_owner_mismatch_returns_403
# #region test_expired_confirmation_cannot_be_confirmed [TYPE Function]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF 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.
def test_expired_confirmation_cannot_be_confirmed():
_clear_assistant_state()
task_manager = _FakeTaskManager()
db = _FakeDb()
create = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
@@ -317,7 +246,6 @@ def test_expired_confirmation_cannot_be_confirmed():
assistant_module.CONFIRMATIONS[create.confirmation_id].expires_at = (
datetime.utcnow() - timedelta(minutes=1)
)
with pytest.raises(HTTPException) as exc:
_run_async(
assistant_module.confirm_operation(
@@ -330,14 +258,10 @@ def test_expired_confirmation_cannot_be_confirmed():
)
assert exc.value.status_code == 400
assert task_manager.get_tasks(limit=10, offset=0) == []
# [/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.
# #endregion test_expired_confirmation_cannot_be_confirmed
# #region test_limited_user_cannot_launch_restricted_operation [TYPE Function]
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# @BRIEF 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.
def test_limited_user_cannot_launch_restricted_operation():
@@ -354,7 +278,5 @@ def test_limited_user_cannot_launch_restricted_operation():
)
)
assert response.state == "denied"
# [/DEF:test_limited_user_cannot_launch_restricted_operation:Function]
# [/DEF:TestAssistantAuthz:Module]
# #endregion test_limited_user_cannot_launch_restricted_operation
# #endregion TestAssistantAuthz

View File

@@ -1,15 +1,10 @@
# [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.
# #region TestCleanReleaseApi [TYPE Module] [C:3] [SEMANTICS tests, api, clean-release, checks, reports]
# @RELATION BELONGS_TO -> SrcRoot
# @BRIEF Contract tests for clean release checks and reports endpoints.
# @LAYER: Domain
# @INVARIANT: API returns deterministic payload shapes for checks and reports.
from datetime import datetime, timezone
from fastapi.testclient import TestClient
from src.app import app
from src.dependencies import get_clean_release_repository
from src.models.clean_release import (
@@ -23,10 +18,8 @@ from src.models.clean_release import (
CheckFinalStatus,
)
from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:_repo_with_seed_data:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
# #region _repo_with_seed_data [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseApi
def _repo_with_seed_data() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
repo.save_candidate(
@@ -72,20 +65,15 @@ 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
# @PURPOSE: Validate checks start endpoint returns expected identifiers and status endpoint reflects the same run.
# #endregion _repo_with_seed_data
# #region test_start_check_and_get_status_contract [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseApi
# @BRIEF Validate checks start endpoint returns expected identifiers and status endpoint reflects the same run.
def test_start_check_and_get_status_contract():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
try:
client = TestClient(app)
start = client.post(
"/api/clean-release/checks",
json={
@@ -100,7 +88,6 @@ def test_start_check_and_get_status_contract():
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}")
assert status_resp.status_code == 200
@@ -110,14 +97,10 @@ def test_start_check_and_get_status_contract():
assert "checks" in status_payload
finally:
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
# @PURPOSE: Validate reports endpoint returns 404 for an unknown report identifier.
# #endregion test_start_check_and_get_status_contract
# #region test_get_report_not_found_returns_404 [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseApi
# @BRIEF Validate reports endpoint returns 404 for an unknown report identifier.
def test_get_report_not_found_returns_404():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -127,14 +110,10 @@ def test_get_report_not_found_returns_404():
assert resp.status_code == 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
# @PURPOSE: Validate reports endpoint returns persisted report payload for an existing report identifier.
# #endregion test_get_report_not_found_returns_404
# #region test_get_report_success [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseApi
# @BRIEF Validate reports endpoint returns persisted report payload for an existing report identifier.
def test_get_report_success():
repo = _repo_with_seed_data()
report = ComplianceReport(
@@ -157,14 +136,10 @@ def test_get_report_success():
assert resp.json()["report_id"] == "rep-1"
finally:
app.dependency_overrides.clear()
# [/DEF:test_get_report_success:Function]
# [DEF:test_prepare_candidate_api_success:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
# @PURPOSE: Validate candidate preparation endpoint returns prepared status and manifest identifier on valid input.
# #endregion test_get_report_success
# #region test_prepare_candidate_api_success [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseApi
# @BRIEF Validate candidate preparation endpoint returns prepared status and manifest identifier on valid input.
def test_prepare_candidate_api_success():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -187,7 +162,5 @@ def test_prepare_candidate_api_success():
assert "manifest_id" in data
finally:
app.dependency_overrides.clear()
# [/DEF:test_prepare_candidate_api_success:Function]
# [/DEF:TestCleanReleaseApi:Module]
# #endregion test_prepare_candidate_api_success
# #endregion TestCleanReleaseApi

View File

@@ -1,21 +1,15 @@
# [DEF:TestCleanReleaseLegacyCompat:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @PURPOSE: Compatibility tests for legacy clean-release API paths retained during v2 migration.
# #region TestCleanReleaseLegacyCompat [TYPE Module] [C:3] [SEMANTICS test, clean-release, legacy, compat]
# @RELATION BELONGS_TO -> SrcRoot
# @BRIEF Compatibility tests for legacy clean-release API paths retained during v2 migration.
# @LAYER: Tests
from __future__ import annotations
import os
from datetime import datetime, timezone
from fastapi.testclient import TestClient
os.environ.setdefault("DATABASE_URL", "sqlite:///./test_clean_release_legacy_compat.db")
os.environ.setdefault(
"AUTH_DATABASE_URL", "sqlite:///./test_clean_release_legacy_auth.db"
)
from src.app import app
from src.dependencies import get_clean_release_repository
from src.models.clean_release import (
@@ -28,17 +22,14 @@ from src.models.clean_release import (
ResourceSourceRegistry,
)
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.
# #region _seed_legacy_repo [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseLegacyCompat
# @BRIEF 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.
def _seed_legacy_repo() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
now = datetime.now(timezone.utc)
repo.save_candidate(
ReleaseCandidate(
id="legacy-rc-001",
@@ -49,7 +40,6 @@ def _seed_legacy_repo() -> CleanReleaseRepository:
status=ReleaseCandidateStatus.DRAFT,
)
)
registry = ResourceSourceRegistry(
registry_id="legacy-reg-1",
name="Legacy Internal Registry",
@@ -71,7 +61,6 @@ def _seed_legacy_repo() -> CleanReleaseRepository:
setattr(registry, "allowed_schemes", ["https"])
setattr(registry, "allowed_source_types", ["artifact-repo"])
repo.save_registry(registry)
policy = CleanProfilePolicy(
policy_id="legacy-pol-1",
policy_version="1.0.0",
@@ -94,7 +83,6 @@ def _seed_legacy_repo() -> CleanReleaseRepository:
},
)
repo.save_policy(policy)
repo.save_manifest(
DistributionManifest(
id="legacy-manifest-1",
@@ -112,16 +100,11 @@ def _seed_legacy_repo() -> CleanReleaseRepository:
immutable=True,
)
)
return repo
# [/DEF:_seed_legacy_repo:Function]
# [DEF:test_legacy_prepare_endpoint_still_available:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseLegacyCompat
# @PURPOSE: Verify legacy prepare endpoint remains reachable and returns a status payload.
# #endregion _seed_legacy_repo
# #region test_legacy_prepare_endpoint_still_available [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseLegacyCompat
# @BRIEF Verify legacy prepare endpoint remains reachable and returns a status payload.
def test_legacy_prepare_endpoint_still_available() -> None:
repo = _seed_legacy_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -144,14 +127,10 @@ def test_legacy_prepare_endpoint_still_available() -> None:
assert payload["status"] in {"prepared", "blocked", "PREPARED", "BLOCKED"}
finally:
app.dependency_overrides.clear()
# [/DEF:test_legacy_prepare_endpoint_still_available:Function]
# [DEF:test_legacy_checks_endpoints_still_available:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseLegacyCompat
# @PURPOSE: Verify legacy checks start/status endpoints remain available during v2 transition.
# #endregion test_legacy_prepare_endpoint_still_available
# #region test_legacy_checks_endpoints_still_available [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseLegacyCompat
# @BRIEF Verify legacy checks start/status endpoints remain available during v2 transition.
def test_legacy_checks_endpoints_still_available() -> None:
repo = _seed_legacy_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -170,7 +149,6 @@ def test_legacy_checks_endpoints_still_available() -> None:
start_payload = start_response.json()
assert "check_run_id" in start_payload
assert start_payload["candidate_id"] == "legacy-rc-001"
status_response = client.get(
f"/api/clean-release/checks/{start_payload['check_run_id']}"
)
@@ -181,7 +159,5 @@ def test_legacy_checks_endpoints_still_available() -> None:
assert "checks" in status_payload
finally:
app.dependency_overrides.clear()
# [/DEF:test_legacy_checks_endpoints_still_available:Function]
# [/DEF:TestCleanReleaseLegacyCompat:Module]
# #endregion test_legacy_checks_endpoints_still_available
# #endregion TestCleanReleaseLegacyCompat

View File

@@ -1,14 +1,10 @@
# [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.
# #region TestCleanReleaseSourcePolicy [TYPE Module] [C:3] [SEMANTICS tests, api, clean-release, source-policy]
# @RELATION BELONGS_TO -> SrcRoot
# @BRIEF Validate API behavior for source isolation violations in clean release preparation.
# @LAYER: Domain
# @INVARIANT: External endpoints must produce blocking violation entries.
from datetime import datetime, timezone
from fastapi.testclient import TestClient
from src.app import app
from src.dependencies import get_clean_release_repository
from src.models.clean_release import (
@@ -20,14 +16,11 @@ from src.models.clean_release import (
ResourceSourceRegistry,
)
from src.services.clean_release.repository import CleanReleaseRepository
# [DEF:_repo_with_seed_data:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseSourcePolicy
# @PURPOSE: Seed repository with candidate, registry, and active policy for source isolation test flow.
# #region _repo_with_seed_data [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseSourcePolicy
# @BRIEF Seed repository with candidate, registry, and active policy for source isolation test flow.
def _repo_with_seed_data() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
repo.save_candidate(
ReleaseCandidate(
candidate_id="2026.03.03-rc1",
@@ -39,7 +32,6 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
status=ReleaseCandidateStatus.DRAFT,
)
)
repo.save_registry(
ResourceSourceRegistry(
registry_id="registry-internal-v1",
@@ -58,7 +50,6 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
status="active",
)
)
repo.save_policy(
CleanProfilePolicy(
policy_id="policy-enterprise-clean-v1",
@@ -73,18 +64,13 @@ 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
# @PURPOSE: Verify candidate preparation is blocked when at least one source host is external to the trusted registry.
# #endregion _repo_with_seed_data
# #region test_prepare_candidate_blocks_external_source [TYPE Function]
# @RELATION BINDS_TO -> TestCleanReleaseSourcePolicy
# @BRIEF Verify candidate preparation is blocked when at least one source host is external to the trusted registry.
def test_prepare_candidate_blocks_external_source():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
try:
client = TestClient(app)
response = client.post(
@@ -108,7 +94,5 @@ def test_prepare_candidate_blocks_external_source():
assert any(v["category"] == "external-source" for v in data["violations"])
finally:
app.dependency_overrides.clear()
# [/DEF:test_prepare_candidate_blocks_external_source:Function]
# [/DEF:TestCleanReleaseSourcePolicy:Module]
# #endregion test_prepare_candidate_blocks_external_source
# #endregion TestCleanReleaseSourcePolicy

View File

@@ -1,16 +1,12 @@
# [DEF:CleanReleaseV2ApiTests:Module]
# @COMPLEXITY: 3
# @PURPOSE: API contract tests for redesigned clean release endpoints.
# #region CleanReleaseV2ApiTests [TYPE Module] [C:3] [SEMANTICS test, clean-release, v2, api, contract]
# @BRIEF API contract tests for redesigned clean release endpoints.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> [CleanReleaseV2Api]
# @RELATION DEPENDS_ON -> [CleanReleaseV2Api]
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from src.app import app
from src.dependencies import get_clean_release_repository, get_config_manager
from src.models.clean_release import (
@@ -20,14 +16,11 @@ from src.models.clean_release import (
SourceRegistrySnapshot,
)
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
# @PURPOSE: Validate candidate registration endpoint creates a draft candidate with expected identifier contract.
# #region test_candidate_registration_contract [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ApiTests
# @BRIEF Validate candidate registration endpoint creates a draft candidate with expected identifier contract.
def test_candidate_registration_contract():
"""
@TEST_SCENARIO: candidate_registration -> Should return 201 and candidate DTO.
@@ -44,14 +37,10 @@ def test_candidate_registration_contract():
data = response.json()
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
# @PURPOSE: Validate artifact import endpoint accepts candidate artifacts and returns success status payload.
# #endregion test_candidate_registration_contract
# #region test_artifact_import_contract [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ApiTests
# @BRIEF Validate artifact import endpoint accepts candidate artifacts and returns success status payload.
def test_artifact_import_contract():
"""
@TEST_SCENARIO: artifact_import -> Should return 200 and success status.
@@ -68,7 +57,6 @@ def test_artifact_import_contract():
"/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}
@@ -79,14 +67,10 @@ def test_artifact_import_contract():
)
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
# @PURPOSE: Validate manifest build endpoint produces manifest payload linked to the target candidate.
# #endregion test_artifact_import_contract
# #region test_manifest_build_contract [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ApiTests
# @BRIEF Validate manifest build endpoint produces manifest payload linked to the target candidate.
def test_manifest_build_contract():
"""
@TEST_SCENARIO: manifest_build -> Should return 201 and manifest DTO.
@@ -103,13 +87,10 @@ def test_manifest_build_contract():
"/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")
assert response.status_code == 201
data = response.json()
assert "manifest_digest" in data
assert data["candidate_id"] == candidate_id
# [/DEF:test_manifest_build_contract:Function]
# [/DEF:CleanReleaseV2ApiTests:Module]
# #endregion test_manifest_build_contract
# #endregion CleanReleaseV2ApiTests

View File

@@ -1,36 +1,26 @@
# [DEF:CleanReleaseV2ReleaseApiTests:Module]
# @COMPLEXITY: 3
# @PURPOSE: API contract test scaffolding for clean release approval and publication endpoints.
# #region CleanReleaseV2ReleaseApiTests [TYPE Module] [C:3] [SEMANTICS test, clean-release, release, approval, publication]
# @BRIEF API contract test scaffolding for clean release approval and publication endpoints.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> [CleanReleaseV2Api]
# @RELATION DEPENDS_ON -> [CleanReleaseV2Api]
"""Contract tests for redesigned approval/publication API endpoints."""
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.routes.clean_release_v2 import router as clean_release_v2_router
from src.dependencies import get_clean_release_repository
from src.models.clean_release import ComplianceReport, ReleaseCandidate
from src.services.clean_release.enums import CandidateStatus, ComplianceDecision
test_app = FastAPI()
test_app.include_router(clean_release_v2_router)
client = TestClient(test_app)
# [DEF:_seed_candidate_and_passed_report:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ReleaseApiTests
# @PURPOSE: Seed repository with approvable candidate and passed report for release endpoint contracts.
# #region _seed_candidate_and_passed_report [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ReleaseApiTests
# @BRIEF Seed repository with approvable candidate and passed report for release endpoint contracts.
def _seed_candidate_and_passed_report() -> tuple[str, str]:
repository = get_clean_release_repository()
candidate_id = f"api-release-candidate-{uuid4()}"
report_id = f"api-release-report-{uuid4()}"
repository.save_candidate(
ReleaseCandidate(
id=candidate_id,
@@ -57,18 +47,13 @@ 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
# @PURPOSE: Verify approve, publish, and revoke endpoints preserve expected release lifecycle contract.
# #endregion _seed_candidate_and_passed_report
# #region test_release_approve_and_publish_revoke_contract [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ReleaseApiTests
# @BRIEF Verify approve, publish, and revoke endpoints preserve expected release lifecycle contract.
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()
approve_response = client.post(
f"/api/v2/clean-release/candidates/{candidate_id}/approve",
json={"report_id": report_id, "decided_by": "api-test", "comment": "approved"},
@@ -77,7 +62,6 @@ def test_release_approve_and_publish_revoke_contract() -> None:
approve_payload = approve_response.json()
assert approve_payload["status"] == "ok"
assert approve_payload["decision"] == "APPROVED"
publish_response = client.post(
f"/api/v2/clean-release/candidates/{candidate_id}/publish",
json={
@@ -91,7 +75,6 @@ def test_release_approve_and_publish_revoke_contract() -> None:
publish_payload = publish_response.json()
assert publish_payload["status"] == "ok"
assert publish_payload["publication"]["status"] == "ACTIVE"
publication_id = publish_payload["publication"]["id"]
revoke_response = client.post(
f"/api/v2/clean-release/publications/{publication_id}/revoke",
@@ -101,18 +84,13 @@ def test_release_approve_and_publish_revoke_contract() -> None:
revoke_payload = revoke_response.json()
assert revoke_payload["status"] == "ok"
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
# @PURPOSE: Verify reject endpoint returns successful rejection decision payload.
# #endregion test_release_approve_and_publish_revoke_contract
# #region test_release_reject_contract [TYPE Function]
# @RELATION BINDS_TO -> CleanReleaseV2ReleaseApiTests
# @BRIEF Verify reject endpoint returns successful rejection decision payload.
def test_release_reject_contract() -> None:
"""Contract for reject endpoint."""
candidate_id, report_id = _seed_candidate_and_passed_report()
reject_response = client.post(
f"/api/v2/clean-release/candidates/{candidate_id}/reject",
json={"report_id": report_id, "decided_by": "api-test", "comment": "rejected"},
@@ -121,7 +99,5 @@ def test_release_reject_contract() -> None:
payload = reject_response.json()
assert payload["status"] == "ok"
assert payload["decision"] == "REJECTED"
# [/DEF:test_release_reject_contract:Function]
# [/DEF:CleanReleaseV2ReleaseApiTests:Module]
# #endregion test_release_reject_contract
# #endregion CleanReleaseV2ReleaseApiTests

View File

@@ -1,19 +1,15 @@
# [DEF:ConnectionsRoutesTests:Module]
# @COMPLEXITY: 3
# @PURPOSE: Verifies connection routes bootstrap their table before CRUD access.
# #region ConnectionsRoutesTests [TYPE Module] [C:3] [SEMANTICS test, connection, route, crud, bootstrap]
# @BRIEF Verifies connection routes bootstrap their table before CRUD access.
# @LAYER: API
# @RELATION: DEPENDS_ON -> ConnectionsRouter
# @RELATION DEPENDS_ON -> ConnectionsRouter
import os
import sys
import asyncio
from pathlib import Path
import pytest
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
# Force SQLite in-memory for database module imports.
# @SIDE_EFFECT_WARNING: os.environ mutation at module import time — no teardown. This bleeds into all subsequently collected tests. Migrate to pytest.fixture(autouse=True) with monkeypatch.setenv.
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
@@ -23,12 +19,9 @@ os.environ["TASKS_DATABASE_URL"] = "sqlite:///:memory:"
os.environ["AUTH_DATABASE_URL"] = "sqlite:///:memory:"
# @SIDE_EFFECT_WARNING: os.environ mutation at module import time — no teardown. This bleeds into all subsequently collected tests. Migrate to pytest.fixture(autouse=True) with monkeypatch.setenv.
os.environ["ENVIRONMENT"] = "testing"
backend_dir = str(Path(__file__).parent.parent.parent.parent.resolve())
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
@pytest.fixture
def db_session():
engine = create_engine(
@@ -41,30 +34,21 @@ def db_session():
yield session
finally:
session.close()
# [DEF:test_list_connections_bootstraps_missing_table:Function]
# @RELATION: BINDS_TO -> ConnectionsRoutesTests
# @PURPOSE: Ensure listing connections auto-creates missing table and returns empty payload.
# #region test_list_connections_bootstraps_missing_table [TYPE Function]
# @RELATION BINDS_TO -> ConnectionsRoutesTests
# @BRIEF Ensure listing connections auto-creates missing table and returns empty payload.
def test_list_connections_bootstraps_missing_table(db_session):
from src.api.routes.connections import list_connections
result = asyncio.run(list_connections(db=db_session))
inspector = inspect(db_session.get_bind())
assert result == []
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
# @PURPOSE: Ensure connection creation bootstraps table and persists returned connection fields.
# #endregion test_list_connections_bootstraps_missing_table
# #region test_create_connection_bootstraps_missing_table [TYPE Function]
# @RELATION BINDS_TO -> ConnectionsRoutesTests
# @BRIEF Ensure connection creation bootstraps table and persists returned connection fields.
def test_create_connection_bootstraps_missing_table(db_session):
from src.api.routes.connections import ConnectionCreate, create_connection
payload = ConnectionCreate(
name="Analytics Warehouse",
type="postgres",
@@ -74,14 +58,10 @@ def test_create_connection_bootstraps_missing_table(db_session):
username="reporter",
password="secret",
)
created = asyncio.run(create_connection(connection=payload, db=db_session))
inspector = inspect(db_session.get_bind())
assert created.name == "Analytics Warehouse"
assert created.host == "warehouse.internal"
assert "connection_configs" in inspector.get_table_names()
# [/DEF:test_create_connection_bootstraps_missing_table:Function]
# [/DEF:ConnectionsRoutesTests:Module]
# #endregion test_create_connection_bootstraps_missing_table
# #endregion ConnectionsRoutesTests

View File

@@ -1,9 +1,7 @@
# [DEF:DashboardsApiTests:Module]
# @COMPLEXITY: 3
# @PURPOSE: Unit tests for dashboards API endpoints.
# #region DashboardsApiTests [TYPE Module] [C:3] [SEMANTICS test, dashboard, api, listing, migration]
# @BRIEF Unit tests for dashboards API endpoints.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [DashboardsApi]
# @RELATION DEPENDS_ON -> [DashboardsApi]
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from datetime import datetime, timezone
@@ -20,7 +18,6 @@ from src.dependencies import (
)
from src.core.database import get_db
from src.services.profile_service import ProfileService as DomainProfileService
# Global mock user for get_current_user dependency overrides
mock_user = MagicMock()
mock_user.id = "u-1"
@@ -29,24 +26,19 @@ mock_user.roles = []
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
app.dependency_overrides[get_task_manager] = lambda: task_manager
app.dependency_overrides[get_resource_service] = lambda: resource_service
app.dependency_overrides[get_mapping_service] = lambda: mapping_service
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[has_permission("plugin:migration", "READ")] = (
lambda: mock_user
)
@@ -57,7 +49,6 @@ def mock_deps():
lambda: mock_user
)
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: mock_user
yield {
"config": config_manager,
"task": task_manager,
@@ -66,14 +57,10 @@ def mock_deps():
"db": db,
}
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.
# #region test_get_dashboards_success [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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
# @POST: Response matches DashboardsResponse schema
@@ -83,7 +70,6 @@ def test_get_dashboards_success(mock_deps):
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
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=[
@@ -96,9 +82,7 @@ def test_get_dashboards_success(mock_deps):
}
]
)
response = client.get("/api/dashboards?env_id=prod")
assert response.status_code == 200
data = response.json()
# exhaustive @POST assertions
@@ -108,14 +92,10 @@ def test_get_dashboards_success(mock_deps):
assert data["total"] == 1
assert "page" in data
DashboardsResponse(**data)
# [/DEF:test_get_dashboards_success:Function]
# [DEF:test_get_dashboards_with_search:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing applies the search filter and returns only matching rows.
# #endregion test_get_dashboards_success
# #region test_get_dashboards_with_search [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF Validate dashboards listing applies the search filter and returns only matching rows.
# @TEST: GET /api/dashboards filters by search term
# @PRE: search parameter provided
# @POST: Only matching dashboards returned
@@ -124,7 +104,6 @@ def test_get_dashboards_with_search(mock_deps):
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
async def mock_get_dashboards(env, tasks, include_git_status=False):
return [
{
@@ -142,26 +121,19 @@ def test_get_dashboards_with_search(mock_deps):
"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
assert len(data["dashboards"]) == 1
assert data["dashboards"][0]["title"] == "Sales Report"
# [/DEF:test_get_dashboards_with_search:Function]
# [DEF:test_get_dashboards_empty:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing returns an empty payload for an environment without dashboards.
# #endregion test_get_dashboards_with_search
# #region test_get_dashboards_empty [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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):
"""@TEST_EDGE: empty_dashboards -> {env_id: 'empty_env', expected_total: 0}"""
@@ -170,7 +142,6 @@ def test_get_dashboards_empty(mock_deps):
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=[])
response = client.get("/api/dashboards?env_id=empty_env")
assert response.status_code == 200
data = response.json()
@@ -178,14 +149,10 @@ 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.
# #endregion test_get_dashboards_empty
# #region test_get_dashboards_superset_failure [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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):
"""@TEST_EDGE: external_superset_failure -> {env_id: 'bad_conn', status: 503}"""
@@ -196,35 +163,25 @@ def test_get_dashboards_superset_failure(mock_deps):
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
side_effect=Exception("Connection refused")
)
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.
# #endregion test_get_dashboards_superset_failure
# #region test_get_dashboards_env_not_found [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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
# @POST: Returns 404 error
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"]
# [/DEF:test_get_dashboards_env_not_found:Function]
# [DEF:test_get_dashboards_invalid_pagination:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing rejects invalid pagination parameters with 400 responses.
# #endregion test_get_dashboards_env_not_found
# #region test_get_dashboards_invalid_pagination [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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
# @POST: Returns 400 error
@@ -236,26 +193,20 @@ 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.
# #endregion test_get_dashboards_invalid_pagination
# #region test_get_dashboard_detail_success [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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):
with patch("src.api.routes.dashboards._detail_routes.SupersetClient") as mock_client_cls:
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_client = MagicMock()
mock_client.get_dashboard_detail.return_value = {
"id": 42,
@@ -289,40 +240,28 @@ def test_get_dashboard_detail_success(mock_deps):
"dataset_count": 1,
}
mock_client_cls.return_value = mock_client
response = client.get("/api/dashboards/42?env_id=prod")
assert response.status_code == 200
payload = response.json()
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.
# #endregion test_get_dashboard_detail_success
# #region test_get_dashboard_detail_env_not_found [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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
# #endregion test_get_dashboard_detail_env_not_found
# #region test_migrate_dashboards_success [TYPE 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.
# @BRIEF Validate dashboard migration request creates an async task and returns its identifier.
# @POST: Returns task_id and create_task was called
def test_migrate_dashboards_success(mock_deps):
mock_source = MagicMock()
@@ -330,11 +269,9 @@ def test_migrate_dashboards_success(mock_deps):
mock_target = MagicMock()
mock_target.id = "target"
mock_deps["config"].get_environments.return_value = [mock_source, mock_target]
mock_task = MagicMock()
mock_task.id = "task-migrate-123"
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
response = client.post(
"/api/dashboards/migrate",
json={
@@ -344,22 +281,17 @@ def test_migrate_dashboards_success(mock_deps):
"db_mappings": {"old_db": "new_db"},
},
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once()
# [/DEF:test_migrate_dashboards_success:Function]
# [DEF:test_migrate_dashboards_no_ids:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# #endregion test_migrate_dashboards_success
# #region test_migrate_dashboards_no_ids [TYPE 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.
# @BRIEF Validate dashboard migration rejects empty dashboard identifier lists.
# @POST: Returns 400 error
def test_migrate_dashboards_no_ids(mock_deps):
response = client.post(
@@ -370,17 +302,12 @@ def test_migrate_dashboards_no_ids(mock_deps):
"dashboard_ids": [],
},
)
assert response.status_code == 400
assert "At least one dashboard ID must be provided" in response.json()["detail"]
# [/DEF:test_migrate_dashboards_no_ids:Function]
# [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.
# #endregion test_migrate_dashboards_no_ids
# #region test_migrate_dashboards_env_not_found [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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):
"""@PRE: source_env_id and target_env_id are valid environment IDs."""
@@ -391,44 +318,33 @@ def test_migrate_dashboards_env_not_found(mock_deps):
)
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
# #endregion test_migrate_dashboards_env_not_found
# #region test_backup_dashboards_success [TYPE 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.
# @BRIEF Validate dashboard backup request creates an async backup task and returns its identifier.
# @POST: Returns task_id and create_task was called
def test_backup_dashboards_success(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_task = MagicMock()
mock_task.id = "task-backup-456"
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
response = client.post(
"/api/dashboards/backup",
json={"env_id": "prod", "dashboard_ids": [1, 2, 3], "schedule": "0 0 * * *"},
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once()
# [/DEF:test_backup_dashboards_success:Function]
# [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.
# #endregion test_backup_dashboards_success
# #region test_backup_dashboards_env_not_found [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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."""
@@ -438,16 +354,12 @@ def test_backup_dashboards_env_not_found(mock_deps):
)
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
# #endregion test_backup_dashboards_env_not_found
# #region test_get_database_mappings_success [TYPE 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.
# @BRIEF Validate database mapping suggestions are returned for valid source and target environments.
# @POST: Returns list of database mappings
def test_get_database_mappings_success(mock_deps):
mock_source = MagicMock()
@@ -455,7 +367,6 @@ def test_get_database_mappings_success(mock_deps):
mock_target = MagicMock()
mock_target.id = "staging"
mock_deps["config"].get_environments.return_value = [mock_source, mock_target]
mock_deps["mapping"].get_suggestions = AsyncMock(
return_value=[
{
@@ -467,24 +378,18 @@ def test_get_database_mappings_success(mock_deps):
}
]
)
response = client.get(
"/api/dashboards/db-mappings?source_env_id=prod&target_env_id=staging"
)
assert response.status_code == 200
data = response.json()
assert "mappings" in data
assert len(data["mappings"]) == 1
assert data["mappings"][0]["confidence"] == 0.95
# [/DEF:test_get_database_mappings_success:Function]
# [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.
# #endregion test_get_database_mappings_success
# #region test_get_database_mappings_env_not_found [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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."""
@@ -493,18 +398,13 @@ def test_get_database_mappings_env_not_found(mock_deps):
"/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.
# #endregion test_get_database_mappings_env_not_found
# #region test_get_dashboard_tasks_history_filters_success [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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):
now = datetime.now(timezone.utc)
llm_task = MagicMock()
llm_task.id = "task-llm-1"
llm_task.plugin_id = "llm_dashboard_validation"
@@ -513,7 +413,6 @@ def test_get_dashboard_tasks_history_filters_success(mock_deps):
llm_task.finished_at = now
llm_task.params = {"dashboard_id": "42", "environment_id": "prod"}
llm_task.result = {"summary": "LLM validation complete"}
backup_task = MagicMock()
backup_task.id = "task-backup-1"
backup_task.plugin_id = "superset-backup"
@@ -522,7 +421,6 @@ def test_get_dashboard_tasks_history_filters_success(mock_deps):
backup_task.finished_at = None
backup_task.params = {"env": "prod", "dashboards": [42]}
backup_task.result = {}
other_task = MagicMock()
other_task.id = "task-other"
other_task.plugin_id = "superset-backup"
@@ -531,11 +429,8 @@ def test_get_dashboard_tasks_history_filters_success(mock_deps):
other_task.finished_at = now
other_task.params = {"env": "prod", "dashboards": [777]}
other_task.result = {}
mock_deps["task"].get_all_tasks.return_value = [other_task, llm_task, backup_task]
response = client.get("/api/dashboards/42/tasks?env_id=prod&limit=10")
assert response.status_code == 200
data = response.json()
assert data["dashboard_id"] == 42
@@ -544,48 +439,35 @@ def test_get_dashboard_tasks_history_filters_success(mock_deps):
"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.
# #endregion test_get_dashboard_tasks_history_filters_success
# #region test_get_dashboard_thumbnail_success [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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):
with patch("src.api.routes.dashboards._detail_routes.SupersetClient") as mock_client_cls:
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b"fake-image-bytes"
mock_response.headers = {"Content-Type": "image/png"}
def _network_request(method, endpoint, **kwargs):
if method == "POST":
return {"image_url": "/api/v1/dashboard/42/screenshot/abc123/"}
return mock_response
mock_client.network.request.side_effect = _network_request
mock_client_cls.return_value = mock_client
response = client.get("/api/dashboards/42/thumbnail?env_id=prod")
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.
# #endregion test_get_dashboard_thumbnail_success
# #region _build_profile_preference_stub [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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):
@@ -595,44 +477,33 @@ def _build_profile_preference_stub(username: str, enabled: bool):
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.
# #endregion _build_profile_preference_stub
# #region _matches_actor_case_insensitive [TYPE Function]
# @RELATION BINDS_TO -> DashboardsApiTests
# @BRIEF 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.
def _matches_actor_case_insensitive(bound_username, owners, modified_by):
normalized_bound = str(bound_username or "").strip().lower()
if not normalized_bound:
return False
owner_tokens = []
for owner in owners or []:
token = str(owner or "").strip().lower()
if token:
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
)
# [/DEF:_matches_actor_case_insensitive:Function]
# [DEF:test_get_dashboards_profile_filter_contract_owners_or_modified_by:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# #endregion _matches_actor_case_insensitive
# #region test_get_dashboards_profile_filter_contract_owners_or_modified_by [TYPE 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.
# @BRIEF 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.
# @POST: Response includes only matching dashboards and effective_profile_filter metadata.
def test_get_dashboards_profile_filter_contract_owners_or_modified_by(mock_deps):
@@ -665,7 +536,6 @@ def test_get_dashboards_profile_filter_contract_owners_or_modified_by(mock_deps)
},
]
)
with patch("src.api.routes.dashboards._listing_routes.ProfileService") as profile_service_cls:
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
@@ -676,14 +546,11 @@ def test_get_dashboards_profile_filter_contract_owners_or_modified_by(mock_deps)
_matches_actor_case_insensitive
)
profile_service_cls.return_value = profile_service
response = client.get(
"/api/dashboards?env_id=prod&page_context=dashboards_main&apply_profile_default=true"
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 2
assert {item["id"] for item in payload["dashboards"]} == {1, 2}
assert payload["effective_profile_filter"]["applied"] is True
@@ -691,15 +558,11 @@ 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
# #endregion test_get_dashboards_profile_filter_contract_owners_or_modified_by
# #region test_get_dashboards_override_show_all_contract [TYPE 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.
# @BRIEF 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.
# @POST: Response remains unfiltered and effective_profile_filter.applied is false.
def test_get_dashboards_override_show_all_contract(mock_deps):
@@ -725,7 +588,6 @@ def test_get_dashboards_override_show_all_contract(mock_deps):
},
]
)
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
@@ -736,14 +598,11 @@ def test_get_dashboards_override_show_all_contract(mock_deps):
_matches_actor_case_insensitive
)
profile_service_cls.return_value = profile_service
response = client.get(
"/api/dashboards?env_id=prod&page_context=dashboards_main&apply_profile_default=true&override_show_all=true"
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 2
assert {item["id"] for item in payload["dashboards"]} == {1, 2}
assert payload["effective_profile_filter"]["applied"] is False
@@ -752,15 +611,11 @@ 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
# #endregion test_get_dashboards_override_show_all_contract
# #region test_get_dashboards_profile_filter_no_match_results_contract [TYPE 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.
# @BRIEF 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.
# @POST: Response total is 0 with deterministic pagination and active effective_profile_filter metadata.
def test_get_dashboards_profile_filter_no_match_results_contract(mock_deps):
@@ -786,7 +641,6 @@ def test_get_dashboards_profile_filter_no_match_results_contract(mock_deps):
},
]
)
with patch("src.api.routes.dashboards._listing_routes.ProfileService") as profile_service_cls:
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
@@ -797,14 +651,11 @@ def test_get_dashboards_profile_filter_no_match_results_contract(mock_deps):
_matches_actor_case_insensitive
)
profile_service_cls.return_value = profile_service
response = client.get(
"/api/dashboards?env_id=prod&page_context=dashboards_main&apply_profile_default=true"
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 0
assert payload["dashboards"] == []
assert payload["page"] == 1
@@ -815,15 +666,11 @@ 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
# #endregion test_get_dashboards_profile_filter_no_match_results_contract
# #region test_get_dashboards_page_context_other_disables_profile_default [TYPE 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.
# @BRIEF 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.
# @POST: Response remains unfiltered and metadata reflects source_page=other.
def test_get_dashboards_page_context_other_disables_profile_default(mock_deps):
@@ -849,7 +696,6 @@ def test_get_dashboards_page_context_other_disables_profile_default(mock_deps):
},
]
)
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
@@ -860,14 +706,11 @@ def test_get_dashboards_page_context_other_disables_profile_default(mock_deps):
_matches_actor_case_insensitive
)
profile_service_cls.return_value = profile_service
response = client.get(
"/api/dashboards?env_id=prod&page_context=other&apply_profile_default=true"
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 2
assert {item["id"] for item in payload["dashboards"]} == {1, 2}
assert payload["effective_profile_filter"]["applied"] is False
@@ -876,15 +719,11 @@ 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
# #endregion test_get_dashboards_page_context_other_disables_profile_default
# #region test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout [TYPE 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.
# @BRIEF 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(
@@ -914,7 +753,6 @@ def test_get_dashboards_profile_filter_matches_display_alias_without_detail_fano
},
]
)
with (
patch("src.api.routes.dashboards._listing_routes.ProfileService") as profile_service_cls,
patch("src.api.routes.dashboards._projection.SupersetClient") as superset_client_cls,
@@ -931,10 +769,8 @@ def test_get_dashboards_profile_filter_matches_display_alias_without_detail_fano
_matches_actor_case_insensitive
)
profile_service_cls.return_value = profile_service
superset_client = MagicMock()
superset_client_cls.return_value = superset_client
lookup_adapter = MagicMock()
lookup_adapter.get_users_page.return_value = {
"items": [
@@ -949,11 +785,9 @@ def test_get_dashboards_profile_filter_matches_display_alias_without_detail_fano
"total": 1,
}
lookup_adapter_cls.return_value = lookup_adapter
response = client.get(
"/api/dashboards?env_id=prod&page_context=dashboards_main&apply_profile_default=true"
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 1
@@ -961,15 +795,11 @@ 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
# #endregion test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout
# #region test_get_dashboards_profile_filter_matches_owner_object_payload_contract [TYPE 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.
# @BRIEF 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.
# @POST: Response keeps dashboards where owner object resolves to bound username alias.
def test_get_dashboards_profile_filter_matches_owner_object_payload_contract(mock_deps):
@@ -1011,7 +841,6 @@ def test_get_dashboards_profile_filter_matches_owner_object_payload_contract(moc
},
]
)
with (
patch("src.api.routes.dashboards._listing_routes.ProfileService") as profile_service_cls,
patch(
@@ -1033,19 +862,13 @@ def test_get_dashboards_profile_filter_matches_owner_object_payload_contract(moc
)
)
profile_service_cls.return_value = profile_service
response = client.get(
"/api/dashboards?env_id=prod&page_context=dashboards_main&apply_profile_default=true"
)
assert response.status_code == 200
payload = response.json()
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]
# [/DEF:DashboardsApiTests:Module]
# #endregion test_get_dashboards_profile_filter_matches_owner_object_payload_contract
# #endregion DashboardsApiTests

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,8 @@
# [DEF:DatasetsApiTests:Module]
# @COMPLEXITY: 3
# @SEMANTICS: datasets, api, tests, pagination, mapping, docs
# @PURPOSE: Unit tests for datasets API endpoints.
# #region DatasetsApiTests [TYPE Module] [C:3] [SEMANTICS datasets, api, tests, pagination, mapping, docs]
# @BRIEF Unit tests for datasets API endpoints.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [DatasetsApi]
# @RELATION DEPENDS_ON -> [DatasetsApi]
# @INVARIANT: Endpoint contracts remain stable for success and validation failure paths.
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from fastapi.testclient import TestClient
@@ -19,7 +16,6 @@ from src.dependencies import (
get_resource_service,
get_mapping_service,
)
# Global mock user for get_current_user dependency overrides
mock_user = MagicMock()
mock_user.username = "testuser"
@@ -27,12 +23,9 @@ mock_user.roles = []
admin_role = MagicMock()
admin_role.name = "Admin"
mock_user.roles.append(admin_role)
@pytest.fixture(autouse=True)
def mock_deps():
"""Bare MagicMock — no spec guards. All service method calls succeed silently.
Authorization, data integrity, and error paths are invisible to this fixture.
"""
# @INVARIANT: unconstrained mock — no spec= enforced; attribute typos will silently pass
@@ -42,13 +35,11 @@ def mock_deps():
# @INVARIANT: unconstrained mock — no spec= enforced; attribute typos will silently pass
resource_service = MagicMock()
mapping_service = MagicMock()
app.dependency_overrides[get_config_manager] = lambda: config_manager
app.dependency_overrides[get_task_manager] = lambda: task_manager
app.dependency_overrides[get_resource_service] = lambda: resource_service
app.dependency_overrides[get_mapping_service] = lambda: mapping_service
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[has_permission("plugin:migration", "READ")] = (
lambda: mock_user
)
@@ -59,7 +50,6 @@ def mock_deps():
lambda: mock_user
)
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: mock_user
yield {
"config": config_manager,
"task": task_manager,
@@ -67,14 +57,10 @@ def mock_deps():
"mapping": mapping_service,
}
app.dependency_overrides.clear()
client = TestClient(app)
# [DEF:test_get_datasets_success:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate successful datasets listing contract for an existing environment.
# #region test_get_datasets_success [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# @BRIEF Validate successful datasets listing contract for an existing environment.
# @TEST: GET /api/datasets returns 200 and valid schema
# @PRE: env_id exists
# @POST: Response matches DatasetsResponse schema
@@ -83,7 +69,6 @@ def test_get_datasets_success(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
# Mock resource service response
mock_deps["resource"].get_datasets_with_status = AsyncMock(
return_value=[
@@ -97,41 +82,29 @@ def test_get_datasets_success(mock_deps):
}
]
)
response = client.get("/api/datasets?env_id=prod")
assert response.status_code == 200
data = response.json()
assert "datasets" in data
assert len(data["datasets"]) >= 0
# Validate against Pydantic model
DatasetsResponse(**data)
# [/DEF:test_get_datasets_success:Function]
# [DEF:test_get_datasets_env_not_found:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate datasets listing returns 404 when the requested environment does not exist.
# #endregion test_get_datasets_success
# #region test_get_datasets_env_not_found [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# @BRIEF Validate datasets listing returns 404 when the requested environment does not exist.
# @TEST: GET /api/datasets returns 404 if env_id missing
# @PRE: env_id does not exist
# @POST: Returns 404 error
def test_get_datasets_env_not_found(mock_deps):
mock_deps["config"].get_environments.return_value = []
response = client.get("/api/datasets?env_id=nonexistent")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_get_datasets_env_not_found:Function]
# [DEF:test_get_datasets_invalid_pagination:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate datasets listing rejects invalid pagination parameters with 400 responses.
# #endregion test_get_datasets_env_not_found
# #region test_get_datasets_invalid_pagination [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# @BRIEF Validate datasets listing rejects invalid pagination parameters with 400 responses.
# @TEST: GET /api/datasets returns 400 for invalid page/page_size
# @PRE: page < 1 or page_size > 100
# @POST: Returns 400 error
@@ -139,29 +112,22 @@ def test_get_datasets_invalid_pagination(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
# Invalid page
response = client.get("/api/datasets?env_id=prod&page=0")
assert response.status_code == 400
assert "Page must be >= 1" in response.json()["detail"]
# Invalid page_size (too small)
response = client.get("/api/datasets?env_id=prod&page_size=0")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# @TEST_EDGE: page_size > 100 exceeds max
response = client.get("/api/datasets?env_id=prod&page_size=101")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# [/DEF:test_get_datasets_invalid_pagination:Function]
# [DEF:test_map_columns_success:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate map-columns request creates an async mapping task and returns its identifier.
# #endregion test_get_datasets_invalid_pagination
# #region test_map_columns_success [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# @BRIEF Validate map-columns request creates an async mapping task and returns its identifier.
# @TEST: POST /api/datasets/map-columns creates mapping task
# @PRE: Valid env_id, dataset_ids, source_type
# @POST: Returns task_id
@@ -170,30 +136,23 @@ def test_map_columns_success(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-123"
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
response = client.post(
"/api/datasets/map-columns",
json={"env_id": "prod", "dataset_ids": [1, 2, 3], "source_type": "postgresql"},
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once()
# [/DEF:test_map_columns_success:Function]
# [DEF:test_map_columns_invalid_source_type:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate map-columns rejects unsupported source types with a 400 contract response.
# #endregion test_map_columns_success
# #region test_map_columns_invalid_source_type [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# @BRIEF Validate map-columns rejects unsupported source types with a 400 contract response.
# @TEST: POST /api/datasets/map-columns returns 400 for invalid source_type
# @PRE: source_type is not 'postgresql' or 'xlsx'
# @POST: Returns 400 error
@@ -202,49 +161,37 @@ def test_map_columns_invalid_source_type(mock_deps):
"/api/datasets/map-columns",
json={"env_id": "prod", "dataset_ids": [1], "source_type": "invalid"},
)
assert response.status_code == 400
assert "Source type must be 'postgresql' or 'xlsx'" in response.json()["detail"]
# [/DEF:test_map_columns_invalid_source_type:Function]
# [DEF:test_generate_docs_success:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# #endregion test_map_columns_invalid_source_type
# #region test_generate_docs_success [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# @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.
# @BRIEF Validate generate-docs request creates an async documentation task and returns its identifier.
# @POST: Returns task_id
def test_generate_docs_success(mock_deps):
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-456"
mock_deps["task"].create_task = AsyncMock(return_value=mock_task)
response = client.post(
"/api/datasets/generate-docs",
json={"env_id": "prod", "dataset_ids": [1], "llm_provider": "openai"},
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# @POST/@SIDE_EFFECT: create_task was called
mock_deps["task"].create_task.assert_called_once()
# [/DEF:test_generate_docs_success:Function]
# [DEF:test_map_columns_empty_ids:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate map-columns rejects empty dataset identifier lists.
# #endregion test_generate_docs_success
# #region test_map_columns_empty_ids [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# @BRIEF Validate map-columns rejects empty dataset identifier lists.
# @TEST: POST /api/datasets/map-columns returns 400 for empty dataset_ids
# @PRE: dataset_ids is empty
# @POST: Returns 400 error
@@ -256,14 +203,10 @@ def test_map_columns_empty_ids(mock_deps):
)
assert response.status_code == 400
assert "At least one dataset ID must be provided" in response.json()["detail"]
# [/DEF:test_map_columns_empty_ids:Function]
# [DEF:test_generate_docs_empty_ids:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate generate-docs rejects empty dataset identifier lists.
# #endregion test_map_columns_empty_ids
# #region test_generate_docs_empty_ids [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# @BRIEF Validate generate-docs rejects empty dataset identifier lists.
# @TEST: POST /api/datasets/generate-docs returns 400 for empty dataset_ids
# @PRE: dataset_ids is empty
# @POST: Returns 400 error
@@ -275,16 +218,12 @@ def test_generate_docs_empty_ids(mock_deps):
)
assert response.status_code == 400
assert "At least one dataset ID must be provided" in response.json()["detail"]
# [/DEF:test_generate_docs_empty_ids:Function]
# [DEF:test_generate_docs_env_not_found:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# #endregion test_generate_docs_empty_ids
# #region test_generate_docs_env_not_found [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# @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.
# @BRIEF Validate generate-docs returns 404 when the requested environment cannot be resolved.
# @POST: Returns 404 error
def test_generate_docs_env_not_found(mock_deps):
"""@PRE: env_id must be a valid environment."""
@@ -295,14 +234,10 @@ def test_generate_docs_env_not_found(mock_deps):
)
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_generate_docs_env_not_found:Function]
# [DEF:test_get_datasets_superset_failure:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate datasets listing surfaces a 503 contract when Superset access fails.
# #endregion test_generate_docs_env_not_found
# #region test_get_datasets_superset_failure [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# @BRIEF Validate datasets listing surfaces a 503 contract when Superset access fails.
# @TEST_EDGE: external_superset_failure -> {status: 503}
# @POST: Returns 503 with stable error detail when upstream dataset fetch fails.
def test_get_datasets_superset_failure(mock_deps):
@@ -314,13 +249,8 @@ def test_get_datasets_superset_failure(mock_deps):
mock_deps["resource"].get_datasets_with_status = AsyncMock(
side_effect=Exception("Connection refused")
)
response = client.get("/api/datasets?env_id=bad_conn")
assert response.status_code == 503
assert "Failed to fetch datasets" in response.json()["detail"]
# [/DEF:test_get_datasets_superset_failure:Function]
# [/DEF:DatasetsApiTests:Module]
# #endregion test_get_datasets_superset_failure
# #endregion DatasetsApiTests

View File

@@ -1,20 +1,15 @@
# [DEF:TestGitApi:Module]
# @COMPLEXITY: 3
# @RELATION: VERIFIES -> [GitApi]
# @PURPOSE: API tests for Git configurations and repository operations.
# #region TestGitApi [TYPE Module] [C:3] [SEMANTICS test, git, api, config, repository]
# @RELATION VERIFIES -> [GitApi]
# @BRIEF API tests for Git configurations and repository operations.
import pytest
import asyncio
from unittest.mock import MagicMock
from fastapi import HTTPException
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.
# #region DbMock [TYPE Class] [C:2]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF 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):
@@ -22,12 +17,10 @@ class DbMock:
self._deleted = []
self._added = []
self._filtered = None
def query(self, model):
self._model = model
self._filtered = None
return self
def filter(self, condition):
# Honor simple SQLAlchemy equality expressions used by these route tests.
candidates = [
@@ -49,7 +42,6 @@ class DbMock:
except Exception:
self._filtered = candidates
return self
def first(self):
if self._filtered is not None:
return self._filtered[0] if self._filtered else None
@@ -57,39 +49,30 @@ class DbMock:
if hasattr(self, "_model") and isinstance(item, self._model):
return item
return None
def all(self):
if self._filtered is not None:
return list(self._filtered)
return self._data
def add(self, item):
self._added.append(item)
if not hasattr(item, "id") or not item.id:
item.id = "mocked-id"
self._data.append(item)
def delete(self, item):
self._deleted.append(item)
if item in self._data:
self._data.remove(item)
def commit(self):
pass
def refresh(self, item):
if not hasattr(item, "status"):
item.status = GitStatus.CONNECTED
if not hasattr(item, "last_validated"):
item.last_validated = "2026-03-08T00:00:00Z"
# [/DEF:DbMock:Class]
# [DEF:test_get_git_configs_masks_pat:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate listing git configs masks stored PAT values in API-facing responses.
# #endregion DbMock
# #region test_get_git_configs_masks_pat [TYPE Function]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF Validate listing git configs masks stored PAT values in API-facing responses.
def test_get_git_configs_masks_pat():
"""
@PRE: Database session `db` is available.
@@ -108,27 +91,20 @@ def test_get_git_configs_masks_pat():
)
]
)
result = asyncio.run(git_routes.get_git_configs(db=db))
assert len(result) == 1
assert result[0].pat == "********"
assert result[0].name == "Test Server"
# [/DEF:test_get_git_configs_masks_pat:Function]
# [DEF:test_create_git_config_persists_config:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate creating git config persists supplied server attributes in backing session.
# #endregion test_get_git_configs_masks_pat
# #region test_create_git_config_persists_config [TYPE Function]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF Validate creating git config persists supplied server attributes in backing session.
def test_create_git_config_persists_config():
"""
@PRE: `config` contains valid GitServerConfigCreate data.
@POST: A new GitServerConfig record is created in the database.
"""
from src.api.routes.git_schemas import GitServerConfigCreate
db = DbMock()
config = GitServerConfigCreate(
name="New Server",
@@ -137,9 +113,7 @@ def test_create_git_config_persists_config():
pat="new-token",
default_branch="master",
)
result = asyncio.run(git_routes.create_git_config(config=config, db=db))
assert len(db._added) == 1
assert db._added[0].name == "New Server"
assert db._added[0].pat == "new-token"
@@ -147,16 +121,11 @@ def test_create_git_config_persists_config():
assert (
result.pat == "new-token"
) # Note: route returns unmasked until serialized by FastAPI usually, but in tests schema might catch it or not.
# [/DEF:test_create_git_config_persists_config:Function]
# #endregion test_create_git_config_persists_config
from src.api.routes.git_schemas import GitServerConfigUpdate
# [DEF:test_update_git_config_modifies_record:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate updating git config modifies mutable fields while preserving masked PAT semantics.
# #region test_update_git_config_modifies_record [TYPE Function]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF Validate updating git config modifies mutable fields while preserving masked PAT semantics.
def test_update_git_config_modifies_record():
"""
@PRE: `config_id` corresponds to an existing configuration.
@@ -171,49 +140,36 @@ def test_update_git_config_modifies_record():
status=GitStatus.CONNECTED,
last_validated="2026-03-08T00:00:00Z",
)
# The monkeypatched query will return existing_config as it's the only one in the list
# [DEF:SingleConfigDbMock:Class] @PURPOSE: Fake SQLAlchemy session returning single config row. @INVARIANT: Returns hardcoded single-item list; does not simulate empty or multi-row results.
# #region SingleConfigDbMock [TYPE Class]
class SingleConfigDbMock:
def query(self, *args):
return self
def filter(self, *args):
return self
def first(self):
return existing_config
def commit(self):
pass
def refresh(self, config):
pass
# [/DEF:SingleConfigDbMock:Class]
# #endregion SingleConfigDbMock
db = SingleConfigDbMock()
update_data = GitServerConfigUpdate(name="Updated Server", pat="********")
result = asyncio.run(
git_routes.update_git_config(
config_id="config-1", config_update=update_data, db=db
)
)
assert existing_config.name == "Updated Server"
assert (
existing_config.pat == "old-token"
) # Ensure PAT is not overwritten with asterisks
assert result.pat == "********"
# [/DEF:test_update_git_config_modifies_record:Function]
# [DEF:test_update_git_config_raises_404_if_not_found:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate updating non-existent git config raises HTTP 404 contract response.
# #endregion test_update_git_config_modifies_record
# #region test_update_git_config_raises_404_if_not_found [TYPE Function]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF Validate updating non-existent git config raises HTTP 404 contract response.
def test_update_git_config_raises_404_if_not_found():
"""
@PRE: `config_id` corresponds to a missing configuration.
@@ -221,80 +177,57 @@ def test_update_git_config_raises_404_if_not_found():
"""
db = DbMock([]) # Empty db
update_data = GitServerConfigUpdate(name="Updated Server", pat="new-token")
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
git_routes.update_git_config(
config_id="config-1", config_update=update_data, db=db
)
)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Configuration not found"
# [/DEF:test_update_git_config_raises_404_if_not_found:Function]
# [DEF:test_delete_git_config_removes_record:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate deleting existing git config removes record and returns success payload.
# #endregion test_update_git_config_raises_404_if_not_found
# #region test_delete_git_config_removes_record [TYPE Function]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF Validate deleting existing git config removes record and returns success payload.
def test_delete_git_config_removes_record():
"""
@PRE: `config_id` corresponds to an existing configuration.
@POST: The configuration record is removed from the database.
"""
existing_config = GitServerConfig(id="config-1")
# [DEF:SingleConfigDbMock:Class] @PURPOSE: Fake SQLAlchemy session returning single config row. @INVARIANT: Returns hardcoded single-item list; does not simulate empty or multi-row results.
# #region SingleConfigDbMock [TYPE Class]
class SingleConfigDbMock:
def query(self, *args):
return self
def filter(self, *args):
return self
def first(self):
return existing_config
def delete(self, config):
self.deleted = config
def commit(self):
pass
# [/DEF:SingleConfigDbMock:Class]
# #endregion SingleConfigDbMock
db = SingleConfigDbMock()
result = asyncio.run(git_routes.delete_git_config(config_id="config-1", db=db))
assert db.deleted == existing_config
assert result["status"] == "success"
# [/DEF:test_delete_git_config_removes_record:Function]
# [DEF:test_test_git_config_validates_connection_successfully:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate test-connection endpoint returns success when provider connectivity check passes.
# #endregion test_delete_git_config_removes_record
# #region test_test_git_config_validates_connection_successfully [TYPE Function]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF Validate test-connection endpoint returns success when provider connectivity check passes.
def test_test_git_config_validates_connection_successfully(monkeypatch):
"""
@PRE: `config` contains provider, url, and pat.
@POST: Returns success if the connection is validated via GitService.
"""
# [DEF:MockGitService:Class] @PURPOSE: Stub GitService returning controlled responses. @INVARIANT: Returns only the configured response; does not simulate partial failure or exception paths.
# #region MockGitService [TYPE Class]
class MockGitService:
async def test_connection(self, provider, url, pat):
return True
# [/DEF:MockGitService:Class]
# #endregion MockGitService
monkeypatch.setattr(git_routes, "git_service", MockGitService())
from src.api.routes.git_schemas import GitServerConfigCreate
config = GitServerConfigCreate(
name="Test Server",
provider=GitProvider.GITHUB,
@@ -302,34 +235,24 @@ def test_test_git_config_validates_connection_successfully(monkeypatch):
pat="test-pat",
)
db = DbMock([])
result = asyncio.run(git_routes.test_git_config(config=config, db=db))
assert result["status"] == "success"
# [/DEF:test_test_git_config_validates_connection_successfully:Function]
# [DEF:test_test_git_config_fails_validation:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate test-connection endpoint raises HTTP 400 when provider connectivity check fails.
# #endregion test_test_git_config_validates_connection_successfully
# #region test_test_git_config_fails_validation [TYPE Function]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF Validate test-connection endpoint raises HTTP 400 when provider connectivity check fails.
def test_test_git_config_fails_validation(monkeypatch):
"""
@PRE: `config` contains provider, url, and pat BUT connection fails.
@THROW: HTTPException 400
"""
# [DEF:MockGitService:Class] @PURPOSE: Stub GitService returning controlled responses. @INVARIANT: Returns only the configured response; does not simulate partial failure or exception paths.
# #region MockGitService [TYPE Class]
class MockGitService:
async def test_connection(self, provider, url, pat):
return False
# [/DEF:MockGitService:Class]
# #endregion MockGitService
monkeypatch.setattr(git_routes, "git_service", MockGitService())
from src.api.routes.git_schemas import GitServerConfigCreate
config = GitServerConfigCreate(
name="Test Server",
provider=GitProvider.GITHUB,
@@ -337,35 +260,26 @@ def test_test_git_config_fails_validation(monkeypatch):
pat="bad-pat",
)
db = DbMock([])
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.test_git_config(config=config, db=db))
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "Connection failed"
# [/DEF:test_test_git_config_fails_validation:Function]
# [DEF:test_list_gitea_repositories_returns_payload:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate gitea repositories endpoint returns normalized list for GITEA provider configs.
# #endregion test_test_git_config_fails_validation
# #region test_list_gitea_repositories_returns_payload [TYPE Function]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF Validate gitea repositories endpoint returns normalized list for GITEA provider configs.
def test_list_gitea_repositories_returns_payload(monkeypatch):
"""
@PRE: config_id exists and provider is GITEA.
@POST: Returns repositories visible to PAT user.
"""
# [DEF:MockGitService:Class] @PURPOSE: Stub GitService returning controlled responses. @INVARIANT: Returns only the configured response; does not simulate partial failure or exception paths.
# #region MockGitService [TYPE Class]
class MockGitService:
async def list_gitea_repositories(self, url, pat):
return [
{"name": "test-repo", "full_name": "owner/test-repo", "private": True}
]
# [/DEF:MockGitService:Class]
# #endregion MockGitService
monkeypatch.setattr(git_routes, "git_service", MockGitService())
existing_config = GitServerConfig(
id="config-1",
@@ -375,22 +289,16 @@ def test_list_gitea_repositories_returns_payload(monkeypatch):
pat="gitea-token",
)
db = DbMock([existing_config])
result = asyncio.run(
git_routes.list_gitea_repositories(config_id="config-1", db=db)
)
assert len(result) == 1
assert result[0].name == "test-repo"
assert result[0].private is True
# [/DEF:test_list_gitea_repositories_returns_payload:Function]
# [DEF:test_list_gitea_repositories_rejects_non_gitea:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate gitea repositories endpoint rejects non-GITEA providers with HTTP 400.
# #endregion test_list_gitea_repositories_returns_payload
# #region test_list_gitea_repositories_rejects_non_gitea [TYPE Function]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF Validate gitea repositories endpoint rejects non-GITEA providers with HTTP 400.
def test_list_gitea_repositories_rejects_non_gitea(monkeypatch):
"""
@PRE: config_id exists and provider is NOT GITEA.
@@ -404,27 +312,20 @@ def test_list_gitea_repositories_rejects_non_gitea(monkeypatch):
pat="token",
)
db = DbMock([existing_config])
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.list_gitea_repositories(config_id="config-1", db=db))
assert exc_info.value.status_code == 400
assert "GITEA provider only" in exc_info.value.detail
# [/DEF:test_list_gitea_repositories_rejects_non_gitea:Function]
# [DEF:test_create_remote_repository_creates_provider_repo:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate remote repository creation endpoint maps provider response into normalized payload.
# #endregion test_list_gitea_repositories_rejects_non_gitea
# #region test_create_remote_repository_creates_provider_repo [TYPE Function]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF Validate remote repository creation endpoint maps provider response into normalized payload.
def test_create_remote_repository_creates_provider_repo(monkeypatch):
"""
@PRE: config_id exists and PAT has creation permissions.
@POST: Returns normalized remote repository payload.
"""
# [DEF:MockGitService:Class] @PURPOSE: Stub GitService returning controlled responses. @INVARIANT: Returns only the configured response; does not simulate partial failure or exception paths.
# #region MockGitService [TYPE Class]
class MockGitService:
async def create_gitlab_repository(
self, server_url, pat, name, private, description, auto_init, default_branch
@@ -435,12 +336,9 @@ def test_create_remote_repository_creates_provider_repo(monkeypatch):
"private": private,
"clone_url": f"{server_url}/user/{name}.git",
}
# [/DEF:MockGitService:Class]
# #endregion MockGitService
monkeypatch.setattr(git_routes, "git_service", MockGitService())
from src.api.routes.git_schemas import RemoteRepoCreateRequest
existing_config = GitServerConfig(
id="config-1",
name="GitLab Server",
@@ -449,42 +347,32 @@ def test_create_remote_repository_creates_provider_repo(monkeypatch):
pat="token",
)
db = DbMock([existing_config])
request = RemoteRepoCreateRequest(name="new-repo", private=True, description="desc")
result = asyncio.run(
git_routes.create_remote_repository(
config_id="config-1", request=request, db=db
)
)
assert result.provider == GitProvider.GITLAB
assert result.name == "new-repo"
assert result.full_name == "user/new-repo"
# [/DEF:test_create_remote_repository_creates_provider_repo:Function]
# [DEF:test_init_repository_initializes_and_saves_binding:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate repository initialization endpoint creates local repo and persists dashboard binding.
# #endregion test_create_remote_repository_creates_provider_repo
# #region test_init_repository_initializes_and_saves_binding [TYPE Function]
# @RELATION BINDS_TO -> [TestGitApi]
# @BRIEF Validate repository initialization endpoint creates local repo and persists dashboard binding.
def test_init_repository_initializes_and_saves_binding(monkeypatch):
"""
@PRE: `dashboard_ref` exists and `init_data` contains valid config_id and remote_url.
@POST: Repository is initialized on disk and a GitRepository record is saved in DB.
"""
from src.api.routes.git_schemas import RepoInitRequest
# [DEF:MockGitService:Class] @PURPOSE: Stub GitService returning controlled responses. @INVARIANT: Returns only the configured response; does not simulate partial failure or exception paths.
# #region MockGitService [TYPE Class]
class MockGitService:
def init_repo(self, dashboard_id, remote_url, pat, repo_key, default_branch):
self.init_called = True
def _get_repo_path(self, dashboard_id, repo_key):
return f"/tmp/repos/{repo_key}"
# [/DEF:MockGitService:Class]
# #endregion MockGitService
git_service_mock = MockGitService()
monkeypatch.setattr(git_routes, "git_service", git_service_mock)
monkeypatch.setattr(
@@ -495,7 +383,6 @@ def test_init_repository_initializes_and_saves_binding(monkeypatch):
"_resolve_repo_key_from_ref",
lambda *args, **kwargs: "dashboard-123",
)
existing_config = GitServerConfig(
id="config-1",
name="GitLab Server",
@@ -505,23 +392,18 @@ def test_init_repository_initializes_and_saves_binding(monkeypatch):
default_branch="main",
)
db = DbMock([existing_config])
init_data = RepoInitRequest(
config_id="config-1", remote_url="https://git.local/repo.git"
)
result = asyncio.run(
git_routes.init_repository(
dashboard_ref="123", init_data=init_data, config_manager=MagicMock(), db=db
)
)
assert result["status"] == "success"
assert git_service_mock.init_called is True
assert len(db._added) == 1
assert isinstance(db._added[0], GitRepository)
assert db._added[0].dashboard_id == 123
# [/DEF:test_init_repository_initializes_and_saves_binding:Function]
# [/DEF:TestGitApi:Module]
# #endregion test_init_repository_initializes_and_saves_binding
# #endregion TestGitApi

View File

@@ -1,213 +1,158 @@
# [DEF:TestGitStatusRoute:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, git, api, status, no_repo
# @PURPOSE: Validate status endpoint behavior for missing and error repository states.
# #region TestGitStatusRoute [TYPE Module] [C:3] [SEMANTICS tests, git, api, status, no_repo]
# @BRIEF Validate status endpoint behavior for missing and error repository states.
# @LAYER: Domain (Tests)
# @RELATION: VERIFIES -> [GitApi]
# @RELATION VERIFIES -> [GitApi]
from fastapi import HTTPException
import pytest
import asyncio
from unittest.mock import MagicMock
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.
# #region test_get_repository_status_returns_no_repo_payload_for_missing_repo [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF 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.
def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypatch):
class MissingRepoGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/missing-repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
raise AssertionError("get_status must not be called when repository path is missing")
monkeypatch.setattr(git_routes, "git_service", MissingRepoGitService())
response = asyncio.run(git_routes.get_repository_status(34))
assert response["sync_status"] == "NO_REPO"
assert response["sync_state"] == "NO_REPO"
assert response["has_repo"] is False
assert response["current_branch"] is None
# [/DEF:test_get_repository_status_returns_no_repo_payload_for_missing_repo:Function]
# [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.
# #endregion test_get_repository_status_returns_no_repo_payload_for_missing_repo
# #region test_get_repository_status_propagates_non_404_http_exception [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF 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.
def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
class ConflictGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/existing-repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
raise HTTPException(status_code=409, detail="Conflict")
monkeypatch.setattr(git_routes, "git_service", ConflictGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.get_repository_status(34))
assert exc_info.value.status_code == 409
assert exc_info.value.detail == "Conflict"
# [/DEF:test_get_repository_status_propagates_non_404_http_exception:Function]
# [DEF:test_get_repository_diff_propagates_http_exception:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure diff endpoint preserves domain HTTP errors from GitService.
# #endregion test_get_repository_status_propagates_non_404_http_exception
# #region test_get_repository_diff_propagates_http_exception [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure diff endpoint preserves domain HTTP errors from GitService.
# @PRE: GitService.get_diff raises HTTPException.
# @POST: Endpoint raises same HTTPException values.
def test_get_repository_diff_propagates_http_exception(monkeypatch):
class DiffGitService:
def get_diff(self, dashboard_id: int, file_path=None, staged: bool = False) -> str:
raise HTTPException(status_code=404, detail="Repository missing")
monkeypatch.setattr(git_routes, "git_service", DiffGitService())
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.get_repository_diff(12))
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Repository missing"
# [/DEF:test_get_repository_diff_propagates_http_exception:Function]
# [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.
# #endregion test_get_repository_diff_propagates_http_exception
# #region test_get_history_wraps_unexpected_error_as_500 [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF 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.
def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
class HistoryGitService:
def get_commit_history(self, dashboard_id: int, limit: int = 50):
raise ValueError("broken parser")
monkeypatch.setattr(git_routes, "git_service", HistoryGitService())
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.get_history(12))
assert exc_info.value.status_code == 500
assert exc_info.value.detail == "get_history failed: broken parser"
# [/DEF:test_get_history_wraps_unexpected_error_as_500:Function]
# [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.
# #endregion test_get_history_wraps_unexpected_error_as_500
# #region test_commit_changes_wraps_unexpected_error_as_500 [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure commit endpoint does not leak unexpected errors as 400.
# @PRE: GitService.commit_changes raises RuntimeError.
# @POST: Endpoint raises HTTPException(500) with route context.
def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
class CommitGitService:
def commit_changes(self, dashboard_id: int, message: str, files):
raise RuntimeError("index lock")
class CommitPayload:
message = "test"
files = ["dashboards/a.yaml"]
monkeypatch.setattr(git_routes, "git_service", CommitGitService())
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.commit_changes(12, CommitPayload()))
assert exc_info.value.status_code == 500
assert exc_info.value.detail == "commit_changes failed: index lock"
# [/DEF:test_commit_changes_wraps_unexpected_error_as_500:Function]
# [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.
# #endregion test_commit_changes_wraps_unexpected_error_as_500
# #region test_get_repository_status_batch_returns_mixed_statuses [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF 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.
def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
class BatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
if dashboard_id == 2:
return {"sync_state": "SYNCED", "sync_status": "OK"}
raise HTTPException(status_code=404, detail="not found")
monkeypatch.setattr(git_routes, "git_service", BatchGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda path: path.endswith("/repo-2"))
class BatchRequest:
dashboard_ids = [1, 2]
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
assert response.statuses["1"]["sync_status"] == "NO_REPO"
assert response.statuses["2"]["sync_state"] == "SYNCED"
# [/DEF:test_get_repository_status_batch_returns_mixed_statuses:Function]
# [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.
# #endregion test_get_repository_status_batch_returns_mixed_statuses
# #region test_get_repository_status_batch_marks_item_as_error_on_service_failure [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF 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.
def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monkeypatch):
class BatchErrorGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
raise RuntimeError("boom")
monkeypatch.setattr(git_routes, "git_service", BatchErrorGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
class BatchRequest:
dashboard_ids = [9]
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
assert response.statuses["9"]["sync_status"] == "ERROR"
assert response.statuses["9"]["sync_state"] == "ERROR"
# [/DEF:test_get_repository_status_batch_marks_item_as_error_on_service_failure:Function]
# [DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure batch endpoint protects server from oversized payloads.
# #endregion test_get_repository_status_batch_marks_item_as_error_on_service_failure
# #region test_get_repository_status_batch_deduplicates_and_truncates_ids [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF 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.
def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch):
class SafeBatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
return {"sync_state": "SYNCED", "sync_status": "OK"}
monkeypatch.setattr(git_routes, "git_service", SafeBatchGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
class BatchRequest:
dashboard_ids = [1, 1] + list(range(2, 90))
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
assert len(response.statuses) == git_routes.MAX_REPOSITORY_STATUS_BATCH
assert "1" in response.statuses
# [/DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
# [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.
# #endregion test_get_repository_status_batch_deduplicates_and_truncates_ids
# #region test_commit_changes_applies_profile_identity_before_commit [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF 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.
def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
@@ -215,35 +160,26 @@ def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
def __init__(self):
self.configured_identity = None
self.commit_payload = None
def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email)
def commit_changes(self, dashboard_id: int, message: str, files):
self.commit_payload = (dashboard_id, message, files)
class PreferenceRow:
git_username = "user_1"
git_email = "user1@mail.ru"
class PreferenceQuery:
def filter(self, *_args, **_kwargs):
return self
def first(self):
return PreferenceRow()
class DbStub:
def query(self, _model):
return PreferenceQuery()
class UserStub:
id = "u-1"
class CommitPayload:
message = "test"
files = ["dashboards/a.yaml"]
identity_service = IdentityGitService()
monkeypatch.setattr(git_routes, "git_service", identity_service)
monkeypatch.setattr(
@@ -251,7 +187,6 @@ def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
"_resolve_dashboard_id_from_ref",
lambda *_args, **_kwargs: 12,
)
asyncio.run(
git_routes.commit_changes(
"dashboard-12",
@@ -261,15 +196,12 @@ def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
current_user=UserStub(),
)
)
assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru")
assert identity_service.commit_payload == (12, "test", ["dashboards/a.yaml"])
# [/DEF:test_commit_changes_applies_profile_identity_before_commit:Function]
# [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.
# #endregion test_commit_changes_applies_profile_identity_before_commit
# #region test_pull_changes_applies_profile_identity_before_pull [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF 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.
def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
@@ -277,31 +209,23 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
def __init__(self):
self.configured_identity = None
self.pulled_dashboard_id = None
def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email)
def pull_changes(self, dashboard_id: int):
self.pulled_dashboard_id = dashboard_id
class PreferenceRow:
git_username = "user_1"
git_email = "user1@mail.ru"
class PreferenceQuery:
def filter(self, *_args, **_kwargs):
return self
def first(self):
return PreferenceRow()
class DbStub:
def query(self, _model):
return PreferenceQuery()
class UserStub:
id = "u-1"
identity_service = IdentityGitService()
monkeypatch.setattr(git_routes, "git_service", identity_service)
monkeypatch.setattr(
@@ -309,7 +233,6 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
"_resolve_dashboard_id_from_ref",
lambda *_args, **_kwargs: 12,
)
asyncio.run(
git_routes.pull_changes(
"dashboard-12",
@@ -318,15 +241,12 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
current_user=UserStub(),
)
)
assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru")
assert identity_service.pulled_dashboard_id == 12
# [/DEF:test_pull_changes_applies_profile_identity_before_pull:Function]
# [DEF:test_get_merge_status_returns_service_payload:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure merge status route returns service payload as-is.
# #endregion test_pull_changes_applies_profile_identity_before_pull
# #region test_get_merge_status_returns_service_payload [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF 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.
def test_get_merge_status_returns_service_payload(monkeypatch):
@@ -341,46 +261,36 @@ def test_get_merge_status_returns_service_payload(monkeypatch):
"merge_message_preview": "merge msg",
"conflicts_count": 2,
}
monkeypatch.setattr(git_routes, "git_service", MergeStatusGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
response = asyncio.run(
git_routes.get_merge_status(
"dashboard-12",
config_manager=MagicMock(),
)
)
assert response["has_unfinished_merge"] is True
assert response["conflicts_count"] == 2
# [/DEF:test_get_merge_status_returns_service_payload:Function]
# [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.
# #endregion test_get_merge_status_returns_service_payload
# #region test_resolve_merge_conflicts_passes_resolution_items_to_service [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF 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.
def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch):
captured = {}
class MergeResolveGitService:
def resolve_merge_conflicts(self, dashboard_id: int, resolutions):
captured["dashboard_id"] = dashboard_id
captured["resolutions"] = resolutions
return ["dashboards/a.yaml"]
class ResolveData:
class _Resolution:
def dict(self):
return {"file_path": "dashboards/a.yaml", "resolution": "mine", "content": None}
resolutions = [_Resolution()]
monkeypatch.setattr(git_routes, "git_service", MergeResolveGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
response = asyncio.run(
git_routes.resolve_merge_conflicts(
"dashboard-12",
@@ -388,16 +298,13 @@ def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch)
config_manager=MagicMock(),
)
)
assert captured["dashboard_id"] == 12
assert captured["resolutions"][0]["resolution"] == "mine"
assert response["resolved_files"] == ["dashboards/a.yaml"]
# [/DEF:test_resolve_merge_conflicts_passes_resolution_items_to_service:Function]
# [DEF:test_abort_merge_calls_service_and_returns_result:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure abort route delegates to service.
# #endregion test_resolve_merge_conflicts_passes_resolution_items_to_service
# #region test_abort_merge_calls_service_and_returns_result [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure abort route delegates to service.
# @PRE: Service abort_merge returns aborted status.
# @POST: Route returns aborted status.
def test_abort_merge_calls_service_and_returns_result(monkeypatch):
@@ -405,24 +312,19 @@ def test_abort_merge_calls_service_and_returns_result(monkeypatch):
def abort_merge(self, dashboard_id: int):
assert dashboard_id == 12
return {"status": "aborted"}
monkeypatch.setattr(git_routes, "git_service", AbortGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
response = asyncio.run(
git_routes.abort_merge(
"dashboard-12",
config_manager=MagicMock(),
)
)
assert response["status"] == "aborted"
# [/DEF:test_abort_merge_calls_service_and_returns_result:Function]
# [DEF:test_continue_merge_passes_message_and_returns_commit:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure continue route passes commit message to service.
# #endregion test_abort_merge_calls_service_and_returns_result
# #region test_continue_merge_passes_message_and_returns_commit [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure continue route passes commit message to service.
# @PRE: continue_data.message is provided.
# @POST: Route returns committed status and hash.
def test_continue_merge_passes_message_and_returns_commit(monkeypatch):
@@ -431,13 +333,10 @@ def test_continue_merge_passes_message_and_returns_commit(monkeypatch):
assert dashboard_id == 12
assert message == "Resolve all conflicts"
return {"status": "committed", "commit_hash": "abc123"}
class ContinueData:
message = "Resolve all conflicts"
monkeypatch.setattr(git_routes, "git_service", ContinueGitService())
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
response = asyncio.run(
git_routes.continue_merge(
"dashboard-12",
@@ -445,10 +344,7 @@ def test_continue_merge_passes_message_and_returns_commit(monkeypatch):
config_manager=MagicMock(),
)
)
assert response["status"] == "committed"
assert response["commit_hash"] == "abc123"
# [/DEF:test_continue_merge_passes_message_and_returns_commit:Function]
# [/DEF:TestGitStatusRoute:Module]
# #endregion test_continue_merge_passes_message_and_returns_commit
# #endregion TestGitStatusRoute

View File

@@ -1,23 +1,19 @@
# [DEF:TestMigrationRoutes:Module]
# #region TestMigrationRoutes [TYPE Module] [C:3] [SEMANTICS test, migration, api, route, handler]
#
# @COMPLEXITY: 3
# @PURPOSE: Unit tests for migration API route handlers.
# @BRIEF Unit tests for migration API route handlers.
# @LAYER: API
# @RELATION: VERIFIES -> backend.src.api.routes.migration
# @RELATION VERIFIES -> backend.src.api.routes.migration
#
import pytest
import sys
from pathlib import Path
from unittest.mock import MagicMock, AsyncMock, patch
from datetime import datetime, timezone
# Add backend directory to sys.path
backend_dir = str(Path(__file__).parent.parent.parent.parent.resolve())
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
import os
# Force SQLite in-memory for all database connections BEFORE importing any application code
# @SIDE_EFFECT_WARNING: os.environ mutation at module import time — no teardown. This bleeds into all subsequently collected tests. Migrate to pytest.fixture(autouse=True) with monkeypatch.setenv.
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
@@ -27,27 +23,18 @@ os.environ["TASKS_DATABASE_URL"] = "sqlite:///:memory:"
os.environ["AUTH_DATABASE_URL"] = "sqlite:///:memory:"
# @SIDE_EFFECT_WARNING: os.environ mutation at module import time — no teardown. This bleeds into all subsequently collected tests. Migrate to pytest.fixture(autouse=True) with monkeypatch.setenv.
os.environ["ENVIRONMENT"] = "testing"
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.models.mapping import Base, ResourceMapping, ResourceType
# Patch the get_db dependency if `src.api.routes.migration` imports it
from unittest.mock import patch
patch("src.core.database.get_db").start()
# --- Fixtures ---
@pytest.fixture
def db_session():
"""In-memory SQLite session for testing."""
from sqlalchemy.pool import StaticPool
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
@@ -58,10 +45,8 @@ def db_session():
session = Session()
yield session
session.close()
# [DEF:_make_config_manager:Function]
# @RELATION: BINDS_TO -> TestMigrationRoutes
# #region _make_config_manager [TYPE 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()
@@ -72,83 +57,53 @@ def _make_config_manager(cron="0 2 * * *"):
cm.get_config.return_value = config
cm.save_config = MagicMock()
return cm
# --- get_migration_settings tests ---
# [/DEF:_make_config_manager:Function]
# #endregion _make_config_manager
@pytest.mark.asyncio
async def test_get_migration_settings_returns_default_cron():
"""Verify the settings endpoint returns the stored cron string."""
from src.api.routes.migration import get_migration_settings
cm = _make_config_manager(cron="0 3 * * *")
# Call the handler directly, bypassing Depends
result = await get_migration_settings(config_manager=cm, _=None)
assert result == {"cron": "0 3 * * *"}
cm.get_config.assert_called_once()
@pytest.mark.asyncio
async def test_get_migration_settings_returns_fallback_when_no_cron():
"""When migration_sync_cron uses the default, should return '0 2 * * *'."""
from src.api.routes.migration import get_migration_settings
# Use the default cron value (simulating a fresh config)
cm = _make_config_manager()
result = await get_migration_settings(config_manager=cm, _=None)
assert result == {"cron": "0 2 * * *"}
# --- update_migration_settings tests ---
@pytest.mark.asyncio
async def test_update_migration_settings_saves_cron():
"""Verify that a valid cron update saves to config."""
from src.api.routes.migration import update_migration_settings
cm = _make_config_manager()
result = await update_migration_settings(
payload={"cron": "0 4 * * *"}, config_manager=cm, _=None
)
assert result["cron"] == "0 4 * * *"
assert result["status"] == "updated"
cm.save_config.assert_called_once()
@pytest.mark.asyncio
async def test_update_migration_settings_rejects_missing_cron():
"""Verify 400 error when 'cron' key is missing from payload."""
from src.api.routes.migration import update_migration_settings
cm = _make_config_manager()
with pytest.raises(HTTPException) as exc_info:
await update_migration_settings(
payload={"interval": "daily"}, config_manager=cm, _=None
)
assert exc_info.value.status_code == 400
assert "cron" in exc_info.value.detail.lower()
# --- get_resource_mappings tests ---
@pytest.mark.asyncio
async def test_get_resource_mappings_returns_formatted_list(db_session):
"""Verify mappings are returned as formatted dicts with correct keys."""
from src.api.routes.migration import get_resource_mappings
# Populate test data
m1 = ResourceMapping(
environment_id="prod",
@@ -160,7 +115,6 @@ async def test_get_resource_mappings_returns_formatted_list(db_session):
)
db_session.add(m1)
db_session.commit()
result = await get_resource_mappings(
skip=0,
limit=50,
@@ -170,7 +124,6 @@ async def test_get_resource_mappings_returns_formatted_list(db_session):
db=db_session,
_=None,
)
assert result["total"] == 1
assert len(result["items"]) == 1
assert result["items"][0]["environment_id"] == "prod"
@@ -179,13 +132,10 @@ async def test_get_resource_mappings_returns_formatted_list(db_session):
assert result["items"][0]["remote_id"] == "42"
assert result["items"][0]["resource_name"] == "Sales Chart"
assert result["items"][0]["last_synced_at"] is not None
@pytest.mark.asyncio
async def test_get_resource_mappings_respects_pagination(db_session):
"""Verify skip and limit parameters work correctly."""
from src.api.routes.migration import get_resource_mappings
for i in range(5):
db_session.add(
ResourceMapping(
@@ -196,7 +146,6 @@ async def test_get_resource_mappings_respects_pagination(db_session):
)
)
db_session.commit()
result = await get_resource_mappings(
skip=2,
limit=2,
@@ -206,16 +155,12 @@ async def test_get_resource_mappings_respects_pagination(db_session):
db=db_session,
_=None,
)
assert result["total"] == 5
assert len(result["items"]) == 2
@pytest.mark.asyncio
async def test_get_resource_mappings_search_by_name(db_session):
"""Verify search filters by resource_name."""
from src.api.routes.migration import get_resource_mappings
db_session.add(
ResourceMapping(
environment_id="prod",
@@ -235,7 +180,6 @@ async def test_get_resource_mappings_search_by_name(db_session):
)
)
db_session.commit()
result = await get_resource_mappings(
skip=0,
limit=50,
@@ -247,13 +191,10 @@ async def test_get_resource_mappings_search_by_name(db_session):
)
assert result["total"] == 1
assert result["items"][0]["resource_name"] == "Sales Chart"
@pytest.mark.asyncio
async def test_get_resource_mappings_filter_by_env(db_session):
"""Verify env_id filter returns only matching environment."""
from src.api.routes.migration import get_resource_mappings
db_session.add(
ResourceMapping(
environment_id="ss1",
@@ -273,7 +214,6 @@ async def test_get_resource_mappings_filter_by_env(db_session):
)
)
db_session.commit()
result = await get_resource_mappings(
skip=0,
limit=50,
@@ -285,13 +225,10 @@ async def test_get_resource_mappings_filter_by_env(db_session):
)
assert result["total"] == 1
assert result["items"][0]["environment_id"] == "ss2"
@pytest.mark.asyncio
async def test_get_resource_mappings_filter_by_type(db_session):
"""Verify resource_type filter returns only matching type."""
from src.api.routes.migration import get_resource_mappings
db_session.add(
ResourceMapping(
environment_id="prod",
@@ -311,7 +248,6 @@ async def test_get_resource_mappings_filter_by_type(db_session):
)
)
db_session.commit()
result = await get_resource_mappings(
skip=0,
limit=50,
@@ -323,14 +259,10 @@ async def test_get_resource_mappings_filter_by_type(db_session):
)
assert result["total"] == 1
assert result["items"][0]["resource_type"] == "dataset"
# --- trigger_sync_now tests ---
@pytest.fixture
# [DEF:_mock_env:Function]
# @RELATION: BINDS_TO -> TestMigrationRoutes
# #region _mock_env [TYPE Function]
# @RELATION BINDS_TO -> TestMigrationRoutes
def _mock_env():
"""Creates a mock config environment object."""
env = MagicMock()
@@ -342,13 +274,9 @@ def _mock_env():
env.verify_ssl = False
env.timeout = 30
return env
# [/DEF:_mock_env:Function]
# [DEF:_make_sync_config_manager:Function]
# @RELATION: BINDS_TO -> TestMigrationRoutes
# #endregion _mock_env
# #region _make_sync_config_manager [TYPE Function]
# @RELATION BINDS_TO -> TestMigrationRoutes
def _make_sync_config_manager(environments):
"""Creates a mock config manager with environments list."""
settings = MagicMock()
@@ -360,20 +288,14 @@ def _make_sync_config_manager(environments):
cm.get_config.return_value = config
cm.get_environments.return_value = environments
return cm
# [/DEF:_make_sync_config_manager:Function]
# #endregion _make_sync_config_manager
@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,
preventing FK constraint violations on resource_mappings inserts."""
from src.api.routes.migration import trigger_sync_now
from src.models.mapping import Environment as EnvironmentModel
cm = _make_sync_config_manager([_mock_env])
with (
patch("src.api.routes.migration.SupersetClient") as MockClient,
patch("src.api.routes.migration.IdMappingService") as MockService,
@@ -382,42 +304,31 @@ async def test_trigger_sync_now_creates_env_row_and_syncs(db_session, _mock_env)
MockClient.return_value = mock_client_instance
mock_service_instance = MagicMock()
MockService.return_value = mock_service_instance
result = await trigger_sync_now(config_manager=cm, db=db_session, _=None)
# Environment row must exist in DB
env_row = db_session.query(EnvironmentModel).filter_by(id="test-env-1").first()
assert env_row is not None
assert env_row.name == "Test Env"
assert env_row.url == "http://superset.test"
# Sync must have been called
mock_service_instance.sync_environment.assert_called_once_with(
"test-env-1", mock_client_instance
)
assert result["synced_count"] == 1
assert result["failed_count"] == 0
@pytest.mark.asyncio
async def test_trigger_sync_now_rejects_empty_environments(db_session):
"""Verify 400 error when no environments are configured."""
from src.api.routes.migration import trigger_sync_now
cm = _make_sync_config_manager([])
with pytest.raises(HTTPException) as exc_info:
await trigger_sync_now(config_manager=cm, db=db_session, _=None)
assert exc_info.value.status_code == 400
assert "No environments" in exc_info.value.detail
@pytest.mark.asyncio
async def test_trigger_sync_now_handles_partial_failure(db_session, _mock_env):
"""Verify that if sync_environment raises for one env, it's captured in failed list."""
from src.api.routes.migration import trigger_sync_now
env2 = MagicMock()
env2.id = "test-env-2"
env2.name = "Failing Env"
@@ -426,9 +337,7 @@ async def test_trigger_sync_now_handles_partial_failure(db_session, _mock_env):
env2.password = "admin"
env2.verify_ssl = False
env2.timeout = 30
cm = _make_sync_config_manager([_mock_env, env2])
with (
patch("src.api.routes.migration.SupersetClient") as MockClient,
patch("src.api.routes.migration.IdMappingService") as MockService,
@@ -440,110 +349,78 @@ async def test_trigger_sync_now_handles_partial_failure(db_session, _mock_env):
]
MockService.return_value = mock_service_instance
MockClient.return_value = MagicMock()
result = await trigger_sync_now(config_manager=cm, db=db_session, _=None)
assert result["synced_count"] == 1
assert result["failed_count"] == 1
assert result["details"]["failed"][0]["env_id"] == "test-env-2"
@pytest.mark.asyncio
async def test_trigger_sync_now_idempotent_env_upsert(db_session, _mock_env):
"""Verify that calling sync twice doesn't duplicate the Environment row."""
from src.api.routes.migration import trigger_sync_now
from src.models.mapping import Environment as EnvironmentModel
cm = _make_sync_config_manager([_mock_env])
with (
patch("src.api.routes.migration.SupersetClient"),
patch("src.api.routes.migration.IdMappingService"),
):
await trigger_sync_now(config_manager=cm, db=db_session, _=None)
await trigger_sync_now(config_manager=cm, db=db_session, _=None)
env_count = db_session.query(EnvironmentModel).filter_by(id="test-env-1").count()
assert env_count == 1
# --- get_dashboards tests ---
@pytest.mark.asyncio
async def test_get_dashboards_success(_mock_env):
from src.api.routes.migration import get_dashboards
cm = _make_sync_config_manager([_mock_env])
with patch("src.api.routes.migration.SupersetClient") as MockClient:
mock_client = MagicMock()
mock_client.get_dashboards_summary.return_value = [{"id": 1, "title": "Test"}]
MockClient.return_value = mock_client
result = await get_dashboards(env_id="test-env-1", config_manager=cm, _=None)
assert len(result) == 1
assert result[0]["id"] == 1
@pytest.mark.asyncio
async def test_get_dashboards_invalid_env_raises_404(_mock_env):
from src.api.routes.migration import get_dashboards
cm = _make_sync_config_manager([_mock_env])
with pytest.raises(HTTPException) as exc:
await get_dashboards(env_id="wrong-env", config_manager=cm, _=None)
assert exc.value.status_code == 404
# --- execute_migration tests ---
@pytest.mark.asyncio
async def test_execute_migration_success(_mock_env):
from src.api.routes.migration import execute_migration
from src.models.dashboard import DashboardSelection
cm = _make_sync_config_manager([_mock_env, _mock_env]) # Need both source/target
tm = MagicMock()
tm.create_task = AsyncMock(return_value=MagicMock(id="task-123"))
selection = DashboardSelection(
source_env_id="test-env-1", target_env_id="test-env-1", selected_ids=[1, 2]
)
result = await execute_migration(
selection=selection, config_manager=cm, task_manager=tm, _=None
)
assert result["task_id"] == "task-123"
tm.create_task.assert_called_once()
@pytest.mark.asyncio
async def test_execute_migration_invalid_env_raises_400(_mock_env):
from src.api.routes.migration import execute_migration
from src.models.dashboard import DashboardSelection
cm = _make_sync_config_manager([_mock_env])
selection = DashboardSelection(
source_env_id="test-env-1", target_env_id="non-existent", selected_ids=[1]
)
with pytest.raises(HTTPException) as exc:
await execute_migration(
selection=selection, config_manager=cm, task_manager=MagicMock(), _=None
)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_dry_run_migration_returns_diff_and_risk(db_session):
# @TEST_EDGE: missing_target_datasource -> validates high risk item generation
# @TEST_EDGE: breaking_reference -> validates high risk on missing dataset link
from src.api.routes.migration import dry_run_migration
from src.models.dashboard import DashboardSelection
env_source = MagicMock()
env_source.id = "src"
env_source.name = "Source"
@@ -552,7 +429,6 @@ async def test_dry_run_migration_returns_diff_and_risk(db_session):
env_source.password = "admin"
env_source.verify_ssl = False
env_source.timeout = 30
env_target = MagicMock()
env_target.id = "tgt"
env_target.name = "Target"
@@ -561,7 +437,6 @@ async def test_dry_run_migration_returns_diff_and_risk(db_session):
env_target.password = "admin"
env_target.verify_ssl = False
env_target.timeout = 30
cm = _make_sync_config_manager([env_source, env_target])
selection = DashboardSelection(
selected_ids=[42],
@@ -570,7 +445,6 @@ async def test_dry_run_migration_returns_diff_and_risk(db_session):
replace_db_config=False,
fix_cross_filters=True,
)
with (
patch("src.api.routes.migration.SupersetClient") as MockClient,
patch("src.api.routes.migration.MigrationDryRunService") as MockService,
@@ -578,7 +452,6 @@ async def test_dry_run_migration_returns_diff_and_risk(db_session):
source_client = MagicMock()
target_client = MagicMock()
MockClient.side_effect = [source_client, target_client]
service_instance = MagicMock()
service_payload = {
"generated_at": "2026-02-27T00:00:00+00:00",
@@ -614,24 +487,19 @@ async def test_dry_run_migration_returns_diff_and_risk(db_session):
}
service_instance.run.return_value = service_payload
MockService.return_value = service_instance
result = await dry_run_migration(
selection=selection, config_manager=cm, db=db_session, _=None
)
assert result["summary"]["dashboards"]["update"] == 1
assert result["summary"]["charts"]["create"] == 1
assert result["summary"]["datasets"]["create"] == 1
assert result["risk"]["score"] > 0
assert any(item["code"] == "missing_datasource" for item in result["risk"]["items"])
assert any(item["code"] == "breaking_reference" for item in result["risk"]["items"])
@pytest.mark.asyncio
async def test_dry_run_migration_rejects_same_environment(db_session):
from src.api.routes.migration import dry_run_migration
from src.models.dashboard import DashboardSelection
env = MagicMock()
env.id = "same"
env.name = "Same"
@@ -640,17 +508,13 @@ async def test_dry_run_migration_rejects_same_environment(db_session):
env.password = "admin"
env.verify_ssl = False
env.timeout = 30
cm = _make_sync_config_manager([env])
selection = DashboardSelection(
selected_ids=[1], source_env_id="same", target_env_id="same"
)
with pytest.raises(HTTPException) as exc:
await dry_run_migration(
selection=selection, config_manager=cm, db=db_session, _=None
)
assert exc.value.status_code == 400
# [/DEF:TestMigrationRoutes:Module]
# #endregion TestMigrationRoutes

View File

@@ -1,16 +1,11 @@
# [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.
# #region TestProfileApi [TYPE Module] [C:3] [SEMANTICS tests, profile, api, preferences, lookup, contract]
# @RELATION BELONGS_TO -> SrcRoot
# @BRIEF Verifies profile API route contracts for preference read/update and Superset account lookup.
# @LAYER: API
# [SECTION: IMPORTS]
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from src.app import app
from src.core.database import get_db
from src.dependencies import get_config_manager, get_current_user
@@ -28,51 +23,38 @@ from src.services.profile_service import (
ProfileValidationError,
)
# [/SECTION]
client = TestClient(app)
# [DEF:mock_profile_route_dependencies:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Provides deterministic dependency overrides for profile route tests.
# #region mock_profile_route_dependencies [TYPE Function]
# @RELATION BINDS_TO -> TestProfileApi
# @BRIEF Provides deterministic dependency overrides for profile route tests.
# @PRE: App instance is initialized.
# @POST: Dependencies are overridden for current test and restored afterward.
def mock_profile_route_dependencies():
mock_user = MagicMock()
mock_user.id = "u-1"
mock_user.username = "test-user"
mock_db = MagicMock()
mock_config_manager = MagicMock()
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_db] = lambda: mock_db
app.dependency_overrides[get_config_manager] = lambda: mock_config_manager
return mock_user, mock_db, mock_config_manager
# [/DEF:mock_profile_route_dependencies:Function]
# [DEF:profile_route_deps_fixture:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Pytest fixture wrapper for profile route dependency overrides.
# #endregion mock_profile_route_dependencies
# #region profile_route_deps_fixture [TYPE Function]
# @RELATION BINDS_TO -> TestProfileApi
# @BRIEF Pytest fixture wrapper for profile route dependency overrides.
# @PRE: None.
# @POST: Yields overridden dependencies and clears overrides after test.
import pytest
@pytest.fixture(autouse=True)
def profile_route_deps_fixture():
yielded = mock_profile_route_dependencies()
yield yielded
app.dependency_overrides.clear()
# [/DEF:profile_route_deps_fixture:Function]
# [DEF:_build_preference_response:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Builds stable profile preference response payload for route tests.
# #endregion profile_route_deps_fixture
# #region _build_preference_response [TYPE Function]
# @RELATION BINDS_TO -> TestProfileApi
# @BRIEF Builds stable profile preference response payload for route tests.
# @PRE: user_id is provided.
# @POST: Returns ProfilePreferenceResponse object with deterministic timestamps.
def _build_preference_response(user_id: str = "u-1") -> ProfilePreferenceResponse:
@@ -108,22 +90,18 @@ def _build_preference_response(user_id: str = "u-1") -> ProfilePreferenceRespons
],
),
)
# [/DEF:_build_preference_response:Function]
# [DEF:test_get_profile_preferences_returns_self_payload:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies GET /api/profile/preferences returns stable self-scoped payload.
# #endregion _build_preference_response
# #region test_get_profile_preferences_returns_self_payload [TYPE Function]
# @RELATION BINDS_TO -> TestProfileApi
# @BRIEF 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.
def test_get_profile_preferences_returns_self_payload(profile_route_deps_fixture):
mock_user, _, _ = profile_route_deps_fixture
service = MagicMock()
service.get_my_preference.return_value = _build_preference_response(user_id=mock_user.id)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.get("/api/profile/preferences")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "success"
@@ -141,19 +119,16 @@ def test_get_profile_preferences_returns_self_payload(profile_route_deps_fixture
assert payload["security"]["current_role"] == "Data Engineer"
assert payload["security"]["permissions"][0]["key"] == "migration:run"
service.get_my_preference.assert_called_once_with(mock_user)
# [/DEF:test_get_profile_preferences_returns_self_payload:Function]
# [DEF:test_patch_profile_preferences_success:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies PATCH /api/profile/preferences persists valid payload through route mapping.
# #endregion test_get_profile_preferences_returns_self_payload
# #region test_patch_profile_preferences_success [TYPE Function]
# @RELATION BINDS_TO -> TestProfileApi
# @BRIEF 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.
def test_patch_profile_preferences_success(profile_route_deps_fixture):
mock_user, _, _ = profile_route_deps_fixture
service = MagicMock()
service.update_my_preference.return_value = _build_preference_response(user_id=mock_user.id)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.patch(
"/api/profile/preferences",
@@ -169,7 +144,6 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
"dashboards_table_density": "free",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "success"
@@ -182,7 +156,6 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
assert payload["preference"]["auto_open_task_drawer"] is False
assert payload["preference"]["dashboards_table_density"] == "compact"
service.update_my_preference.assert_called_once()
called_kwargs = service.update_my_preference.call_args.kwargs
assert called_kwargs["current_user"] == mock_user
assert called_kwargs["payload"].git_username == "ivan.ivanov"
@@ -192,12 +165,10 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
assert called_kwargs["payload"].start_page == "reports-logs"
assert called_kwargs["payload"].auto_open_task_drawer is False
assert called_kwargs["payload"].dashboards_table_density == "free"
# [/DEF:test_patch_profile_preferences_success:Function]
# [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.
# #endregion test_patch_profile_preferences_success
# #region test_patch_profile_preferences_validation_error [TYPE Function]
# @RELATION BINDS_TO -> TestProfileApi
# @BRIEF 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.
def test_patch_profile_preferences_validation_error(profile_route_deps_fixture):
@@ -205,7 +176,6 @@ def test_patch_profile_preferences_validation_error(profile_route_deps_fixture):
service.update_my_preference.side_effect = ProfileValidationError(
["Superset username is required when default filter is enabled."]
)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.patch(
"/api/profile/preferences",
@@ -214,17 +184,14 @@ def test_patch_profile_preferences_validation_error(profile_route_deps_fixture):
"show_only_my_dashboards": True,
},
)
assert response.status_code == 422
payload = response.json()
assert "detail" in payload
assert "Superset username is required when default filter is enabled." in payload["detail"]
# [/DEF:test_patch_profile_preferences_validation_error:Function]
# [DEF:test_patch_profile_preferences_cross_user_denied:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies route maps domain authorization guard failure to HTTP 403.
# #endregion test_patch_profile_preferences_validation_error
# #region test_patch_profile_preferences_cross_user_denied [TYPE Function]
# @RELATION BINDS_TO -> TestProfileApi
# @BRIEF Verifies route maps domain authorization guard failure to HTTP 403.
# @PRE: Service raises ProfileAuthorizationError.
# @POST: Response status is 403 with denial message.
def test_patch_profile_preferences_cross_user_denied(profile_route_deps_fixture):
@@ -232,7 +199,6 @@ def test_patch_profile_preferences_cross_user_denied(profile_route_deps_fixture)
service.update_my_preference.side_effect = ProfileAuthorizationError(
"Cross-user preference mutation is forbidden"
)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.patch(
"/api/profile/preferences",
@@ -241,16 +207,13 @@ def test_patch_profile_preferences_cross_user_denied(profile_route_deps_fixture)
"show_only_my_dashboards": True,
},
)
assert response.status_code == 403
payload = response.json()
assert payload["detail"] == "Cross-user preference mutation is forbidden"
# [/DEF:test_patch_profile_preferences_cross_user_denied:Function]
# [DEF:test_lookup_superset_accounts_success:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies lookup route returns success payload with normalized candidates.
# #endregion test_patch_profile_preferences_cross_user_denied
# #region test_lookup_superset_accounts_success [TYPE Function]
# @RELATION BINDS_TO -> TestProfileApi
# @BRIEF 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.
def test_lookup_superset_accounts_success(profile_route_deps_fixture):
@@ -272,22 +235,18 @@ def test_lookup_superset_accounts_success(profile_route_deps_fixture):
)
],
)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.get("/api/profile/superset-accounts?environment_id=dev")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "success"
assert payload["environment_id"] == "dev"
assert payload["total"] == 1
assert payload["items"][0]["username"] == "john_doe"
# [/DEF:test_lookup_superset_accounts_success:Function]
# [DEF:test_lookup_superset_accounts_env_not_found:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies lookup route maps missing environment to HTTP 404.
# #endregion test_lookup_superset_accounts_success
# #region test_lookup_superset_accounts_env_not_found [TYPE Function]
# @RELATION BINDS_TO -> TestProfileApi
# @BRIEF Verifies lookup route maps missing environment to HTTP 404.
# @PRE: Service raises EnvironmentNotFoundError.
# @POST: Response status is 404 with explicit message.
def test_lookup_superset_accounts_env_not_found(profile_route_deps_fixture):
@@ -295,13 +254,10 @@ def test_lookup_superset_accounts_env_not_found(profile_route_deps_fixture):
service.lookup_superset_accounts.side_effect = EnvironmentNotFoundError(
"Environment 'missing-env' not found"
)
with patch("src.api.routes.profile._get_profile_service", return_value=service):
response = client.get("/api/profile/superset-accounts?environment_id=missing-env")
assert response.status_code == 404
payload = response.json()
assert payload["detail"] == "Environment 'missing-env' not found"
# [/DEF:test_lookup_superset_accounts_env_not_found:Function]
# [/DEF:TestProfileApi:Module]
# #endregion test_lookup_superset_accounts_env_not_found
# #endregion TestProfileApi

View File

@@ -1,52 +1,35 @@
# [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.
# #region TestReportsApi [TYPE Module] [C:3] [SEMANTICS tests, reports, api, contract, pagination, filtering]
# @RELATION BELONGS_TO -> SrcRoot
# @BRIEF Contract tests for GET /api/reports defaults, pagination, and filtering behavior.
# @LAYER: Domain (Tests)
# @INVARIANT: API response contract contains {items,total,page,page_size,has_next,applied_filters}.
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from fastapi.testclient import TestClient
from src.app import app
from src.core.task_manager.models import Task, TaskStatus
from src.dependencies import get_current_user, get_task_manager
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
# [DEF:_FakeTaskManager:Class]
# @RELATION: BINDS_TO -> [TestReportsApi]
# @COMPLEXITY: 1
# @PURPOSE: Minimal task-manager double exposing only get_all_tasks used by reports route tests.
# #region _FakeTaskManager [TYPE Class] [C:1]
# @RELATION BINDS_TO -> [TestReportsApi]
# @BRIEF Minimal task-manager double exposing only get_all_tasks used by reports route tests.
# @INVARIANT: Returns pre-seeded tasks without mutation or side effects.
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
def get_all_tasks(self):
return self._tasks
# [/DEF:_FakeTaskManager:Class]
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> TestReportsApi
# @PURPOSE: Build deterministic admin principal accepted by reports authorization guard.
# #endregion _FakeTaskManager
# #region _admin_user [TYPE Function]
# @RELATION BINDS_TO -> TestReportsApi
# @BRIEF Build deterministic admin principal accepted by reports authorization guard.
def _admin_user():
admin_role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[admin_role])
# [/DEF:_admin_user:Function]
# [DEF:_make_task:Function]
# @RELATION: BINDS_TO -> TestReportsApi
# @PURPOSE: Build Task fixture with controlled timestamps/status for reports list/detail normalization.
# #endregion _admin_user
# #region _make_task [TYPE Function]
# @RELATION BINDS_TO -> TestReportsApi
# @BRIEF Build Task fixture with controlled timestamps/status for reports list/detail normalization.
def _make_task(
task_id: str,
plugin_id: str,
@@ -64,14 +47,10 @@ def _make_task(
params={"environment_id": "env-1"},
result=result or {"summary": f"{plugin_id} {status.value.lower()}"},
)
# [/DEF:_make_task:Function]
# [DEF:test_get_reports_default_pagination_contract:Function]
# @RELATION: BINDS_TO -> TestReportsApi
# @PURPOSE: Validate reports list endpoint default pagination and contract keys for mixed task statuses.
# #endregion _make_task
# #region test_get_reports_default_pagination_contract [TYPE Function]
# @RELATION BINDS_TO -> TestReportsApi
# @BRIEF Validate reports list endpoint default pagination and contract keys for mixed task statuses.
def test_get_reports_default_pagination_contract():
now = datetime.utcnow()
tasks = [
@@ -97,15 +76,12 @@ def test_get_reports_default_pagination_contract():
None,
),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports")
assert response.status_code == 200
data = response.json()
assert set(
["items", "total", "page", "page_size", "has_next", "applied_filters"]
@@ -118,14 +94,10 @@ def test_get_reports_default_pagination_contract():
assert data["applied_filters"]["sort_order"] == "desc"
finally:
app.dependency_overrides.clear()
# [/DEF:test_get_reports_default_pagination_contract:Function]
# [DEF:test_get_reports_filter_and_pagination:Function]
# @RELATION: BINDS_TO -> TestReportsApi
# @PURPOSE: Validate reports list endpoint applies task-type/status filters and pagination boundaries.
# #endregion test_get_reports_default_pagination_contract
# #region test_get_reports_filter_and_pagination [TYPE Function]
# @RELATION BINDS_TO -> TestReportsApi
# @BRIEF Validate reports list endpoint applies task-type/status filters and pagination boundaries.
def test_get_reports_filter_and_pagination():
now = datetime.utcnow()
tasks = [
@@ -151,17 +123,14 @@ def test_get_reports_filter_and_pagination():
now - timedelta(minutes=9),
),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get(
"/api/reports?task_types=backup&statuses=failed&page=1&page_size=1"
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["page"] == 1
@@ -172,14 +141,10 @@ def test_get_reports_filter_and_pagination():
assert data["items"][0]["status"] == "failed"
finally:
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
# @PURPOSE: Validate reports sorting remains stable when task timestamps mix naive and timezone-aware datetimes.
# #endregion test_get_reports_filter_and_pagination
# #region test_get_reports_handles_mixed_naive_and_aware_datetimes [TYPE Function]
# @RELATION BINDS_TO -> TestReportsApi
# @BRIEF Validate reports sorting remains stable when task timestamps mix naive and timezone-aware datetimes.
def test_get_reports_handles_mixed_naive_and_aware_datetimes():
naive_now = datetime.utcnow()
aware_now = datetime.now(timezone.utc)
@@ -199,10 +164,8 @@ def test_get_reports_handles_mixed_naive_and_aware_datetimes():
aware_now - timedelta(minutes=2),
),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports?sort_by=updated_at&sort_order=desc")
@@ -212,14 +175,10 @@ def test_get_reports_handles_mixed_naive_and_aware_datetimes():
assert len(data["items"]) == 2
finally:
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
# @PURPOSE: Validate reports list endpoint rejects unsupported task type filters with HTTP 400.
# #endregion test_get_reports_handles_mixed_naive_and_aware_datetimes
# #region test_get_reports_invalid_filter_returns_400 [TYPE Function]
# @RELATION BINDS_TO -> TestReportsApi
# @BRIEF Validate reports list endpoint rejects unsupported task type filters with HTTP 400.
def test_get_reports_invalid_filter_returns_400():
now = datetime.utcnow()
tasks = [
@@ -231,10 +190,8 @@ def test_get_reports_invalid_filter_returns_400():
now - timedelta(minutes=4),
)
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports?task_types=bad_type")
@@ -243,7 +200,5 @@ def test_get_reports_invalid_filter_returns_400():
assert "detail" in body
finally:
app.dependency_overrides.clear()
# [/DEF:test_get_reports_invalid_filter_returns_400:Function]
# [/DEF:TestReportsApi:Module]
# #endregion test_get_reports_invalid_filter_returns_400
# #endregion TestReportsApi

View File

@@ -1,52 +1,35 @@
# [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.
# #region TestReportsDetailApi [TYPE Module] [C:3] [SEMANTICS tests, reports, api, detail, diagnostics]
# @RELATION BELONGS_TO -> SrcRoot
# @BRIEF Contract tests for GET /api/reports/{report_id} detail endpoint behavior.
# @LAYER: Domain (Tests)
# @INVARIANT: Detail endpoint tests must keep deterministic assertions for success and not-found contracts.
from datetime import datetime, timedelta
from types import SimpleNamespace
from fastapi.testclient import TestClient
from src.app import app
from src.core.task_manager.models import Task, TaskStatus
from src.dependencies import get_current_user, get_task_manager
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
# [DEF:_FakeTaskManager:Class]
# @RELATION: BINDS_TO -> [TestReportsDetailApi]
# @COMPLEXITY: 1
# @PURPOSE: Minimal task-manager double exposing pre-seeded tasks to detail endpoint under test.
# #region _FakeTaskManager [TYPE Class] [C:1]
# @RELATION BINDS_TO -> [TestReportsDetailApi]
# @BRIEF Minimal task-manager double exposing pre-seeded tasks to detail endpoint under test.
# @INVARIANT: get_all_tasks returns exactly seeded tasks list.
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
def get_all_tasks(self):
return self._tasks
# [/DEF:_FakeTaskManager:Class]
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> TestReportsDetailApi
# @PURPOSE: Provide admin principal fixture accepted by reports detail authorization policy.
# #endregion _FakeTaskManager
# #region _admin_user [TYPE Function]
# @RELATION BINDS_TO -> TestReportsDetailApi
# @BRIEF Provide admin principal fixture accepted by reports detail authorization policy.
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
# @PURPOSE: Build deterministic Task payload for reports detail endpoint contract assertions.
# #endregion _admin_user
# #region _make_task [TYPE Function]
# @RELATION BINDS_TO -> TestReportsDetailApi
# @BRIEF Build deterministic Task payload for reports detail endpoint contract assertions.
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=None):
now = datetime.utcnow()
return Task(
@@ -60,14 +43,10 @@ def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=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
# @PURPOSE: Validate report detail endpoint returns report body with diagnostics and next actions for existing task.
# #endregion _make_task
# #region test_get_report_detail_success [TYPE Function]
# @RELATION BINDS_TO -> TestReportsDetailApi
# @BRIEF Validate report detail endpoint returns report body with diagnostics and next actions for existing task.
def test_get_report_detail_success():
task = _make_task(
"detail-1",
@@ -80,15 +59,12 @@ def test_get_report_detail_success():
}
},
)
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
try:
client = TestClient(app)
response = client.get("/api/reports/detail-1")
assert response.status_code == 200
data = response.json()
assert "report" in data
assert data["report"]["report_id"] == "detail-1"
@@ -96,27 +72,19 @@ def test_get_report_detail_success():
assert "next_actions" in data
finally:
app.dependency_overrides.clear()
# [/DEF:test_get_report_detail_success:Function]
# [DEF:test_get_report_detail_not_found:Function]
# @RELATION: BINDS_TO -> TestReportsDetailApi
# @PURPOSE: Validate report detail endpoint returns 404 when requested report identifier is absent.
# #endregion test_get_report_detail_success
# #region test_get_report_detail_not_found [TYPE Function]
# @RELATION BINDS_TO -> TestReportsDetailApi
# @BRIEF Validate report detail endpoint returns 404 when requested report identifier is absent.
def test_get_report_detail_not_found():
task = _make_task("detail-2", "superset-backup", TaskStatus.SUCCESS)
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
try:
client = TestClient(app)
response = client.get("/api/reports/unknown-id")
assert response.status_code == 404
finally:
app.dependency_overrides.clear()
# [/DEF:test_get_report_detail_not_found:Function]
# [/DEF:TestReportsDetailApi:Module]
# #endregion test_get_report_detail_not_found
# #endregion TestReportsDetailApi

View File

@@ -1,51 +1,34 @@
# [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.
# #region TestReportsOpenapiConformance [TYPE Module] [C:3] [SEMANTICS tests, reports, openapi, conformance]
# @RELATION BELONGS_TO -> SrcRoot
# @BRIEF Validate implemented reports payload shape against OpenAPI-required top-level contract fields.
# @LAYER: Domain (Tests)
# @INVARIANT: List and detail payloads include required contract keys.
from datetime import datetime
from types import SimpleNamespace
from fastapi.testclient import TestClient
from src.app import app
from src.core.task_manager.models import Task, TaskStatus
from src.dependencies import get_current_user, get_task_manager
# [DEF:_FakeTaskManager:Class]
# @RELATION: BINDS_TO -> [TestReportsOpenapiConformance]
# @COMPLEXITY: 1
# @PURPOSE: Minimal task-manager fake exposing static task list for OpenAPI conformance checks.
# #region _FakeTaskManager [TYPE Class] [C:1]
# @RELATION BINDS_TO -> [TestReportsOpenapiConformance]
# @BRIEF Minimal task-manager fake exposing static task list for OpenAPI conformance checks.
# @INVARIANT: get_all_tasks returns seeded tasks unchanged.
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
def get_all_tasks(self):
return self._tasks
# [/DEF:_FakeTaskManager:Class]
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> TestReportsOpenapiConformance
# @PURPOSE: Provide admin principal fixture required by reports routes in conformance tests.
# #endregion _FakeTaskManager
# #region _admin_user [TYPE Function]
# @RELATION BINDS_TO -> TestReportsOpenapiConformance
# @BRIEF Provide admin principal fixture required by reports routes in conformance tests.
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
# @PURPOSE: Construct deterministic task fixture consumed by reports list/detail payload assertions.
# #endregion _admin_user
# #region _task [TYPE Function]
# @RELATION BINDS_TO -> TestReportsOpenapiConformance
# @BRIEF Construct deterministic task fixture consumed by reports list/detail payload assertions.
def _task(task_id: str, plugin_id: str, status: TaskStatus):
now = datetime.utcnow()
return Task(
@@ -57,14 +40,10 @@ def _task(task_id: str, plugin_id: str, status: TaskStatus):
params={"environment_id": "env-1"},
result={"summary": f"{plugin_id} {status.value.lower()}"},
)
# [/DEF:_task:Function]
# [DEF:test_reports_list_openapi_required_keys:Function]
# @RELATION: BINDS_TO -> TestReportsOpenapiConformance
# @PURPOSE: Verify reports list endpoint includes all required OpenAPI top-level keys.
# #endregion _task
# #region test_reports_list_openapi_required_keys [TYPE Function]
# @RELATION BINDS_TO -> TestReportsOpenapiConformance
# @BRIEF Verify reports list endpoint includes all required OpenAPI top-level keys.
def test_reports_list_openapi_required_keys():
tasks = [
_task("r-1", "superset-backup", TaskStatus.SUCCESS),
@@ -72,12 +51,10 @@ def test_reports_list_openapi_required_keys():
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports")
assert response.status_code == 200
body = response.json()
required = {
"items",
@@ -90,29 +67,21 @@ def test_reports_list_openapi_required_keys():
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
# @PURPOSE: Verify reports detail endpoint returns payload containing the report object key.
# #endregion test_reports_list_openapi_required_keys
# #region test_reports_detail_openapi_required_keys [TYPE Function]
# @RELATION BINDS_TO -> TestReportsOpenapiConformance
# @BRIEF Verify reports detail endpoint returns payload containing the report object key.
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()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports/r-3")
assert response.status_code == 200
body = response.json()
assert "report" in body
finally:
app.dependency_overrides.clear()
# [/DEF:test_reports_detail_openapi_required_keys:Function]
# [/DEF:TestReportsOpenapiConformance:Module]
# #endregion test_reports_detail_openapi_required_keys
# #endregion TestReportsOpenapiConformance

View File

@@ -1,50 +1,37 @@
# [DEF:test_tasks_logs_module:Module]
# @RELATION: VERIFIES -> [src.api.routes.tasks:Module]
# @COMPLEXITY: 2
# @SEMANTICS: tests, tasks, logs, api, contract, validation
# @PURPOSE: Contract testing for task logs API endpoints.
# #region test_tasks_logs_module [TYPE Module] [C:2] [SEMANTICS tests, tasks, logs, api, contract, validation]
# @RELATION VERIFIES -> [src.api.routes.tasks:Module]
# @BRIEF Contract testing for task logs API endpoints.
# @LAYER: Domain (Tests)
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from unittest.mock import MagicMock
from src.dependencies import get_task_manager, has_permission
from src.api.routes.tasks import router
# @TEST_FIXTURE: mock_app
@pytest.fixture
def client():
app = FastAPI()
app.include_router(router, prefix="/tasks")
# Mock TaskManager
# @INVARIANT: unconstrained mock — no spec= enforced
mock_tm = MagicMock()
app.dependency_overrides[get_task_manager] = lambda: mock_tm
# Mock permissions (bypass for unit test)
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: True
return TestClient(app), mock_tm
# @TEST_CONTRACT: get_task_logs_api -> Invariants
# @TEST_FIXTURE: valid_task_logs_request
# [DEF:test_get_task_logs_success:Function]
# @RELATION: BINDS_TO -> test_tasks_logs_module
# @PURPOSE: Validate task logs endpoint returns filtered logs for an existing task.
# #region test_get_task_logs_success [TYPE Function]
# @RELATION BINDS_TO -> test_tasks_logs_module
# @BRIEF Validate task logs endpoint returns filtered logs for an existing task.
def test_get_task_logs_success(client):
tc, tm = client
# Setup mock task
mock_task = MagicMock()
tm.get_task.return_value = mock_task
tm.get_task_logs.return_value = [{"level": "INFO", "message": "msg1"}]
response = tc.get("/tasks/task-1/logs?level=INFO")
assert response.status_code == 200
assert response.json() == [{"level": "INFO", "message": "msg1"}]
tm.get_task.assert_called_with("task-1")
@@ -52,57 +39,39 @@ def test_get_task_logs_success(client):
args = tm.get_task_logs.call_args
assert args[0][0] == "task-1"
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 -> test_tasks_logs_module
# @PURPOSE: Validate task logs endpoint returns 404 when the task identifier is missing.
# #endregion test_get_task_logs_success
# #region test_get_task_logs_not_found [TYPE Function]
# @RELATION BINDS_TO -> test_tasks_logs_module
# @BRIEF Validate task logs endpoint returns 404 when the task identifier is missing.
def test_get_task_logs_not_found(client):
tc, tm = client
tm.get_task.return_value = None
response = tc.get("/tasks/missing/logs")
assert response.status_code == 404
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 -> test_tasks_logs_module
# @PURPOSE: Validate task logs endpoint enforces query validation for limit lower bound.
# #endregion test_get_task_logs_not_found
# #region test_get_task_logs_invalid_limit [TYPE Function]
# @RELATION BINDS_TO -> test_tasks_logs_module
# @BRIEF Validate task logs endpoint enforces query validation for limit lower bound.
def test_get_task_logs_invalid_limit(client):
tc, tm = client
# limit=0 is ge=1 in Query
response = tc.get("/tasks/task-1/logs?limit=0")
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 -> test_tasks_logs_module
# @PURPOSE: Validate log stats endpoint returns success payload for an existing task.
# #endregion test_get_task_logs_invalid_limit
# #region test_get_task_log_stats_success [TYPE Function]
# @RELATION BINDS_TO -> test_tasks_logs_module
# @BRIEF Validate log stats endpoint returns success payload for an existing task.
def test_get_task_log_stats_success(client):
tc, tm = client
tm.get_task.return_value = MagicMock()
tm.get_task_log_stats.return_value = {"INFO": 5, "ERROR": 1}
response = tc.get("/tasks/task-1/logs/stats")
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]
# [/DEF:test_tasks_logs_module:Module]
# #endregion test_get_task_log_stats_success
# #endregion test_tasks_logs_module

View File

@@ -1,4 +1,4 @@
# #region AdminApi [C:3] [TYPE Module] [SEMANTICS api, admin, users, roles, permissions]
# #region AdminApi [C:3] [TYPE Module] [SEMANTICS fastapi, admin, api, rbac, user]
#
# @BRIEF Admin API endpoints for user and role management.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region AssistantApi [C:5] [TYPE Module] [SEMANTICS api, assistant, chat, command, confirmation]
# #region AssistantApi [C:5] [TYPE Module] [SEMANTICS assistant, api, package, llm, execution]
# @BRIEF API routes for LLM assistant command parsing and safe execution orchestration.
# @LAYER: API
# @RELATION DEPENDS_ON -> [TaskManager]

View File

@@ -1,4 +1,4 @@
# #region AssistantAdminRoutes [C:5] [TYPE Module]
# #region AssistantAdminRoutes [C:5] [TYPE Module] [SEMANTICS assistant, admin, route, audit, conversation]
# @BRIEF FastAPI route handlers for assistant admin operations — conversation listing, deletion, history, audit.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantRoutes]

View File

@@ -1,4 +1,4 @@
# #region AssistantCommandParser [C:4] [TYPE Module]
# #region AssistantCommandParser [C:4] [TYPE Module] [SEMANTICS assistant, command, parser, nlu, intent]
# @BRIEF Deterministic RU/EN command text parser that converts user messages into intent payloads.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantResolvers]

View File

@@ -1,4 +1,4 @@
# #region AssistantDatasetReview [C:4] [TYPE Module]
# #region AssistantDatasetReview [C:4] [TYPE Module] [SEMANTICS assistant, dataset, review, context, intent]
# @BRIEF Dataset review context loading and intent planning for the assistant API.
# @LAYER: API
# @RELATION DEPENDS_ON -> [DatasetReviewOrchestrator]

View File

@@ -1,4 +1,4 @@
# #region AssistantDatasetReviewDispatch [C:4] [TYPE Module]
# #region AssistantDatasetReviewDispatch [C:4] [TYPE Module] [SEMANTICS assistant, dataset, review, dispatch, confirm]
# @BRIEF Dispatch and confirmation handling for dataset-review assistant intents.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantDatasetReview]

View File

@@ -1,4 +1,4 @@
# #region AssistantDispatch [C:5] [TYPE Module]
# #region AssistantDispatch [C:5] [TYPE Module] [SEMANTICS assistant, dispatch, confirm, execution, orchestration]
# @BRIEF Intent dispatch engine, confirmation summary, and clarification text for the assistant API.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantSchemas]

View File

@@ -1,4 +1,4 @@
# #region AssistantHistory [C:2] [TYPE Module]
# #region AssistantHistory [C:2] [TYPE Module] [SEMANTICS assistant, history, audit, persistence, conversation]
# @BRIEF Conversation history, audit trail, and confirmation persistence helpers for the assistant API.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantSchemas]

View File

@@ -1,4 +1,4 @@
# #region AssistantLlmPlanner [C:3] [TYPE Module]
# #region AssistantLlmPlanner [C:3] [TYPE Module] [SEMANTICS assistant, llm, planner, tool, catalog]
# @BRIEF LLM-based intent planning, tool catalog construction, and authorization for the assistant API.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantSchemas]

View File

@@ -1,4 +1,4 @@
# #region AssistantLlmPlannerIntent [C:3] [TYPE Module]
# #region AssistantLlmPlannerIntent [C:3] [TYPE Module] [SEMANTICS assistant, llm, intent, planning, authorization]
# @BRIEF LLM-based intent planning and authorization for the assistant API — separated from tool catalog.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantLlmPlanner]

View File

@@ -1,4 +1,4 @@
# #region AssistantResolvers [C:2] [TYPE Module]
# #region AssistantResolvers [C:2] [TYPE Module] [SEMANTICS assistant, resolver, lookup, environment, mapper]
# @BRIEF Environment, dashboard, provider, and task resolution utilities for the assistant API.
# @LAYER: API
# @RELATION DEPENDS_ON -> [ConfigManager]

View File

@@ -1,4 +1,4 @@
# #region AssistantRoutes [C:5] [TYPE Module]
# #region AssistantRoutes [C:5] [TYPE Module] [SEMANTICS assistant, api, route, chat, execution]
# @BRIEF FastAPI route handlers for the assistant API — message sending, confirmation, conversation management.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantSchemas]

View File

@@ -1,4 +1,4 @@
# #region AssistantSchemas [C:2] [TYPE Module]
# #region AssistantSchemas [C:2] [TYPE Module] [SEMANTICS assistant, pydantic, schema, store, permission]
# @BRIEF Pydantic models, in-memory stores, and permission mappings for the assistant API.
# @LAYER: API
# @RELATION USED_BY -> [AssistantRoutes]

View File

@@ -1,4 +1,4 @@
# #region CleanReleaseApi [C:4] [TYPE Module] [SEMANTICS api, clean-release, candidate-preparation, compliance]
# #region CleanReleaseApi [C:4] [TYPE Module] [SEMANTICS fastapi, clean-release, api, compliance, candidate, release]
# @BRIEF Expose clean release endpoints for candidate preparation and subsequent compliance flow.
# @LAYER: API
# @RELATION DEPENDS_ON -> [get_clean_release_repository]

View File

@@ -1,4 +1,4 @@
# #region CleanReleaseV2Api [C:4] [TYPE Module]
# #region CleanReleaseV2Api [C:4] [TYPE Module] [SEMANTICS fastapi, clean-release, candidate, lifecycle, api]
# @BRIEF Redesigned clean release API for headless candidate lifecycle.
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]

View File

@@ -1,4 +1,4 @@
# #region ConnectionsRouter [C:3] [TYPE Module] [SEMANTICS api, router, connections, database]
# #region ConnectionsRouter [C:3] [TYPE Module] [SEMANTICS fastapi, connection, api, search]
# @BRIEF Defines the FastAPI router for managing external database connections.
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [get_db]

View File

@@ -1,4 +1,4 @@
# #region DashboardsApi [C:5] [TYPE Module] [SEMANTICS api, dashboards, resources, hub]
# #region DashboardsApi [C:5] [TYPE Module] [SEMANTICS dashboard, api, package, search, git, task]
#
# @BRIEF API endpoints for the Dashboard Hub - listing dashboards with Git and task status
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region DashboardActionRoutes [C:2] [TYPE Module] [SEMANTICS api, dashboards, actions, routes]
# #region DashboardActionRoutes [C:2] [TYPE Module] [SEMANTICS fastapi, dashboard, api, backup]
# @BRIEF Dashboard action route handlers — migrate, backup.
# @LAYER: API
# @RELATION DEPENDS_ON -> [DashboardRouter]

View File

@@ -1,4 +1,4 @@
# #region DashboardDetailRoutes [C:3] [TYPE Module] [SEMANTICS api, dashboards, detail, routes]
# #region DashboardDetailRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, dashboard, api, transform, search, task]
# @BRIEF Dashboard detail, db-mappings, task history, thumbnail route handlers.
# @LAYER: API
# @RELATION DEPENDS_ON -> [DashboardRouter]

View File

@@ -1,4 +1,4 @@
# #region DashboardHelpers [C:2] [TYPE Module] [SEMANTICS api, dashboards, helpers, resolution]
# #region DashboardHelpers [C:2] [TYPE Module] [SEMANTICS fastapi, dashboard, api, search, filter]
# @BRIEF Basic helper functions for dashboard route handlers — slug resolution, filter normalization.
# @LAYER: Infra
# @RELATION DEPENDS_ON -> [SupersetClient]

View File

@@ -1,4 +1,4 @@
# #region DashboardListingRoutes [C:4] [TYPE Module] [SEMANTICS api, dashboards, listing, routes]
# #region DashboardListingRoutes [C:4] [TYPE Module] [SEMANTICS fastapi, dashboard, api, search]
# @BRIEF Dashboard listing route handler for Dashboard Hub.
# @LAYER: API
# @RELATION DEPENDS_ON -> [DashboardRouter]

View File

@@ -1,4 +1,4 @@
# #region DashboardProjection [C:2] [TYPE Module] [SEMANTICS api, dashboards, projection, profile, filtering]
# #region DashboardProjection [C:2] [TYPE Module] [SEMANTICS dashboard, api, transform, profile, filter]
# @BRIEF Dashboard response projection and profile-filter helpers for Dashboard Hub routes.
# @LAYER: Infra
# @RELATION DEPENDS_ON -> [SupersetClient]

View File

@@ -1,4 +1,4 @@
# #region DashboardsRouter [C:1] [TYPE Block]
# #region DashboardsRouter [C:1] [TYPE Block] [SEMANTICS dashboard, api, router]
# @BRIEF Single APIRouter instance for all dashboard endpoints.
# @RELATION BINDS_TO -> [DashboardsApi]
from fastapi import APIRouter

View File

@@ -1,4 +1,4 @@
# #region DashboardSchemas [C:1] [TYPE Module] [SEMANTICS api, dashboards, schemas, dto]
# #region DashboardSchemas [C:1] [TYPE Module] [SEMANTICS pydantic, dashboard, api, dto, git-status]
# @BRIEF DTO classes for the Dashboard Hub API.
# @LAYER: Infra
# @RELATION DEPENDS_ON -> [Pydantic]

View File

@@ -1,4 +1,4 @@
# #region DatasetReviewApi [C:3] [TYPE Module] [SEMANTICS dataset_review, api, session_lifecycle, exports, rbac, feature_flags]
# #region DatasetReviewApi [C:3] [TYPE Module] [SEMANTICS dataset, review, api, facade]
# @BRIEF Thin facade re-exporting router and public symbols from decomposed dataset review API sub-modules.
# @LAYER: API
# @RATIONALE: Original 2484-line monolith violated INV_7 (400-line module limit) by 6x. Decomposed into _dependencies (DTOs/guards/serializers) and _routes (handlers).

View File

@@ -1,4 +1,4 @@
# #region DatasetReviewDependencies [C:2] [TYPE Module]
# #region DatasetReviewDependencies [C:2] [TYPE Module] [SEMANTICS dataset, review, dependency, di, serialize]
# @BRIEF Dependency injection, serialization helpers, and feature-flag guards for dataset review endpoints.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region DatasetReviewRoutes [C:3] [TYPE Module]
# #region DatasetReviewRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, dataset, review, feedback, route]
# @BRIEF Persist thumbs up/down feedback for clarification question/answer content.

View File

@@ -1,4 +1,4 @@
# #region DatasetsApi [C:3] [TYPE Module] [SEMANTICS api, datasets, resources, hub]
# #region DatasetsApi [C:3] [TYPE Module] [SEMANTICS fastapi, dataset, api, search, mapping, mapped-fields]
#
# @BRIEF API endpoints for the Dataset Hub - listing datasets with mapping progress
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region EnvironmentsApi [C:3] [TYPE Module] [SEMANTICS api, environments, superset, databases]
# #region EnvironmentsApi [C:3] [TYPE Module] [SEMANTICS fastapi, environment, api]
#
# @BRIEF API endpoints for listing environments and their databases.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region GitPackage [C:3] [TYPE Module] [SEMANTICS git, package, re-exports]
# #region GitPackage [C:3] [TYPE Module] [SEMANTICS git, api, package, sync]
# @BRIEF Package root for decomposed git routes. Re-exports all public symbols from submodules.
# @LAYER: API
# @RELATION USES -> [GitRouter, GitDeps, GitHelpers, GitConfigRoutes, GitGiteaRoutes,

View File

@@ -1,4 +1,4 @@
# #region GitConfigRoutes [C:2] [TYPE Module] [SEMANTICS git, config, routes, crud]
# #region GitConfigRoutes [C:2] [TYPE Module] [SEMANTICS fastapi, git, api, search, connection]
# @BRIEF FastAPI endpoints for Git server configuration CRUD and connection testing.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region GitDeps [C:1] [TYPE Module] [SEMANTICS git, deps, service-locator]
# #region GitDeps [C:1] [TYPE Module] [SEMANTICS git, dependency, service, provider]
# @BRIEF Shared dependency wiring for monkeypatch-safe git_service access and constants.
# @LAYER: API
# @INVARIANT: get_git_service() resolves from sys.modules at call time so test monkeypatching

View File

@@ -1,4 +1,4 @@
# #region GitEnvironmentRoutes [C:2] [TYPE Module] [SEMANTICS git, environments, routes]
# #region GitEnvironmentRoutes [C:2] [TYPE Module] [SEMANTICS fastapi, git, environment, api]
# @BRIEF FastAPI endpoint for listing deployment environments.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region GitGiteaRoutes [C:2] [TYPE Module] [SEMANTICS git, gitea, routes, repositories]
# #region GitGiteaRoutes [C:2] [TYPE Module] [SEMANTICS fastapi, git, gitea, api]
# @BRIEF FastAPI endpoints for Gitea-specific repository operations.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region GitHelpers [C:3] [TYPE Module] [SEMANTICS git, helpers, resolution, identity]
# #region GitHelpers [C:3] [TYPE Module] [SEMANTICS fastapi, git, api]
# @BRIEF Shared helper functions for Git route modules.
# @LAYER: API
# @RELATION USES -> [GitDeps]

View File

@@ -1,4 +1,4 @@
# #region GitMergeRoutes [C:3] [TYPE Module] [SEMANTICS git, merge, routes, conflicts]
# #region GitMergeRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, git, api]
# @BRIEF FastAPI endpoints for merge operations (status, conflicts, resolve, abort, continue).
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region GitRepoLifecycleRoutes [C:3] [TYPE Module] [SEMANTICS git, lifecycle, sync, promote, deploy]
# #region GitRepoLifecycleRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, git, api, sync, deploy]
# @BRIEF FastAPI endpoints for Git lifecycle operations (sync, promote, deploy).
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region GitRepoOperationsRoutes [C:3] [TYPE Module] [SEMANTICS git, repository, operations, commit, push, pull, status]
# #region GitRepoOperationsRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, git, api, history, diff]
# @BRIEF FastAPI endpoints for Git repository operations (commit, push, pull, status, diff, history).
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region GitRepoRoutes [C:3] [TYPE Module] [SEMANTICS git, repository, routes, init, branches]
# #region GitRepoRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, git, api, search]
# @BRIEF FastAPI endpoints for core Git repository operations (init, binding, branches, checkout).
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region GitRouter [C:1] [TYPE Module] [SEMANTICS git, routes, fastapi, router]
# #region GitRouter [C:1] [TYPE Module] [SEMANTICS fastapi, git, api]
# @BRIEF Shared APIRouter for all Git route modules.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region GitSchemas [C:1] [TYPE Module] [SEMANTICS git, schemas, pydantic, api, contracts]
# #region GitSchemas [C:1] [TYPE Module] [SEMANTICS fastapi, git, api, git-server-config-base]
#
# @BRIEF Defines Pydantic models for the Git integration API layer.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region health_router [C:3] [TYPE Module] [SEMANTICS health, monitoring, dashboards]
# #region health_router [C:3] [TYPE Module] [SEMANTICS fastapi, health, api, search, dashboard]
# @BRIEF API endpoints for dashboard health monitoring and status aggregation.
# @LAYER: UI/API
# @RELATION DEPENDS_ON -> health_service

View File

@@ -1,4 +1,4 @@
# #region LlmRoutes [C:3] [TYPE Module] [SEMANTICS api, routes, llm]
# #region LlmRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, llm, api, provider]
# @BRIEF API routes for LLM provider configuration and management.
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [LLMProviderService]

View File

@@ -1,4 +1,4 @@
# #region MappingsApi [C:3] [TYPE Module] [SEMANTICS api, mappings, database, fuzzy-matching]
# #region MappingsApi [C:3] [TYPE Module] [SEMANTICS fastapi, mapping, api, mapping-create]
#
# @BRIEF API endpoints for managing database mappings and getting suggestions.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region MigrationApi [C:5] [TYPE Module] [SEMANTICS api, migration, dashboards, sync, dry-run]
# #region MigrationApi [C:5] [TYPE Module] [SEMANTICS fastapi, migration, api, sync, search, mapping]
# @BRIEF HTTP contract layer for migration orchestration, settings, dry-run, and mapping sync endpoints.
# @LAYER: Infra
# @RELATION DEPENDS_ON -> [AppDependencies]

View File

@@ -1,4 +1,4 @@
# #region PluginsRouter [C:3] [TYPE Module] [SEMANTICS api, router, plugins, list]
# #region PluginsRouter [C:3] [TYPE Module] [SEMANTICS fastapi, plugin, api]
# @BRIEF Defines the FastAPI router for plugin-related endpoints, allowing clients to list available plugins.
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [PluginConfig]

View File

@@ -1,4 +1,4 @@
# #region ProfileApiModule [C:3] [TYPE Module] [SEMANTICS api, profile, preferences, self-service, account-lookup]
# #region ProfileApiModule [C:3] [TYPE Module] [SEMANTICS fastapi, profile, api, validate, search, superset]
#
# @BRIEF Exposes self-scoped profile preference endpoints and environment-based Superset account lookup.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region ReportsRouter [C:5] [TYPE Module] [SEMANTICS api, reports, list, detail, pagination, filters]
# #region ReportsRouter [C:5] [TYPE Module] [SEMANTICS fastapi, report, api, transform, search, task]
# @BRIEF FastAPI router for unified task report list and detail retrieval endpoints.
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [ReportsService:Class]

View File

@@ -1,4 +1,4 @@
# #region SettingsRouter [C:3] [TYPE Module] [SEMANTICS settings, api, router, fastapi]
# #region SettingsRouter [C:3] [TYPE Module] [SEMANTICS fastapi, api, superset, logging-config-response]
#
# @BRIEF Provides API endpoints for managing application settings and Superset environments.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region storage_routes [C:3] [TYPE Module] [SEMANTICS storage, files, upload, download, backup, repository]
# #region storage_routes [C:3] [TYPE Module] [SEMANTICS fastapi, storage, api, upload, download]
#
# @BRIEF API endpoints for file storage management (backups and repositories).
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region TasksRouter [C:3] [TYPE Module] [SEMANTICS api, router, tasks, create, list, get, logs]
# #region TasksRouter [C:3] [TYPE Module] [SEMANTICS fastapi, task, api, search, create-task-request, resolve-task-request]
# @BRIEF 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 -> [TaskManager]

View File

@@ -1,4 +1,4 @@
# #region TranslateRoutes [C:4] [TYPE Module] [SEMANTICS api, routes, translate]
# #region TranslateRoutes [C:4] [TYPE Module] [SEMANTICS translate, api, package, schedule, dashboard, llm]
# @BRIEF API routes for LLM-based SQL/dashboard translation management including terminology dictionary CRUD and import.
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [TranslateJobResponse]

View File

@@ -1,4 +1,4 @@
# #region TranslateCorrectionRoutesModule [C:2] [TYPE Module] [SEMANTICS api, routes, translate, corrections]
# #region TranslateCorrectionRoutesModule [C:2] [TYPE Module] [SEMANTICS fastapi, translate, api, search]
# @BRIEF Term correction submission endpoints.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region TranslateDictionaryRoutesModule [C:3] [TYPE Module] [SEMANTICS api, routes, translate, dictionaries]
# #region TranslateDictionaryRoutesModule [C:3] [TYPE Module] [SEMANTICS fastapi, translate, api, search]
# @BRIEF Terminology Dictionary CRUD, entries management, and import routes.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region TranslateHelpersModule [C:2] [TYPE Module] [SEMANTICS api, routes, translate, helpers]
# #region TranslateHelpersModule [C:2] [TYPE Module] [SEMANTICS sqlalchemy, translate, helper, query, mapping]
# @BRIEF Shared helper functions for translate route handlers.
# @LAYER: API
# @RELATION DEPENDS_ON -> [models.translate]

View File

@@ -1,4 +1,4 @@
# #region TranslateJobRoutesModule [C:3] [TYPE Module] [SEMANTICS api, routes, translate, jobs]
# #region TranslateJobRoutesModule [C:3] [TYPE Module] [SEMANTICS fastapi, translate, api, search]
# @BRIEF Translation Job CRUD and datasource column routes.
# @LAYER: API

View File

@@ -1,4 +1,4 @@
# #region TranslateMetricsRoutesModule [C:2] [TYPE Module] [SEMANTICS api, routes, translate, metrics]
# #region TranslateMetricsRoutesModule [C:2] [TYPE Module] [SEMANTICS fastapi, translate, api, search]
# @BRIEF Translation Metrics endpoints.
# @LAYER: API

Some files were not shown because too many files have changed in this diff Show More