Feat: Enforce PostgreSQL for integration tests; add Docker test stack

- conftest.py: pytest_configure guard rejects non-postgresql+asyncpg:// URLs
  before any test collects (per constitution §2.5/§5.2 v1.3.0)
- docker-compose.test.yml: isolated postgres-test (5433) + minio-test (9002)
  + api-test runner; one command runs the full suite against real PostgreSQL
- Makefile: test-unit and test-integration targets
- .env.test.example: documents variables needed to run tests outside Docker
- Fix pre-existing test bug: integration tests using client fixture (NoOpAuthProvider)
  for write operations (upload/delete/patch) now use authed_client with Bearer
  token — these were never caught because tests never ran against a live stack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 19:14:12 +00:00
parent 354c85292d
commit f3e0021ee8
17 changed files with 761 additions and 39 deletions

View File

@@ -1,5 +1,6 @@
import os
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
@@ -9,11 +10,11 @@ os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-testing-only")
os.environ.setdefault("OWNER_USERNAME", "testowner")
os.environ.setdefault("OWNER_PASSWORD", "testpassword")
from app.auth.jwt_provider import JWTAuthProvider
from app.config import get_settings
from app.database import Base
from app.dependencies import get_auth, get_db, get_storage
from app.main import app
from app.auth.jwt_provider import JWTAuthProvider # noqa: E402
from app.config import get_settings # noqa: E402
from app.database import Base # noqa: E402
from app.dependencies import get_auth, get_db, get_storage # noqa: E402
from app.main import app # noqa: E402
# Bust the LRU cache so get_settings() picks up the env vars set above
get_settings.cache_clear()
@@ -26,8 +27,6 @@ _TEST_OWNER_PASSWORD = os.environ["OWNER_PASSWORD"]
@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
import os
db_url = os.getenv("TEST_DATABASE_URL", settings.database_url)
eng = create_async_engine(db_url, echo=False)
async with eng.begin() as conn:
@@ -108,3 +107,15 @@ async def authed_client(db_session, jwt_auth_provider):
yield c, valid_token
app.dependency_overrides.clear()
def pytest_configure(config):
db_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL", "")
if not db_url.startswith("postgresql+asyncpg://"):
pytest.exit(
"Integration tests require a PostgreSQL database "
"(postgresql+asyncpg://...). "
"Set TEST_DATABASE_URL or DATABASE_URL accordingly. "
f"Got: {db_url!r}",
returncode=1,
)

View File

