[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/unit/__init__.py
Normal file
0
api/tests/unit/__init__.py
Normal file
40
api/tests/unit/test_config.py
Normal file
40
api/tests/unit/test_config.py
Normal 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
|
||||
20
api/tests/unit/test_hashing.py
Normal file
20
api/tests/unit/test_hashing.py
Normal 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)
|
||||
42
api/tests/unit/test_tags.py
Normal file
42
api/tests/unit/test_tags.py
Normal 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"
|
||||
34
api/tests/unit/test_validation.py
Normal file
34
api/tests/unit/test_validation.py
Normal 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)
|
||||
Reference in New Issue
Block a user