[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,40 @@
import os
import pytest
def test_settings_load_from_env(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
monkeypatch.setenv("S3_REGION", "us-east-1")
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
# Import inside test to pick up monkeypatched env
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
assert s.database_url == "postgresql+asyncpg://u:p@localhost/db"
assert s.s3_bucket_name == "test-bucket"
assert s.max_upload_bytes == 52428800 # default
def test_settings_max_upload_bytes_override(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
monkeypatch.setenv("S3_REGION", "us-east-1")
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
monkeypatch.setenv("MAX_UPLOAD_BYTES", "10485760")
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
assert s.max_upload_bytes == 10485760

View File

@@ -0,0 +1,20 @@
import hashlib
from app.utils import compute_sha256
def test_sha256_known_bytes():
data = b"hello world"
expected = hashlib.sha256(data).hexdigest()
assert compute_sha256(data) == expected
def test_sha256_empty_bytes():
data = b""
expected = hashlib.sha256(data).hexdigest()
assert compute_sha256(data) == expected
def test_sha256_returns_64_char_hex():
result = compute_sha256(b"test data")
assert len(result) == 64
assert all(c in "0123456789abcdef" for c in result)

View File

@@ -0,0 +1,42 @@
"""
T037 — tag normalisation: uppercase → lowercase, whitespace stripped
T038 — tag validation: rejects names > 64 chars, invalid chars
"""
import pytest
from app.repositories.tag_repo import TagRepository
@pytest.mark.parametrize("raw,expected", [
("Cat", "cat"),
(" funny ", "funny"),
("REACTION", "reaction"),
(" MiXeD ", "mixed"),
])
def test_normalise_lowercases_and_strips(raw, expected):
assert TagRepository.normalise(raw) == expected
def test_validate_accepts_valid_tags():
for name in ["cat", "funny-face", "my_tag", "tag123", "a" * 64]:
TagRepository.normalise_and_validate(name) # should not raise
def test_validate_rejects_too_long():
with pytest.raises(ValueError):
TagRepository.normalise_and_validate("a" * 65)
def test_validate_rejects_invalid_chars():
with pytest.raises(ValueError):
TagRepository.normalise_and_validate("bad tag!") # space + exclamation
def test_validate_rejects_empty():
with pytest.raises(ValueError):
TagRepository.normalise_and_validate("")
def test_validate_applies_normalisation_first():
# "CAT" normalises to "cat" which is valid
result = TagRepository.normalise_and_validate("CAT")
assert result == "cat"

View File

@@ -0,0 +1,34 @@
import pytest
from app.validation import validate_mime_type, validate_file_size, MimeTypeError, FileSizeError
ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
REJECTED_TYPES = ["application/pdf", "video/mp4", "text/plain", "application/octet-stream"]
@pytest.mark.parametrize("mime_type", ACCEPTED_TYPES)
def test_mime_type_accepts_images(mime_type):
validate_mime_type(mime_type) # should not raise
@pytest.mark.parametrize("mime_type", REJECTED_TYPES)
def test_mime_type_rejects_non_images(mime_type):
with pytest.raises(MimeTypeError):
validate_mime_type(mime_type)
def test_file_size_accepts_within_limit():
validate_file_size(1024, max_bytes=52_428_800) # should not raise
def test_file_size_accepts_exact_limit():
validate_file_size(52_428_800, max_bytes=52_428_800) # should not raise
def test_file_size_rejects_over_limit():
with pytest.raises(FileSizeError):
validate_file_size(52_428_801, max_bytes=52_428_800)
def test_file_size_rejects_zero():
with pytest.raises(FileSizeError):
validate_file_size(0, max_bytes=52_428_800)