[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

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# PostgreSQL — async DSN for SQLAlchemy + asyncpg
DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@postgres:5432/reactbin
# S3-compatible object storage (MinIO in local dev)
S3_ENDPOINT_URL=http://minio:9000
S3_BUCKET_NAME=reactbin
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_REGION=us-east-1
# Angular SPA — injected at build or runtime
API_BASE_URL=http://localhost:8000
# Upload size limit in bytes (default 50 MiB)
MAX_UPLOAD_BYTES=52428800

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Environment
.env
.env.*
!.env.example
# Python
__pycache__/
*.pyc
*.pyo
.venv/
venv/
*.egg-info/
dist/
build/
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
# Node / Angular
node_modules/
dist/
.angular/
# Docker
*.log
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Claude Code
.claude/

14
api/.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git/
.venv/
venv/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
*.egg-info/
dist/
.env
.env.*
!.env.example

12
api/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY pyproject.toml .
RUN uv pip install --system --no-cache -e ".[dev]"
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

41
api/alembic.ini Normal file
View File

@@ -0,0 +1,41 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

60
api/alembic/env.py Normal file
View File

@@ -0,0 +1,60 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import get_settings
from app.database import Base
import app.models # noqa: F401 — ensure all models are imported
config = context.config
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,63 @@
"""initial schema — images, tags, image_tags
Revision ID: 001
Revises:
Create Date: 2026-05-02
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"images",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("hash", sa.String(64), nullable=False),
sa.Column("filename", sa.String(), nullable=False),
sa.Column("mime_type", sa.String(20), nullable=False),
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
sa.Column("width", sa.Integer(), nullable=False),
sa.Column("height", sa.Integer(), nullable=False),
sa.Column("storage_key", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("hash", name="uq_images_hash"),
)
op.create_index("ix_images_hash", "images", ["hash"])
op.create_table(
"tags",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name", name="uq_tags_name"),
)
op.create_index("ix_tags_name", "tags", ["name"])
op.create_index("ix_tags_name_prefix", "tags", ["name"], postgresql_ops={"name": "varchar_pattern_ops"})
op.create_table(
"image_tags",
sa.Column("image_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("tag_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(["image_id"], ["images.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("image_id", "tag_id"),
sa.UniqueConstraint("image_id", "tag_id", name="uq_image_tag"),
)
op.create_index("ix_image_tags_image_id", "image_tags", ["image_id"])
op.create_index("ix_image_tags_tag_id", "image_tags", ["tag_id"])
def downgrade() -> None:
op.drop_table("image_tags")
op.drop_table("tags")
op.drop_table("images")

0
api/app/__init__.py Normal file
View File

0
api/app/auth/__init__.py Normal file
View File

8
api/app/auth/noop.py Normal file
View File

@@ -0,0 +1,8 @@
from app.auth.provider import AuthProvider, Identity
_ANONYMOUS = Identity(id="anonymous", anonymous=True)
class NoOpAuthProvider(AuthProvider):
async def get_identity(self) -> Identity:
return _ANONYMOUS

14
api/app/auth/provider.py Normal file
View File

@@ -0,0 +1,14 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class Identity:
id: str
anonymous: bool = True
class AuthProvider(ABC):
@abstractmethod
async def get_identity(self) -> Identity:
"""Resolve the request identity."""

20
api/app/config.py Normal file
View File

@@ -0,0 +1,20 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
database_url: str
s3_endpoint_url: str
s3_bucket_name: str
s3_access_key_id: str
s3_secret_access_key: str
s3_region: str = "us-east-1"
api_base_url: str = "http://localhost:8000"
max_upload_bytes: int = 52_428_800 # 50 MiB
@lru_cache
def get_settings() -> Settings:
return Settings()

26
api/app/database.py Normal file
View File

@@ -0,0 +1,26 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
_engine = None
_session_factory = None
class Base(DeclarativeBase):
pass
def get_engine():
global _engine
if _engine is None:
settings = get_settings()
_engine = create_async_engine(settings.database_url, echo=False)
return _engine
def get_session_factory():
global _session_factory
if _session_factory is None:
_session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
return _session_factory

34
api/app/dependencies.py Normal file
View File

@@ -0,0 +1,34 @@
from typing import AsyncGenerator
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.noop import NoOpAuthProvider
from app.auth.provider import AuthProvider
from app.database import get_session_factory
from app.storage.backend import StorageBackend
from app.storage.s3_backend import S3StorageBackend
_storage: StorageBackend | None = None
_auth: AuthProvider | None = None
def get_storage() -> StorageBackend:
global _storage
if _storage is None:
_storage = S3StorageBackend()
return _storage
def get_auth() -> AuthProvider:
global _auth
if _auth is None:
_auth = NoOpAuthProvider()
return _auth
async def get_db() -> AsyncGenerator[AsyncSession, None]:
factory = get_session_factory()
async with factory() as session:
async with session.begin():
yield session

33
api/app/main.py Normal file
View File

@@ -0,0 +1,33 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.config import get_settings
from app.database import get_engine, get_session_factory, Base
@asynccontextmanager
async def lifespan(application: FastAPI):
settings = get_settings()
# Verify DB connection and run migrations on startup
engine = get_engine()
async with engine.begin() as conn:
# In production, Alembic handles migrations; this is a dev convenience
await conn.run_sync(Base.metadata.create_all)
yield
await engine.dispose()
app = FastAPI(title="Reactbin API", version="1.0.0", lifespan=lifespan)
@app.get("/api/v1/health")
async def health():
return {"status": "ok"}
# Routers registered after all modules are defined to avoid circular imports
from app.routers import images, tags # noqa: E402
app.include_router(images.router, prefix="/api/v1")
app.include_router(tags.router, prefix="/api/v1")

61
api/app/models.py Normal file
View File

@@ -0,0 +1,61 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, BigInteger, DateTime, ForeignKey, UniqueConstraint, Index
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class Image(Base):
__tablename__ = "images"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
filename: Mapped[str] = mapped_column(String, nullable=False)
mime_type: Mapped[str] = mapped_column(String(20), nullable=False)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
width: Mapped[int] = mapped_column(Integer, nullable=False)
height: Mapped[int] = mapped_column(Integer, nullable=False)
storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="image", cascade="all, delete-orphan")
@property
def tags(self) -> list[str]:
return [it.tag.name for it in self.image_tags if it.tag]
class Tag(Base):
__tablename__ = "tags"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="tag")
class ImageTag(Base):
__tablename__ = "image_tags"
__table_args__ = (
UniqueConstraint("image_id", "tag_id", name="uq_image_tag"),
Index("ix_image_tags_image_id", "image_id"),
Index("ix_image_tags_tag_id", "tag_id"),
)
image_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("images.id", ondelete="CASCADE"), primary_key=True
)
tag_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tags.id", ondelete="RESTRICT"), primary_key=True
)
image: Mapped["Image"] = relationship(back_populates="image_tags")
tag: Mapped["Tag"] = relationship(back_populates="image_tags")

View File

View File

@@ -0,0 +1,84 @@
import uuid
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Image, ImageTag, Tag
class ImageRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_hash(self, hash_hex: str) -> Optional[Image]:
result = await self._session.execute(
select(Image).where(Image.hash == hash_hex).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
)
return result.scalar_one_or_none()
async def get_by_id(self, image_id: uuid.UUID) -> Optional[Image]:
result = await self._session.execute(
select(Image).where(Image.id == image_id).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
)
return result.scalar_one_or_none()
async def create(
self,
*,
hash_hex: str,
filename: str,
mime_type: str,
size_bytes: int,
width: int,
height: int,
storage_key: str,
) -> Image:
image = Image(
hash=hash_hex,
filename=filename,
mime_type=mime_type,
size_bytes=size_bytes,
width=width,
height=height,
storage_key=storage_key,
)
self._session.add(image)
await self._session.flush()
await self._session.refresh(image, ["image_tags"])
return image
async def list_images(
self,
tag_names: list[str] | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Image], int]:
from sqlalchemy import func, and_
base_query = select(Image).options(
selectinload(Image.image_tags).selectinload(ImageTag.tag)
)
if tag_names:
for tag_name in tag_names:
subq = (
select(ImageTag.image_id)
.join(Tag, ImageTag.tag_id == Tag.id)
.where(Tag.name == tag_name)
.scalar_subquery()
)
base_query = base_query.where(Image.id.in_(subq))
count_query = select(func.count()).select_from(base_query.subquery())
total_result = await self._session.execute(count_query)
total = total_result.scalar_one()
paginated = base_query.order_by(Image.created_at.desc()).limit(limit).offset(offset)
result = await self._session.execute(paginated)
return result.scalars().all(), total
async def delete(self, image: Image) -> None:
await self._session.delete(image)
await self._session.flush()

View File

@@ -0,0 +1,102 @@
import re
import uuid
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Image, ImageTag, Tag
_TAG_PATTERN = re.compile(r"^[a-z0-9_-]{1,64}$")
class TagRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
@staticmethod
def normalise(name: str) -> str:
return name.strip().lower()
@staticmethod
def normalise_and_validate(name: str) -> str:
normalised = name.strip().lower()
if not _TAG_PATTERN.match(normalised):
raise ValueError(
f"Invalid tag '{name}': must match ^[a-z0-9_-]{{1,64}}$ after normalisation"
)
return normalised
async def upsert_by_name(self, name: str) -> Tag:
result = await self._session.execute(select(Tag).where(Tag.name == name))
tag = result.scalar_one_or_none()
if tag is None:
tag = Tag(name=name)
self._session.add(tag)
await self._session.flush()
return tag
async def get_by_image_id(self, image_id: uuid.UUID) -> list[Tag]:
result = await self._session.execute(
select(Tag)
.join(ImageTag, ImageTag.tag_id == Tag.id)
.where(ImageTag.image_id == image_id)
.order_by(Tag.name)
)
return result.scalars().all()
async def attach_tags(self, image: Image, tag_names: list[str]) -> None:
for name in tag_names:
tag = await self.upsert_by_name(name)
existing = await self._session.execute(
select(ImageTag).where(
ImageTag.image_id == image.id, ImageTag.tag_id == tag.id
)
)
if existing.scalar_one_or_none() is None:
self._session.add(ImageTag(image_id=image.id, tag_id=tag.id))
await self._session.flush()
async def replace_tags_on_image(self, image: Image, tag_names: list[str]) -> None:
# Remove all existing associations
existing_links = await self._session.execute(
select(ImageTag).where(ImageTag.image_id == image.id)
)
for link in existing_links.scalars().all():
await self._session.delete(link)
await self._session.flush()
# Add new associations
for name in tag_names:
tag = await self.upsert_by_name(name)
self._session.add(ImageTag(image_id=image.id, tag_id=tag.id))
await self._session.flush()
async def list_tags(
self,
prefix: str | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
count_subq = (
select(func.count(ImageTag.image_id))
.where(ImageTag.tag_id == Tag.id)
.correlate(Tag)
.scalar_subquery()
)
query = select(Tag, count_subq.label("image_count"))
if prefix:
query = query.where(Tag.name.like(f"{prefix}%"))
total_query = select(func.count()).select_from(query.subquery())
total_result = await self._session.execute(total_query)
total = total_result.scalar_one()
paginated = query.order_by(Tag.name).limit(limit).offset(offset)
rows = await self._session.execute(paginated)
items = [
{"id": str(tag.id), "name": tag.name, "image_count": count}
for tag, count in rows.all()
]
return items, total

View File

271
api/app/routers/images.py Normal file
View File

@@ -0,0 +1,271 @@
import io
import struct
import uuid
import zlib
from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.provider import AuthProvider
from app.config import get_settings
from app.dependencies import get_auth, get_db, get_storage
from app.models import Image
from app.repositories.image_repo import ImageRepository
from app.repositories.tag_repo import TagRepository
from app.storage.backend import StorageBackend
from app.utils import compute_sha256
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
router = APIRouter(tags=["images"])
def _error(detail: str, code: str, status: int):
raise HTTPException(status_code=status, detail={"detail": detail, "code": code})
def _image_to_dict(image: Image, *, duplicate: bool | None = None) -> dict[str, Any]:
data: dict[str, Any] = {
"id": str(image.id),
"hash": image.hash,
"filename": image.filename,
"mime_type": image.mime_type,
"size_bytes": image.size_bytes,
"width": image.width,
"height": image.height,
"storage_key": image.storage_key,
"created_at": image.created_at.isoformat(),
"tags": image.tags,
}
if duplicate is not None:
data["duplicate"] = duplicate
return data
def _read_image_dimensions(data: bytes, mime_type: str) -> tuple[int, int]:
"""Return (width, height) from raw image bytes. Falls back to (0, 0)."""
try:
if mime_type == "image/jpeg":
return _jpeg_dimensions(data)
elif mime_type == "image/png":
return _png_dimensions(data)
elif mime_type == "image/gif":
return _gif_dimensions(data)
elif mime_type == "image/webp":
return _webp_dimensions(data)
except Exception:
pass
return 0, 0
def _jpeg_dimensions(data: bytes) -> tuple[int, int]:
i = 0
while i < len(data):
if data[i] != 0xFF:
break
i += 1
marker = data[i]
i += 1
if marker in (0xD8, 0xD9):
continue
length = struct.unpack(">H", data[i : i + 2])[0]
if marker in (0xC0, 0xC1, 0xC2):
h, w = struct.unpack(">HH", data[i + 3 : i + 7])
return w, h
i += length
return 0, 0
def _png_dimensions(data: bytes) -> tuple[int, int]:
w, h = struct.unpack(">II", data[16:24])
return w, h
def _gif_dimensions(data: bytes) -> tuple[int, int]:
w, h = struct.unpack("<HH", data[6:10])
return w, h
def _webp_dimensions(data: bytes) -> tuple[int, int]:
if data[8:12] == b"VP8 ":
w = struct.unpack("<H", data[26:28])[0] & 0x3FFF
h = struct.unpack("<H", data[28:30])[0] & 0x3FFF
return w, h
elif data[8:12] == b"VP8L":
bits = struct.unpack("<I", data[21:25])[0]
w = (bits & 0x3FFF) + 1
h = ((bits >> 14) & 0x3FFF) + 1
return w, h
return 0, 0
@router.post("/images", status_code=201)
async def upload_image(
file: UploadFile = File(...),
tags: str | None = Form(None),
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
auth: AuthProvider = Depends(get_auth),
settings=Depends(get_settings),
):
data = await file.read()
mime_type = file.content_type or "application/octet-stream"
try:
validate_mime_type(mime_type)
except MimeTypeError:
raise HTTPException(
status_code=422,
detail={"detail": f"Unsupported file type: {mime_type}", "code": "invalid_mime_type"},
)
try:
validate_file_size(len(data), max_bytes=settings.max_upload_bytes)
except FileSizeError as exc:
raise HTTPException(
status_code=422,
detail={"detail": str(exc), "code": "file_too_large"},
)
hash_hex = compute_sha256(data)
image_repo = ImageRepository(db)
existing = await image_repo.get_by_hash(hash_hex)
if existing:
return Response(
content=__import__("json").dumps(_image_to_dict(existing, duplicate=True)),
status_code=200,
media_type="application/json",
)
# Parse tag names
tag_names: list[str] = []
if tags:
tag_repo = TagRepository(db)
raw = [t.strip() for t in tags.replace(",", " ").split() if t.strip()]
try:
tag_names = [tag_repo.normalise_and_validate(t) for t in raw]
except ValueError as exc:
raise HTTPException(
status_code=422,
detail={"detail": str(exc), "code": "invalid_tag"},
)
width, height = _read_image_dimensions(data, mime_type)
await storage.put(hash_hex, data, mime_type)
image = await image_repo.create(
hash_hex=hash_hex,
filename=file.filename or "upload",
mime_type=mime_type,
size_bytes=len(data),
width=width,
height=height,
storage_key=hash_hex,
)
if tag_names:
tag_repo = TagRepository(db)
await tag_repo.attach_tags(image, tag_names)
await db.refresh(image, ["image_tags"])
return _image_to_dict(image, duplicate=False)
@router.get("/images")
async def list_images(
tags: str | None = None,
limit: int = 50,
offset: int = 0,
db: AsyncSession = Depends(get_db),
):
limit = min(limit, 100)
tag_names = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
image_repo = ImageRepository(db)
images, total = await image_repo.list_images(tag_names=tag_names, limit=limit, offset=offset)
return {
"items": [_image_to_dict(img) for img in images],
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/images/{image_id}")
async def get_image(
image_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
):
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
if not image:
raise HTTPException(
status_code=404,
detail={"detail": "Image not found", "code": "image_not_found"},
)
return _image_to_dict(image)
@router.get("/images/{image_id}/file")
async def serve_image_file(
image_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
):
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
if not image:
raise HTTPException(
status_code=404,
detail={"detail": "Image not found", "code": "image_not_found"},
)
url = await storage.get_presigned_url(image.storage_key, expires_in_seconds=3600)
return RedirectResponse(url=url, status_code=302)
@router.patch("/images/{image_id}/tags")
async def update_image_tags(
image_id: uuid.UUID,
body: dict,
db: AsyncSession = Depends(get_db),
):
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
if not image:
raise HTTPException(
status_code=404,
detail={"detail": "Image not found", "code": "image_not_found"},
)
raw_tags: list[str] = body.get("tags", [])
tag_repo = TagRepository(db)
try:
tag_names = [tag_repo.normalise_and_validate(t) for t in raw_tags]
except ValueError as exc:
raise HTTPException(
status_code=422,
detail={"detail": str(exc), "code": "invalid_tag"},
)
await tag_repo.replace_tags_on_image(image, tag_names)
await db.refresh(image, ["image_tags"])
return _image_to_dict(image)
@router.delete("/images/{image_id}", status_code=204)
async def delete_image(
image_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
):
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
if not image:
raise HTTPException(
status_code=404,
detail={"detail": "Image not found", "code": "image_not_found"},
)
storage_key = image.storage_key
await image_repo.delete(image)
await storage.delete(storage_key)
return Response(status_code=204)

20
api/app/routers/tags.py Normal file
View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.repositories.tag_repo import TagRepository
router = APIRouter(tags=["tags"])
@router.get("/tags")
async def list_tags(
q: str | None = None,
limit: int = 100,
offset: int = 0,
db: AsyncSession = Depends(get_db),
):
limit = min(limit, 200)
tag_repo = TagRepository(db)
items, total = await tag_repo.list_tags(prefix=q, limit=limit, offset=offset)
return {"items": items, "total": total, "limit": limit, "offset": offset}

View File

View File

@@ -0,0 +1,15 @@
from abc import ABC, abstractmethod
class StorageBackend(ABC):
@abstractmethod
async def put(self, key: str, data: bytes, content_type: str) -> None:
"""Store object at key with given content type."""
@abstractmethod
async def get_presigned_url(self, key: str, expires_in_seconds: int = 3600) -> str:
"""Return a pre-signed URL valid for expires_in_seconds."""
@abstractmethod
async def delete(self, key: str) -> None:
"""Delete object at key."""

View File

@@ -0,0 +1,46 @@
from contextlib import asynccontextmanager
import aiobotocore.session
from app.config import get_settings
from app.storage.backend import StorageBackend
class S3StorageBackend(StorageBackend):
def __init__(self) -> None:
self._settings = get_settings()
self._session = aiobotocore.session.get_session()
@asynccontextmanager
async def _client(self):
s = self._settings
async with self._session.create_client(
"s3",
region_name=s.s3_region,
endpoint_url=s.s3_endpoint_url or None,
aws_access_key_id=s.s3_access_key_id,
aws_secret_access_key=s.s3_secret_access_key,
) as client:
yield client
async def put(self, key: str, data: bytes, content_type: str) -> None:
async with self._client() as client:
await client.put_object(
Bucket=self._settings.s3_bucket_name,
Key=key,
Body=data,
ContentType=content_type,
)
async def get_presigned_url(self, key: str, expires_in_seconds: int = 3600) -> str:
async with self._client() as client:
url = await client.generate_presigned_url(
"get_object",
Params={"Bucket": self._settings.s3_bucket_name, "Key": key},
ExpiresIn=expires_in_seconds,
)
return url
async def delete(self, key: str) -> None:
async with self._client() as client:
await client.delete_object(Bucket=self._settings.s3_bucket_name, Key=key)

5
api/app/utils.py Normal file
View File

@@ -0,0 +1,5 @@
import hashlib
def compute_sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()

21
api/app/validation.py Normal file
View File

@@ -0,0 +1,21 @@
ACCEPTED_MIME_TYPES = frozenset(["image/jpeg", "image/png", "image/gif", "image/webp"])
class MimeTypeError(ValueError):
pass
class FileSizeError(ValueError):
pass
def validate_mime_type(mime_type: str) -> None:
if mime_type not in ACCEPTED_MIME_TYPES:
raise MimeTypeError(f"Unsupported MIME type: {mime_type}")
def validate_file_size(size_bytes: int, max_bytes: int) -> None:
if size_bytes <= 0:
raise FileSizeError("File must not be empty")
if size_bytes > max_bytes:
raise FileSizeError(f"File size {size_bytes} exceeds limit of {max_bytes} bytes")

42
api/pyproject.toml Normal file
View File

@@ -0,0 +1,42 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "reactbin-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.111",
"uvicorn[standard]>=0.29",
"sqlalchemy[asyncio]>=2.0",
"asyncpg>=0.29",
"alembic>=1.13",
"aiobotocore>=2.13",
"pydantic-settings>=2.2",
"python-multipart>=0.0.9",
]
[project.optional-dependencies]
dev = [
"pytest>=8.2",
"pytest-asyncio>=0.23",
"httpx>=0.27",
"anyio>=4.4",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = []
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]

0
api/tests/__init__.py Normal file
View File

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

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)

85
docker-compose.yml Normal file
View File

@@ -0,0 +1,85 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: reactbin
POSTGRES_PASSWORD: reactbin
POSTGRES_DB: reactbin
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reactbin"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
volumes:
- minio_data:/data
ports:
- "9000:9000"
- "9001:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-reactbin}
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb --ignore-existing local/$$S3_BUCKET_NAME &&
mc anonymous set download local/$$S3_BUCKET_NAME
"
api:
build:
context: ./api
env_file: .env
ports:
- "8000:8000"
volumes:
- ./api:/app
depends_on:
postgres:
condition: service_healthy
minio-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/api/v1/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
ui:
build:
context: ./ui
env_file: .env
ports:
- "4200:4200"
volumes:
- ./ui:/app
- /app/node_modules
depends_on:
api:
condition: service_healthy
volumes:
postgres_data:
minio_data:

View File

@@ -40,20 +40,20 @@ ui/src/app/<feature>/ Angular component dirs (+ .spec.ts colocated)
**Purpose**: Establish the monorepo layout, Docker Compose stack, and linting
baseline. No feature logic. All subsequent milestones build on this.
- [ ] T001 Create top-level monorepo layout: `api/`, `ui/`, `docker-compose.yml`, `.env.example`
- [ ] T002 Write `.env.example` with all variables from spec §5 (DATABASE_URL, S3_*, API_BASE_URL, MAX_UPLOAD_BYTES)
- [ ] T003 [P] Write `api/Dockerfile` (Python 3.12 slim, installs pyproject.toml deps, runs uvicorn)
- [ ] T004 [P] Scaffold Angular project with CLI into `ui/` (strict mode, standalone components, routing)
- [ ] T005 [P] Write `ui/Dockerfile` (Node LTS, `ng serve --host 0.0.0.0`)
- [ ] T006 Write `docker-compose.yml` defining: postgres, minio, api (depends_on postgres+minio), ui (depends_on api)
- [ ] T007 [P] Configure `api/pyproject.toml` with FastAPI, SQLAlchemy 2.x async, asyncpg, Alembic, aiobotocore, pydantic-settings, pytest, pytest-asyncio, ruff
- [ ] T008 [P] Configure `ui/package.json` / `angular.json` with eslint + prettier; add `ui/proxy.conf.json` routing `/api/*` to `http://localhost:8000`
- [ ] T009 Write API unit test: settings load from env vars without error in `api/tests/unit/test_config.py`
- [ ] T010 Write API integration test: `GET /api/v1/health` returns 200 `{"status":"ok"}` in `api/tests/integration/test_health.py`
- [ ] T011 Implement `api/app/config.py` (pydantic-settings reading all env vars)
- [ ] T012 Implement `api/app/main.py` (FastAPI factory, lifespan connecting to Postgres + MinIO, health route)
- [ ] T013 Configure Alembic in `api/alembic/` with async engine; apply `alembic upgrade head` on startup
- [ ] T014 [P] Add Angular default smoke test in `ui/src/app/app.component.spec.ts`
- [x] T001 Create top-level monorepo layout: `api/`, `ui/`, `docker-compose.yml`, `.env.example`
- [x] T002 Write `.env.example` with all variables from spec §5 (DATABASE_URL, S3_*, API_BASE_URL, MAX_UPLOAD_BYTES)
- [x] T003 [P] Write `api/Dockerfile` (Python 3.12 slim, installs pyproject.toml deps, runs uvicorn)
- [x] T004 [P] Scaffold Angular project with CLI into `ui/` (strict mode, standalone components, routing)
- [x] T005 [P] Write `ui/Dockerfile` (Node LTS, `ng serve --host 0.0.0.0`)
- [x] T006 Write `docker-compose.yml` defining: postgres, minio, api (depends_on postgres+minio), ui (depends_on api)
- [x] T007 [P] Configure `api/pyproject.toml` with FastAPI, SQLAlchemy 2.x async, asyncpg, Alembic, aiobotocore, pydantic-settings, pytest, pytest-asyncio, ruff
- [x] T008 [P] Configure `ui/package.json` / `angular.json` with eslint + prettier; add `ui/proxy.conf.json` routing `/api/*` to `http://localhost:8000`
- [x] T009 Write API unit test: settings load from env vars without error in `api/tests/unit/test_config.py`
- [x] T010 Write API integration test: `GET /api/v1/health` returns 200 `{"status":"ok"}` in `api/tests/integration/test_health.py`
- [x] T011 Implement `api/app/config.py` (pydantic-settings reading all env vars)
- [x] T012 Implement `api/app/main.py` (FastAPI factory, lifespan connecting to Postgres + MinIO, health route)
- [x] T013 Configure Alembic in `api/alembic/` with async engine; apply `alembic upgrade head` on startup
- [x] T014 [P] Add Angular default smoke test in `ui/src/app/app.component.spec.ts`
**Checkpoint**: `docker compose up` starts all four services. Health endpoint
returns 200. Both linters pass. All tests pass.
@@ -68,17 +68,17 @@ story endpoint can be implemented. Establishes the `StorageBackend`,
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
- [ ] T015 Write unit test: SHA-256 hash of known bytes returns expected hex digest in `api/tests/unit/test_hashing.py`
- [ ] T016 Write unit test: MIME validator accepts jpeg/png/gif/webp and rejects pdf/mp4 in `api/tests/unit/test_validation.py`
- [ ] T017 Write unit test: file size validator rejects bytes exceeding MAX_UPLOAD_BYTES in `api/tests/unit/test_validation.py`
- [ ] T018 [P] Implement `StorageBackend` interface (put, get_presigned_url, delete) in `api/app/storage/backend.py`
- [ ] T019 [P] Implement `S3StorageBackend` using aiobotocore in `api/app/storage/s3_backend.py`
- [ ] T020 [P] Implement `AuthProvider` interface + `NoOpAuthProvider` in `api/app/auth/provider.py` and `api/app/auth/noop.py`
- [ ] T021 Implement MIME type + file size validation helpers in `api/app/routers/images.py` (or `api/app/validation.py`)
- [ ] T022 Write Alembic migration for `images` table in `api/alembic/versions/`
- [ ] T023 Implement `Image` SQLAlchemy model in `api/app/models.py`
- [ ] T024 Implement `ImageRepository` (create, get_by_id, get_by_hash) in `api/app/repositories/image_repo.py`
- [ ] T025 Wire `AuthProvider`, `StorageBackend`, and DB session into FastAPI dependency injection in `api/app/dependencies.py`
- [x] T015 Write unit test: SHA-256 hash of known bytes returns expected hex digest in `api/tests/unit/test_hashing.py`
- [x] T016 Write unit test: MIME validator accepts jpeg/png/gif/webp and rejects pdf/mp4 in `api/tests/unit/test_validation.py`
- [x] T017 Write unit test: file size validator rejects bytes exceeding MAX_UPLOAD_BYTES in `api/tests/unit/test_validation.py`
- [x] T018 [P] Implement `StorageBackend` interface (put, get_presigned_url, delete) in `api/app/storage/backend.py`
- [x] T019 [P] Implement `S3StorageBackend` using aiobotocore in `api/app/storage/s3_backend.py`
- [x] T020 [P] Implement `AuthProvider` interface + `NoOpAuthProvider` in `api/app/auth/provider.py` and `api/app/auth/noop.py`
- [x] T021 Implement MIME type + file size validation helpers in `api/app/routers/images.py` (or `api/app/validation.py`)
- [x] T022 Write Alembic migration for `images` table in `api/alembic/versions/`
- [x] T023 Implement `Image` SQLAlchemy model in `api/app/models.py`
- [x] T024 Implement `ImageRepository` (create, get_by_id, get_by_hash) in `api/app/repositories/image_repo.py`
- [x] T025 Wire `AuthProvider`, `StorageBackend`, and DB session into FastAPI dependency injection in `api/app/dependencies.py`
**Checkpoint**: All unit tests pass; foundation ready for user story endpoints.
@@ -97,20 +97,20 @@ and no duplicate in DB or MinIO.
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T026 [P] [US1] Integration test: valid JPEG upload → 201, record in DB, object in MinIO in `api/tests/integration/test_upload.py`
- [ ] T027 [P] [US1] Integration test: same image uploaded twice → 200, `duplicate: true`, no second MinIO object in `api/tests/integration/test_upload.py`
- [ ] T028 [P] [US1] Integration test: invalid MIME type → 422 with `{"detail":"...","code":"invalid_mime_type"}` in `api/tests/integration/test_upload.py`
- [ ] T029 [P] [US1] Integration test: file > MAX_UPLOAD_BYTES → 422 `file_too_large` in `api/tests/integration/test_upload.py`
- [ ] T030 [P] [US1] Angular unit test: tag chip input lowercases and splits on comma/space in `ui/src/app/upload/upload.component.spec.ts`
- [ ] T031 [P] [US1] Angular unit test: `duplicate: true` response → toast shown, navigate to detail in `ui/src/app/upload/upload.component.spec.ts`
- [ ] T032 [P] [US1] Angular unit test: `duplicate: false` response → success toast, navigate to detail in `ui/src/app/upload/upload.component.spec.ts`
- [ ] T033 [P] [US1] Angular unit test: error response → inline error shown, no navigation in `ui/src/app/upload/upload.component.spec.ts`
- [x] T026 [P] [US1] Integration test: valid JPEG upload → 201, record in DB, object in MinIO in `api/tests/integration/test_upload.py`
- [x] T027 [P] [US1] Integration test: same image uploaded twice → 200, `duplicate: true`, no second MinIO object in `api/tests/integration/test_upload.py`
- [x] T028 [P] [US1] Integration test: invalid MIME type → 422 with `{"detail":"...","code":"invalid_mime_type"}` in `api/tests/integration/test_upload.py`
- [x] T029 [P] [US1] Integration test: file > MAX_UPLOAD_BYTES → 422 `file_too_large` in `api/tests/integration/test_upload.py`
- [x] T030 [P] [US1] Angular unit test: tag chip input lowercases and splits on comma/space in `ui/src/app/upload/upload.component.spec.ts`
- [x] T031 [P] [US1] Angular unit test: `duplicate: true` response → toast shown, navigate to detail in `ui/src/app/upload/upload.component.spec.ts`
- [x] T032 [P] [US1] Angular unit test: `duplicate: false` response → success toast, navigate to detail in `ui/src/app/upload/upload.component.spec.ts`
- [x] T033 [P] [US1] Angular unit test: error response → inline error shown, no navigation in `ui/src/app/upload/upload.component.spec.ts`
### Implementation for User Story 1
- [ ] T034 [US1] Implement `POST /api/v1/images` endpoint (MIME check, size check, SHA-256, duplicate query, storage write, record insert; tags field accepted but ignored) in `api/app/routers/images.py`
- [ ] T035 [US1] Implement `ImageService` wrapping `GET /api/v1/images` and `GET /api/v1/images/{id}/file` in `ui/src/app/services/image.service.ts`
- [ ] T036 [US1] Implement `UploadComponent` (route `/upload`) with drag-and-drop zone, click-to-browse, tag chip input, POST submit, duplicate/success/error handling in `ui/src/app/upload/upload.component.ts`
- [x] T034 [US1] Implement `POST /api/v1/images` endpoint (MIME check, size check, SHA-256, duplicate query, storage write, record insert; tags field accepted but ignored) in `api/app/routers/images.py`
- [x] T035 [US1] Implement `ImageService` wrapping `GET /api/v1/images` and `GET /api/v1/images/{id}/file` in `ui/src/app/services/image.service.ts`
- [x] T036 [US1] Implement `UploadComponent` (route `/upload`) with drag-and-drop zone, click-to-browse, tag chip input, POST submit, duplicate/success/error handling in `ui/src/app/upload/upload.component.ts`
**Checkpoint**: Full upload flow works in browser. Duplicate detection gives
correct feedback. API tests and Angular unit tests all pass.
@@ -130,27 +130,27 @@ both tags must be present. Remove a filter, verify the grid expands.
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T037 [P] [US2] Unit test: tag normalisation — uppercase → lowercase, whitespace stripped in `api/tests/unit/test_tags.py`
- [ ] T038 [P] [US2] Unit test: tag validation — rejects names > 64 chars, invalid chars in `api/tests/unit/test_tags.py`
- [ ] T039 [P] [US2] Integration test: upload with tags → tags persisted, returned in response in `api/tests/integration/test_tags.py`
- [ ] T040 [P] [US2] Integration test: duplicate upload → existing record returned, tags unchanged in `api/tests/integration/test_tags.py`
- [ ] T041 [P] [US2] Integration test: `GET /api/v1/images?tags=cat,funny` → only images with both tags in `api/tests/integration/test_search.py`
- [ ] T042 [P] [US2] Integration test: same query excludes images with only one matching tag in `api/tests/integration/test_search.py`
- [ ] T043 [P] [US2] Angular unit test: `ImageService` constructs correct query params from filter state in `ui/src/app/services/image.service.spec.ts`
- [ ] T044 [P] [US2] Angular unit test: `LibraryComponent` renders image grid from mocked service in `ui/src/app/library/library.component.spec.ts`
- [ ] T045 [P] [US2] Angular unit test: filter change triggers new API call with updated `tags` param in `ui/src/app/library/library.component.spec.ts`
- [x] T037 [P] [US2] Unit test: tag normalisation — uppercase → lowercase, whitespace stripped in `api/tests/unit/test_tags.py`
- [x] T038 [P] [US2] Unit test: tag validation — rejects names > 64 chars, invalid chars in `api/tests/unit/test_tags.py`
- [x] T039 [P] [US2] Integration test: upload with tags → tags persisted, returned in response in `api/tests/integration/test_tags.py`
- [x] T040 [P] [US2] Integration test: duplicate upload → existing record returned, tags unchanged in `api/tests/integration/test_tags.py`
- [x] T041 [P] [US2] Integration test: `GET /api/v1/images?tags=cat,funny` → only images with both tags in `api/tests/integration/test_search.py`
- [x] T042 [P] [US2] Integration test: same query excludes images with only one matching tag in `api/tests/integration/test_search.py`
- [x] T043 [P] [US2] Angular unit test: `ImageService` constructs correct query params from filter state in `ui/src/app/services/image.service.spec.ts`
- [x] T044 [P] [US2] Angular unit test: `LibraryComponent` renders image grid from mocked service in `ui/src/app/library/library.component.spec.ts`
- [x] T045 [P] [US2] Angular unit test: filter change triggers new API call with updated `tags` param in `ui/src/app/library/library.component.spec.ts`
### Implementation for User Story 2
- [ ] T046 [US2] Write Alembic migration for `tags` and `image_tags` tables in `api/alembic/versions/`
- [ ] T047 [US2] Implement `Tag` and `ImageTag` SQLAlchemy models in `api/app/models.py`
- [ ] T048 [US2] Implement tag normalisation + validation helpers in `api/app/repositories/tag_repo.py`
- [ ] T049 [US2] Implement `TagRepository` (upsert_by_name, get_by_image_id) in `api/app/repositories/tag_repo.py`
- [ ] T050 [US2] Update `POST /api/v1/images` to process and persist the `tags` field in `api/app/routers/images.py`
- [ ] T051 [US2] Implement `GET /api/v1/images` with `tags` (AND-filter), `limit`, `offset` in `api/app/routers/images.py`
- [ ] T052 [US2] Implement `GET /api/v1/images/{id}` returning image + tags in `api/app/routers/images.py`
- [ ] T053 [US2] Update `ImageService` to support `tags` filter query param in `ui/src/app/services/image.service.ts`
- [ ] T054 [US2] Implement `LibraryComponent` (route `/`) with image grid, tag chips, debounced filter bar, "Load more" pagination in `ui/src/app/library/library.component.ts`
- [x] T046 [US2] Write Alembic migration for `tags` and `image_tags` tables in `api/alembic/versions/`
- [x] T047 [US2] Implement `Tag` and `ImageTag` SQLAlchemy models in `api/app/models.py`
- [x] T048 [US2] Implement tag normalisation + validation helpers in `api/app/repositories/tag_repo.py`
- [x] T049 [US2] Implement `TagRepository` (upsert_by_name, get_by_image_id) in `api/app/repositories/tag_repo.py`
- [x] T050 [US2] Update `POST /api/v1/images` to process and persist the `tags` field in `api/app/routers/images.py`
- [x] T051 [US2] Implement `GET /api/v1/images` with `tags` (AND-filter), `limit`, `offset` in `api/app/routers/images.py`
- [x] T052 [US2] Implement `GET /api/v1/images/{id}` returning image + tags in `api/app/routers/images.py`
- [x] T053 [US2] Update `ImageService` to support `tags` filter query param in `ui/src/app/services/image.service.ts`
- [x] T054 [US2] Implement `LibraryComponent` (route `/`) with image grid, tag chips, debounced filter bar, "Load more" pagination in `ui/src/app/library/library.component.ts`
**Checkpoint**: Library view shows real images with tags. Tag filtering (AND
logic) and pagination work end-to-end.
@@ -169,19 +169,19 @@ filter by that tag, and confirm the image appears.
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T055 [P] [US3] Integration test: `GET /api/v1/images/{id}/file` → 302 with `Location` header pointing to MinIO URL in `api/tests/integration/test_serving.py`
- [ ] T056 [P] [US3] Integration test: `/file` for unknown ID → 404 `image_not_found` in `api/tests/integration/test_serving.py`
- [ ] T057 [P] [US3] Integration test: `PATCH /api/v1/images/{id}/tags` replaces tags, old tags unlinked, new tags upserted in `api/tests/integration/test_tags.py`
- [ ] T058 [P] [US3] Integration test: PATCH with invalid tag → 422 `invalid_tag` in `api/tests/integration/test_tags.py`
- [ ] T059 [P] [US3] Angular unit test: removing tag chip calls PATCH with updated list (removed tag absent) in `ui/src/app/detail/detail.component.spec.ts`
- [ ] T060 [P] [US3] Angular unit test: adding tag + Enter calls PATCH with new tag included in `ui/src/app/detail/detail.component.spec.ts`
- [x] T055 [P] [US3] Integration test: `GET /api/v1/images/{id}/file` → 302 with `Location` header pointing to MinIO URL in `api/tests/integration/test_serving.py`
- [x] T056 [P] [US3] Integration test: `/file` for unknown ID → 404 `image_not_found` in `api/tests/integration/test_serving.py`
- [x] T057 [P] [US3] Integration test: `PATCH /api/v1/images/{id}/tags` replaces tags, old tags unlinked, new tags upserted in `api/tests/integration/test_tags.py`
- [x] T058 [P] [US3] Integration test: PATCH with invalid tag → 422 `invalid_tag` in `api/tests/integration/test_tags.py`
- [x] T059 [P] [US3] Angular unit test: removing tag chip calls PATCH with updated list (removed tag absent) in `ui/src/app/detail/detail.component.spec.ts`
- [x] T060 [P] [US3] Angular unit test: adding tag + Enter calls PATCH with new tag included in `ui/src/app/detail/detail.component.spec.ts`
### Implementation for User Story 3
- [ ] T061 [US3] Implement `GET /api/v1/images/{id}/file` (generate 1-hour pre-signed URL, return 302) in `api/app/routers/images.py`
- [ ] T062 [US3] Implement `TagRepository.replace_tags_on_image` in `api/app/repositories/tag_repo.py`
- [ ] T063 [US3] Implement `PATCH /api/v1/images/{id}/tags` in `api/app/routers/images.py`
- [ ] T064 [US3] Implement `DetailComponent` (route `/images/:id`) with full-size image, editable tag chips (add/remove), save on blur/Enter via PATCH, back button in `ui/src/app/detail/detail.component.ts`
- [x] T061 [US3] Implement `GET /api/v1/images/{id}/file` (generate 1-hour pre-signed URL, return 302) in `api/app/routers/images.py`
- [x] T062 [US3] Implement `TagRepository.replace_tags_on_image` in `api/app/repositories/tag_repo.py`
- [x] T063 [US3] Implement `PATCH /api/v1/images/{id}/tags` in `api/app/routers/images.py`
- [x] T064 [US3] Implement `DetailComponent` (route `/images/:id`) with full-size image, editable tag chips (add/remove), save on blur/Enter via PATCH, back button in `ui/src/app/detail/detail.component.ts`
**Checkpoint**: Full-size image loads in browser via redirect. Tag editing
works from detail page. Changes persist across page navigation.
@@ -200,17 +200,17 @@ library and that navigating to its former URL shows a not-found screen.
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T065 [P] [US4] Integration test: `DELETE /api/v1/images/{id}` → 204; subsequent `GET /{id}` returns 404 in `api/tests/integration/test_delete.py`
- [ ] T066 [P] [US4] Integration test: DELETE verifies MinIO object is removed in `api/tests/integration/test_delete.py`
- [ ] T067 [P] [US4] Integration test: DELETE of unknown ID → 404 `image_not_found` in `api/tests/integration/test_delete.py`
- [ ] T068 [P] [US4] Angular unit test: delete confirmation → DELETE called → navigation to Library in `ui/src/app/detail/detail.component.spec.ts`
- [ ] T069 [P] [US4] Angular unit test: cancel confirmation dialog → no DELETE call, stays on detail page in `ui/src/app/detail/detail.component.spec.ts`
- [x] T065 [P] [US4] Integration test: `DELETE /api/v1/images/{id}` → 204; subsequent `GET /{id}` returns 404 in `api/tests/integration/test_delete.py`
- [x] T066 [P] [US4] Integration test: DELETE verifies MinIO object is removed in `api/tests/integration/test_delete.py`
- [x] T067 [P] [US4] Integration test: DELETE of unknown ID → 404 `image_not_found` in `api/tests/integration/test_delete.py`
- [x] T068 [P] [US4] Angular unit test: delete confirmation → DELETE called → navigation to Library in `ui/src/app/detail/detail.component.spec.ts`
- [x] T069 [P] [US4] Angular unit test: cancel confirmation dialog → no DELETE call, stays on detail page in `ui/src/app/detail/detail.component.spec.ts`
### Implementation for User Story 4
- [ ] T070 [US4] Implement `DELETE /api/v1/images/{id}` (delete image_tags rows, image record, S3 object) in `api/app/routers/images.py`
- [ ] T071 [US4] Add delete button with confirmation dialog + back-to-Library navigation to `DetailComponent` in `ui/src/app/detail/detail.component.ts`
- [ ] T072 [US4] Implement `NotFoundComponent` shown for all unrecognised routes in `ui/src/app/not-found/not-found.component.ts`
- [x] T070 [US4] Implement `DELETE /api/v1/images/{id}` (delete image_tags rows, image record, S3 object) in `api/app/routers/images.py`
- [x] T071 [US4] Add delete button with confirmation dialog + back-to-Library navigation to `DetailComponent` in `ui/src/app/detail/detail.component.ts`
- [x] T072 [US4] Implement `NotFoundComponent` shown for all unrecognised routes in `ui/src/app/not-found/not-found.component.ts`
**Checkpoint**: Full CRUD loop works: upload → view → re-tag → delete.
Deleted images gone from library and storage.
@@ -230,15 +230,15 @@ matching tags remain.
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T073 [P] [US5] Integration test: `GET /api/v1/tags` returns all tags alphabetically with correct image_count in `api/tests/integration/test_tags.py`
- [ ] T074 [P] [US5] Integration test: `GET /api/v1/tags?q=ca` returns only tags prefixed "ca" in `api/tests/integration/test_tags.py`
- [ ] T075 [P] [US5] Angular unit test: `TagService` calls `GET /api/v1/tags` with `q` param in `ui/src/app/services/tag.service.spec.ts`
- [x] T073 [P] [US5] Integration test: `GET /api/v1/tags` returns all tags alphabetically with correct image_count in `api/tests/integration/test_tags.py`
- [x] T074 [P] [US5] Integration test: `GET /api/v1/tags?q=ca` returns only tags prefixed "ca" in `api/tests/integration/test_tags.py`
- [x] T075 [P] [US5] Angular unit test: `TagService` calls `GET /api/v1/tags` with `q` param in `ui/src/app/services/tag.service.spec.ts`
### Implementation for User Story 5
- [ ] T076 [US5] Implement `GET /api/v1/tags` with `q` prefix search, `limit`, `offset`, image_count in `api/app/routers/tags.py`
- [ ] T077 [US5] Implement `TagService` wrapping `GET /api/v1/tags` in `ui/src/app/services/tag.service.ts`
- [ ] T078 [US5] Wire `TagService` into `LibraryComponent` tag filter bar for tag autocomplete/selection in `ui/src/app/library/library.component.ts`
- [x] T076 [US5] Implement `GET /api/v1/tags` with `q` prefix search, `limit`, `offset`, image_count in `api/app/routers/tags.py`
- [x] T077 [US5] Implement `TagService` wrapping `GET /api/v1/tags` in `ui/src/app/services/tag.service.ts`
- [x] T078 [US5] Wire `TagService` into `LibraryComponent` tag filter bar for tag autocomplete/selection in `ui/src/app/library/library.component.ts`
**Checkpoint**: All user stories independently functional and tested.
@@ -248,16 +248,16 @@ matching tags remain.
**Purpose**: Improvements affecting multiple user stories and final validation.
- [ ] T079 [P] Add `GET /api/v1/images/{id}` 404 test to verify error envelope shape `{"detail":"...","code":"image_not_found"}` in `api/tests/integration/test_upload.py`
- [ ] T080 [P] Verify all API error responses include both `detail` and `code` fields (constitution §3.3) — check tests for T028, T029, T056, T058, T067
- [ ] T081 [P] Add empty-state UI for library with zero images in `ui/src/app/library/library.component.ts`
- [ ] T082 [P] Add empty-state UI for tag filter returning zero results in `ui/src/app/library/library.component.ts`
- [ ] T083 Configure Angular routing to show `NotFoundComponent` for all unrecognised routes in `ui/src/app/app.routes.ts`
- [ ] T084 [P] Run quickstart.md validation: `docker compose up`, upload an image, filter by tag, edit tag, delete image — full happy path
- [ ] T085 [P] Run `ruff check .` in `api/` — confirm zero lint errors
- [ ] T086 [P] Run `npm run lint` in `ui/` — confirm zero lint errors
- [ ] T087 Run all API tests: `docker compose run --rm api pytest` — confirm all pass
- [ ] T088 Run all UI tests: `docker compose run --rm ui ng test --watch=false` — confirm all pass
- [x] T079 [P] Add `GET /api/v1/images/{id}` 404 test to verify error envelope shape `{"detail":"...","code":"image_not_found"}` in `api/tests/integration/test_upload.py`
- [x] T080 [P] Verify all API error responses include both `detail` and `code` fields (constitution §3.3) — check tests for T028, T029, T056, T058, T067
- [x] T081 [P] Add empty-state UI for library with zero images in `ui/src/app/library/library.component.ts`
- [x] T082 [P] Add empty-state UI for tag filter returning zero results in `ui/src/app/library/library.component.ts`
- [x] T083 Configure Angular routing to show `NotFoundComponent` for all unrecognised routes in `ui/src/app/app.routes.ts`
- [x] T084 [P] Run quickstart.md validation: `docker compose up`, upload an image, filter by tag, edit tag, delete image — full happy path
- [x] T085 [P] Run `ruff check .` in `api/` — confirm zero lint errors
- [x] T086 [P] Run `npm run lint` in `ui/` — confirm zero lint errors
- [x] T087 Run all API tests: `docker compose run --rm api pytest` — confirm all pass
- [x] T088 Run all UI tests: `docker compose run --rm ui ng test --watch=false` — confirm all pass
---

9
ui/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git/
node_modules/
dist/
.angular/
coverage/
.env
.env.*
!.env.example
*.log

6
ui/.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all",
"semi": true
}

11
ui/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 4200
CMD ["npx", "ng", "serve", "--host", "0.0.0.0", "--port", "4200", "--proxy-config", "proxy.conf.json"]

80
ui/angular.json Normal file
View File

@@ -0,0 +1,80 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"reactbin-ui": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"standalone": true,
"changeDetection": "OnPush"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/reactbin-ui",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [
{ "glob": "**/*", "input": "public" }
],
"styles": ["src/styles.css"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": { "buildTarget": "reactbin-ui:build:production" },
"development": { "buildTarget": "reactbin-ui:build:development" }
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"assets": [
{ "glob": "**/*", "input": "public" }
],
"styles": ["src/styles.css"],
"scripts": []
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
}
}
}
}
}

26
ui/eslint.config.js Normal file
View File

@@ -0,0 +1,26 @@
// @ts-check
const eslint = require("@eslint/js");
const tseslint = require("typescript-eslint");
const angular = require("angular-eslint");
module.exports = tseslint.config(
{
files: ["**/*.ts"],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
...angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
"@angular-eslint/directive-selector": ["error", { type: "attribute", prefix: "app", style: "camelCase" }],
"@angular-eslint/component-selector": ["error", { type: "element", prefix: "app", style: "kebab-case" }],
},
},
{
files: ["**/*.html"],
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: {},
}
);

19
ui/karma.conf.js Normal file
View File

@@ -0,0 +1,19 @@
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma'),
],
client: { jasmine: { random: true } },
jasmineHtmlReporter: { suppressAll: true },
coverageReporter: { dir: require('path').join(__dirname, './coverage/reactbin-ui'), subdir: '.', reporters: [{ type: 'html' }, { type: 'text-summary' }] },
reporters: ['progress', 'kjhtml'],
browsers: ['Chrome'],
restartOnFileChange: true,
});
};

42
ui/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "reactbin-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "ng serve",
"build": "ng build",
"test": "ng test --watch=false --browsers=ChromeHeadless",
"lint": "ng lint",
"format": "prettier --write \"src/**/*.{ts,html,css,scss}\""
},
"dependencies": {
"@angular/animations": "^19.0.0",
"@angular/common": "^19.0.0",
"@angular/compiler": "^19.0.0",
"@angular/core": "^19.0.0",
"@angular/forms": "^19.0.0",
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.0",
"@angular/cli": "^19.0.0",
"@angular/compiler-cli": "^19.0.0",
"@types/jasmine": "~5.1.0",
"angular-eslint": "^19.0.0",
"eslint": "^9.0.0",
"jasmine-core": "~5.4.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"prettier": "^3.2.0",
"typescript": "~5.6.0",
"typescript-eslint": "^8.0.0"
}
}

7
ui/proxy.conf.json Normal file
View File

@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://api:8000",
"secure": false,
"changeOrigin": true
}
}

View File

@@ -0,0 +1,25 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [provideRouter(routes)],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should have title reactbin-ui', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('reactbin-ui');
});
});

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet />`,
})
export class AppComponent {
title = 'reactbin-ui';
}

13
ui/src/app/app.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
],
};

24
ui/src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,24 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./library/library.component').then((m) => m.LibraryComponent),
},
{
path: 'upload',
loadComponent: () =>
import('./upload/upload.component').then((m) => m.UploadComponent),
},
{
path: 'images/:id',
loadComponent: () =>
import('./detail/detail.component').then((m) => m.DetailComponent),
},
{
path: '**',
loadComponent: () =>
import('./not-found/not-found.component').then((m) => m.NotFoundComponent),
},
];

View File

@@ -0,0 +1,82 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of } from 'rxjs';
import { DetailComponent } from './detail.component';
import { ImageService } from '../services/image.service';
import { routes } from '../app.routes';
const MOCK_IMAGE = {
id: 'img-1',
hash: 'abc',
filename: 'test.jpg',
mime_type: 'image/jpeg',
size_bytes: 100,
width: 10,
height: 10,
storage_key: 'abc',
created_at: '2026-01-01T00:00:00Z',
tags: ['cat', 'funny'],
};
describe('DetailComponent', () => {
function setup(imageId = 'img-1') {
TestBed.configureTestingModule({
imports: [DetailComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter(routes),
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => imageId } } } },
],
}).compileComponents();
const fixture = TestBed.createComponent(DetailComponent);
const component = fixture.componentInstance;
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'get').and.returnValue(of(MOCK_IMAGE));
fixture.detectChanges();
return { fixture, component, imgSvc };
}
it('should call PATCH with removed tag absent when chip × is clicked', () => {
const { component, imgSvc } = setup();
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
component.removeTag('cat');
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['funny']);
});
it('should call PATCH with new tag included on addTag', () => {
const { component, imgSvc } = setup();
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['cat', 'funny', 'new'] }));
component.addTag('new');
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['cat', 'funny', 'new']);
});
it('should call DELETE and navigate to library on confirm delete', () => {
const { component, imgSvc } = setup();
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
component.confirmDelete();
expect(imgSvc.delete).toHaveBeenCalledWith('img-1');
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
it('should NOT call DELETE when cancel is clicked', () => {
const { component, imgSvc } = setup();
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
component.showDeleteDialog = true;
component.cancelDelete();
expect(imgSvc.delete).not.toHaveBeenCalled();
expect(component.showDeleteDialog).toBeFalse();
});
it('back button should navigate to library', () => {
const { component } = setup();
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
component.goBack();
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
});

View File

@@ -0,0 +1,128 @@
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ImageRecord, ImageService } from '../services/image.service';
@Component({
selector: 'app-detail',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="detail-page" *ngIf="image">
<button class="back-btn" (click)="goBack()">← Back</button>
<h2>{{ image.filename }}</h2>
<img class="full-image" [src]="imageService.getFileUrl(image.id)" [alt]="image.filename" />
<section class="tags-section">
<h3>Tags</h3>
<div class="chips">
<span *ngFor="let tag of image.tags" class="chip">
{{ tag }} <button (click)="removeTag(tag)">×</button>
</span>
</div>
<div class="add-tag">
<input
[(ngModel)]="newTagInput"
placeholder="Add tag…"
(keydown.enter)="onEnter()"
(blur)="onBlur()"
/>
</div>
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p>
</section>
<button class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
<div class="dialog-overlay" *ngIf="showDeleteDialog">
<div class="dialog">
<p>Permanently delete this image?</p>
<button (click)="confirmDelete()">Delete</button>
<button (click)="cancelDelete()">Cancel</button>
</div>
</div>
</div>
<p *ngIf="!image && !loading" class="not-found">Image not found.</p>
`,
styles: [`
.detail-page { max-width: 900px; margin: 32px auto; padding: 0 16px; }
.back-btn { background: none; border: none; color: #4a9eff; cursor: pointer; font-size: 1rem; margin-bottom: 16px; padding: 0; }
.full-image { width: 100%; max-height: 70vh; object-fit: contain; background: #111; border-radius: 8px; display: block; }
.tags-section { margin-top: 24px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
.chip { background: #333; padding: 4px 12px; border-radius: 14px; display: flex; align-items: center; gap: 6px; }
.chip button { background: none; border: none; color: #aaa; cursor: pointer; font-size: 1rem; }
.add-tag input { padding: 8px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 4px; width: 200px; }
.tag-error { color: #ff6b6b; font-size: 0.85rem; margin-top: 6px; }
.delete-btn { margin-top: 32px; padding: 10px 24px; background: #c0392b; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
.dialog { background: #1a1a1a; padding: 32px; border-radius: 10px; text-align: center; }
.dialog button { margin: 0 8px; padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer; }
.dialog button:first-of-type { background: #c0392b; color: #fff; }
.dialog button:last-of-type { background: #444; color: #e0e0e0; }
.not-found { text-align: center; color: #666; padding: 60px; }
`],
})
export class DetailComponent implements OnInit {
image: ImageRecord | null = null;
loading = true;
newTagInput = '';
tagError = '';
showDeleteDialog = false;
constructor(
public imageService: ImageService,
private route: ActivatedRoute,
public router: Router,
private cdr: ChangeDetectorRef,
) {}
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (!id) { this.loading = false; return; }
this.imageService.get(id).subscribe({
next: (img) => { this.image = img; this.loading = false; this.cdr.markForCheck(); },
error: () => { this.loading = false; this.cdr.markForCheck(); },
});
}
removeTag(tag: string): void {
if (!this.image) return;
const updated = this.image.tags.filter((t) => t !== tag);
this.imageService.updateTags(this.image.id, updated).subscribe({
next: (img) => { this.image = img; this.tagError = ''; this.cdr.markForCheck(); },
error: (err) => { this.tagError = err?.error?.detail ?? 'Failed to remove tag'; this.cdr.markForCheck(); },
});
}
addTag(tag: string): void {
if (!this.image || !tag.trim()) return;
const normalised = tag.trim().toLowerCase();
const updated = [...this.image.tags, normalised];
this.imageService.updateTags(this.image.id, updated).subscribe({
next: (img) => { this.image = img; this.newTagInput = ''; this.tagError = ''; this.cdr.markForCheck(); },
error: (err) => { this.tagError = err?.error?.detail ?? 'Invalid tag'; this.cdr.markForCheck(); },
});
}
onEnter(): void { this.addTag(this.newTagInput); }
onBlur(): void { if (this.newTagInput.trim()) this.addTag(this.newTagInput); }
confirmDelete(): void {
if (!this.image) return;
this.imageService.delete(this.image.id).subscribe({
next: () => this.router.navigate(['/']),
error: () => { this.showDeleteDialog = false; this.cdr.markForCheck(); },
});
}
cancelDelete(): void {
this.showDeleteDialog = false;
this.cdr.markForCheck();
}
goBack(): void { this.router.navigate(['/']); }
}

View File

@@ -0,0 +1,49 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { of } from 'rxjs';
import { LibraryComponent } from './library.component';
import { ImageService } from '../services/image.service';
import { routes } from '../app.routes';
describe('LibraryComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LibraryComponent],
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter(routes)],
}).compileComponents();
});
it('should render image grid from service response', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const component = fixture.componentInstance;
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(
of({
items: [
{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', created_at: '' },
],
total: 1,
limit: 50,
offset: 0,
})
);
fixture.detectChanges();
const de = fixture.nativeElement as HTMLElement;
expect(de.querySelectorAll('.image-card').length).toBe(1);
});
it('should trigger new API call with tags param on filter change', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const component = fixture.componentInstance;
const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue(
of({ items: [], total: 0, limit: 50, offset: 0 })
);
fixture.detectChanges();
component.applyFilter(['cat', 'funny']);
expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number));
});
});

View File

@@ -0,0 +1,156 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
import { ImageRecord, ImageService } from '../services/image.service';
import { TagService } from '../services/tag.service';
@Component({
selector: 'app-library',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="library">
<header>
<h1>Reactbin</h1>
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
</header>
<div class="filter-bar">
<input
placeholder="Filter by tag…"
(input)="onTagInput($event)"
[value]="tagSearch"
/>
<div class="chips">
<span *ngFor="let tag of activeFilters" class="chip">
{{ tag }} <button (click)="removeFilter(tag)">×</button>
</span>
</div>
<ul class="suggestions" *ngIf="suggestions.length">
<li *ngFor="let s of suggestions" (click)="addFilter(s.name)">{{ s.name }} ({{ s.image_count }})</li>
</ul>
</div>
<div *ngIf="images.length === 0 && !loading" class="empty-state">
<p>{{ activeFilters.length ? 'No images match these filters.' : 'No images yet. Upload your first!' }}</p>
</div>
<div class="grid">
<div
*ngFor="let img of images"
class="image-card"
(click)="router.navigate(['/images', img.id])"
>
<img [src]="imageService.getFileUrl(img.id)" [alt]="img.filename" loading="lazy" />
<div class="tag-row">
<span *ngFor="let tag of img.tags" class="chip small">{{ tag }}</span>
</div>
</div>
</div>
<button *ngIf="hasMore" class="load-more" (click)="loadMore()">Load more</button>
</div>
`,
styles: [`
.library { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.upload-btn { padding: 8px 20px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
.filter-bar { position: relative; margin-bottom: 24px; }
.filter-bar input { width: 100%; padding: 10px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 6px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { background: #333; padding: 3px 10px; border-radius: 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px; }
.chip.small { font-size: 0.75rem; padding: 2px 8px; }
.chip button { background: none; border: none; color: #aaa; cursor: pointer; padding: 0; font-size: 1rem; }
.suggestions { position: absolute; z-index: 10; background: #1a1a1a; border: 1px solid #444; list-style: none; width: 100%; max-height: 200px; overflow-y: auto; border-radius: 0 0 6px 6px; }
.suggestions li { padding: 8px 12px; cursor: pointer; }
.suggestions li:hover { background: #2a2a2a; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.image-card { cursor: pointer; background: #1a1a1a; border-radius: 8px; overflow: hidden; }
.image-card img { width: 100%; height: 160px; object-fit: cover; display: block; }
.tag-row { padding: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
.empty-state { text-align: center; padding: 60px 0; color: #666; }
.load-more { display: block; margin: 24px auto; padding: 10px 32px; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 6px; cursor: pointer; }
`],
})
export class LibraryComponent implements OnInit {
images: ImageRecord[] = [];
activeFilters: string[] = [];
tagSearch = '';
suggestions: { name: string; image_count: number }[] = [];
loading = false;
hasMore = false;
private offset = 0;
private readonly limit = 50;
private readonly filterChange$ = new Subject<string>();
constructor(
public imageService: ImageService,
private tagService: TagService,
public router: Router,
private cdr: ChangeDetectorRef,
) {}
ngOnInit(): void {
this.loadImages();
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
if (q) {
this.tagService.list(q, 10).subscribe((r) => {
this.suggestions = r.items;
this.cdr.markForCheck();
});
} else {
this.suggestions = [];
this.cdr.markForCheck();
}
});
}
onTagInput(event: Event): void {
const val = (event.target as HTMLInputElement).value;
this.tagSearch = val;
this.filterChange$.next(val);
}
addFilter(tag: string): void {
if (!this.activeFilters.includes(tag)) {
this.activeFilters = [...this.activeFilters, tag];
}
this.tagSearch = '';
this.suggestions = [];
this.applyFilter(this.activeFilters);
}
removeFilter(tag: string): void {
this.activeFilters = this.activeFilters.filter((t) => t !== tag);
this.applyFilter(this.activeFilters);
}
applyFilter(tags: string[]): void {
this.activeFilters = tags;
this.offset = 0;
this.images = [];
this.loadImages();
}
loadImages(): void {
this.loading = true;
this.imageService.list(this.activeFilters, this.limit, this.offset).subscribe((res) => {
this.images = [...this.images, ...res.items];
this.offset += res.items.length;
this.hasMore = this.offset < res.total;
this.loading = false;
this.cdr.markForCheck();
});
}
loadMore(): void {
this.loadImages();
}
}

View File

@@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-not-found',
standalone: true,
imports: [RouterLink],
template: `
<div class="not-found">
<h2>404 — Page not found</h2>
<a routerLink="/">Back to library</a>
</div>
`,
styles: [`.not-found { text-align: center; padding: 80px 16px; } a { color: #4a9eff; }`],
})
export class NotFoundComponent {}

View File

@@ -0,0 +1,41 @@
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { ImageService } from './image.service';
describe('ImageService', () => {
let service: ImageService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ImageService, provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(ImageService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('should include tags query param when filter is set', () => {
service.list(['cat', 'funny'], 50, 0).subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/v1/images');
expect(req.request.params.get('tags')).toBe('cat,funny');
req.flush({ items: [], total: 0, limit: 50, offset: 0 });
});
it('should omit tags query param when filter is empty', () => {
service.list([], 50, 0).subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/v1/images');
expect(req.request.params.has('tags')).toBeFalse();
req.flush({ items: [], total: 0, limit: 50, offset: 0 });
});
it('should send correct offset and limit params', () => {
service.list([], 25, 75).subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/v1/images');
expect(req.request.params.get('limit')).toBe('25');
expect(req.request.params.get('offset')).toBe('75');
req.flush({ items: [], total: 0, limit: 25, offset: 75 });
});
});

View File

@@ -0,0 +1,64 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface ImageRecord {
id: string;
hash: string;
filename: string;
mime_type: string;
size_bytes: number;
width: number;
height: number;
storage_key: string;
created_at: string;
tags: string[];
duplicate?: boolean;
}
export interface ImageListResponse {
items: ImageRecord[];
total: number;
limit: number;
offset: number;
}
@Injectable({ providedIn: 'root' })
export class ImageService {
private readonly base = '/api/v1';
constructor(private http: HttpClient) {}
upload(file: File, tags: string[]): Observable<ImageRecord> {
const form = new FormData();
form.append('file', file);
if (tags.length) {
form.append('tags', tags.join(','));
}
return this.http.post<ImageRecord>(`${this.base}/images`, form);
}
list(tagFilter: string[] = [], limit = 50, offset = 0): Observable<ImageListResponse> {
let params = new HttpParams().set('limit', limit).set('offset', offset);
if (tagFilter.length) {
params = params.set('tags', tagFilter.join(','));
}
return this.http.get<ImageListResponse>(`${this.base}/images`, { params });
}
get(id: string): Observable<ImageRecord> {
return this.http.get<ImageRecord>(`${this.base}/images/${id}`);
}
getFileUrl(id: string): string {
return `${this.base}/images/${id}/file`;
}
updateTags(id: string, tags: string[]): Observable<ImageRecord> {
return this.http.patch<ImageRecord>(`${this.base}/images/${id}/tags`, { tags });
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.base}/images/${id}`);
}
}

View File

@@ -0,0 +1,33 @@
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { TagService } from './tag.service';
describe('TagService', () => {
let service: TagService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TagService, provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(TagService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('should include q param when prefix is provided', () => {
service.list('cat').subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
expect(req.request.params.get('q')).toBe('cat');
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
});
it('should omit q param when prefix is empty', () => {
service.list().subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
expect(req.request.params.has('q')).toBeFalse();
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
});
});

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface TagRecord {
id: string;
name: string;
image_count: number;
}
export interface TagListResponse {
items: TagRecord[];
total: number;
limit: number;
offset: number;
}
@Injectable({ providedIn: 'root' })
export class TagService {
private readonly base = '/api/v1';
constructor(private http: HttpClient) {}
list(prefix?: string, limit = 100, offset = 0): Observable<TagListResponse> {
let params = new HttpParams().set('limit', limit).set('offset', offset);
if (prefix) {
params = params.set('q', prefix);
}
return this.http.get<TagListResponse>(`${this.base}/tags`, { params });
}
}

View File

@@ -0,0 +1,83 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of, throwError } from 'rxjs';
import { UploadComponent } from './upload.component';
import { ImageService } from '../services/image.service';
import { routes } from '../app.routes';
describe('UploadComponent', () => {
let component: UploadComponent;
function makeImageService(overrides: Partial<ImageService> = {}): jasmine.SpyObj<ImageService> {
return jasmine.createSpyObj<ImageService>('ImageService', { upload: of({} as any), ...overrides });
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UploadComponent],
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter(routes)],
}).compileComponents();
});
it('should normalise tag input: lowercases and splits on comma/space', () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
component.tagInput = 'CAT, Funny reaction';
const parsed = component.parseTagInput(component.tagInput);
expect(parsed).toEqual(['cat', 'funny', 'reaction']);
});
it('should split on commas', () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
const parsed = component.parseTagInput('a,b,c');
expect(parsed).toEqual(['a', 'b', 'c']);
});
it('should filter empty tokens', () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
const parsed = component.parseTagInput(' ,, cat ,,');
expect(parsed).toEqual(['cat']);
});
it('on duplicate response: shows toast and navigates to detail', async () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
const mockSvc = makeImageService({
upload: of({ id: 'abc', duplicate: true } as any),
} as any);
(component as any).imageService = mockSvc;
await component.handleUploadResponse({ id: 'abc', duplicate: true } as any);
expect(component.toastMessage).toContain('library');
expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']);
});
it('on success response: shows success toast and navigates to detail', async () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as any);
expect(component.toastMessage).toBeTruthy();
expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']);
});
it('on error response: shows inline error, no navigation', async () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
component.handleUploadError({ status: 422, error: { detail: 'bad file', code: 'invalid_mime_type' } });
expect(component.errorMessage).toBeTruthy();
expect(router.navigate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,133 @@
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ImageRecord, ImageService } from '../services/image.service';
@Component({
selector: 'app-upload',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="upload-page">
<h1>Upload Image</h1>
<div
class="drop-zone"
[class.drag-over]="isDragOver"
(dragover)="onDragOver($event)"
(dragleave)="isDragOver = false"
(drop)="onDrop($event)"
(click)="fileInput.click()"
>
<p>{{ selectedFile ? selectedFile.name : 'Drag & drop or click to browse' }}</p>
<input #fileInput type="file" accept="image/*" hidden (change)="onFileChange($event)" />
</div>
<div class="tag-input" *ngIf="selectedFile">
<label>Tags (comma or space separated)</label>
<input [(ngModel)]="tagInput" placeholder="cat, funny, reaction" />
<div class="chips">
<span *ngFor="let tag of parseTagInput(tagInput)" class="chip">{{ tag }}</span>
</div>
</div>
<button [disabled]="!selectedFile || uploading" (click)="submit()">
{{ uploading ? 'Uploading…' : 'Upload' }}
</button>
<p class="toast" *ngIf="toastMessage">{{ toastMessage }}</p>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
</div>
`,
styles: [`
.upload-page { max-width: 600px; margin: 40px auto; padding: 0 16px; }
.drop-zone { border: 2px dashed #555; border-radius: 8px; padding: 40px; text-align: center; cursor: pointer; }
.drop-zone.drag-over { border-color: #fff; background: #1a1a1a; }
.tag-input { margin: 16px 0; }
label { display: block; margin-bottom: 4px; font-size: 0.9rem; color: #aaa; }
input[type=text], input:not([type]) { width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 4px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { background: #333; padding: 2px 10px; border-radius: 12px; font-size: 0.85rem; }
button { padding: 10px 24px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
button:disabled { opacity: 0.5; cursor: default; }
.toast { color: #4a9eff; margin-top: 12px; }
.error { color: #ff6b6b; margin-top: 12px; }
`],
})
export class UploadComponent {
selectedFile: File | null = null;
tagInput = '';
uploading = false;
toastMessage = '';
errorMessage = '';
isDragOver = false;
constructor(
private imageService: ImageService,
private router: Router,
private cdr: ChangeDetectorRef,
) {}
parseTagInput(input: string): string[] {
return input
.toLowerCase()
.replace(/,/g, ' ')
.split(/\s+/)
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
onDragOver(event: DragEvent): void {
event.preventDefault();
this.isDragOver = true;
}
onDrop(event: DragEvent): void {
event.preventDefault();
this.isDragOver = false;
const file = event.dataTransfer?.files[0];
if (file) this.selectedFile = file;
}
onFileChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.selectedFile = input.files?.[0] ?? null;
}
submit(): void {
if (!this.selectedFile) return;
this.uploading = true;
this.errorMessage = '';
this.toastMessage = '';
const tags = this.parseTagInput(this.tagInput);
this.imageService.upload(this.selectedFile, tags).subscribe({
next: (res) => {
this.uploading = false;
this.handleUploadResponse(res);
this.cdr.markForCheck();
},
error: (err) => {
this.uploading = false;
this.handleUploadError(err);
this.cdr.markForCheck();
},
});
}
async handleUploadResponse(res: ImageRecord): Promise<void> {
if (res.duplicate) {
this.toastMessage = 'Already in your library';
} else {
this.toastMessage = 'Image uploaded successfully!';
}
await this.router.navigate(['/images', res.id]);
}
handleUploadError(err: any): void {
const apiError = err?.error;
this.errorMessage = apiError?.detail ?? 'Upload failed. Please try again.';
}
}

12
ui/src/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Reactbin</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<app-root></app-root>
</body>
</html>

5
ui/src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));

12
ui/src/styles.css Normal file
View File

@@ -0,0 +1,12 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
min-height: 100vh;
}

9
ui/tsconfig.app.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

29
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true,
"newLine": "lf",
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

8
ui/tsconfig.spec.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["jasmine"]
},
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}