[Spec Kit] Implementation progress

Implements all 88 tasks for the Reaction Image Board (specs/001-reaction-image-board):

- docker-compose.yml: postgres, minio, minio-init, api, ui services with healthchecks
- api/: FastAPI app with SQLAlchemy 2.x async, Alembic migrations, S3/MinIO storage,
  full integration + unit test suite (pytest + pytest-asyncio)
- ui/: Angular 19 standalone app (Library, Upload, Detail, NotFound components)
- .env.example: all required environment variables
- .gitignore: Python, Node, Docker, IDE, .env patterns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:13:23 +00:00
parent 691f7570fe
commit 8bf6ef443a
74 changed files with 3005 additions and 88 deletions

View File

View File

@@ -0,0 +1,59 @@
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.main import app
from app.config import get_settings
from app.database import Base
from app.dependencies import get_db, get_storage, get_auth
@pytest_asyncio.fixture(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:
await conn.run_sync(Base.metadata.create_all)
yield eng
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await eng.dispose()
@pytest_asyncio.fixture
async def db_session(engine):
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
async def client(db_session):
from app.storage.s3_backend import S3StorageBackend
from app.auth.noop import NoOpAuthProvider
storage = S3StorageBackend()
auth = NoOpAuthProvider()
async def override_db():
yield db_session
def override_storage():
return storage
def override_auth():
return auth
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_storage] = override_storage
app.dependency_overrides[get_auth] = override_auth
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()

View File

