From 13be9085b559466962b46efb1ca75d089a7f52a7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 15 May 2026 22:23:32 -0400 Subject: [PATCH] fix(integration): asyncio auto-mode, per-test truncation, artist images IN-subquery --- backend/app/services/artist_service.py | 16 ++++++++++------ pyproject.toml | 5 +++++ tests/conftest.py | 26 ++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/backend/app/services/artist_service.py b/backend/app/services/artist_service.py index 9aeaa8e..c195fcb 100644 --- a/backend/app/services/artist_service.py +++ b/backend/app/services/artist_service.py @@ -157,12 +157,16 @@ class ArtistService: if artist is None: return None - stmt = ( - select(ImageRecord) - .join(ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id) - .join(Source, Source.id == ImageProvenance.source_id) - .where(Source.artist_id == artist.id) - .distinct() + # Dedupe via IN-subquery rather than JOIN + DISTINCT: an image can + # have several provenance rows for one artist, and SELECT DISTINCT + # over ImageRecord fails in Postgres because its json columns have + # no equality operator. + stmt = select(ImageRecord).where( + ImageRecord.id.in_( + select(ImageProvenance.image_record_id) + .join(Source, Source.id == ImageProvenance.source_id) + .where(Source.artist_id == artist.id) + ) ) if cursor: cur_ts, cur_id = decode_cursor(cursor) diff --git a/pyproject.toml b/pyproject.toml index eb26856..18206b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,11 @@ where = ["."] include = ["backend*"] [tool.pytest.ini_options] +# auto mode: async test fns and async fixtures don't need explicit +# @pytest.mark.asyncio / @pytest_asyncio.fixture decoration. Required +# because the test_api_* fixtures use plain @pytest.fixture on async +# app/client; strict mode errors on those under pytest-asyncio 1.x. +asyncio_mode = "auto" # The fast backend job runs `-m "not integration"`; the dedicated CI # `integration` job (pgvector Postgres + Redis service containers) runs # `-m integration`. Both also runnable locally via docker-compose. diff --git a/tests/conftest.py b/tests/conftest.py index 73b3ef9..619f4b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,8 @@ from sqlalchemy.ext.asyncio import ( ) from sqlalchemy.orm import sessionmaker +from backend.app.models import Base + def _async_database_url() -> str: user = os.environ.get("DB_USER", "fabledcurator") @@ -73,3 +75,27 @@ def db_sync(sync_engine): yield session finally: session.rollback() + + +@pytest.fixture(autouse=True) +def _truncate_after_integration(request): + """Integration tests exercise app/celery code that commits on its own + connection, which the rollback fixtures above can't undo — so data + would leak between tests. Truncate every model table after each + integration-marked test. Scoped to the integration marker so the + DB-less fast unit job never connects here. + """ + yield + if request.node.get_closest_marker("integration") is None: + return + tables = ", ".join(t.name for t in Base.metadata.sorted_tables) + if not tables: + return + eng = create_engine(_sync_database_url(), future=True) + try: + with eng.begin() as conn: + conn.exec_driver_sql( + f"TRUNCATE {tables} RESTART IDENTITY CASCADE" + ) + finally: + eng.dispose()