[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:
0
api/tests/integration/__init__.py
Normal file
0
api/tests/integration/__init__.py
Normal file
59
api/tests/integration/conftest.py
Normal file
59
api/tests/integration/conftest.py
Normal 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()
|
||||
60
api/tests/integration/test_delete.py
Normal file
60
api/tests/integration/test_delete.py
Normal 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"
|
||||
8
api/tests/integration/test_health.py
Normal file
8
api/tests/integration/test_health.py
Normal 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"}
|
||||
60
api/tests/integration/test_search.py
Normal file
60
api/tests/integration/test_search.py
Normal 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
|
||||
41
api/tests/integration/test_serving.py
Normal file
41
api/tests/integration/test_serving.py
Normal 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"
|
||||
132
api/tests/integration/test_tags.py
Normal file
132
api/tests/integration/test_tags.py
Normal 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"])
|
||||
100
api/tests/integration/test_upload.py
Normal file
100
api/tests/integration/test_upload.py
Normal 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
|
||||
Reference in New Issue
Block a user