From 6bfda271502065b6157b28d4847f45fea5b1038d Mon Sep 17 00:00:00 2001 From: agatha Date: Sat, 2 May 2026 16:32:23 +0000 Subject: [PATCH] Fix build failures and get all 45 tests passing Build fixes: - ui/Dockerfile: npm install instead of npm ci (no lockfile) - api/pyproject.toml: setuptools.build_meta instead of setuptools.backends.legacy:build - api/Dockerfile: install curl so the Docker healthcheck doesn't always fail - docker-compose.yml: add start_period: 30s to API healthcheck Test fixes: - pyproject.toml: asyncio_default_fixture_loop_scope/test_loop_scope = session to prevent asyncpg connections being used across different event loops - conftest.py: loop_scope="session" on session-scoped engine fixture - main.py: custom HTTPException handler to flatten dict details to top level (FastAPI wraps dict details as {"detail": {...}} by default) - test_upload.py: use env var + cache_clear() to override max_upload_bytes since monkeypatch can't reach past @lru_cache and already-imported references - image_repo.py: add reload_with_tags() with populate_existing=True to force SQLAlchemy to repopulate the identity-map object after tag mutations - images.py: use reload_with_tags() instead of db.refresh(image, ["image_tags"]) which only loaded ImageTag rows without their .tag sub-relationship, causing MissingGreenlet on any access to image.tags after attach/replace operations Co-Authored-By: Claude Sonnet 4.6 --- api/Dockerfile | 2 ++ api/app/main.py | 11 ++++++++- api/app/repositories/image_repo.py | 9 ++++++++ api/app/routers/images.py | 4 ++-- api/pyproject.toml | 4 +++- api/tests/integration/conftest.py | 2 +- api/tests/integration/test_upload.py | 34 +++++++++++++--------------- docker-compose.yml | 1 + ui/Dockerfile | 2 +- 9 files changed, 45 insertions(+), 24 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index eea1f33..b85f6cf 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,6 +2,8 @@ FROM python:3.12-slim WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + RUN pip install --no-cache-dir uv COPY pyproject.toml . diff --git a/api/app/main.py b/api/app/main.py index b2a7902..00e6ceb 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1,6 +1,8 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.exceptions import HTTPException +from fastapi.responses import JSONResponse from app.config import get_settings from app.database import get_engine, get_session_factory, Base @@ -21,6 +23,13 @@ async def lifespan(application: FastAPI): app = FastAPI(title="Reactbin API", version="1.0.0", lifespan=lifespan) +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + if isinstance(exc.detail, dict): + return JSONResponse(status_code=exc.status_code, content=exc.detail) + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + @app.get("/api/v1/health") async def health(): return {"status": "ok"} diff --git a/api/app/repositories/image_repo.py b/api/app/repositories/image_repo.py index 9823b0f..3ded93b 100644 --- a/api/app/repositories/image_repo.py +++ b/api/app/repositories/image_repo.py @@ -79,6 +79,15 @@ class ImageRepository: result = await self._session.execute(paginated) return result.scalars().all(), total + async def reload_with_tags(self, image_id: uuid.UUID) -> Image: + result = await self._session.execute( + select(Image) + .where(Image.id == image_id) + .options(selectinload(Image.image_tags).selectinload(ImageTag.tag)) + .execution_options(populate_existing=True) + ) + return result.scalar_one() + async def delete(self, image: Image) -> None: await self._session.delete(image) await self._session.flush() diff --git a/api/app/routers/images.py b/api/app/routers/images.py index b99882e..e22a658 100644 --- a/api/app/routers/images.py +++ b/api/app/routers/images.py @@ -167,7 +167,7 @@ async def upload_image( if tag_names: tag_repo = TagRepository(db) await tag_repo.attach_tags(image, tag_names) - await db.refresh(image, ["image_tags"]) + image = await image_repo.reload_with_tags(image.id) return _image_to_dict(image, duplicate=False) @@ -248,7 +248,7 @@ async def update_image_tags( ) await tag_repo.replace_tags_on_image(image, tag_names) - await db.refresh(image, ["image_tags"]) + image = await image_repo.reload_with_tags(image.id) return _image_to_dict(image) diff --git a/api/pyproject.toml b/api/pyproject.toml index db02816..506d831 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [build-system] requires = ["setuptools>=68"] -build-backend = "setuptools.backends.legacy:build" +build-backend = "setuptools.build_meta" [project] name = "reactbin-api" @@ -35,6 +35,8 @@ ignore = [] [tool.pytest.ini_options] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" testpaths = ["tests"] [tool.setuptools.packages.find] diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index a60c598..96eacd0 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -9,7 +9,7 @@ from app.database import Base from app.dependencies import get_db, get_storage, get_auth -@pytest_asyncio.fixture(scope="session") +@pytest_asyncio.fixture(scope="session", loop_scope="session") async def engine(): settings = get_settings() # Use a separate test database URL if TEST_DATABASE_URL is set diff --git a/api/tests/integration/test_upload.py b/api/tests/integration/test_upload.py index 25b8dd5..8125612 100644 --- a/api/tests/integration/test_upload.py +++ b/api/tests/integration/test_upload.py @@ -68,26 +68,24 @@ async def test_upload_invalid_mime_type_returns_422(client): @pytest.mark.asyncio -async def test_upload_oversized_file_returns_422(client, monkeypatch): - import app.config as config_module - original_settings = config_module.get_settings() +async def test_upload_oversized_file_returns_422(client): + import os + from app.config import get_settings - class SmallSettings: - def __getattr__(self, name): - val = getattr(original_settings, name) - if name == "max_upload_bytes": - return 10 - return val + os.environ["MAX_UPLOAD_BYTES"] = "10" + get_settings.cache_clear() - monkeypatch.setattr(config_module, "get_settings", lambda: SmallSettings()) - - response = await client.post( - "/api/v1/images", - files={"file": ("big.jpg", io.BytesIO(b"x" * 11), "image/jpeg")}, - ) - assert response.status_code == 422 - body = response.json() - assert body["code"] == "file_too_large" + try: + response = await client.post( + "/api/v1/images", + files={"file": ("big.jpg", io.BytesIO(b"x" * 11), "image/jpeg")}, + ) + assert response.status_code == 422 + body = response.json() + assert body["code"] == "file_too_large" + finally: + del os.environ["MAX_UPLOAD_BYTES"] + get_settings.cache_clear() @pytest.mark.asyncio diff --git a/docker-compose.yml b/docker-compose.yml index 44c4b5f..0f8769a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,6 +66,7 @@ services: interval: 10s timeout: 5s retries: 5 + start_period: 30s ui: build: diff --git a/ui/Dockerfile b/ui/Dockerfile index ac1b19a..8f88821 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -3,7 +3,7 @@ FROM node:22-slim WORKDIR /app COPY package*.json ./ -RUN npm ci +RUN npm install COPY . .