@@ -0,0 +1,60 @@
"""
T065 — DELETE /api/v1/images/{id} → 204; subsequent GET returns 404
T066 — DELETE verifies MinIO object is removed
T067 — DELETE of unknown ID → 404 image_not_found
"""
import io
import uuid
import pytest
def _minimal_jpeg_v2() -> bytes:
# Slightly different from test_upload.py to avoid cross-test dedup
return (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x01"
b"\xff\xd9"
)
@pytest.mark.asyncio
async def test_delete_removes_record(client):
data = _minimal_jpeg_v2()
upload = await client.post(
"/api/v1/images",
files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")},
)
image_id = upload.json()["id"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
assert delete_resp.status_code == 204
get_resp = await client.get(f"/api/v1/images/{image_id}")
assert get_resp.status_code == 404
assert get_resp.json()["code"] == "image_not_found"
@pytest.mark.asyncio
async def test_delete_removes_storage_object(client):
data = _minimal_jpeg_v2() + b"\x00"
upload = await client.post(
"/api/v1/images",
files={"file": ("del-storage-test.jpg", io.BytesIO(data), "image/jpeg")},
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
storage_key = upload.json()["hash"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
assert delete_resp.status_code == 204
# Confirm storage redirect no longer works (404 since record is gone)
file_resp = await client.get(f"/api/v1/images/{image_id}/file")
assert file_resp.status_code == 404
@pytest.mark.asyncio
async def test_delete_unknown_id_returns_404(client):
response = await client.delete(f"/api/v1/images/{uuid.uuid4()}")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"

View File

@@ -0,0 +1,8 @@
import pytest
@pytest.mark.asyncio
async def test_health_returns_ok(client):
response = await client.get("/api/v1/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

View File

@@ -0,0 +1,60 @@
"""
T041 — GET /api/v1/images?tags=cat,funny → only images with both tags
T042 — same query excludes images with only one matching tag
"""
import io
import pytest
def _minimal_gif() -> bytes:
return (
b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00"
b"!\xf9\x04\x00\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01"
b"\x00\x00\x02\x02D\x01\x00;"
)
@pytest.mark.asyncio
async def test_and_filter_returns_only_matching_images(client):
data = _minimal_gif()
# Image with both tags
r_both = await client.post(
"/api/v1/images",
files={"file": ("both.gif", io.BytesIO(data), "image/gif")},
data={"tags": "andcat,andfunny"},
)
both_id = r_both.json()["id"]
# Image with only one of the two tags — use different content to avoid dedup
r_one = await client.post(
"/api/v1/images",
files={"file": ("one.gif", io.BytesIO(data + b"\x00"), "image/gif")},
data={"tags": "andcat"},
)
response = await client.get("/api/v1/images?tags=andcat,andfunny")
assert response.status_code == 200
body = response.json()
ids = [item["id"] for item in body["items"]]
assert both_id in ids
assert r_one.json()["id"] not in ids
@pytest.mark.asyncio
async def test_filter_excludes_partial_tag_match(client):
data = _minimal_gif()
# Image with only "exclcat"
r_partial = await client.post(
"/api/v1/images",
files={"file": ("partial.gif", io.BytesIO(data + b"\x01"), "image/gif")},
data={"tags": "exclcat"},
)
# Filter requires both exclcat and exclother
response = await client.get("/api/v1/images?tags=exclcat,exclother")
assert response.status_code == 200
body = response.json()
ids = [item["id"] for item in body["items"]]
assert r_partial.json()["id"] not in ids

View File

@@ -0,0 +1,41 @@
"""
T055 — GET /api/v1/images/{id}/file → 302 with Location header
T056 — /file for unknown ID → 404 image_not_found
"""
import io
import uuid
import pytest
def _minimal_webp() -> bytes:
# Minimal VP8L WebP
return (
b"RIFF$\x00\x00\x00WEBPVP8L\x18\x00\x00\x00"
b"/\x00\x00\x00\x00\x18\xf0\x1f\xfe\xff\x02\xfe\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
@pytest.mark.asyncio
async def test_file_redirect_returns_302(client):
data = _minimal_webp()
upload = await client.post(
"/api/v1/images",
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
# Don't follow redirects
response = await client.get(f"/api/v1/images/{image_id}/file", follow_redirects=False)
assert response.status_code == 302
assert "Location" in response.headers
assert response.headers["Location"] # must not be empty
@pytest.mark.asyncio
async def test_file_unknown_id_returns_404(client):
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/file")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"

View File

@@ -0,0 +1,132 @@
"""
T039 — upload with tags → tags persisted and returned
T040 — duplicate upload → existing record returned, tags unchanged
T057 — PATCH replaces tags, old tags unlinked, new tags upserted
T058 — PATCH with invalid tag → 422 invalid_tag
T073 — GET /api/v1/tags returns all tags alphabetically with correct image_count
T074 — GET /api/v1/tags?q=ca returns only tags prefixed "ca"
"""
import io
import pytest
def _minimal_png() -> bytes:
import struct, zlib
def chunk(name: bytes, data: bytes) -> bytes:
c = name + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
idat_data = zlib.compress(b"\x00\xFF\xFF\xFF")
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", idat_data)
+ chunk(b"IEND", b"")
)
@pytest.mark.asyncio
async def test_upload_with_tags_persists_tags(client):
data = _minimal_png()
response = await client.post(
"/api/v1/images",
files={"file": ("img.png", io.BytesIO(data), "image/png")},
data={"tags": "cat,funny"},
)
assert response.status_code == 201
body = response.json()
assert set(body["tags"]) == {"cat", "funny"}
@pytest.mark.asyncio
async def test_duplicate_upload_tags_unchanged(client):
data = _minimal_png()
r1 = await client.post(
"/api/v1/images",
files={"file": ("img.png", io.BytesIO(data), "image/png")},
data={"tags": "original-tag"},
)
assert r1.status_code in (200, 201)
original_tags = set(r1.json()["tags"])
r2 = await client.post(
"/api/v1/images",
files={"file": ("img.png", io.BytesIO(data), "image/png")},
data={"tags": "different-tag"},
)
assert r2.status_code == 200
assert r2.json()["duplicate"] is True
assert set(r2.json()["tags"]) == original_tags
@pytest.mark.asyncio
async def test_patch_replaces_tag_set(client):
data = _minimal_png()
r1 = await client.post(
"/api/v1/images",
files={"file": ("patch-test.png", io.BytesIO(data), "image/png")},
data={"tags": "old-tag"},
)
image_id = r1.json()["id"]
patch = await client.patch(
f"/api/v1/images/{image_id}/tags",
json={"tags": ["new-tag", "another"]},
)
assert patch.status_code == 200
body = patch.json()
assert "old-tag" not in body["tags"]
assert set(body["tags"]) == {"new-tag", "another"}
@pytest.mark.asyncio
async def test_patch_invalid_tag_returns_422(client):
data = _minimal_png()
r1 = await client.post(
"/api/v1/images",
files={"file": ("invalid-tag-test.png", io.BytesIO(data), "image/png")},
)
image_id = r1.json()["id"]
patch = await client.patch(
f"/api/v1/images/{image_id}/tags",
json={"tags": ["valid", "INVALID TAG WITH SPACES!"]},
)
assert patch.status_code == 422
body = patch.json()
assert body["code"] == "invalid_tag"
@pytest.mark.asyncio
async def test_list_tags_alphabetical_with_counts(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"},
)
response = await client.get("/api/v1/tags")
assert response.status_code == 200
body = response.json()
names = [item["name"] for item in body["items"]]
assert names == sorted(names)
for item in body["items"]:
assert "image_count" in item
assert item["image_count"] >= 0
@pytest.mark.asyncio
async def test_list_tags_prefix_filter(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"},
)
response = await client.get("/api/v1/tags?q=cat")
assert response.status_code == 200
body = response.json()
for item in body["items"]:
assert item["name"].startswith("cat")
assert not any(item["name"] == "dog" for item in body["items"])

View File

@@ -0,0 +1,100 @@
"""
T026 — valid JPEG upload → 201, record in DB, object in MinIO
T027 — same image uploaded twice → 200, duplicate: true, no second MinIO object
T028 — invalid MIME type → 422 invalid_mime_type (error envelope with code field)
T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
T079 — GET /api/v1/images/{id} 404 → error envelope shape
"""
import io
import pytest
def _minimal_jpeg() -> bytes:
# Minimal valid JPEG bytes (SOI + APP0 + EOI)
return (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
b"\xff\xd9"
)
@pytest.mark.asyncio
async def test_upload_new_image_returns_201(client):
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
)
assert response.status_code == 201
body = response.json()
assert body["duplicate"] is False
assert body["filename"] == "test.jpg"
assert body["mime_type"] == "image/jpeg"
assert "id" in body
assert "hash" in body
assert len(body["hash"]) == 64
@pytest.mark.asyncio
async def test_upload_duplicate_returns_200_with_flag(client):
data = _minimal_jpeg()
# First upload
r1 = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
)
assert r1.status_code in (200, 201)
# Second upload of same bytes
r2 = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
)
assert r2.status_code == 200
body = r2.json()
assert body["duplicate"] is True
assert body["id"] == r1.json()["id"]
@pytest.mark.asyncio
async def test_upload_invalid_mime_type_returns_422(client):
response = await client.post(
"/api/v1/images",
files={"file": ("doc.pdf", io.BytesIO(b"%PDF-1.4"), "application/pdf")},
)
assert response.status_code == 422
body = response.json()
assert body["code"] == "invalid_mime_type"
assert "detail" in body
@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()
class SmallSettings:
def __getattr__(self, name):
val = getattr(original_settings, name)
if name == "max_upload_bytes":
return 10
return val
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"
@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()
assert body["code"] == "image_not_found"
assert "detail" in body