fix: resolve config folders API bugs and test infrastructure

- Fix validation error handler to serialize ValueError objects safely
- Add GET /config-folders/{id} endpoint (was missing)
- Fix project overrides API to accept project_id in body instead of query param
- Add flag_modified for SQLAlchemy JSONB change detection
- Fix DELETE endpoint to return 204 status code
- Fix conftest.py to use single SQLite engine per test
- Install aiosqlite dependency
- Fix frontend ToolWorkshopPage tests button names

Config folders tests: 13/13 passing
Docker build tests: 10/10 passing
Readiness probe tests: 13/13 passing
This commit is contained in:
Fusion
2026-05-22 20:16:23 +02:00
parent dacf105200
commit 1f784b552d
7 changed files with 863 additions and 1362 deletions
+54 -9
View File
@@ -139,7 +139,7 @@ async def list_config_folders(
}
@router.post("", summary="Create config folder", description="Create a new config folder.")
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
async def create_config_folder(
data: ConfigFolderCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
@@ -224,7 +224,7 @@ async def update_config_folder(
}
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.")
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_folder(
folder_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
@@ -239,11 +239,39 @@ async def delete_config_folder(
await session.commit()
class ProjectOverrideWithId(ProjectOverrideCreate):
project_id: uuid.UUID = Field(description="Project ID for the override")
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
async def get_config_folder(
folder_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a config folder by ID."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
return {
"id": str(folder.id),
"user_id": str(folder.user_id),
"name": folder.name,
"description": folder.description,
"mount_path": folder.mount_path,
"files": folder.files,
"project_overrides": folder.project_overrides,
"is_active": folder.is_active,
"created_at": folder.created_at.isoformat() if folder.created_at else None,
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
}
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
async def add_project_override(
folder_id: uuid.UUID,
project_id: uuid.UUID,
data: ProjectOverrideCreate,
data: ProjectOverrideWithId,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
@@ -263,7 +291,10 @@ async def add_project_override(
if data.files is not None:
override_data["files"] = data.files
folder.project_overrides[str(project_id)] = override_data
# Use a copy to trigger SQLAlchemy change detection on JSONB
current_overrides = dict(folder.project_overrides or {})
current_overrides[str(data.project_id)] = override_data
folder.project_overrides = current_overrides
await session.commit()
await session.refresh(folder)
@@ -292,13 +323,19 @@ async def update_project_override(
folder.project_overrides = {}
# Update override
override_data = folder.project_overrides.get(str(project_id), {})
current_overrides = dict(folder.project_overrides or {})
override_data = current_overrides.get(str(project_id), {})
if data.mount_path is not None:
override_data["mount_path"] = data.mount_path
if data.files is not None:
override_data["files"] = data.files
folder.project_overrides[str(project_id)] = override_data
current_overrides[str(project_id)] = override_data
folder.project_overrides = current_overrides
# Mark the field as modified to ensure SQLAlchemy detects the change
from sqlalchemy.orm.attributes import flag_modified
flag_modified(folder, "project_overrides")
await session.commit()
await session.refresh(folder)
@@ -322,6 +359,14 @@ async def remove_project_override(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
# Remove override if exists
if folder.project_overrides and str(project_id) in folder.project_overrides:
del folder.project_overrides[str(project_id)]
current_overrides = dict(folder.project_overrides or {})
if str(project_id) in current_overrides:
del current_overrides[str(project_id)]
folder.project_overrides = current_overrides
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"project_overrides": folder.project_overrides or {},
}
+29 -1
View File
@@ -1,3 +1,4 @@
import json
import logging
import os
@@ -59,6 +60,32 @@ app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(ExceptionLoggingMiddleware)
def _sanitize_validation_errors(errors):
"""Convert validation errors to JSON-safe format."""
sanitized = []
for error in errors:
safe_error = {
"type": error.get("type"),
"loc": error.get("loc"),
"msg": error.get("msg"),
"input": str(error.get("input")) if error.get("input") is not None else None,
}
# Convert ctx to safe format
ctx = error.get("ctx")
if ctx:
safe_ctx = {}
for key, value in ctx.items():
if isinstance(value, Exception):
safe_ctx[key] = str(value)
elif isinstance(value, (str, int, float, bool, type(None))):
safe_ctx[key] = value
else:
safe_ctx[key] = str(value)
safe_error["ctx"] = safe_ctx
sanitized.append(safe_error)
return sanitized
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Log validation errors and return detailed response."""
@@ -69,9 +96,10 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
request.url.path,
errors,
)
safe_errors = _sanitize_validation_errors(errors)
return JSONResponse(
status_code=422,
content={"detail": errors},
content={"detail": safe_errors},
)