@@ -19,15 +19,18 @@ def _minimal_jpeg_v2() -> bytes:
@pytest.mark.asyncio
async def test_delete_removes_record(client):
async def test_delete_removes_record(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_jpeg_v2()
upload = await client.post(
"/api/v1/images",
files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
image_id = upload.json()["id"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
assert delete_resp.status_code == 204
get_resp = await client.get(f"/api/v1/images/{image_id}")
@@ -36,16 +39,19 @@ async def test_delete_removes_record(client):
@pytest.mark.asyncio
async def test_delete_removes_storage_object(client):
async def test_delete_removes_storage_object(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_jpeg_v2() + b"\x00"
upload = await client.post(
"/api/v1/images",
files={"file": ("del-storage-test.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
assert delete_resp.status_code == 204
# Confirm storage redirect no longer works (404 since record is gone)
@@ -54,15 +60,21 @@ async def test_delete_removes_storage_object(client):
@pytest.mark.asyncio
async def test_delete_unknown_id_returns_404(client):
response = await client.delete(f"/api/v1/images/{uuid.uuid4()}")
async def test_delete_unknown_id_returns_404(authed_client):
client, token = authed_client
response = await client.delete(
f"/api/v1/images/{uuid.uuid4()}",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"
@pytest.mark.asyncio
async def test_delete_removes_thumbnail(client):
async def test_delete_removes_thumbnail(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
buf = io.BytesIO()
PILImage.new("RGB", (200, 150), color=(60, 90, 120)).save(buf, format="JPEG")
data = buf.getvalue()
@@ -70,12 +82,13 @@ async def test_delete_removes_thumbnail(client):
upload = await client.post(
"/api/v1/images",
files={"file": ("thumb-del.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert upload.status_code == 201
image_id = upload.json()["id"]
assert upload.json()["thumbnail_key"] is not None
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
assert delete_resp.status_code == 204
thumb_resp = await client.get(f"/api/v1/images/{image_id}/thumbnail")

View File

@@ -16,7 +16,9 @@ def _minimal_gif() -> bytes:
@pytest.mark.asyncio
async def test_and_filter_returns_only_matching_images(client):
async def test_and_filter_returns_only_matching_images(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_gif()
# Image with both tags
@@ -24,6 +26,7 @@ async def test_and_filter_returns_only_matching_images(client):
"/api/v1/images",
files={"file": ("both.gif", io.BytesIO(data), "image/gif")},
data={"tags": "andcat,andfunny"},
headers=headers,
)
both_id = r_both.json()["id"]
@@ -32,6 +35,7 @@ async def test_and_filter_returns_only_matching_images(client):
"/api/v1/images",
files={"file": ("one.gif", io.BytesIO(data + b"\x00"), "image/gif")},
data={"tags": "andcat"},
headers=headers,
)
response = await client.get("/api/v1/images?tags=andcat,andfunny")
@@ -43,7 +47,9 @@ async def test_and_filter_returns_only_matching_images(client):
@pytest.mark.asyncio
async def test_filter_excludes_partial_tag_match(client):
async def test_filter_excludes_partial_tag_match(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_gif()
# Image with only "exclcat"
@@ -51,6 +57,7 @@ async def test_filter_excludes_partial_tag_match(client):
"/api/v1/images",
files={"file": ("partial.gif", io.BytesIO(data + b"\x01"), "image/gif")},
data={"tags": "exclcat"},
headers=headers,
)
# Filter requires both exclcat and exclother

View File

@@ -29,11 +29,13 @@ def _minimal_webp() -> bytes:
@pytest.mark.asyncio
async def test_file_returns_200_with_content(client):
async def test_file_returns_200_with_content(authed_client):
client, token = authed_client
data = _minimal_webp()
upload = await client.post(
"/api/v1/images",
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code in (200, 201)
upload_body = upload.json()
@@ -57,11 +59,13 @@ async def test_file_unknown_id_returns_404(client):
@pytest.mark.asyncio
async def test_file_response_exposes_no_storage_details(client):
async def test_file_response_exposes_no_storage_details(authed_client):
client, token = authed_client
data = _minimal_webp()
upload = await client.post(
"/api/v1/images",
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
@@ -75,11 +79,13 @@ async def test_file_response_exposes_no_storage_details(client):
@pytest.mark.asyncio
async def test_thumbnail_returns_webp(client):
async def test_thumbnail_returns_webp(authed_client):
client, token = authed_client
data = _real_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("t.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code == 201
body = upload.json()
@@ -95,11 +101,13 @@ async def test_thumbnail_returns_webp(client):
@pytest.mark.asyncio
async def test_thumbnail_fallback_returns_original(client, db_session):
async def test_thumbnail_fallback_returns_original(authed_client, db_session):
client, token = authed_client
data = _real_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("fallback.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code == 201
image_id = upload.json()["id"]

View File

@@ -31,12 +31,14 @@ def _minimal_png() -> bytes:
@pytest.mark.asyncio
async def test_upload_with_tags_persists_tags(client):
async def test_upload_with_tags_persists_tags(authed_client):
client, token = authed_client
data = _minimal_png()
response = await client.post(
"/api/v1/images",
files={"file": ("img.png", io.BytesIO(data), "image/png")},
data={"tags": "cat,funny"},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
@@ -44,12 +46,15 @@ async def test_upload_with_tags_persists_tags(client):
@pytest.mark.asyncio
async def test_duplicate_upload_tags_unchanged(client):
async def test_duplicate_upload_tags_unchanged(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_png()
r1 = await client.post(
"/api/v1/images",
files={"file": ("img.png", io.BytesIO(data), "image/png")},
data={"tags": "original-tag"},
headers=headers,
)
assert r1.status_code in (200, 201)
original_tags = set(r1.json()["tags"])
@@ -58,6 +63,7 @@ async def test_duplicate_upload_tags_unchanged(client):
"/api/v1/images",
files={"file": ("img.png", io.BytesIO(data), "image/png")},
data={"tags": "different-tag"},
headers=headers,
)
assert r2.status_code == 200
assert r2.json()["duplicate"] is True
@@ -65,18 +71,22 @@ async def test_duplicate_upload_tags_unchanged(client):
@pytest.mark.asyncio
async def test_patch_replaces_tag_set(client):
async def test_patch_replaces_tag_set(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_png()
r1 = await client.post(
"/api/v1/images",
files={"file": ("patch-test.png", io.BytesIO(data), "image/png")},
data={"tags": "old-tag"},
headers=headers,
)
image_id = r1.json()["id"]
patch = await client.patch(
f"/api/v1/images/{image_id}/tags",
json={"tags": ["new-tag", "another"]},
headers=headers,
)
assert patch.status_code == 200
body = patch.json()
@@ -85,17 +95,21 @@ async def test_patch_replaces_tag_set(client):
@pytest.mark.asyncio
async def test_patch_invalid_tag_returns_422(client):
async def test_patch_invalid_tag_returns_422(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _minimal_png()
r1 = await client.post(
"/api/v1/images",
files={"file": ("invalid-tag-test.png", io.BytesIO(data), "image/png")},
headers=headers,
)
image_id = r1.json()["id"]
patch = await client.patch(
f"/api/v1/images/{image_id}/tags",
json={"tags": ["valid", "INVALID TAG WITH SPACES!"]},
headers=headers,
)
assert patch.status_code == 422
body = patch.json()
@@ -103,12 +117,14 @@ async def test_patch_invalid_tag_returns_422(client):
@pytest.mark.asyncio
async def test_list_tags_alphabetical_with_counts(client):
async def test_list_tags_alphabetical_with_counts(authed_client):
client, token = authed_client
data = _minimal_png()
await client.post(
"/api/v1/images",
files={"file": ("tag-list-test.png", io.BytesIO(data), "image/png")},
data={"tags": "zebra,apple"},
headers={"Authorization": f"Bearer {token}"},
)
response = await client.get("/api/v1/tags")
assert response.status_code == 200
@@ -121,12 +137,14 @@ async def test_list_tags_alphabetical_with_counts(client):
@pytest.mark.asyncio
async def test_list_tags_prefix_filter(client):
async def test_list_tags_prefix_filter(authed_client):
client, token = authed_client
data = _minimal_png()
await client.post(
"/api/v1/images",
files={"file": ("prefix-test.png", io.BytesIO(data), "image/png")},
data={"tags": "cat,catfish,caterpillar,dog"},
headers={"Authorization": f"Bearer {token}"},
)
response = await client.get("/api/v1/tags?q=cat")
assert response.status_code == 200
@@ -155,13 +173,16 @@ def _unique_png(seed: int) -> bytes:
@pytest.mark.asyncio
async def test_list_tags_sort_count_desc(client):
async def test_list_tags_sort_count_desc(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
# popular-sort-tag appears on 2 images, rare-sort-tag on 1 — verify count_desc ordering
for seed in (100, 101):
await client.post(
"/api/v1/images",
files={"file": (f"sort-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
data={"tags": "popular-sort-tag,rare-sort-tag" if seed == 100 else "popular-sort-tag"},
headers=headers,
)
response = await client.get("/api/v1/tags?sort=count_desc")
assert response.status_code == 200
@@ -177,13 +198,16 @@ async def test_list_tags_sort_count_desc(client):
@pytest.mark.asyncio
async def test_list_tags_min_count_excludes_below_threshold(client):
async def test_list_tags_min_count_excludes_below_threshold(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
# common-min-tag appears on 2 images, uncommon-min-tag on 1
for seed in (200, 201):
await client.post(
"/api/v1/images",
files={"file": (f"min-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
data={"tags": "common-min-tag,uncommon-min-tag" if seed == 200 else "common-min-tag"},
headers=headers,
)
# min_count=2 should exclude uncommon-min-tag (count=1) but keep common-min-tag (count=2)
response = await client.get("/api/v1/tags?min_count=2")

View File

@@ -6,6 +6,7 @@ T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
T079 — GET /api/v1/images/{id} 404 → error envelope shape
"""
import io
import uuid
from unittest.mock import patch
import pytest
@@ -27,11 +28,13 @@ def _minimal_jpeg() -> bytes:
@pytest.mark.asyncio
async def test_upload_new_image_returns_201(client):
async def test_upload_new_image_returns_201(authed_client):
client, token = authed_client
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
@@ -44,12 +47,15 @@ async def test_upload_new_image_returns_201(client):
@pytest.mark.asyncio
async def test_upload_duplicate_returns_200_with_flag(client):
async def test_upload_duplicate_returns_200_with_flag(authed_client):
client, token = authed_client
data = _minimal_jpeg()
headers = {"Authorization": f"Bearer {token}"}
# First upload
r1 = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r1.status_code in (200, 201)
@@ -57,6 +63,7 @@ async def test_upload_duplicate_returns_200_with_flag(client):
r2 = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r2.status_code == 200
body = r2.json()
@@ -65,10 +72,12 @@ async def test_upload_duplicate_returns_200_with_flag(client):
@pytest.mark.asyncio
async def test_upload_invalid_mime_type_returns_422(client):
async def test_upload_invalid_mime_type_returns_422(authed_client):
client, token = authed_client
response = await client.post(
"/api/v1/images",
files={"file": ("doc.pdf", io.BytesIO(b"%PDF-1.4"), "application/pdf")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 422
body = response.json()
@@ -77,11 +86,12 @@ async def test_upload_invalid_mime_type_returns_422(client):
@pytest.mark.asyncio
async def test_upload_oversized_file_returns_422(client):
async def test_upload_oversized_file_returns_422(authed_client):
import os
from app.config import get_settings
client, token = authed_client
os.environ["MAX_UPLOAD_BYTES"] = "10"
get_settings.cache_clear()
@@ -89,6 +99,7 @@ async def test_upload_oversized_file_returns_422(client):
response = await client.post(
"/api/v1/images",
files={"file": ("big.jpg", io.BytesIO(b"x" * 11), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 422
body = response.json()
@@ -100,7 +111,6 @@ async def test_upload_oversized_file_returns_422(client):
@pytest.mark.asyncio
async def test_get_unknown_image_returns_404_with_envelope(client):
import uuid
response = await client.get(f"/api/v1/images/{uuid.uuid4()}")
assert response.status_code == 404
body = response.json()
@@ -109,11 +119,13 @@ async def test_get_unknown_image_returns_404_with_envelope(client):
@pytest.mark.asyncio
async def test_upload_returns_thumbnail_key(client):
async def test_upload_returns_thumbnail_key(authed_client):
client, token = authed_client
data = _real_jpeg(color=(100, 150, 200))
response = await client.post(
"/api/v1/images",
files={"file": ("thumb_test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
@@ -123,17 +135,21 @@ async def test_upload_returns_thumbnail_key(client):
@pytest.mark.asyncio
async def test_duplicate_upload_reuses_thumbnail_key(client):
async def test_duplicate_upload_reuses_thumbnail_key(authed_client):
client, token = authed_client
headers = {"Authorization": f"Bearer {token}"}
data = _real_jpeg(color=(200, 100, 50))
r1 = await client.post(
"/api/v1/images",
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r1.status_code in (200, 201)
r2 = await client.post(
"/api/v1/images",
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r2.status_code == 200
@@ -144,12 +160,14 @@ async def test_duplicate_upload_reuses_thumbnail_key(client):
@pytest.mark.asyncio
async def test_upload_succeeds_when_thumbnail_fails(client):
async def test_upload_succeeds_when_thumbnail_fails(authed_client):
client, token = authed_client
data = _real_jpeg(color=(50, 200, 150))
with patch("app.routers.images.generate_thumbnail", side_effect=RuntimeError("simulated")):
response = await client.post(
"/api/v1/images",
files={"file": ("no_thumb.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code in (200, 201)
body = response.json()