Compare commits
9 Commits
004-jwt-be
...
009-login-
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a835d3172 | |||
| f3e0021ee8 | |||
| 354c85292d | |||
| 265b967f6b | |||
| 355014f975 | |||
| 6092a4454e | |||
| 28df9a1261 | |||
| 9246f75fdd | |||
| 5179786261 |
@@ -19,3 +19,11 @@ JWT_SECRET_KEY=change-me-to-a-long-random-string
|
|||||||
JWT_EXPIRY_SECONDS=86400
|
JWT_EXPIRY_SECONDS=86400
|
||||||
OWNER_USERNAME=owner
|
OWNER_USERNAME=owner
|
||||||
OWNER_PASSWORD=change-me
|
OWNER_PASSWORD=change-me
|
||||||
|
|
||||||
|
# Login brute-force protection
|
||||||
|
LOGIN_MAX_FAILURES=5
|
||||||
|
LOGIN_WINDOW_SECONDS=300
|
||||||
|
LOGIN_COOLDOWN_SECONDS=900
|
||||||
|
# Comma-separated IPs/CIDRs of trusted upstream proxies (e.g. nginx ingress pod CIDR).
|
||||||
|
# Leave empty when not behind a reverse proxy.
|
||||||
|
LOGIN_TRUSTED_PROXY_IPS=
|
||||||
|
|||||||
36
.env.test.example
Normal file
36
.env.test.example
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Integration test environment variables
|
||||||
|
# Used when running pytest directly on the host (outside Docker).
|
||||||
|
#
|
||||||
|
# Start test services first:
|
||||||
|
# docker compose -f docker-compose.test.yml up -d postgres-test minio-test minio-init-test
|
||||||
|
#
|
||||||
|
# Then source this file and run tests:
|
||||||
|
# export $(grep -v '^#' .env.test.example | xargs)
|
||||||
|
# cd api && python -m pytest tests/integration/ -v
|
||||||
|
|
||||||
|
# PostgreSQL test database (postgres-test container on host port 5433)
|
||||||
|
TEST_DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@localhost:5433/reactbin_test
|
||||||
|
DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@localhost:5433/reactbin_test
|
||||||
|
|
||||||
|
# MinIO test instance (minio-test container on host port 9002)
|
||||||
|
S3_ENDPOINT_URL=http://localhost:9002
|
||||||
|
S3_BUCKET_NAME=reactbin-test
|
||||||
|
S3_ACCESS_KEY_ID=minioadmin
|
||||||
|
S3_SECRET_ACCESS_KEY=minioadmin
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
|
||||||
|
# Auth (test values — not for production)
|
||||||
|
JWT_SECRET_KEY=test-secret-key-for-testing-only
|
||||||
|
OWNER_USERNAME=testowner
|
||||||
|
OWNER_PASSWORD=testpassword
|
||||||
|
|
||||||
|
# API
|
||||||
|
API_BASE_URL=http://localhost:8000
|
||||||
|
MAX_UPLOAD_BYTES=52428800
|
||||||
|
|
||||||
|
# Login brute-force protection
|
||||||
|
LOGIN_MAX_FAILURES=5
|
||||||
|
LOGIN_WINDOW_SECONDS=300
|
||||||
|
LOGIN_COOLDOWN_SECONDS=900
|
||||||
|
# Comma-separated IPs/CIDRs of trusted upstream proxies; leave empty for direct connections.
|
||||||
|
LOGIN_TRUSTED_PROXY_IPS=
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,7 +1,11 @@
|
|||||||
|
# Developer notes
|
||||||
|
notes/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.test.example
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"feature_directory": "specs/004-jwt-bearer-auth"
|
"feature_directory": "specs/009-login-rate-limiting"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<!--
|
<!--
|
||||||
SYNC IMPACT REPORT
|
SYNC IMPACT REPORT
|
||||||
==================
|
==================
|
||||||
Version change: 1.1.0 → 1.1.1
|
Version change: 1.2.0 → 1.3.0
|
||||||
Ratified: 2026-05-01 | Last amended: 2026-05-03
|
Ratified: 2026-05-01 | Last amended: 2026-05-06
|
||||||
|
|
||||||
Principles introduced (first population from docs/CONSTITUTION.md):
|
Principles introduced (first population from docs/CONSTITUTION.md):
|
||||||
- §2 Architecture Principles (6 sub-principles)
|
- §2 Architecture Principles (6 sub-principles)
|
||||||
@@ -82,22 +82,23 @@ or SDK-specific types directly — only the interface contract.
|
|||||||
|
|
||||||
### 2.4 Auth abstraction (progressive)
|
### 2.4 Auth abstraction (progressive)
|
||||||
|
|
||||||
Authentication is treated as a pluggable backend from day one, even though
|
Authentication is treated as a pluggable backend from day one. The API MUST
|
||||||
Phase 1 ships with no auth. The API MUST route all request-identity resolution
|
route all request-identity resolution through a single `AuthProvider` interface.
|
||||||
through a single `AuthProvider` interface. The no-op provider (Phase 1) returns
|
Each phase introduces a new provider implementation; no phase rewrites business
|
||||||
a static anonymous identity. Adding username/password or OIDC in a later phase
|
logic already behind the interface.
|
||||||
MUST be a new provider implementation, not a rewrite of business logic.
|
|
||||||
|
|
||||||
**Phase 1 implements: no-auth (localhost only).**
|
**Phase 1 — no-auth (NoOpAuthProvider): complete.**
|
||||||
**Planned phases: username/password, then OIDC.**
|
**Phase 2 — JWT bearer token (JWTAuthProvider, HS256, single owner): complete.**
|
||||||
|
**Phase 3 — OIDC: planned.**
|
||||||
The constitution acknowledges all three; the spec governs which is built.
|
The constitution acknowledges all three; the spec governs which is built.
|
||||||
|
|
||||||
### 2.5 Database abstraction
|
### 2.5 Database abstraction
|
||||||
|
|
||||||
PostgreSQL is the Phase 1 database. All DB access MUST go through a repository
|
PostgreSQL is the database. All DB access MUST go through a repository layer
|
||||||
layer (one repository class per domain aggregate). Raw SQL or an ORM is
|
(one repository class per domain aggregate). Raw SQL or an ORM is acceptable,
|
||||||
acceptable, but no query logic MAY live outside a repository. This makes the
|
but no query logic MAY live outside a repository. No alternative database
|
||||||
planned PostgreSQL → SQLite refactor a repository-layer change only.
|
engine (SQLite, DuckDB, in-memory substitutes) MAY be used in integration
|
||||||
|
tests — dialect differences mask production bugs.
|
||||||
|
|
||||||
### 2.6 No speculative abstraction
|
### 2.6 No speculative abstraction
|
||||||
|
|
||||||
@@ -179,8 +180,11 @@ before any implementation step.
|
|||||||
### 5.2 Test pyramid
|
### 5.2 Test pyramid
|
||||||
|
|
||||||
- **Unit tests** — pure logic, repository mocks, no I/O
|
- **Unit tests** — pure logic, repository mocks, no I/O
|
||||||
- **Integration tests** — API routes tested against a real (test) database
|
- **Integration tests** — API routes tested against a real PostgreSQL instance
|
||||||
and a real (test) S3-compatible bucket (e.g. MinIO in Docker)
|
and a real S3-compatible bucket (e.g. MinIO in Docker). SQLite and other
|
||||||
|
in-memory database substitutes are **prohibited** — PostgreSQL-specific
|
||||||
|
behaviour (GROUP BY enforcement, JSON operators, constraint handling) MUST
|
||||||
|
be exercised by the test suite.
|
||||||
- **E2E tests** — Angular + API, minimal set covering the core happy paths
|
- **E2E tests** — Angular + API, minimal set covering the core happy paths
|
||||||
|
|
||||||
Unit and integration tests are required. E2E tests are best-effort in v1.
|
Unit and integration tests are required. E2E tests are best-effort in v1.
|
||||||
@@ -206,6 +210,7 @@ NOT be marked complete while CI is failing.
|
|||||||
| ORM / query | SQLAlchemy 2.x (async) + asyncpg driver | Repository layer owns all queries |
|
| ORM / query | SQLAlchemy 2.x (async) + asyncpg driver | Repository layer owns all queries |
|
||||||
| DB migrations | Alembic | Schema changes tracked in version control |
|
| DB migrations | Alembic | Schema changes tracked in version control |
|
||||||
| Object storage | S3-compatible via `boto3` / `aiobotocore` | Swap MinIO ↔ S3 via env config |
|
| Object storage | S3-compatible via `boto3` / `aiobotocore` | Swap MinIO ↔ S3 via env config |
|
||||||
|
| Auth tokens | PyJWT (HS256) | Lightweight; compatible with OIDC migration path |
|
||||||
| UI framework | Angular (latest stable) | Job-relevant, learning goal |
|
| UI framework | Angular (latest stable) | Job-relevant, learning goal |
|
||||||
| UI language | TypeScript strict mode | No `any`, no implicit types |
|
| UI language | TypeScript strict mode | No `any`, no implicit types |
|
||||||
| Containerisation | Docker + Docker Compose | Local dev must start with one command |
|
| Containerisation | Docker + Docker Compose | Local dev must start with one command |
|
||||||
@@ -244,7 +249,6 @@ revised:
|
|||||||
- Image editing or transformation beyond thumbnail generation
|
- Image editing or transformation beyond thumbnail generation
|
||||||
- OR/NOT tag logic
|
- OR/NOT tag logic
|
||||||
- Mobile-native app
|
- Mobile-native app
|
||||||
- Username/password auth (planned Phase 2)
|
|
||||||
- OIDC auth (planned Phase 3)
|
- OIDC auth (planned Phase 3)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -283,7 +287,9 @@ Phase 1 design is complete.
|
|||||||
| 1.1.0 | 2026-05-01 | asyncpg driver explicit; SHA-256 deduplication added to data model; deduplication removed from out-of-scope |
|
| 1.1.0 | 2026-05-01 | asyncpg driver explicit; SHA-256 deduplication added to data model; deduplication removed from out-of-scope |
|
||||||
| 1.1.0 | 2026-05-02 | Adopted into Spec Kit memory; fixed duplicate §4.3 → §4.4; strengthened "should" language to MUST/MUST NOT; added §9 Governance |
|
| 1.1.0 | 2026-05-02 | Adopted into Spec Kit memory; fixed duplicate §4.3 → §4.4; strengthened "should" language to MUST/MUST NOT; added §9 Governance |
|
||||||
| 1.1.1 | 2026-05-03 | Clarify that the only acceptable form of image transformation or editing is thumbnail generation |
|
| 1.1.1 | 2026-05-03 | Clarify that the only acceptable form of image transformation or editing is thumbnail generation |
|
||||||
|
| 1.2.0 | 2026-05-03 | §2.4: Mark Phase 2 (JWT bearer auth) complete, reword phase status; §6: Add PyJWT to tech stack table; §8: Remove username/password auth from out-of-scope (now shipped) |
|
||||||
|
| 1.3.0 | 2026-05-06 | §2.5: Remove planned PostgreSQL → SQLite refactor note; prohibit alternative database engines in integration tests. §5.2: Explicitly require PostgreSQL for integration tests; prohibit SQLite — a production HAVING/GROUP BY bug was masked by SQLite's permissive dialect. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version**: 1.1.1 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-03
|
**Version**: 1.3.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-06
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- SPECKIT START -->
|
<!-- SPECKIT START -->
|
||||||
For additional context about technologies to be used, project structure,
|
For additional context about technologies to be used, project structure,
|
||||||
shell commands, and other important information, read the current plan at
|
shell commands, and other important information, read the current plan at
|
||||||
`specs/004-jwt-bearer-auth/plan.md`.
|
`specs/009-login-rate-limiting/plan.md`.
|
||||||
<!-- SPECKIT END -->
|
<!-- SPECKIT END -->
|
||||||
|
|||||||
7
Makefile
Normal file
7
Makefile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.PHONY: test-unit test-integration
|
||||||
|
|
||||||
|
test-unit:
|
||||||
|
cd api && python -m pytest tests/unit/ -v
|
||||||
|
|
||||||
|
test-integration:
|
||||||
|
docker compose -f docker-compose.test.yml run --rm api-test
|
||||||
91
api/app/auth/rate_limiter.py
Normal file
91
api/app/auth/rate_limiter.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from ipaddress import IPv4Network, IPv6Network
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(
|
||||||
|
request: Request,
|
||||||
|
trusted_networks: list[IPv4Network | IPv6Network],
|
||||||
|
) -> str:
|
||||||
|
"""Return the resolved client IP, honouring X-Forwarded-For when the
|
||||||
|
TCP peer is a trusted upstream proxy. Falls back to the TCP peer address
|
||||||
|
when no trusted networks are configured or the peer is not in the list."""
|
||||||
|
peer = request.client.host if request.client else "unknown"
|
||||||
|
if trusted_networks and peer != "unknown":
|
||||||
|
try:
|
||||||
|
peer_addr = ipaddress.ip_address(peer)
|
||||||
|
if any(peer_addr in net for net in trusted_networks):
|
||||||
|
xff = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||||||
|
if xff:
|
||||||
|
return xff
|
||||||
|
real_ip = request.headers.get("X-Real-IP", "").strip()
|
||||||
|
if real_ip:
|
||||||
|
return real_ip
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Record:
|
||||||
|
failures: int = 0
|
||||||
|
window_start: float = field(default_factory=time.time)
|
||||||
|
blocked_until: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRateLimiter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
max_failures: int = 5,
|
||||||
|
window_seconds: int = 300,
|
||||||
|
cooldown_seconds: int = 900,
|
||||||
|
) -> None:
|
||||||
|
self._max = max_failures
|
||||||
|
self._window = window_seconds
|
||||||
|
self._cooldown = cooldown_seconds
|
||||||
|
self._store: dict[str, _Record] = {}
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cooldown_seconds(self) -> int:
|
||||||
|
return self._cooldown
|
||||||
|
|
||||||
|
def is_blocked(self, ip: str) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
rec = self._store.get(ip)
|
||||||
|
if rec is None:
|
||||||
|
return False
|
||||||
|
if rec.blocked_until > now:
|
||||||
|
return True
|
||||||
|
if rec.blocked_until > 0:
|
||||||
|
del self._store[ip]
|
||||||
|
return False
|
||||||
|
|
||||||
|
def record_failure(self, ip: str) -> None:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
rec = self._store.get(ip)
|
||||||
|
if rec is None:
|
||||||
|
rec = _Record(window_start=now)
|
||||||
|
self._store[ip] = rec
|
||||||
|
if now - rec.window_start > self._window:
|
||||||
|
rec.failures = 0
|
||||||
|
rec.window_start = now
|
||||||
|
rec.failures += 1
|
||||||
|
if rec.failures >= self._max:
|
||||||
|
rec.blocked_until = now + self._cooldown
|
||||||
|
logger.warning(
|
||||||
|
"Login blocked for %s after %d failures", ip, rec.failures
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_success(self, ip: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._store.pop(ip, None)
|
||||||
@@ -18,6 +18,10 @@ class Settings(BaseSettings):
|
|||||||
jwt_expiry_seconds: int = 86400
|
jwt_expiry_seconds: int = 86400
|
||||||
owner_username: str
|
owner_username: str
|
||||||
owner_password: str
|
owner_password: str
|
||||||
|
login_max_failures: int = 5
|
||||||
|
login_window_seconds: int = 300
|
||||||
|
login_cooldown_seconds: int = 900
|
||||||
|
login_trusted_proxy_ips: str = ""
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
from contextlib import asynccontextmanager
|
import ipaddress
|
||||||
|
from contextlib import asynccontextmanager, suppress
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.auth.rate_limiter import LoginRateLimiter
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import Base, get_engine
|
from app.database import Base, get_engine
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(application: FastAPI):
|
async def lifespan(application: FastAPI):
|
||||||
get_settings()
|
settings = get_settings()
|
||||||
# Verify DB connection and run migrations on startup
|
application.state.login_rate_limiter = LoginRateLimiter(
|
||||||
|
max_failures=settings.login_max_failures,
|
||||||
|
window_seconds=settings.login_window_seconds,
|
||||||
|
cooldown_seconds=settings.login_cooldown_seconds,
|
||||||
|
)
|
||||||
|
trusted_networks = []
|
||||||
|
for part in settings.login_trusted_proxy_ips.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part:
|
||||||
|
with suppress(ValueError):
|
||||||
|
trusted_networks.append(ipaddress.ip_network(part, strict=False))
|
||||||
|
application.state.login_trusted_networks = trusted_networks
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
# In production, Alembic handles migrations; this is a dev convenience
|
# In production, Alembic handles migrations; this is a dev convenience
|
||||||
@@ -22,6 +35,10 @@ async def lifespan(application: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="Reactbin API", version="1.0.0", lifespan=lifespan)
|
app = FastAPI(title="Reactbin API", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
# Defaults so app.state is populated even when lifespan doesn't run (e.g. tests)
|
||||||
|
app.state.login_rate_limiter = LoginRateLimiter()
|
||||||
|
app.state.login_trusted_networks = []
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import String, Integer, BigInteger, DateTime, ForeignKey, UniqueConstraint, Index
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ from app.database import Base
|
|||||||
|
|
||||||
|
|
||||||
def _utcnow() -> datetime:
|
def _utcnow() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
class Image(Base):
|
class Image(Base):
|
||||||
@@ -24,9 +24,13 @@ class Image(Base):
|
|||||||
height: 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)
|
storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
|
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, 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")
|
image_tags: Mapped[list["ImageTag"]] = relationship(
|
||||||
|
back_populates="image", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tags(self) -> list[str]:
|
def tags(self) -> list[str]:
|
||||||
@@ -38,7 +42,9 @@ class Tag(Base):
|
|||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
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)
|
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)
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=_utcnow, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="tag")
|
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="tag")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -12,15 +11,19 @@ class ImageRepository:
|
|||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
|
|
||||||
async def get_by_hash(self, hash_hex: str) -> Optional[Image]:
|
async def get_by_hash(self, hash_hex: str) -> Image | None:
|
||||||
result = await self._session.execute(
|
result = await self._session.execute(
|
||||||
select(Image).where(Image.hash == hash_hex).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
select(Image)
|
||||||
|
.where(Image.hash == hash_hex)
|
||||||
|
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def get_by_id(self, image_id: uuid.UUID) -> Optional[Image]:
|
async def get_by_id(self, image_id: uuid.UUID) -> Image | None:
|
||||||
result = await self._session.execute(
|
result = await self._session.execute(
|
||||||
select(Image).where(Image.id == image_id).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
select(Image)
|
||||||
|
.where(Image.id == image_id)
|
||||||
|
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -57,7 +60,7 @@ class ImageRepository:
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> tuple[list[Image], int]:
|
) -> tuple[list[Image], int]:
|
||||||
from sqlalchemy import func, and_
|
from sqlalchemy import func
|
||||||
|
|
||||||
base_query = select(Image).options(
|
base_query = select(Image).options(
|
||||||
selectinload(Image.image_tags).selectinload(ImageTag.tag)
|
selectinload(Image.image_tags).selectinload(ImageTag.tag)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models import Image, ImageTag, Tag
|
from app.models import Image, ImageTag, Tag
|
||||||
@@ -76,6 +76,8 @@ class TagRepository:
|
|||||||
prefix: str | None = None,
|
prefix: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
sort: str = "name",
|
||||||
|
min_count: int = 0,
|
||||||
) -> tuple[list[dict], int]:
|
) -> tuple[list[dict], int]:
|
||||||
count_subq = (
|
count_subq = (
|
||||||
select(func.count(ImageTag.image_id))
|
select(func.count(ImageTag.image_id))
|
||||||
@@ -87,12 +89,16 @@ class TagRepository:
|
|||||||
query = select(Tag, count_subq.label("image_count"))
|
query = select(Tag, count_subq.label("image_count"))
|
||||||
if prefix:
|
if prefix:
|
||||||
query = query.where(Tag.name.like(f"{prefix}%"))
|
query = query.where(Tag.name.like(f"{prefix}%"))
|
||||||
|
if min_count > 0:
|
||||||
|
query = query.where(count_subq >= min_count)
|
||||||
|
|
||||||
total_query = select(func.count()).select_from(query.subquery())
|
total_query = select(func.count()).select_from(query.subquery())
|
||||||
total_result = await self._session.execute(total_query)
|
total_result = await self._session.execute(total_query)
|
||||||
total = total_result.scalar_one()
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
paginated = query.order_by(Tag.name).limit(limit).offset(offset)
|
order = [count_subq.desc(), Tag.name.asc()] if sort == "count_desc" else [Tag.name.asc()]
|
||||||
|
|
||||||
|
paginated = query.order_by(*order).limit(limit).offset(offset)
|
||||||
rows = await self._session.execute(paginated)
|
rows = await self._session.execute(paginated)
|
||||||
|
|
||||||
items = [
|
items = [
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.auth.jwt_provider import JWTAuthProvider
|
from app.auth.jwt_provider import JWTAuthProvider
|
||||||
|
from app.auth.rate_limiter import LoginRateLimiter, get_client_ip
|
||||||
from app.dependencies import get_jwt_auth
|
from app.dependencies import get_jwt_auth
|
||||||
|
|
||||||
router = APIRouter(tags=["auth"])
|
router = APIRouter(tags=["auth"])
|
||||||
@@ -19,12 +21,32 @@ class TokenResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/auth/token", response_model=TokenResponse)
|
@router.post("/auth/token", response_model=TokenResponse)
|
||||||
async def login(body: LoginRequest, auth: JWTAuthProvider = Depends(get_jwt_auth)):
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
body: LoginRequest,
|
||||||
|
auth: JWTAuthProvider = Depends(get_jwt_auth),
|
||||||
|
):
|
||||||
|
limiter: LoginRateLimiter = request.app.state.login_rate_limiter
|
||||||
|
ip: str = get_client_ip(request, request.app.state.login_trusted_networks)
|
||||||
|
|
||||||
|
if limiter.is_blocked(ip):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={
|
||||||
|
"detail": "Too many failed login attempts. Please try again later.",
|
||||||
|
"code": "login_rate_limited",
|
||||||
|
},
|
||||||
|
headers={"Retry-After": str(limiter.cooldown_seconds)},
|
||||||
|
)
|
||||||
|
|
||||||
if not auth.verify_credentials(body.username, body.password):
|
if not auth.verify_credentials(body.username, body.password):
|
||||||
|
limiter.record_failure(ip)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
|
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
limiter.record_success(ip)
|
||||||
token = auth.create_token()
|
token = auth.create_token()
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=token,
|
access_token=token,
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ async def list_tags(
|
|||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
sort: str = "name",
|
||||||
|
min_count: int = 0,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
limit = min(limit, 200)
|
limit = min(limit, 500)
|
||||||
tag_repo = TagRepository(db)
|
tag_repo = TagRepository(db)
|
||||||
items, total = await tag_repo.list_tags(prefix=q, limit=limit, offset=offset)
|
items, total = await tag_repo.list_tags(
|
||||||
|
prefix=q, limit=limit, offset=offset, sort=sort, min_count=min_count
|
||||||
|
)
|
||||||
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from httpx import AsyncClient, ASGITransport
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
# Provide required settings for the test environment before any app imports resolve them
|
# Provide required settings for the test environment before any app imports resolve them
|
||||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-testing-only")
|
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-testing-only")
|
||||||
os.environ.setdefault("OWNER_USERNAME", "testowner")
|
os.environ.setdefault("OWNER_USERNAME", "testowner")
|
||||||
os.environ.setdefault("OWNER_PASSWORD", "testpassword")
|
os.environ.setdefault("OWNER_PASSWORD", "testpassword")
|
||||||
|
|
||||||
from app.main import app
|
from app.auth.jwt_provider import JWTAuthProvider # noqa: E402
|
||||||
from app.config import get_settings
|
from app.config import get_settings # noqa: E402
|
||||||
from app.database import Base
|
from app.database import Base # noqa: E402
|
||||||
from app.dependencies import get_db, get_storage, get_auth
|
from app.dependencies import get_auth, get_db, get_storage # noqa: E402
|
||||||
from app.auth.jwt_provider import JWTAuthProvider
|
from app.main import app # noqa: E402
|
||||||
|
|
||||||
# Bust the LRU cache so get_settings() picks up the env vars set above
|
# Bust the LRU cache so get_settings() picks up the env vars set above
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
@@ -26,8 +27,6 @@ _TEST_OWNER_PASSWORD = os.environ["OWNER_PASSWORD"]
|
|||||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||||
async def engine():
|
async def engine():
|
||||||
settings = get_settings()
|
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)
|
db_url = os.getenv("TEST_DATABASE_URL", settings.database_url)
|
||||||
eng = create_async_engine(db_url, echo=False)
|
eng = create_async_engine(db_url, echo=False)
|
||||||
async with eng.begin() as conn:
|
async with eng.begin() as conn:
|
||||||
@@ -48,8 +47,8 @@ async def db_session(engine):
|
|||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def client(db_session):
|
async def client(db_session):
|
||||||
from app.storage.s3_backend import S3StorageBackend
|
|
||||||
from app.auth.noop import NoOpAuthProvider
|
from app.auth.noop import NoOpAuthProvider
|
||||||
|
from app.storage.s3_backend import S3StorageBackend
|
||||||
|
|
||||||
storage = S3StorageBackend()
|
storage = S3StorageBackend()
|
||||||
auth = NoOpAuthProvider()
|
auth = NoOpAuthProvider()
|
||||||
@@ -108,3 +107,15 @@ async def authed_client(db_session, jwt_auth_provider):
|
|||||||
yield c, valid_token
|
yield c, valid_token
|
||||||
|
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
db_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL", "")
|
||||||
|
if not db_url.startswith("postgresql+asyncpg://"):
|
||||||
|
pytest.exit(
|
||||||
|
"Integration tests require a PostgreSQL database "
|
||||||
|
"(postgresql+asyncpg://...). "
|
||||||
|
"Set TEST_DATABASE_URL or DATABASE_URL accordingly. "
|
||||||
|
f"Got: {db_url!r}",
|
||||||
|
returncode=1,
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,15 +19,18 @@ def _minimal_jpeg_v2() -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_removes_record(client):
|
async def test_delete_removes_record(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
data = _minimal_jpeg_v2()
|
data = _minimal_jpeg_v2()
|
||||||
upload = await client.post(
|
upload = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["id"]
|
||||||
|
|
||||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
|
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
|
||||||
assert delete_resp.status_code == 204
|
assert delete_resp.status_code == 204
|
||||||
|
|
||||||
get_resp = await client.get(f"/api/v1/images/{image_id}")
|
get_resp = await client.get(f"/api/v1/images/{image_id}")
|
||||||
@@ -36,17 +39,19 @@ async def test_delete_removes_record(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_removes_storage_object(client):
|
async def test_delete_removes_storage_object(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
data = _minimal_jpeg_v2() + b"\x00"
|
data = _minimal_jpeg_v2() + b"\x00"
|
||||||
upload = await client.post(
|
upload = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("del-storage-test.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("del-storage-test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert upload.status_code in (200, 201)
|
assert upload.status_code in (200, 201)
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["id"]
|
||||||
storage_key = upload.json()["hash"]
|
|
||||||
|
|
||||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
|
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
|
||||||
assert delete_resp.status_code == 204
|
assert delete_resp.status_code == 204
|
||||||
|
|
||||||
# Confirm storage redirect no longer works (404 since record is gone)
|
# Confirm storage redirect no longer works (404 since record is gone)
|
||||||
@@ -55,15 +60,21 @@ async def test_delete_removes_storage_object(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_unknown_id_returns_404(client):
|
async def test_delete_unknown_id_returns_404(authed_client):
|
||||||
response = await client.delete(f"/api/v1/images/{uuid.uuid4()}")
|
client, token = authed_client
|
||||||
|
response = await client.delete(
|
||||||
|
f"/api/v1/images/{uuid.uuid4()}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["code"] == "image_not_found"
|
assert body["code"] == "image_not_found"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_removes_thumbnail(client):
|
async def test_delete_removes_thumbnail(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
PILImage.new("RGB", (200, 150), color=(60, 90, 120)).save(buf, format="JPEG")
|
PILImage.new("RGB", (200, 150), color=(60, 90, 120)).save(buf, format="JPEG")
|
||||||
data = buf.getvalue()
|
data = buf.getvalue()
|
||||||
@@ -71,12 +82,13 @@ async def test_delete_removes_thumbnail(client):
|
|||||||
upload = await client.post(
|
upload = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("thumb-del.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("thumb-del.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert upload.status_code == 201
|
assert upload.status_code == 201
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["id"]
|
||||||
assert upload.json()["thumbnail_key"] is not None
|
assert upload.json()["thumbnail_key"] is not None
|
||||||
|
|
||||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
|
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
|
||||||
assert delete_resp.status_code == 204
|
assert delete_resp.status_code == 204
|
||||||
|
|
||||||
thumb_resp = await client.get(f"/api/v1/images/{image_id}/thumbnail")
|
thumb_resp = await client.get(f"/api/v1/images/{image_id}/thumbnail")
|
||||||
|
|||||||
121
api/tests/integration/test_login_rate_limit.py
Normal file
121
api/tests/integration/test_login_rate_limit.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.auth.rate_limiter import LoginRateLimiter
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
BAD_CREDS = {"username": "attacker", "password": "wrong"}
|
||||||
|
VALID_CREDS = {
|
||||||
|
"username": os.environ.get("OWNER_USERNAME", "testowner"),
|
||||||
|
"password": os.environ.get("OWNER_PASSWORD", "testpassword"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_limiter():
|
||||||
|
return LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=30)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repeated_failures_trigger_429(client: AsyncClient):
|
||||||
|
original_limiter = app.state.login_rate_limiter
|
||||||
|
original_networks = app.state.login_trusted_networks
|
||||||
|
app.state.login_rate_limiter = _fresh_limiter()
|
||||||
|
app.state.login_trusted_networks = []
|
||||||
|
try:
|
||||||
|
for _ in range(3):
|
||||||
|
await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||||
|
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||||
|
assert resp.status_code == 429
|
||||||
|
assert resp.json()["code"] == "login_rate_limited"
|
||||||
|
finally:
|
||||||
|
app.state.login_rate_limiter = original_limiter
|
||||||
|
app.state.login_trusted_networks = original_networks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_success_resets_counter(client: AsyncClient):
|
||||||
|
original_limiter = app.state.login_rate_limiter
|
||||||
|
original_networks = app.state.login_trusted_networks
|
||||||
|
app.state.login_rate_limiter = _fresh_limiter()
|
||||||
|
app.state.login_trusted_networks = []
|
||||||
|
try:
|
||||||
|
for _ in range(2):
|
||||||
|
await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||||
|
await client.post("/api/v1/auth/token", json=VALID_CREDS)
|
||||||
|
for _ in range(3):
|
||||||
|
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||||
|
assert resp.status_code == 401, "counter should have reset after success"
|
||||||
|
finally:
|
||||||
|
app.state.login_rate_limiter = original_limiter
|
||||||
|
app.state.login_trusted_networks = original_networks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_429_has_retry_after_header(client: AsyncClient):
|
||||||
|
original_limiter = app.state.login_rate_limiter
|
||||||
|
original_networks = app.state.login_trusted_networks
|
||||||
|
app.state.login_rate_limiter = _fresh_limiter()
|
||||||
|
app.state.login_trusted_networks = []
|
||||||
|
try:
|
||||||
|
for _ in range(3):
|
||||||
|
await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||||
|
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||||
|
assert resp.status_code == 429
|
||||||
|
assert "Retry-After" in resp.headers
|
||||||
|
assert int(resp.headers["Retry-After"]) > 0
|
||||||
|
finally:
|
||||||
|
app.state.login_rate_limiter = original_limiter
|
||||||
|
app.state.login_trusted_networks = original_networks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_429_body_shape(client: AsyncClient):
|
||||||
|
original_limiter = app.state.login_rate_limiter
|
||||||
|
original_networks = app.state.login_trusted_networks
|
||||||
|
app.state.login_rate_limiter = _fresh_limiter()
|
||||||
|
app.state.login_trusted_networks = []
|
||||||
|
try:
|
||||||
|
for _ in range(3):
|
||||||
|
await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||||
|
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||||
|
assert resp.status_code == 429
|
||||||
|
assert resp.json() == {
|
||||||
|
"detail": "Too many failed login attempts. Please try again later.",
|
||||||
|
"code": "login_rate_limited",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
app.state.login_rate_limiter = original_limiter
|
||||||
|
app.state.login_trusted_networks = original_networks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_xff_header_ignored_when_no_trusted_networks(client: AsyncClient):
|
||||||
|
original_limiter = app.state.login_rate_limiter
|
||||||
|
original_networks = app.state.login_trusted_networks
|
||||||
|
app.state.login_rate_limiter = _fresh_limiter()
|
||||||
|
app.state.login_trusted_networks = []
|
||||||
|
try:
|
||||||
|
# Send 3 failures all claiming to be "1.2.3.4" via XFF
|
||||||
|
for _ in range(3):
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/token",
|
||||||
|
json=BAD_CREDS,
|
||||||
|
headers={"X-Forwarded-For": "1.2.3.4"},
|
||||||
|
)
|
||||||
|
# 4th request with a *different* XFF — if XFF were trusted, this
|
||||||
|
# would appear to be a fresh IP and get 401. Since XFF is ignored,
|
||||||
|
# the real peer ("testclient") is blocked and we get 429.
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/token",
|
||||||
|
json=BAD_CREDS,
|
||||||
|
headers={"X-Forwarded-For": "9.9.9.9"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 429, (
|
||||||
|
"XFF should be ignored when no trusted networks are configured; "
|
||||||
|
"expected real peer to be blocked"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
app.state.login_rate_limiter = original_limiter
|
||||||
|
app.state.login_trusted_networks = original_networks
|
||||||
@@ -3,7 +3,6 @@ US3 regression tests: all read endpoints must remain accessible without a token
|
|||||||
even after require_auth is applied to write endpoints.
|
even after require_auth is applied to write endpoints.
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
import uuid
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ T041 — GET /api/v1/images?tags=cat,funny → only images with both tags
|
|||||||
T042 — same query excludes images with only one matching tag
|
T042 — same query excludes images with only one matching tag
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@@ -15,7 +16,9 @@ def _minimal_gif() -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_and_filter_returns_only_matching_images(client):
|
async def test_and_filter_returns_only_matching_images(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
data = _minimal_gif()
|
data = _minimal_gif()
|
||||||
|
|
||||||
# Image with both tags
|
# Image with both tags
|
||||||
@@ -23,6 +26,7 @@ async def test_and_filter_returns_only_matching_images(client):
|
|||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("both.gif", io.BytesIO(data), "image/gif")},
|
files={"file": ("both.gif", io.BytesIO(data), "image/gif")},
|
||||||
data={"tags": "andcat,andfunny"},
|
data={"tags": "andcat,andfunny"},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
both_id = r_both.json()["id"]
|
both_id = r_both.json()["id"]
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ async def test_and_filter_returns_only_matching_images(client):
|
|||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("one.gif", io.BytesIO(data + b"\x00"), "image/gif")},
|
files={"file": ("one.gif", io.BytesIO(data + b"\x00"), "image/gif")},
|
||||||
data={"tags": "andcat"},
|
data={"tags": "andcat"},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await client.get("/api/v1/images?tags=andcat,andfunny")
|
response = await client.get("/api/v1/images?tags=andcat,andfunny")
|
||||||
@@ -42,7 +47,9 @@ async def test_and_filter_returns_only_matching_images(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_filter_excludes_partial_tag_match(client):
|
async def test_filter_excludes_partial_tag_match(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
data = _minimal_gif()
|
data = _minimal_gif()
|
||||||
|
|
||||||
# Image with only "exclcat"
|
# Image with only "exclcat"
|
||||||
@@ -50,6 +57,7 @@ async def test_filter_excludes_partial_tag_match(client):
|
|||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("partial.gif", io.BytesIO(data + b"\x01"), "image/gif")},
|
files={"file": ("partial.gif", io.BytesIO(data + b"\x01"), "image/gif")},
|
||||||
data={"tags": "exclcat"},
|
data={"tags": "exclcat"},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filter requires both exclcat and exclother
|
# Filter requires both exclcat and exclother
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ def _minimal_webp() -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_file_returns_200_with_content(client):
|
async def test_file_returns_200_with_content(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
data = _minimal_webp()
|
data = _minimal_webp()
|
||||||
upload = await client.post(
|
upload = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
|
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert upload.status_code in (200, 201)
|
assert upload.status_code in (200, 201)
|
||||||
upload_body = upload.json()
|
upload_body = upload.json()
|
||||||
@@ -57,11 +59,13 @@ async def test_file_unknown_id_returns_404(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_file_response_exposes_no_storage_details(client):
|
async def test_file_response_exposes_no_storage_details(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
data = _minimal_webp()
|
data = _minimal_webp()
|
||||||
upload = await client.post(
|
upload = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
|
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert upload.status_code in (200, 201)
|
assert upload.status_code in (200, 201)
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["id"]
|
||||||
@@ -75,11 +79,13 @@ async def test_file_response_exposes_no_storage_details(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_thumbnail_returns_webp(client):
|
async def test_thumbnail_returns_webp(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
data = _real_jpeg()
|
data = _real_jpeg()
|
||||||
upload = await client.post(
|
upload = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("t.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("t.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert upload.status_code == 201
|
assert upload.status_code == 201
|
||||||
body = upload.json()
|
body = upload.json()
|
||||||
@@ -95,11 +101,13 @@ async def test_thumbnail_returns_webp(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_thumbnail_fallback_returns_original(client, db_session):
|
async def test_thumbnail_fallback_returns_original(authed_client, db_session):
|
||||||
|
client, token = authed_client
|
||||||
data = _real_jpeg()
|
data = _real_jpeg()
|
||||||
upload = await client.post(
|
upload = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("fallback.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("fallback.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert upload.status_code == 201
|
assert upload.status_code == 201
|
||||||
image_id = upload.json()["id"]
|
image_id = upload.json()["id"]
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ T057 — PATCH replaces tags, old tags unlinked, new tags upserted
|
|||||||
T058 — PATCH with invalid tag → 422 invalid_tag
|
T058 — PATCH with invalid tag → 422 invalid_tag
|
||||||
T073 — GET /api/v1/tags returns all tags alphabetically with correct image_count
|
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"
|
T074 — GET /api/v1/tags?q=ca returns only tags prefixed "ca"
|
||||||
|
T001 — GET /api/v1/tags?sort=count_desc returns tags ordered highest-count-first
|
||||||
|
T002 — GET /api/v1/tags?min_count=N excludes tags with image_count < N
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def _minimal_png() -> bytes:
|
def _minimal_png() -> bytes:
|
||||||
import struct, zlib
|
import struct
|
||||||
|
import zlib
|
||||||
def chunk(name: bytes, data: bytes) -> bytes:
|
def chunk(name: bytes, data: bytes) -> bytes:
|
||||||
c = name + data
|
c = name + data
|
||||||
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
|
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
|
||||||
@@ -27,12 +31,14 @@ def _minimal_png() -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_with_tags_persists_tags(client):
|
async def test_upload_with_tags_persists_tags(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
data = _minimal_png()
|
data = _minimal_png()
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("img.png", io.BytesIO(data), "image/png")},
|
files={"file": ("img.png", io.BytesIO(data), "image/png")},
|
||||||
data={"tags": "cat,funny"},
|
data={"tags": "cat,funny"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
body = response.json()
|
body = response.json()
|
||||||
@@ -40,12 +46,15 @@ async def test_upload_with_tags_persists_tags(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_duplicate_upload_tags_unchanged(client):
|
async def test_duplicate_upload_tags_unchanged(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
data = _minimal_png()
|
data = _minimal_png()
|
||||||
r1 = await client.post(
|
r1 = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("img.png", io.BytesIO(data), "image/png")},
|
files={"file": ("img.png", io.BytesIO(data), "image/png")},
|
||||||
data={"tags": "original-tag"},
|
data={"tags": "original-tag"},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert r1.status_code in (200, 201)
|
assert r1.status_code in (200, 201)
|
||||||
original_tags = set(r1.json()["tags"])
|
original_tags = set(r1.json()["tags"])
|
||||||
@@ -54,6 +63,7 @@ async def test_duplicate_upload_tags_unchanged(client):
|
|||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("img.png", io.BytesIO(data), "image/png")},
|
files={"file": ("img.png", io.BytesIO(data), "image/png")},
|
||||||
data={"tags": "different-tag"},
|
data={"tags": "different-tag"},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
assert r2.json()["duplicate"] is True
|
assert r2.json()["duplicate"] is True
|
||||||
@@ -61,18 +71,22 @@ async def test_duplicate_upload_tags_unchanged(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_patch_replaces_tag_set(client):
|
async def test_patch_replaces_tag_set(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
data = _minimal_png()
|
data = _minimal_png()
|
||||||
r1 = await client.post(
|
r1 = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("patch-test.png", io.BytesIO(data), "image/png")},
|
files={"file": ("patch-test.png", io.BytesIO(data), "image/png")},
|
||||||
data={"tags": "old-tag"},
|
data={"tags": "old-tag"},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
image_id = r1.json()["id"]
|
image_id = r1.json()["id"]
|
||||||
|
|
||||||
patch = await client.patch(
|
patch = await client.patch(
|
||||||
f"/api/v1/images/{image_id}/tags",
|
f"/api/v1/images/{image_id}/tags",
|
||||||
json={"tags": ["new-tag", "another"]},
|
json={"tags": ["new-tag", "another"]},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert patch.status_code == 200
|
assert patch.status_code == 200
|
||||||
body = patch.json()
|
body = patch.json()
|
||||||
@@ -81,17 +95,21 @@ async def test_patch_replaces_tag_set(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_patch_invalid_tag_returns_422(client):
|
async def test_patch_invalid_tag_returns_422(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
data = _minimal_png()
|
data = _minimal_png()
|
||||||
r1 = await client.post(
|
r1 = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("invalid-tag-test.png", io.BytesIO(data), "image/png")},
|
files={"file": ("invalid-tag-test.png", io.BytesIO(data), "image/png")},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
image_id = r1.json()["id"]
|
image_id = r1.json()["id"]
|
||||||
|
|
||||||
patch = await client.patch(
|
patch = await client.patch(
|
||||||
f"/api/v1/images/{image_id}/tags",
|
f"/api/v1/images/{image_id}/tags",
|
||||||
json={"tags": ["valid", "INVALID TAG WITH SPACES!"]},
|
json={"tags": ["valid", "INVALID TAG WITH SPACES!"]},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert patch.status_code == 422
|
assert patch.status_code == 422
|
||||||
body = patch.json()
|
body = patch.json()
|
||||||
@@ -99,12 +117,14 @@ async def test_patch_invalid_tag_returns_422(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_tags_alphabetical_with_counts(client):
|
async def test_list_tags_alphabetical_with_counts(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
data = _minimal_png()
|
data = _minimal_png()
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("tag-list-test.png", io.BytesIO(data), "image/png")},
|
files={"file": ("tag-list-test.png", io.BytesIO(data), "image/png")},
|
||||||
data={"tags": "zebra,apple"},
|
data={"tags": "zebra,apple"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
response = await client.get("/api/v1/tags")
|
response = await client.get("/api/v1/tags")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -117,12 +137,14 @@ async def test_list_tags_alphabetical_with_counts(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_tags_prefix_filter(client):
|
async def test_list_tags_prefix_filter(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
data = _minimal_png()
|
data = _minimal_png()
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("prefix-test.png", io.BytesIO(data), "image/png")},
|
files={"file": ("prefix-test.png", io.BytesIO(data), "image/png")},
|
||||||
data={"tags": "cat,catfish,caterpillar,dog"},
|
data={"tags": "cat,catfish,caterpillar,dog"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
response = await client.get("/api/v1/tags?q=cat")
|
response = await client.get("/api/v1/tags?q=cat")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -130,3 +152,70 @@ async def test_list_tags_prefix_filter(client):
|
|||||||
for item in body["items"]:
|
for item in body["items"]:
|
||||||
assert item["name"].startswith("cat")
|
assert item["name"].startswith("cat")
|
||||||
assert not any(item["name"] == "dog" for item in body["items"])
|
assert not any(item["name"] == "dog" for item in body["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_png(seed: int) -> bytes:
|
||||||
|
"""Generate a 1x1 PNG with a seed-determined pixel so each seed produces a distinct hash."""
|
||||||
|
import struct
|
||||||
|
import 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)
|
||||||
|
r, g, b = (seed * 37) % 256, (seed * 53) % 256, (seed * 71) % 256
|
||||||
|
idat_data = zlib.compress(bytes([0, r, g, b]))
|
||||||
|
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_list_tags_sort_count_desc(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
# popular-sort-tag appears on 2 images, rare-sort-tag on 1 — verify count_desc ordering
|
||||||
|
for seed in (100, 101):
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/images",
|
||||||
|
files={"file": (f"sort-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
|
||||||
|
data={"tags": "popular-sort-tag,rare-sort-tag" if seed == 100 else "popular-sort-tag"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
response = await client.get("/api/v1/tags?sort=count_desc")
|
||||||
|
assert response.status_code == 200
|
||||||
|
items = response.json()["items"]
|
||||||
|
sort_items = [i for i in items if i["name"] in ("popular-sort-tag", "rare-sort-tag")]
|
||||||
|
assert len(sort_items) == 2
|
||||||
|
# popular-sort-tag (count=2) must come before rare-sort-tag (count=1)
|
||||||
|
names = [i["name"] for i in sort_items]
|
||||||
|
assert names.index("popular-sort-tag") < names.index("rare-sort-tag")
|
||||||
|
# Counts must be non-increasing
|
||||||
|
counts = [i["image_count"] for i in items]
|
||||||
|
assert counts == sorted(counts, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_tags_min_count_excludes_below_threshold(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
# common-min-tag appears on 2 images, uncommon-min-tag on 1
|
||||||
|
for seed in (200, 201):
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/images",
|
||||||
|
files={"file": (f"min-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
|
||||||
|
data={"tags": "common-min-tag,uncommon-min-tag" if seed == 200 else "common-min-tag"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
# min_count=2 should exclude uncommon-min-tag (count=1) but keep common-min-tag (count=2)
|
||||||
|
response = await client.get("/api/v1/tags?min_count=2")
|
||||||
|
assert response.status_code == 200
|
||||||
|
items = response.json()["items"]
|
||||||
|
names = [i["name"] for i in items]
|
||||||
|
assert "common-min-tag" in names
|
||||||
|
assert "uncommon-min-tag" not in names
|
||||||
|
# All returned tags must have image_count >= 2
|
||||||
|
for item in items:
|
||||||
|
assert item["image_count"] >= 2
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
|
|||||||
T079 — GET /api/v1/images/{id} 404 → error envelope shape
|
T079 — GET /api/v1/images/{id} 404 → error envelope shape
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
|
import uuid
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -27,11 +28,13 @@ def _minimal_jpeg() -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_new_image_returns_201(client):
|
async def test_upload_new_image_returns_201(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
data = _minimal_jpeg()
|
data = _minimal_jpeg()
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
body = response.json()
|
body = response.json()
|
||||||
@@ -44,12 +47,15 @@ async def test_upload_new_image_returns_201(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_duplicate_returns_200_with_flag(client):
|
async def test_upload_duplicate_returns_200_with_flag(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
data = _minimal_jpeg()
|
data = _minimal_jpeg()
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
# First upload
|
# First upload
|
||||||
r1 = await client.post(
|
r1 = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert r1.status_code in (200, 201)
|
assert r1.status_code in (200, 201)
|
||||||
|
|
||||||
@@ -57,6 +63,7 @@ async def test_upload_duplicate_returns_200_with_flag(client):
|
|||||||
r2 = await client.post(
|
r2 = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
body = r2.json()
|
body = r2.json()
|
||||||
@@ -65,10 +72,12 @@ async def test_upload_duplicate_returns_200_with_flag(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_invalid_mime_type_returns_422(client):
|
async def test_upload_invalid_mime_type_returns_422(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("doc.pdf", io.BytesIO(b"%PDF-1.4"), "application/pdf")},
|
files={"file": ("doc.pdf", io.BytesIO(b"%PDF-1.4"), "application/pdf")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
body = response.json()
|
body = response.json()
|
||||||
@@ -77,10 +86,12 @@ async def test_upload_invalid_mime_type_returns_422(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_oversized_file_returns_422(client):
|
async def test_upload_oversized_file_returns_422(authed_client):
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
|
client, token = authed_client
|
||||||
os.environ["MAX_UPLOAD_BYTES"] = "10"
|
os.environ["MAX_UPLOAD_BYTES"] = "10"
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
@@ -88,6 +99,7 @@ async def test_upload_oversized_file_returns_422(client):
|
|||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("big.jpg", io.BytesIO(b"x" * 11), "image/jpeg")},
|
files={"file": ("big.jpg", io.BytesIO(b"x" * 11), "image/jpeg")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
body = response.json()
|
body = response.json()
|
||||||
@@ -99,7 +111,6 @@ async def test_upload_oversized_file_returns_422(client):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_unknown_image_returns_404_with_envelope(client):
|
async def test_get_unknown_image_returns_404_with_envelope(client):
|
||||||
import uuid
|
|
||||||
response = await client.get(f"/api/v1/images/{uuid.uuid4()}")
|
response = await client.get(f"/api/v1/images/{uuid.uuid4()}")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
body = response.json()
|
body = response.json()
|
||||||
@@ -108,11 +119,13 @@ async def test_get_unknown_image_returns_404_with_envelope(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_returns_thumbnail_key(client):
|
async def test_upload_returns_thumbnail_key(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
data = _real_jpeg(color=(100, 150, 200))
|
data = _real_jpeg(color=(100, 150, 200))
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("thumb_test.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("thumb_test.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
body = response.json()
|
body = response.json()
|
||||||
@@ -122,17 +135,21 @@ async def test_upload_returns_thumbnail_key(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_duplicate_upload_reuses_thumbnail_key(client):
|
async def test_duplicate_upload_reuses_thumbnail_key(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
data = _real_jpeg(color=(200, 100, 50))
|
data = _real_jpeg(color=(200, 100, 50))
|
||||||
r1 = await client.post(
|
r1 = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert r1.status_code in (200, 201)
|
assert r1.status_code in (200, 201)
|
||||||
|
|
||||||
r2 = await client.post(
|
r2 = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
|
|
||||||
@@ -143,12 +160,14 @@ async def test_duplicate_upload_reuses_thumbnail_key(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_succeeds_when_thumbnail_fails(client):
|
async def test_upload_succeeds_when_thumbnail_fails(authed_client):
|
||||||
|
client, token = authed_client
|
||||||
data = _real_jpeg(color=(50, 200, 150))
|
data = _real_jpeg(color=(50, 200, 150))
|
||||||
with patch("app.routers.images.generate_thumbnail", side_effect=RuntimeError("simulated")):
|
with patch("app.routers.images.generate_thumbnail", side_effect=RuntimeError("simulated")):
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/images",
|
"/api/v1/images",
|
||||||
files={"file": ("no_thumb.jpg", io.BytesIO(data), "image/jpeg")},
|
files={"file": ("no_thumb.jpg", io.BytesIO(data), "image/jpeg")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert response.status_code in (200, 201)
|
assert response.status_code in (200, 201)
|
||||||
body = response.json()
|
body = response.json()
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import os
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
_BASE_ENV = {
|
_BASE_ENV = {
|
||||||
@@ -26,6 +24,7 @@ def test_settings_load_from_env(monkeypatch):
|
|||||||
|
|
||||||
# Import inside test to pick up monkeypatched env
|
# Import inside test to pick up monkeypatched env
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import app.config as config_module
|
import app.config as config_module
|
||||||
importlib.reload(config_module)
|
importlib.reload(config_module)
|
||||||
|
|
||||||
@@ -42,6 +41,7 @@ def test_settings_max_upload_bytes_override(monkeypatch):
|
|||||||
_apply_env(monkeypatch, {"MAX_UPLOAD_BYTES": "10485760"})
|
_apply_env(monkeypatch, {"MAX_UPLOAD_BYTES": "10485760"})
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import app.config as config_module
|
import app.config as config_module
|
||||||
importlib.reload(config_module)
|
importlib.reload(config_module)
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ def test_settings_jwt_expiry_override(monkeypatch):
|
|||||||
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
|
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import app.config as config_module
|
import app.config as config_module
|
||||||
importlib.reload(config_module)
|
importlib.reload(config_module)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from app.utils import compute_sha256
|
from app.utils import compute_sha256
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import time
|
|
||||||
import pytest
|
|
||||||
import jwt as pyjwt
|
import jwt as pyjwt
|
||||||
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.auth.jwt_provider import JWTAuthProvider
|
from app.auth.jwt_provider import JWTAuthProvider
|
||||||
|
|||||||
98
api/tests/unit/test_rate_limiter.py
Normal file
98
api/tests/unit/test_rate_limiter.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import ipaddress
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.auth.rate_limiter import LoginRateLimiter, get_client_ip
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LoginRateLimiter tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_limiter():
|
||||||
|
return LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=300)
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_blocked_initially():
|
||||||
|
assert make_limiter().is_blocked("1.2.3.4") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocked_after_threshold():
|
||||||
|
limiter = make_limiter()
|
||||||
|
for _ in range(3):
|
||||||
|
limiter.record_failure("1.2.3.4")
|
||||||
|
assert limiter.is_blocked("1.2.3.4") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_clears_failures():
|
||||||
|
limiter = make_limiter()
|
||||||
|
limiter.record_failure("1.2.3.4")
|
||||||
|
limiter.record_failure("1.2.3.4")
|
||||||
|
limiter.record_success("1.2.3.4")
|
||||||
|
assert limiter.is_blocked("1.2.3.4") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ips_are_isolated():
|
||||||
|
limiter = make_limiter()
|
||||||
|
for _ in range(3):
|
||||||
|
limiter.record_failure("1.1.1.1")
|
||||||
|
assert limiter.is_blocked("2.2.2.2") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_resets_after_expiry():
|
||||||
|
import time
|
||||||
|
|
||||||
|
limiter = LoginRateLimiter(max_failures=3, window_seconds=0, cooldown_seconds=300)
|
||||||
|
limiter.record_failure("1.2.3.4")
|
||||||
|
limiter.record_failure("1.2.3.4")
|
||||||
|
time.sleep(0.01)
|
||||||
|
limiter.record_failure("1.2.3.4")
|
||||||
|
# window expired — counter reset on third call, so failures = 1, not 3
|
||||||
|
assert limiter.is_blocked("1.2.3.4") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_warning_on_lockout(caplog):
|
||||||
|
import logging
|
||||||
|
|
||||||
|
limiter = make_limiter()
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.auth.rate_limiter"):
|
||||||
|
for _ in range(3):
|
||||||
|
limiter.record_failure("5.6.7.8")
|
||||||
|
assert "Login blocked" in caplog.text
|
||||||
|
assert "5.6.7.8" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_client_ip tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_request(peer: str, headers: dict) -> MagicMock:
|
||||||
|
req = MagicMock(spec=Request)
|
||||||
|
req.client.host = peer
|
||||||
|
req.headers = headers
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_ip_no_trusted_networks_returns_peer():
|
||||||
|
req = make_request("203.0.113.1", {"X-Forwarded-For": "10.0.0.1"})
|
||||||
|
assert get_client_ip(req, []) == "203.0.113.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_ip_trusted_peer_uses_xff():
|
||||||
|
req = make_request("10.0.0.1", {"X-Forwarded-For": "203.0.113.5"})
|
||||||
|
nets = [ipaddress.ip_network("10.0.0.0/8")]
|
||||||
|
assert get_client_ip(req, nets) == "203.0.113.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_ip_untrusted_peer_ignores_xff():
|
||||||
|
req = make_request("8.8.8.8", {"X-Forwarded-For": "203.0.113.5"})
|
||||||
|
nets = [ipaddress.ip_network("10.0.0.0/8")]
|
||||||
|
assert get_client_ip(req, nets) == "8.8.8.8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_ip_trusted_peer_falls_back_to_real_ip():
|
||||||
|
req = make_request("10.0.0.1", {"X-Real-IP": "203.0.113.9"})
|
||||||
|
nets = [ipaddress.ip_network("10.0.0.0/8")]
|
||||||
|
assert get_client_ip(req, nets) == "203.0.113.9"
|
||||||
@@ -3,6 +3,7 @@ T037 — tag normalisation: uppercase → lowercase, whitespace stripped
|
|||||||
T038 — tag validation: rejects names > 64 chars, invalid chars
|
T038 — tag validation: rejects names > 64 chars, invalid chars
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.repositories.tag_repo import TagRepository
|
from app.repositories.tag_repo import TagRepository
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from app.validation import validate_mime_type, validate_file_size, MimeTypeError, FileSizeError
|
|
||||||
|
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
|
||||||
|
|
||||||
ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||||
REJECTED_TYPES = ["application/pdf", "video/mp4", "text/plain", "application/octet-stream"]
|
REJECTED_TYPES = ["application/pdf", "video/mp4", "text/plain", "application/octet-stream"]
|
||||||
|
|||||||
67
docker-compose.test.yml
Normal file
67
docker-compose.test.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
services:
|
||||||
|
postgres-test:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: reactbin
|
||||||
|
POSTGRES_PASSWORD: reactbin
|
||||||
|
POSTGRES_DB: reactbin_test
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U reactbin"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio-test:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
- "9002:9000"
|
||||||
|
- "9003:9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio-init-test:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
minio-test:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
mc alias set local http://minio-test:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||||
|
mc mb --ignore-existing local/reactbin-test
|
||||||
|
"
|
||||||
|
|
||||||
|
api-test:
|
||||||
|
build:
|
||||||
|
context: ./api
|
||||||
|
environment:
|
||||||
|
TEST_DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres-test:5432/reactbin_test
|
||||||
|
DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres-test:5432/reactbin_test
|
||||||
|
S3_ENDPOINT_URL: http://minio-test:9000
|
||||||
|
S3_BUCKET_NAME: reactbin-test
|
||||||
|
S3_ACCESS_KEY_ID: minioadmin
|
||||||
|
S3_SECRET_ACCESS_KEY: minioadmin
|
||||||
|
S3_REGION: us-east-1
|
||||||
|
JWT_SECRET_KEY: test-secret-key-for-testing-only
|
||||||
|
OWNER_USERNAME: testowner
|
||||||
|
OWNER_PASSWORD: testpassword
|
||||||
|
API_BASE_URL: http://localhost:8000
|
||||||
|
MAX_UPLOAD_BYTES: "52428800"
|
||||||
|
depends_on:
|
||||||
|
postgres-test:
|
||||||
|
condition: service_healthy
|
||||||
|
minio-init-test:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
command: ["python", "-m", "pytest", "tests/", "-v"]
|
||||||
|
working_dir: /app
|
||||||
34
specs/005-ui-polish/checklists/requirements.md
Normal file
34
specs/005-ui-polish/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: UI Polish & Design System
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-05-03
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [X] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [X] Focused on user value and business needs
|
||||||
|
- [X] Written for non-technical stakeholders
|
||||||
|
- [X] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [X] Requirements are testable and unambiguous
|
||||||
|
- [X] Success criteria are measurable
|
||||||
|
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [X] All acceptance scenarios are defined
|
||||||
|
- [X] Edge cases are identified
|
||||||
|
- [X] Scope is clearly bounded
|
||||||
|
- [X] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [X] All functional requirements have clear acceptance criteria
|
||||||
|
- [X] User scenarios cover primary flows
|
||||||
|
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [X] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Spec is ready for `/speckit-plan`.
|
||||||
242
specs/005-ui-polish/plan.md
Normal file
242
specs/005-ui-polish/plan.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Implementation Plan: UI Polish & Design System
|
||||||
|
|
||||||
|
**Branch**: `005-ui-polish` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `specs/005-ui-polish/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Refine the existing Angular SPA from functional-but-bare to intentional and
|
||||||
|
finished. All changes are purely front-end: a shared design-token layer
|
||||||
|
(CSS custom properties) is introduced in `styles.css`, and each of the five
|
||||||
|
views (library, upload, detail, login, app shell) is updated to use those tokens
|
||||||
|
and to handle loading, empty, and error states consistently. No new dependencies,
|
||||||
|
no new API endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5 / Angular 19 (standalone components, no NgModules)
|
||||||
|
**Primary Dependencies**: Angular 19, RxJS 7 (already installed; no new deps added)
|
||||||
|
**Storage**: N/A — UI-only feature
|
||||||
|
**Testing**: Karma / Jasmine (Angular CLI default; `npm test`)
|
||||||
|
**Target Platform**: Browser SPA (desktop-primary, 375 px minimum viewport)
|
||||||
|
**Project Type**: Web application — UI layer only
|
||||||
|
**Performance Goals**: Loading indicators must not flash on sub-150 ms responses
|
||||||
|
**Constraints**: No new npm dependencies; no external icon or component library
|
||||||
|
**Scale/Scope**: Five component files + one global CSS file
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| §2.1 Strict separation of concerns — UI knows nothing about storage or DB | ✅ Pass | No API or storage changes |
|
||||||
|
| §2.2 Dependency direction — UI → API only | ✅ Pass | No new API calls introduced |
|
||||||
|
| §2.3 Storage abstraction | ✅ Pass | Not touched |
|
||||||
|
| §2.4 Auth abstraction — identity resolution via AuthProvider | ✅ Pass | Auth logic unchanged; FR-006 (hide write controls) already implemented |
|
||||||
|
| §2.6 No speculative abstraction | ✅ Pass | Tokens centralised because all five views use them; no hypothetical interfaces |
|
||||||
|
| §3.3 Error shape | ✅ Pass | UI consumes existing error envelopes; no API change |
|
||||||
|
| §5.1 TDD non-negotiable | ✅ Pass | All template and state changes will have Angular component tests written first |
|
||||||
|
| §5.2 Test pyramid | ✅ Pass | Unit tests (Karma) cover state logic; E2E visual check is the acceptance gate |
|
||||||
|
| §6 Tech stack | ✅ Pass | Angular + TypeScript; no new languages or frameworks added |
|
||||||
|
| §7.2 No hardcoded values | ✅ Pass | Colours/spacing moved to CSS custom properties, not hardcoded further |
|
||||||
|
| §7.3 Linting non-optional | ✅ Pass | ESLint + Prettier enforced; `ng build` type-check must pass |
|
||||||
|
| §8 Scope boundaries | ✅ Pass | UI-only; no multi-user, no OR/NOT tags, no OIDC |
|
||||||
|
|
||||||
|
**Constitution Check result: ALL GATES PASS**
|
||||||
|
|
||||||
|
No violations. No complexity justification table required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/005-ui-polish/
|
||||||
|
├── plan.md ← this file
|
||||||
|
├── research.md ← Phase 0 output (complete)
|
||||||
|
├── quickstart.md ← Phase 1 output (visual acceptance scenarios)
|
||||||
|
└── tasks.md ← Phase 2 output (/speckit-tasks — not yet created)
|
||||||
|
```
|
||||||
|
|
||||||
|
*No `data-model.md` or `contracts/` — this feature introduces no new data
|
||||||
|
entities and no API surface changes.*
|
||||||
|
|
||||||
|
### Source Code (affected files)
|
||||||
|
|
||||||
|
```text
|
||||||
|
ui/
|
||||||
|
└── src/
|
||||||
|
├── styles.css ← Add CSS custom properties (design tokens)
|
||||||
|
└── app/
|
||||||
|
├── app.component.ts ← Polish header shell
|
||||||
|
├── library/
|
||||||
|
│ └── library.component.ts ← Skeleton load, empty state, error state, card polish
|
||||||
|
├── upload/
|
||||||
|
│ └── upload.component.ts ← Drop-zone polish, in-progress state, success/error states
|
||||||
|
├── detail/
|
||||||
|
│ └── detail.component.ts ← Loading state, not-found state, section organisation
|
||||||
|
└── login/
|
||||||
|
└── login.component.ts ← Visual alignment with design system
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
### Milestone 1 — Design Token Layer (blocks all other milestones)
|
||||||
|
|
||||||
|
Extract the shared colour, spacing, and motion values already present across the
|
||||||
|
five components into CSS custom properties on `:root` in `styles.css`.
|
||||||
|
|
||||||
|
**Deliverable**: `:root` block in `styles.css` with 13 named tokens (see
|
||||||
|
research.md Decision 5). Each existing component still renders identically
|
||||||
|
(tokens match current hard-coded values exactly). `ng build` passes.
|
||||||
|
|
||||||
|
**Token set**:
|
||||||
|
```
|
||||||
|
--bg, --surface, --surface-raised, --border, --border-focus,
|
||||||
|
--text, --text-muted, --accent, --accent-text, --danger, --danger-text,
|
||||||
|
--radius, --radius-chip, --transition
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Milestone 2 — Library View (US1)
|
||||||
|
|
||||||
|
**Loading state**: Replace the current `loading = true` boolean with the
|
||||||
|
150 ms–debounced spinner pattern (see research.md Decision 3). While loading,
|
||||||
|
render a grid of skeleton cards (same dimensions as real cards) using the
|
||||||
|
shimmer CSS class (see research.md Decision 2).
|
||||||
|
|
||||||
|
**Empty state**: The existing empty-state `<p>` is already functional. Polish it:
|
||||||
|
centred layout, muted icon (✦ or similar Unicode), larger text, and a prominent
|
||||||
|
"Upload your first image" link that navigates to `/upload`.
|
||||||
|
|
||||||
|
**Error state**: Add an `error: boolean` flag to the component. If the `list()`
|
||||||
|
call errors, set `error = true` and render an error card with a retry button
|
||||||
|
that calls `load()` again.
|
||||||
|
|
||||||
|
**Card polish**: Apply tokens to card background, border-radius, and tag chips.
|
||||||
|
Add a subtle `box-shadow` and `transform: translateY(-2px)` on hover (using
|
||||||
|
`--transition`). Ensure the card thumbnail `<img>` has an `(error)` fallback
|
||||||
|
(see research.md Decision 4).
|
||||||
|
|
||||||
|
**Responsive**: The existing `auto-fill minmax(200px, 1fr)` grid already handles
|
||||||
|
narrow viewports. Verify it does not overflow at 375 px; reduce min card width
|
||||||
|
to 160 px if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Milestone 3 — Upload View (US2)
|
||||||
|
|
||||||
|
**Drop-zone polish**: Apply token-based border and background to the existing
|
||||||
|
drag-and-drop zone. Add a dashed border accent colour (`--accent` at 40%
|
||||||
|
opacity) on active drag state.
|
||||||
|
|
||||||
|
**In-progress state**: The existing `loading` flag already disables the button.
|
||||||
|
Add a visible spinner or animated label ("Uploading…") inside the button while
|
||||||
|
in-flight so the state change is unmistakable.
|
||||||
|
|
||||||
|
**Success state**: After a successful upload, show a brief success banner
|
||||||
|
(green-tinted surface, tick character) with a "Upload another" link and a "View
|
||||||
|
in library" link. Auto-dismiss after 4 seconds or on navigation.
|
||||||
|
|
||||||
|
**Error states**: Distinct messages for validation errors (wrong type/size —
|
||||||
|
already returned by API) vs. network/server errors (generic retry). Both
|
||||||
|
displayed inline below the form, not in a modal.
|
||||||
|
|
||||||
|
**Double-submit prevention**: Already implemented (button disabled while
|
||||||
|
`loading`). Confirm the disabled style is visually clear using `--text-muted`
|
||||||
|
and reduced opacity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Milestone 4 — Detail View (US3)
|
||||||
|
|
||||||
|
**Loading state**: Add a skeleton layout while `loading = true`: a grey
|
||||||
|
rectangle at full width for the image area, and two skeleton chip rows below.
|
||||||
|
|
||||||
|
**Not-found state**: The existing `!image && !loading` condition renders a
|
||||||
|
plain text paragraph. Replace with a styled not-found card: centred layout,
|
||||||
|
muted icon, "Image not found" heading, and a "Back to library" button.
|
||||||
|
|
||||||
|
**Section organisation**: Visually separate the image area, tags section, and
|
||||||
|
write controls with consistent spacing using `--surface` panels and token-based
|
||||||
|
gaps. Write controls (tag input + delete button) should be grouped in a visually
|
||||||
|
distinct "Owner actions" area when visible.
|
||||||
|
|
||||||
|
**Tag error**: The existing `tagError` renders inline. Apply `--danger` colour
|
||||||
|
and a left border accent to make it unmistakable.
|
||||||
|
|
||||||
|
**Broken image**: Add `(error)` handler on the full-size `<img>` in the detail
|
||||||
|
view (inline SVG placeholder showing a broken-link icon).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Milestone 5 — Login View (US4)
|
||||||
|
|
||||||
|
Apply the token-based design system to the login form:
|
||||||
|
- Centre the card vertically and horizontally on the page
|
||||||
|
- Wrap the form in a `--surface` card with `--radius` and a subtle border
|
||||||
|
- Use token-based input styles matching the library filter bar
|
||||||
|
- Display field-level validation errors using `--danger` colour
|
||||||
|
- The submit button uses the same `--accent` style as the library upload button
|
||||||
|
- In-progress state: button text changes to "Signing in…", button disabled
|
||||||
|
|
||||||
|
No layout changes to the existing reactive-form structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Milestone 6 — App Shell (US5)
|
||||||
|
|
||||||
|
The existing `app.component.ts` header already conditionally renders the
|
||||||
|
sign-out button. Polish:
|
||||||
|
- Slim top bar: `--surface` background, bottom border using `--border`
|
||||||
|
- App name / logo mark on the left (text only, no image asset)
|
||||||
|
- Sign-out button aligned right using `--text-muted` colour and a simple
|
||||||
|
hover state
|
||||||
|
- Header height: `48px` fixed; does not reflow page content on state change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Milestone 1 (tokens)
|
||||||
|
↓
|
||||||
|
Milestone 2 (library) ─┐
|
||||||
|
Milestone 3 (upload) ├─ can proceed in parallel after M1
|
||||||
|
Milestone 4 (detail) │
|
||||||
|
Milestone 5 (login) ─┘
|
||||||
|
Milestone 6 (shell) ← last (touches app.component which wraps all views)
|
||||||
|
```
|
||||||
|
|
||||||
|
M2–M5 are independent of each other (different component files). M6 is last
|
||||||
|
because the app shell wraps all views and its final state is easiest to validate
|
||||||
|
once the inner views are stable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
**Unit tests (Karma/Jasmine)**:
|
||||||
|
- All new state variables (`error`, `showSpinner`, skeleton visibility) are
|
||||||
|
tested via component spec files.
|
||||||
|
- Template conditionals (`*ngIf="error"`, `*ngIf="loading"`) are verified with
|
||||||
|
fixture queries.
|
||||||
|
- The `(error)` image fallback handler is tested by simulating an error event.
|
||||||
|
- Existing tests must continue to pass — no regressions.
|
||||||
|
|
||||||
|
**Visual acceptance (manual, quickstart.md)**:
|
||||||
|
- Each milestone has a corresponding scenario in quickstart.md.
|
||||||
|
- Visual checks are performed in a running `docker compose up` stack.
|
||||||
|
- 375 px viewport check: Chrome DevTools → device toolbar → iPhone SE.
|
||||||
|
|
||||||
|
**Build gate**: `ng build` must pass with zero errors after every milestone.
|
||||||
155
specs/005-ui-polish/quickstart.md
Normal file
155
specs/005-ui-polish/quickstart.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Quickstart: UI Polish Visual Acceptance Scenarios
|
||||||
|
|
||||||
|
Use this guide to manually verify each milestone after implementation.
|
||||||
|
Run `docker compose up` before starting. Open the browser at `http://localhost:4200`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M1 — Design Token Layer
|
||||||
|
|
||||||
|
**Goal**: Tokens exist; visual output is identical to before.
|
||||||
|
|
||||||
|
1. Open the library, upload, detail, and login pages.
|
||||||
|
2. Open browser DevTools → Elements → `<html>` or `<body>`.
|
||||||
|
3. Confirm `--bg`, `--surface`, `--accent`, `--danger` etc. are visible in
|
||||||
|
computed styles.
|
||||||
|
4. Confirm no visible change in any view compared to before M1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M2 — Library View
|
||||||
|
|
||||||
|
### Loading skeleton
|
||||||
|
1. Open DevTools → Network → set throttle to "Slow 3G".
|
||||||
|
2. Hard-refresh the library page.
|
||||||
|
3. **Expect**: A grid of grey shimmer cards appears immediately; no blank white
|
||||||
|
space; no layout jump when real images load in.
|
||||||
|
|
||||||
|
### Empty state
|
||||||
|
1. Ensure no images are uploaded (or use a fresh test database).
|
||||||
|
2. Open the library.
|
||||||
|
3. **Expect**: A centred empty-state panel with explanatory text and a prominent
|
||||||
|
"Upload your first image" link. Clicking the link navigates to `/upload`.
|
||||||
|
|
||||||
|
### Error state
|
||||||
|
1. Stop the API container (`docker compose stop api`).
|
||||||
|
2. Hard-refresh the library.
|
||||||
|
3. **Expect**: An error card with a plain-language message and a "Retry" button.
|
||||||
|
No blank grid, no raw status code.
|
||||||
|
4. Restart the API (`docker compose start api`) and click "Retry".
|
||||||
|
5. **Expect**: Images load successfully.
|
||||||
|
|
||||||
|
### Card polish
|
||||||
|
1. Hover over an image card.
|
||||||
|
2. **Expect**: Card lifts slightly (2 px translate) with a smooth transition.
|
||||||
|
|
||||||
|
### Broken image
|
||||||
|
1. Manually corrupt a storage key in the database (or unplug MinIO) and reload.
|
||||||
|
2. **Expect**: Card shows a grey placeholder graphic, not a broken-image browser icon.
|
||||||
|
|
||||||
|
### 375 px viewport
|
||||||
|
1. DevTools → Device toolbar → iPhone SE (375 × 667).
|
||||||
|
2. **Expect**: Cards stack, no horizontal scrollbar, all content readable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M3 — Upload View
|
||||||
|
|
||||||
|
### Drop-zone idle
|
||||||
|
1. Navigate to `/upload` (must be logged in).
|
||||||
|
2. **Expect**: A visually distinct drop-zone with dashed border and clear
|
||||||
|
instructions. Submit button is disabled/greyed.
|
||||||
|
|
||||||
|
### In-progress
|
||||||
|
1. Select a large image file.
|
||||||
|
2. Click upload.
|
||||||
|
3. **Expect**: Button label changes to "Uploading…" and is disabled. A spinner
|
||||||
|
or animated indicator is visible.
|
||||||
|
|
||||||
|
### Success
|
||||||
|
1. After a successful upload completes.
|
||||||
|
2. **Expect**: A green-tinted success banner with the filename, "Upload another"
|
||||||
|
link, and "View in library" link. Banner disappears after 4 seconds.
|
||||||
|
|
||||||
|
### Validation error
|
||||||
|
1. Attempt to upload a `.txt` file.
|
||||||
|
2. **Expect**: An inline error message names the problem ("Unsupported file type").
|
||||||
|
The form is still usable — no page reload required.
|
||||||
|
|
||||||
|
### Network error
|
||||||
|
1. Stop the API mid-upload (or use DevTools → block the upload request).
|
||||||
|
2. **Expect**: A generic inline error with guidance to retry. Form remains usable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M4 — Detail View
|
||||||
|
|
||||||
|
### Loading skeleton
|
||||||
|
1. Set network throttle to Slow 3G.
|
||||||
|
2. Navigate directly to an image URL (e.g., `http://localhost:4200/images/<id>`).
|
||||||
|
3. **Expect**: A grey rectangle skeleton for the image area and chip skeletons
|
||||||
|
below. No blank page.
|
||||||
|
|
||||||
|
### Not-found state
|
||||||
|
1. Navigate to `http://localhost:4200/images/00000000-0000-0000-0000-000000000000`.
|
||||||
|
2. **Expect**: A styled not-found card with "Image not found" heading and a
|
||||||
|
"Back to library" button. No blank page, no raw 404 text.
|
||||||
|
|
||||||
|
### Authenticated write controls
|
||||||
|
1. Log in and open a detail page.
|
||||||
|
2. **Expect**: Tag editing input and delete button are visible and clearly grouped.
|
||||||
|
|
||||||
|
### Unauthenticated view
|
||||||
|
1. Open the detail page in a private/incognito window (not logged in).
|
||||||
|
2. **Expect**: Image and read-only tag chips are visible. No tag input, no
|
||||||
|
delete button.
|
||||||
|
|
||||||
|
### Tag error
|
||||||
|
1. While logged in, attempt to add a tag with invalid characters (e.g., `TAG!`).
|
||||||
|
2. **Expect**: An inline error in danger colour with a left accent border.
|
||||||
|
Other tags and the image remain visible.
|
||||||
|
|
||||||
|
### Broken image
|
||||||
|
1. Open a detail page for an image whose storage object has been deleted.
|
||||||
|
2. **Expect**: A placeholder graphic replaces the image. Page layout is not broken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M5 — Login View
|
||||||
|
|
||||||
|
### Visual consistency
|
||||||
|
1. Open `/login` without being logged in.
|
||||||
|
2. **Expect**: Dark background matching the library; form centred in a surface
|
||||||
|
card; same font and spacing as other pages.
|
||||||
|
|
||||||
|
### Field validation
|
||||||
|
1. Click the Sign In button without entering any credentials.
|
||||||
|
2. **Expect**: Inline validation messages appear on the username and password
|
||||||
|
fields without a page reload.
|
||||||
|
|
||||||
|
### Invalid credentials error
|
||||||
|
1. Enter wrong credentials and submit.
|
||||||
|
2. **Expect**: A single error message below the form. Fields retain their values.
|
||||||
|
|
||||||
|
### In-progress state
|
||||||
|
1. Submit valid credentials (throttle network if needed to see the state).
|
||||||
|
2. **Expect**: Button label changes to "Signing in…" and is disabled while the
|
||||||
|
request is in flight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M6 — App Shell
|
||||||
|
|
||||||
|
### Authenticated header
|
||||||
|
1. Log in and navigate between library, upload, and an image detail page.
|
||||||
|
2. **Expect**: A consistent 48 px header is present on all pages. Sign-out
|
||||||
|
control is visible on the right. Header does not reflow content.
|
||||||
|
|
||||||
|
### Unauthenticated header
|
||||||
|
1. Open the library or detail page without logging in.
|
||||||
|
2. **Expect**: Header is present but sign-out control is absent.
|
||||||
|
|
||||||
|
### Sign out
|
||||||
|
1. Click the sign-out control in the header.
|
||||||
|
2. **Expect**: Redirected to `/login`. Header no longer shows sign-out control.
|
||||||
|
Navigating to `/upload` redirects back to `/login`.
|
||||||
137
specs/005-ui-polish/research.md
Normal file
137
specs/005-ui-polish/research.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Research: UI Polish & Design System
|
||||||
|
|
||||||
|
## Decision 1: Design token delivery mechanism
|
||||||
|
|
||||||
|
**Decision**: CSS custom properties declared on `:root` in `styles.css`.
|
||||||
|
|
||||||
|
**Rationale**: Angular's default ViewEncapsulation.Emulated scopes component
|
||||||
|
selectors with attribute hashes but does not block CSS custom property
|
||||||
|
inheritance. `:root` variables cascade into every inline component style block
|
||||||
|
without any special configuration. This is now the standard Angular 2025+ token
|
||||||
|
approach and requires zero new dependencies.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- SCSS variables — require a preprocessor; project currently uses plain CSS.
|
||||||
|
- A shared `.css` import per component — works but adds per-component boilerplate
|
||||||
|
and duplicates the token surface unnecessarily.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 2: Skeleton loading pattern
|
||||||
|
|
||||||
|
**Decision**: Pure-CSS shimmer using `::after` pseudo-element animated gradient;
|
||||||
|
no third-party library.
|
||||||
|
|
||||||
|
**Rationale**: The spec assumption explicitly prohibits new icon/component
|
||||||
|
libraries. The standard pure-CSS skeleton pattern (placeholder divs with a
|
||||||
|
light-to-dark horizontal gradient sweeping via `@keyframes`) produces the same
|
||||||
|
visual result as library skeletons with zero added dependencies. The `::after`
|
||||||
|
approach requires no extra DOM nodes per skeleton block.
|
||||||
|
|
||||||
|
**Pattern**:
|
||||||
|
```css
|
||||||
|
@keyframes shimmer {
|
||||||
|
from { background-position: -200% 0; }
|
||||||
|
to { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-raised) 50%, var(--surface) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `@angular/material` skeleton — adds the entire Material library as a dep.
|
||||||
|
- CSS opacity pulse — simpler but less visually informative than a shimmer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 3: Loading-flash debounce
|
||||||
|
|
||||||
|
**Decision**: `timer(150).pipe(takeUntil(response$))` to gate the visibility of
|
||||||
|
loading indicators.
|
||||||
|
|
||||||
|
**Rationale**: Showing a spinner immediately causes a visible flash when the
|
||||||
|
server responds in under ~150 ms (common on localhost). The idiomatic RxJS
|
||||||
|
approach is to start a 150 ms timer alongside the real request; if the request
|
||||||
|
completes first (`takeUntil`), the timer never fires and the spinner never
|
||||||
|
appears. This avoids `race()` complexity and cleanly unsubscribes.
|
||||||
|
|
||||||
|
**Implementation sketch**:
|
||||||
|
```typescript
|
||||||
|
showSpinner = false;
|
||||||
|
load(): void {
|
||||||
|
const req$ = this.service.fetch().pipe(share());
|
||||||
|
timer(150).pipe(takeUntil(req$)).subscribe(() => { this.showSpinner = true; });
|
||||||
|
req$.subscribe(data => { this.showSpinner = false; /* handle data */ });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `race([req$, timer(150)])` — fires timer regardless of req$ speed on certain
|
||||||
|
race conditions; harder to reason about.
|
||||||
|
- CSS `animation-delay` — cannot easily tie delay to actual response time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 4: Broken-image fallback
|
||||||
|
|
||||||
|
**Decision**: Inline `(error)` event binding on `<img>` elements, guarded
|
||||||
|
against recursive fallback.
|
||||||
|
|
||||||
|
**Rationale**: For a small number of distinct image elements (card thumbnail,
|
||||||
|
detail full-image), an event binding is the minimal idiomatic pattern and
|
||||||
|
avoids the complexity of a directive. Recursive fallback is prevented by
|
||||||
|
checking that the current `src` is not already the placeholder before
|
||||||
|
reassigning.
|
||||||
|
|
||||||
|
**Pattern**:
|
||||||
|
```html
|
||||||
|
<img [src]="url" (error)="onImgError($event)" />
|
||||||
|
```
|
||||||
|
```typescript
|
||||||
|
onImgError(e: Event): void {
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
if (!img.src.endsWith('placeholder')) {
|
||||||
|
img.src = 'data:image/svg+xml,...'; // inline SVG placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Custom `ImageFallbackDirective` — reusable but over-engineered for two call
|
||||||
|
sites; can be extracted later if the pattern spreads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 5: Global design token set
|
||||||
|
|
||||||
|
**Decision**: Seven semantic tokens defined on `:root` in `styles.css`, derived
|
||||||
|
from colours already present in the components.
|
||||||
|
|
||||||
|
| Token | Value | Meaning |
|
||||||
|
|--------------------|-----------|----------------------------------------------|
|
||||||
|
| `--bg` | `#0f0f0f` | Page background (already in body) |
|
||||||
|
| `--surface` | `#1a1a1a` | Card / input / panel background |
|
||||||
|
| `--surface-raised` | `#252525` | Hover state, skeleton highlight |
|
||||||
|
| `--border` | `#333` | Subtle dividers, input borders |
|
||||||
|
| `--border-focus` | `#555` | Input focus ring |
|
||||||
|
| `--text` | `#e0e0e0` | Primary text (already on body) |
|
||||||
|
| `--text-muted` | `#777` | Secondary text, placeholders |
|
||||||
|
| `--accent` | `#4a9eff` | CTAs, active chips (already in upload-btn) |
|
||||||
|
| `--accent-text` | `#000` | Text on accent backgrounds |
|
||||||
|
| `--danger` | `#c0392b` | Destructive actions (already in delete-btn) |
|
||||||
|
| `--danger-text` | `#fff` | Text on danger backgrounds |
|
||||||
|
| `--radius` | `6px` | Standard border radius |
|
||||||
|
| `--radius-chip` | `12px` | Pill-shaped chips |
|
||||||
|
| `--transition` | `200ms ease` | Standard animation duration |
|
||||||
|
|
||||||
|
**Rationale**: All values are already present in the components but hard-coded
|
||||||
|
per file. Centralising them eliminates drift without introducing a new colour
|
||||||
|
palette — the visual result is identical to the current state, but consistent.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Introducing a new darker/lighter palette — unnecessary scope creep; the
|
||||||
|
existing colours are well-chosen for a dark personal tool.
|
||||||
180
specs/005-ui-polish/spec.md
Normal file
180
specs/005-ui-polish/spec.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Feature Specification: UI Polish & Design System
|
||||||
|
|
||||||
|
**Feature Branch**: `005-ui-polish`
|
||||||
|
**Created**: 2026-05-03
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Polish the Angular UI with a cohesive visual design. The three main views — library (image grid), upload form, and image detail — should feel intentional and finished. Add proper loading states, empty states, and error states to each view. The overall aesthetic should be dark-themed and minimal, fitting a personal tool used frequently. The login page should also match the design system."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 — Library Feels Complete (Priority: P1)
|
||||||
|
|
||||||
|
The owner opens the app and is greeted by a polished image grid. While images
|
||||||
|
load, something visually coherent fills the space so the page doesn't feel
|
||||||
|
broken. If the library is empty, a helpful prompt explains what to do. If the
|
||||||
|
request fails, a clear error message appears with a way to retry.
|
||||||
|
|
||||||
|
**Why this priority**: The library is the landing page and the most-visited
|
||||||
|
view. Its quality sets first impressions for every session.
|
||||||
|
|
||||||
|
**Independent Test**: Open the app with no images uploaded — confirm an
|
||||||
|
intentional empty state is shown. Upload an image, return to the library —
|
||||||
|
confirm the grid renders cleanly with consistent card sizing. Throttle the
|
||||||
|
network — confirm a loading indicator appears before images arrive.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the library is loading, **When** the page first renders, **Then** a skeleton or spinner occupies the grid area so the layout does not jump or appear blank.
|
||||||
|
2. **Given** no images have been uploaded, **When** the library loads successfully, **Then** an empty-state message is shown explaining that no images exist yet, with a visible prompt to upload the first image.
|
||||||
|
3. **Given** the image fetch fails (network error), **When** the library loads, **Then** an error message is shown with a retry action; the page does not display a blank grid or a raw error code.
|
||||||
|
4. **Given** images exist, **When** the library renders, **Then** all image cards have consistent size, spacing, and visual weight; tag chips are readable and do not overflow their cards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 — Upload Form Feels Finished (Priority: P1)
|
||||||
|
|
||||||
|
The owner navigates to the upload page and finds a form that clearly communicates
|
||||||
|
its state at every step: idle with a helpful drop-zone, active while uploading
|
||||||
|
with visible progress, and resolved with success or a plain-language error.
|
||||||
|
|
||||||
|
**Why this priority**: Upload is the primary write action. A rough upload
|
||||||
|
experience erodes confidence in the whole tool.
|
||||||
|
|
||||||
|
**Independent Test**: Upload a valid image and confirm the flow from drop-zone
|
||||||
|
through in-progress indicator to success result is smooth and clearly
|
||||||
|
communicated. Attempt an upload with an invalid file type and confirm a
|
||||||
|
plain-language validation error appears without a page reload.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the upload page is idle, **When** no file is selected, **Then** a drop-zone with clear instructions is visible; the submit button is visibly disabled.
|
||||||
|
2. **Given** a file is selected and uploading, **When** the upload is in progress, **Then** the submit button is disabled and a visible in-progress indicator is shown; the user cannot accidentally submit twice.
|
||||||
|
3. **Given** an upload succeeds, **When** the server responds, **Then** a success confirmation is shown and the owner can navigate onward without confusion.
|
||||||
|
4. **Given** an upload fails due to an invalid file type or size, **When** the server responds, **Then** a plain-language error message is shown identifying the problem; the form remains usable for another attempt.
|
||||||
|
5. **Given** an upload fails due to a network or server error, **When** the server responds, **Then** a generic error message is shown with guidance to retry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 — Detail Page Is Well Organised (Priority: P1)
|
||||||
|
|
||||||
|
The owner opens an image's detail page and finds the image prominently displayed,
|
||||||
|
tag management clearly grouped, and write controls (edit tags, delete) visually
|
||||||
|
distinct from read content. Visitors who are not logged in see the image and
|
||||||
|
tags but no write controls. Loading and error states are handled gracefully.
|
||||||
|
|
||||||
|
**Why this priority**: The detail page is where tag curation and deletion
|
||||||
|
happen — the two most common editing actions after upload.
|
||||||
|
|
||||||
|
**Independent Test**: Open a detail page while logged in — confirm write
|
||||||
|
controls are visible and clearly grouped. Open the same page while logged out —
|
||||||
|
confirm write controls are hidden. Navigate to a non-existent image ID — confirm
|
||||||
|
a not-found state is shown rather than a blank or broken page.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the detail page is loading, **When** the route is first entered, **Then** a loading indicator is shown in place of the image and metadata.
|
||||||
|
2. **Given** the image exists and the owner is logged in, **When** the page renders, **Then** the image is the focal point; tags are displayed below; tag editing and delete controls are clearly grouped and visually differentiated from read content.
|
||||||
|
3. **Given** the image exists and the visitor is not logged in, **When** the page renders, **Then** the image and tags are visible; no tag-edit input or delete button is present.
|
||||||
|
4. **Given** a non-existent image ID is requested, **When** the page loads, **Then** a not-found state is shown with a link back to the library; no raw error code or blank area is displayed.
|
||||||
|
5. **Given** a tag update fails, **When** the owner submits a tag change, **Then** an inline error message explains the failure; the image and other tags remain visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 — Login Page Matches the Design (Priority: P2)
|
||||||
|
|
||||||
|
The owner lands on the login page (directly or after a redirect) and finds a
|
||||||
|
form that visually belongs to the same application as the library and detail
|
||||||
|
page. The form clearly communicates validation errors and submission state.
|
||||||
|
|
||||||
|
**Why this priority**: Login is visited infrequently. A consistent visual
|
||||||
|
treatment matters, but functional correctness (already implemented) is more
|
||||||
|
critical than aesthetic alignment.
|
||||||
|
|
||||||
|
**Independent Test**: Navigate to `/login` directly — confirm the page uses the
|
||||||
|
same colour scheme, typography, and spacing as the rest of the app. Submit with
|
||||||
|
empty fields — confirm visible validation errors appear without a page reload.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the login page loads, **When** the owner views it, **Then** the page uses the same dark background, colour palette, and typographic scale as all other views.
|
||||||
|
2. **Given** the owner submits with empty username or password, **When** the form is submitted, **Then** inline validation messages appear on the relevant fields without a page reload or server round-trip.
|
||||||
|
3. **Given** the owner submits invalid credentials, **When** the server rejects them, **Then** a single error message is shown below the form; the fields are not cleared.
|
||||||
|
4. **Given** the form is submitting, **When** the request is in-flight, **Then** the submit button is disabled and shows an in-progress label so the owner cannot submit twice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 5 — App Shell Is Consistent (Priority: P2)
|
||||||
|
|
||||||
|
Every page shares a consistent outer frame: a slim header that shows the
|
||||||
|
sign-out control when logged in. The header does not compete with page content
|
||||||
|
for visual attention but is always present and usable.
|
||||||
|
|
||||||
|
**Why this priority**: A coherent shell ties the individual views together into
|
||||||
|
a single application rather than a collection of pages.
|
||||||
|
|
||||||
|
**Independent Test**: Navigate between library, detail, and upload while logged
|
||||||
|
in — confirm the header is consistent across all views. Sign out and visit a
|
||||||
|
public page — confirm the sign-out control is absent.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the owner is logged in, **When** viewing any page, **Then** a slim header is present with a sign-out control; it does not draw excessive visual attention away from the page content.
|
||||||
|
2. **Given** the visitor is not logged in, **When** viewing the library or a detail page, **Then** the header is present but contains no sign-out control.
|
||||||
|
3. **Given** the owner clicks sign out in the header, **When** the action completes, **Then** they are redirected to the login page and the header no longer shows the sign-out control.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens if an image fails to load (broken URL or storage outage)? The card or detail view should show a placeholder, not the browser's default broken-image icon.
|
||||||
|
- What happens on a very narrow viewport (mobile browser)? Cards should stack or resize; the layout must not overflow horizontally.
|
||||||
|
- What happens if a tag is very long? Tag chips must truncate or wrap without breaking the card or detail layout.
|
||||||
|
- What happens during slow network conditions? Loading states must appear promptly and not flash on fast connections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: Every view (library, upload, detail, login) MUST display a loading indicator while async data or actions are in progress.
|
||||||
|
- **FR-002**: The library MUST display a meaningful empty-state message with a call to action when no images exist.
|
||||||
|
- **FR-003**: All four views MUST display plain-language error messages when an operation fails; raw HTTP status codes or stack traces MUST NOT be shown to the user.
|
||||||
|
- **FR-004**: The upload form MUST disable the submit control while an upload is in progress to prevent duplicate submissions.
|
||||||
|
- **FR-005**: The detail page MUST show a not-found state (with a back link) when the requested image does not exist.
|
||||||
|
- **FR-006**: Write controls on the detail page (tag editing, delete) MUST be hidden for unauthenticated visitors and visible only to the logged-in owner.
|
||||||
|
- **FR-007**: All views MUST share a consistent set of visual tokens: background colours, text colours, spacing scale, border radii, and interactive-element styles.
|
||||||
|
- **FR-008**: The application MUST be usable on viewports as narrow as 375 px (iPhone SE width) without horizontal overflow.
|
||||||
|
- **FR-009**: Loading indicators MUST NOT flash on connections fast enough to resolve in under 150 ms; debounced or skeleton-based approaches are preferred.
|
||||||
|
- **FR-010**: Broken image assets (failed loads) MUST display a visible placeholder rather than the browser's default broken-image icon.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Design token set**: The shared palette, spacing scale, and typographic rules that all views derive from (background, surface, border, text-primary, text-muted, accent, danger).
|
||||||
|
- **Loading state**: A visual treatment applied to any view or element while data is being fetched or an action is in progress.
|
||||||
|
- **Empty state**: A purposeful layout shown when a collection has zero items, including explanatory text and a next-action prompt.
|
||||||
|
- **Error state**: A purposeful layout shown when an operation fails, including a plain-language description and (where applicable) a retry action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Every view transitions from loading to content (or error/empty) without a layout shift visible to the naked eye.
|
||||||
|
- **SC-002**: All five views pass a visual consistency check: an observer can identify them as belonging to the same application by colour, typography, and spacing alone.
|
||||||
|
- **SC-003**: The library, upload, and detail views each render without horizontal scrollbars on a 375 px wide viewport.
|
||||||
|
- **SC-004**: Each error condition (network failure, validation failure, not-found) produces a user-visible message within the current view — zero conditions result in a silent failure or blank screen.
|
||||||
|
- **SC-005**: Loading indicators do not appear on responses that complete in under 150 ms in a local development environment (no flicker on fast connections).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The existing dark colour palette already in the components (#1a1a1a backgrounds, #e0e0e0 text, #4a9eff accent) is the correct base; the polish work refines and extends it rather than replacing it wholesale.
|
||||||
|
- No external component library or icon set is introduced; any icons needed are either inline SVG or Unicode characters to avoid new dependencies.
|
||||||
|
- The app remains a single-page application; no server-side rendering or route-level transitions are in scope.
|
||||||
|
- Mobile layout is "good enough to use" at 375 px rather than a fully optimised mobile-first redesign; a dedicated mobile redesign is out of scope.
|
||||||
|
- No new API endpoints are needed; all changes are purely front-end.
|
||||||
|
- Animations and transitions are minimal — a single standard duration applied consistently; no complex motion design.
|
||||||
|
- FR-006 (hiding write controls for unauthenticated visitors) is already implemented in the detail component; this spec confirms the behaviour is preserved and visually correct, not that it needs to be built from scratch.
|
||||||
242
specs/005-ui-polish/tasks.md
Normal file
242
specs/005-ui-polish/tasks.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Tasks: UI Polish & Design System
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/005-ui-polish/`
|
||||||
|
**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, quickstart.md ✓
|
||||||
|
|
||||||
|
**Tests**: Component spec tests are included per §5.1 (TDD non-negotiable). Tests are written first and must fail before implementation begins. Karma/Jasmine via Angular CLI test runner.
|
||||||
|
|
||||||
|
**Organization**: Phase 2 (design token layer) blocks all user story phases. User story phases (Phase 3–7) are independent of each other and can proceed in parallel after Phase 2 completes.
|
||||||
|
|
||||||
|
**Milestone mapping** (cross-reference with `plan.md` and `quickstart.md`):
|
||||||
|
Phase 2 = M1 (Tokens) | Phase 3 = M2 (Library) | Phase 4 = M3 (Upload) | Phase 5 = M4 (Detail) | Phase 6 = M5 (Login) | Phase 7 = M6 (Shell)
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
|
||||||
|
- **[Story]**: Which user story this task belongs to (US1–US5)
|
||||||
|
- All component files are under `ui/src/app/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: Verify baseline state before any changes are made.
|
||||||
|
|
||||||
|
- [X] T001 Confirm `ng build` passes with zero errors in `ui/` (baseline gate before any changes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational — M1: Design Token Layer
|
||||||
|
|
||||||
|
**Purpose**: Establish the shared CSS custom property layer in `ui/src/styles.css`. This is the blocking prerequisite for all five user story phases — no component work begins until these tokens exist.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story phase can begin until T004 passes.
|
||||||
|
|
||||||
|
- [X] T002 Add 13 CSS custom property tokens to `:root` in `ui/src/styles.css`: `--bg`, `--surface`, `--surface-raised`, `--border`, `--border-focus`, `--text`, `--text-muted`, `--accent`, `--accent-text`, `--danger`, `--danger-text`, `--radius`, `--radius-chip`, `--transition` — use exact values from research.md Decision 5
|
||||||
|
- [X] T003 Add `@keyframes shimmer` animation and `.skeleton` utility class to `ui/src/styles.css` using the gradient pattern from research.md Decision 2
|
||||||
|
- [X] T004 Confirm `ng build` passes with zero errors after token additions (M1 gate — components unchanged, visual output identical)
|
||||||
|
|
||||||
|
**Checkpoint**: Design token layer complete. User story phases 3–7 may now start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Library Feels Complete (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: The library view has skeleton loading, a styled empty state, an error state with retry, polished cards with hover lift, image error fallback, and responsive layout at 375 px.
|
||||||
|
|
||||||
|
**Independent Test**: Throttle network to Slow 3G and hard-refresh `/` — confirm shimmer skeleton appears. Remove all images — confirm styled empty state with "Upload your first image" link. Stop API — confirm error card with Retry button. Hover a card — confirm 2 px lift. Set viewport to 375 px — confirm no horizontal scrollbar.
|
||||||
|
|
||||||
|
### Tests for User Story 1 (TDD — write first, confirm failure before T008) ⚠️
|
||||||
|
|
||||||
|
- [X] T005 [US1] Add component tests for `showSpinner` debounce flag, `error` flag, skeleton card count, empty-state link, error card retry button, and `onImgError` handler in `ui/src/app/library/library.component.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T006 [P] [US1] Replace all hardcoded colour and spacing values with CSS token variables in the component styles block of `ui/src/app/library/library.component.ts`
|
||||||
|
- [X] T007 [US1] Replace `loading = true` boolean with `showSpinner = false` and add 150 ms debounce using `timer(150).pipe(takeUntil(req$))` from research.md Decision 3 in `ui/src/app/library/library.component.ts`
|
||||||
|
- [X] T008 [US1] Add skeleton loading grid: while `showSpinner` is true render 8 `<div class="skeleton card-skeleton">` placeholders at the same dimensions as real cards in `ui/src/app/library/library.component.ts`
|
||||||
|
- [X] T009 [US1] Add `error = false` flag; set it on `list()` failure; render an error card with plain-language message and "Retry" button that calls `load()` in `ui/src/app/library/library.component.ts`
|
||||||
|
- [X] T010 [US1] Replace the plain empty-state `<p>` with a centred panel: Unicode icon (✦), larger muted heading, and a `routerLink="/upload"` "Upload your first image" link in `ui/src/app/library/library.component.ts`
|
||||||
|
- [X] T011 [US1] Add card hover effect: `transform: translateY(-2px)` and `box-shadow` with `transition: var(--transition)` using `--surface-raised` in `ui/src/app/library/library.component.ts`
|
||||||
|
- [X] T012 [US1] Add `(error)="onImgError($event)"` on the card thumbnail `<img>` and implement `onImgError` with an inline SVG placeholder (guard against recursive fallback per research.md Decision 4) in `ui/src/app/library/library.component.ts`
|
||||||
|
- [X] T013 [US1] Check the `auto-fill minmax()` value in the grid at 375 px: if cards overflow horizontally, reduce min card width to `160px` and record the change; if no overflow, document "verified at 160px — no change needed" in a code comment in `ui/src/app/library/library.component.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Library view is fully functional with all loading/empty/error/responsive states.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Upload Form Feels Finished (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: The upload form has a visually distinct drop-zone, visible in-progress state ("Uploading…" + spinner), a success banner with auto-dismiss, distinct validation vs. network error messages, and a clearly disabled button style.
|
||||||
|
|
||||||
|
**Independent Test**: Navigate to `/upload` — confirm dashed drop-zone border. Select a large file and click Upload — confirm button shows "Uploading…" and is disabled. After success — confirm green banner appears then disappears after 4 s. Attempt to upload a `.txt` file — confirm "Unsupported file type" inline error.
|
||||||
|
|
||||||
|
### Tests for User Story 2 (TDD — write first, confirm failure before T016) ⚠️
|
||||||
|
|
||||||
|
- [X] T014 [US2] Add component tests for `loading` button-disabled state, "Uploading…" label, `showSuccess` banner visibility, auto-dismiss timer, validation error message, and network error message in `ui/src/app/upload/upload.component.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T015 [P] [US2] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/upload/upload.component.ts`
|
||||||
|
- [X] T016 [US2] Style the drop-zone with a dashed `--accent`-coloured border at 40% opacity; add an active drag state that brightens the border to full `--accent` in `ui/src/app/upload/upload.component.ts`
|
||||||
|
- [X] T017 [US2] Change submit button label to "Uploading…" and add a CSS spinner `<span>` inside the button while `loading = true` in `ui/src/app/upload/upload.component.ts`
|
||||||
|
- [X] T018 [US2] Add `showSuccess = false` and `uploadedFilename = ''`; after a successful upload set both, show a green-tinted banner with filename, "Upload another" link, and "View in library" routerLink, then auto-dismiss after 4 s using `setTimeout` in `ui/src/app/upload/upload.component.ts`
|
||||||
|
- [X] T019 [US2] Show distinct inline error messages: validation errors (wrong type/size from API) show the specific problem; network/server errors show a generic retry message — both rendered below the form without a page reload in `ui/src/app/upload/upload.component.ts`
|
||||||
|
- [X] T020 [US2] Apply `--text-muted` colour and `opacity: 0.5` to the disabled button style to make the disabled state visually unmistakable in `ui/src/app/upload/upload.component.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Upload form communicates every state clearly and prevents duplicate submission.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Detail Page Is Well Organised (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: The detail view has a loading skeleton, a network error state with retry, a styled not-found card with back link, a grouped "Owner actions" panel for write controls, danger-styled tag errors, and a broken-image fallback.
|
||||||
|
|
||||||
|
**Independent Test**: Throttle to Slow 3G and navigate to `/images/<id>` — confirm skeleton appears. Stop the API and hard-refresh a detail page — confirm error card with retry (not a blank page). Navigate to `/images/00000000-0000-0000-0000-000000000000` — confirm not-found card with "Back to library" button. Log in and open a detail page — confirm write controls are grouped. Open detail page while logged out — confirm write controls absent. Add tag with `!` character — confirm danger-coloured inline error.
|
||||||
|
|
||||||
|
### Tests for User Story 3 (TDD — write first, confirm failure before T023) ⚠️
|
||||||
|
|
||||||
|
- [X] T021 [US3] Add component tests for skeleton visibility while `loading=true`, network error card when fetch fails (non-404), not-found card when `!image && !loading && !error`, tag error `--danger` style application, and `onImgError` handler in `ui/src/app/detail/detail.component.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T022 [P] [US3] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/detail/detail.component.ts`
|
||||||
|
- [X] T023 [US3] Add skeleton loading layout while `loading = true`: a full-width grey `.skeleton` rectangle for the image area and two rows of `.skeleton` chip placeholders below in `ui/src/app/detail/detail.component.ts`
|
||||||
|
- [X] T024 [US3] Add `error = false` flag; set it on API fetch failure (non-404 errors); render an error card with plain-language "Failed to load image" message, "Back to library" link, and a "Retry" button that calls the fetch again — distinct from the not-found state in `ui/src/app/detail/detail.component.ts`
|
||||||
|
- [X] T025 [US3] Replace the plain `!image && !loading && !error` paragraph with a styled not-found card: centred layout, muted Unicode icon, "Image not found" `<h2>`, and a "Back to library" `routerLink="/"` button in `ui/src/app/detail/detail.component.ts`
|
||||||
|
- [X] T026 [US3] Wrap the tag-edit input and delete button in a visually distinct "Owner actions" `<section>` with a `--surface` panel, `--border` top separator, and token-based padding/gap in `ui/src/app/detail/detail.component.ts`
|
||||||
|
- [X] T027 [US3] Apply `color: var(--danger)` and `border-left: 3px solid var(--danger)` with left-padding to the `tagError` inline error element in `ui/src/app/detail/detail.component.ts`
|
||||||
|
- [X] T028 [US3] Add `(error)="onImgError($event)"` on the full-size `<img>` and implement `onImgError` with an inline SVG broken-link placeholder (guard against recursive fallback) in `ui/src/app/detail/detail.component.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Detail page handles all states (loading, error, not-found, success) gracefully and clearly separates read from write content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 — Login Page Matches the Design (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: The login page uses the shared dark design system, displays field-level validation errors and server error messages in `--danger` colour without a page reload, shows a single server error below the form on bad credentials, and disables the button with "Signing in…" label while in-flight.
|
||||||
|
|
||||||
|
**Independent Test**: Navigate to `/login` — confirm dark background, surface card, same font as library. Click Sign In with empty fields — confirm field-level errors without page reload. Enter wrong credentials — confirm single error message below form in danger colour; fields retain values. Throttle network and submit valid credentials — confirm button shows "Signing in…" and is disabled.
|
||||||
|
|
||||||
|
### Tests for User Story 4 (TDD — write first, confirm failure before T031) ⚠️
|
||||||
|
|
||||||
|
- [X] T029 [US4] Add component tests for field-level validation error display on empty submit, `errorMessage` server error paragraph visibility after failed login, "Signing in…" button label while `loading=true`, and fields-not-cleared behaviour in `ui/src/app/login/login.component.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [X] T030 [P] [US4] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/login/login.component.ts`
|
||||||
|
- [X] T031 [US4] Centre the login card vertically (`height: 100vh; display: flex; align-items: center; justify-content: center`) and wrap the form in a `--surface` card with `--radius` border-radius and a `1px solid var(--border)` border in `ui/src/app/login/login.component.ts`
|
||||||
|
- [X] T032 [US4] Apply `color: var(--danger)` to field-level reactive-form validation error `<span>` elements for both username and password fields, and to the `errorMessage` server error paragraph below the form in `ui/src/app/login/login.component.ts`
|
||||||
|
- [X] T033 [US4] Change submit button label to "Signing in…" and add `disabled` attribute while `loading = true`; style the button with `--accent` background and `--accent-text` foreground matching other views in `ui/src/app/login/login.component.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Login page is visually consistent with the rest of the app and communicates all form states.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: User Story 5 — App Shell Is Consistent (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Every page shares a 48 px fixed-height header with the app name on the left and the sign-out control on the right (when authenticated). The header uses `--surface` background and `--border` bottom border and does not reflow page content on auth state change.
|
||||||
|
|
||||||
|
**Independent Test**: Log in and navigate between library, upload, and detail — confirm identical 48 px header on all pages. Log out — confirm sign-out control disappears but header height is unchanged. Visit library without logging in — confirm header is present but sign-out control absent.
|
||||||
|
|
||||||
|
### Tests for User Story 5 (TDD — write first, confirm failure before T036) ⚠️
|
||||||
|
|
||||||
|
- [X] T034 [US5] Add component tests for header 48 px height, sign-out button visibility when authenticated, sign-out button absence when unauthenticated, sign-out action redirecting to `/login`, and header height unchanged between auth states in `ui/src/app/app.component.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 5
|
||||||
|
|
||||||
|
- [X] T035 [P] [US5] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/app.component.ts`
|
||||||
|
- [X] T036 [US5] Style the header `<header>` element with `height: 48px`, `background: var(--surface)`, `border-bottom: 1px solid var(--border)`, and a flex layout placing the app name text on the left and the sign-out button on the right; use `color: var(--text-muted)` and a token-based hover state for the sign-out button; ensure `height` is declared on the host element so the 48 px is preserved regardless of sign-out button visibility in `ui/src/app/app.component.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: All five views share a coherent shell. Application feels like one product.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Linting gate, build validation, responsive checks, and visual acceptance walk-through across all milestones.
|
||||||
|
|
||||||
|
- [X] T037 [P] Run `ng lint` in `ui/` and confirm zero ESLint and Prettier violations across all modified component files (§7.3 gate)
|
||||||
|
- [X] T038 [P] Run `ng build` in `ui/` and confirm zero TypeScript or template errors across all modified components
|
||||||
|
- [ ] T039 [P] Run `ng test --watch=false` in `ui/` (or equivalent build-time check) and confirm all new component spec tests pass with zero regressions
|
||||||
|
- [ ] T040 [P] Verify upload form layout at 375 px viewport (Chrome DevTools device toolbar → iPhone SE): confirm no horizontal scrollbar and all form elements are usable in `ui/src/app/upload/upload.component.ts`
|
||||||
|
- [ ] T041 [P] Verify detail page layout at 375 px viewport: confirm image, tags, and (when authenticated) owner actions are all visible without horizontal overflow in `ui/src/app/detail/detail.component.ts`
|
||||||
|
- [ ] T042 Walk through `specs/005-ui-polish/quickstart.md` scenarios M1–M6 in a running `docker compose up` stack and confirm every visual acceptance criterion passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on Phase 1 — **BLOCKS all user story phases**
|
||||||
|
- **User Stories (Phases 3–7)**: ALL depend on Phase 2 (T004 gate); independent of each other
|
||||||
|
- **Polish (Phase 8)**: Depends on all desired user story phases completing
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 Library (Phase 3, P1)**: Can start after Phase 2 — no dependency on US2–US5
|
||||||
|
- **US2 Upload (Phase 4, P1)**: Can start after Phase 2 — no dependency on US1, US3–US5
|
||||||
|
- **US3 Detail (Phase 5, P1)**: Can start after Phase 2 — no dependency on US1–US2, US4–US5
|
||||||
|
- **US4 Login (Phase 6, P2)**: Can start after Phase 2 — no dependency on US1–US3, US5
|
||||||
|
- **US5 App Shell (Phase 7, P2)**: Can start after Phase 2 — best done last as it wraps all views, but technically independent
|
||||||
|
|
||||||
|
### Within Each User Story Phase
|
||||||
|
|
||||||
|
1. Write component spec tests first (TDD) — they MUST fail before implementation
|
||||||
|
2. Token replacement task [P] can run alongside test writing (different files)
|
||||||
|
3. Implementation tasks follow in sequence (each new feature depends on the test that exercises it)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Execution Examples
|
||||||
|
|
||||||
|
### Running US1 (Library) startup in parallel
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Start simultaneously after Phase 2 completes:
|
||||||
|
Task T005: Write library component tests (spec file)
|
||||||
|
Task T006: Apply CSS tokens to library component styles
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running all three P1 stories in parallel (Phase 3, 4, 5)
|
||||||
|
|
||||||
|
```text
|
||||||
|
# All can start simultaneously after T004 (Phase 2 gate):
|
||||||
|
Phase 3 (US1): T005 → T006/T007 → T008 → T009 → T010 → T011 → T012 → T013
|
||||||
|
Phase 4 (US2): T014 → T015/T016 → T017 → T018 → T019 → T020
|
||||||
|
Phase 5 (US3): T021 → T022/T023 → T024 → T025 → T026 → T027 → T028
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (US1 + US2 + US3 — all P1 stories)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Baseline verification
|
||||||
|
2. Complete Phase 2: Design token layer (CRITICAL gate)
|
||||||
|
3. Complete Phases 3, 4, 5 in parallel or sequence (all P1)
|
||||||
|
4. **STOP and VALIDATE**: Run quickstart.md M1–M4 scenarios
|
||||||
|
5. Add Phase 6 (US4 Login) and Phase 7 (US5 Shell) for complete polish
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Phase 1 + Phase 2 → Token layer live (no visible change)
|
||||||
|
2. Phase 3 (US1) → Library feels complete → Demo
|
||||||
|
3. Phase 4 (US2) → Upload flow polished → Demo
|
||||||
|
4. Phase 5 (US3) → Detail page organised → Demo
|
||||||
|
5. Phase 6 (US4) + Phase 7 (US5) → Full design system applied → Final demo
|
||||||
|
6. Phase 8 → Lint clean, build clean, all tests pass, quickstart validated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` tasks have no file conflicts with other concurrent `[P]` tasks in the same phase
|
||||||
|
- TDD order is mandatory: spec tests must be written and confirmed failing before implementation tasks
|
||||||
|
- All five component files are standalone Angular components — changes are isolated
|
||||||
|
- `ng build` is the type-check gate; Karma tests require the full Docker stack for browser runner
|
||||||
|
- No new npm dependencies are introduced in any task
|
||||||
|
- Commit after each milestone (M1–M6) for clean rollback points
|
||||||
34
specs/006-header-nav-signout/checklists/requirements.md
Normal file
34
specs/006-header-nav-signout/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Header Navigation & Sign-Out Destination
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-05-03
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [X] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [X] Focused on user value and business needs
|
||||||
|
- [X] Written for non-technical stakeholders
|
||||||
|
- [X] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [X] Requirements are testable and unambiguous
|
||||||
|
- [X] Success criteria are measurable
|
||||||
|
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [X] All acceptance scenarios are defined
|
||||||
|
- [X] Edge cases are identified
|
||||||
|
- [X] Scope is clearly bounded
|
||||||
|
- [X] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [X] All functional requirements have clear acceptance criteria
|
||||||
|
- [X] User scenarios cover primary flows
|
||||||
|
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [X] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Both changes are small, independent, and clearly bounded. Spec is ready for `/speckit-plan`.
|
||||||
25
specs/006-header-nav-signout/plan.md
Normal file
25
specs/006-header-nav-signout/plan.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Implementation Plan: Header Navigation & Sign-Out Destination
|
||||||
|
|
||||||
|
**Branch**: `006-header-nav-signout` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Two targeted changes to `ui/src/app/app.component.ts`:
|
||||||
|
1. Wrap the header app-name text in a router link to `/`.
|
||||||
|
2. Change the post-sign-out navigation target from `/login` to `/`.
|
||||||
|
|
||||||
|
No API changes. No new dependencies. One component file affected.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5 / Angular 19 (standalone components)
|
||||||
|
**Affected files**: `ui/src/app/app.component.ts`, `ui/src/app/app.component.spec.ts`
|
||||||
|
**Testing**: Karma / Jasmine
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
| Principle | Status |
|
||||||
|
|-----------|--------|
|
||||||
|
| §2.1 Strict separation of concerns | ✅ UI-only change |
|
||||||
|
| §5.1 TDD non-negotiable | ✅ Tests written first |
|
||||||
|
| §7.3 Linting non-optional | ✅ ng lint gate in tasks |
|
||||||
69
specs/006-header-nav-signout/spec.md
Normal file
69
specs/006-header-nav-signout/spec.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Feature Specification: Header Navigation & Sign-Out Destination
|
||||||
|
|
||||||
|
**Feature Branch**: `006-header-nav-signout`
|
||||||
|
**Created**: 2026-05-03
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Simple updates to the UI. Site title in the header should link to the base URL so that it's quick to get back to the main grid view from any sub-page now or in the future. When a user signs out, they should be sent back to the grid view instead of the sign in form."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 — Header Title Links to Grid (Priority: P1)
|
||||||
|
|
||||||
|
The owner (or any visitor) is on a sub-page — an image detail page, the upload form, or any future page — and wants to return to the main image grid. Clicking the application name in the header takes them there immediately, without needing the browser back button or a dedicated navigation link.
|
||||||
|
|
||||||
|
**Why this priority**: The header title is always visible on every page and is the most natural home-navigation affordance. Making it functional costs almost nothing and benefits every session.
|
||||||
|
|
||||||
|
**Independent Test**: Open any sub-page (e.g., an image detail page). Click the application title in the header. Confirm the image grid loads. Works identically whether logged in or not.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user is on any page other than the grid, **When** they click the application title in the header, **Then** they are taken to the image grid view.
|
||||||
|
2. **Given** the user is already on the grid view, **When** they click the application title, **Then** the page either reloads the grid or stays on it — no error, no blank page.
|
||||||
|
3. **Given** the user is not logged in and is on a public detail page, **When** they click the application title, **Then** they are taken to the grid (which is publicly visible) without being redirected to login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 — Sign Out Lands on Grid (Priority: P1)
|
||||||
|
|
||||||
|
The owner signs out of the application and is returned to the image grid rather than the login page. Since the grid is publicly accessible, there is no need to force a redirect to login — the owner can choose to sign back in if they wish.
|
||||||
|
|
||||||
|
**Why this priority**: Sending a signed-out user to the login page is unnecessary friction for a personal tool where the grid content is public. It also makes the sign-out flow feel punitive rather than neutral.
|
||||||
|
|
||||||
|
**Independent Test**: Log in and sign out from the header. Confirm the image grid is shown, not the login form. Confirm the grid shows images in read-only mode (no write controls).
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user is signed in, **When** they click the sign-out control, **Then** their session ends and they are taken to the image grid view.
|
||||||
|
2. **Given** the user has landed on the grid after signing out, **When** they view the page, **Then** tag-edit and delete controls are not shown (consistent with unauthenticated behaviour already in place).
|
||||||
|
3. **Given** the user signs out from a sub-page (e.g., detail page), **When** the sign-out completes, **Then** they are taken to the grid — not to the page they were on, and not to the login form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens if the grid is unavailable when the user clicks the title? The navigation attempt is made; any existing error-state handling on the grid covers this.
|
||||||
|
- What if a future page introduces an auth-required route? The header title links to the grid unconditionally; auth guards on specific routes handle their own redirects independently.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The application title displayed in the persistent header MUST be a navigable link that takes the user to the image grid view from any page in the application.
|
||||||
|
- **FR-002**: The title link MUST be accessible to both authenticated and unauthenticated users; it MUST NOT trigger a login redirect.
|
||||||
|
- **FR-003**: After a user successfully signs out, the application MUST navigate them to the image grid view.
|
||||||
|
- **FR-004**: The sign-out destination MUST be the grid view regardless of which page the user was on when they signed out.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: From any page, the image grid is reachable in exactly one click via the header title — no intermediate pages or redirects.
|
||||||
|
- **SC-002**: After signing out, the user sees the image grid (not the login page) in 100% of sign-out flows.
|
||||||
|
- **SC-003**: The title link functions correctly for both authenticated and unauthenticated sessions — verified across the grid, detail, and upload pages.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The image grid at the root URL is publicly accessible without authentication (confirmed: existing behaviour shows images to unauthenticated visitors).
|
||||||
|
- The application title ("Reactbin") is already rendered as a text element in the persistent header from the UI polish work; this spec adds navigation behaviour to it, not a new visual element.
|
||||||
|
- No change is made to the login redirect behaviour for protected routes (e.g., navigating directly to `/upload` while logged out still redirects to login as before).
|
||||||
|
- The sign-out action clears the session as already implemented; only the post-sign-out destination changes.
|
||||||
15
specs/006-header-nav-signout/tasks.md
Normal file
15
specs/006-header-nav-signout/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Tasks: Header Navigation & Sign-Out Destination
|
||||||
|
|
||||||
|
## Phase 1: Tests (TDD — write first)
|
||||||
|
|
||||||
|
- [X] T001 Add component tests for header title routerLink to `/` and sign-out navigation to `/` in `ui/src/app/app.component.spec.ts`
|
||||||
|
|
||||||
|
## Phase 2: Implementation
|
||||||
|
|
||||||
|
- [X] T002 Wrap the `.app-name` span in a `routerLink="/"` anchor in `ui/src/app/app.component.ts`
|
||||||
|
- [X] T003 Change `onLogout()` navigation target from `/login` to `/` in `ui/src/app/app.component.ts`
|
||||||
|
|
||||||
|
## Phase 3: Validation
|
||||||
|
|
||||||
|
- [X] T004 Run `ng lint` in `ui/` — zero violations
|
||||||
|
- [X] T005 Run `ng build` in `ui/` — zero errors
|
||||||
34
specs/007-tag-browser/checklists/requirements.md
Normal file
34
specs/007-tag-browser/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Tag Browser
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-05-06
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [X] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [X] Focused on user value and business needs
|
||||||
|
- [X] Written for non-technical stakeholders
|
||||||
|
- [X] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [X] Requirements are testable and unambiguous
|
||||||
|
- [X] Success criteria are measurable
|
||||||
|
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [X] All acceptance scenarios are defined
|
||||||
|
- [X] Edge cases are identified
|
||||||
|
- [X] Scope is clearly bounded
|
||||||
|
- [X] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [X] All functional requirements have clear acceptance criteria
|
||||||
|
- [X] User scenarios cover primary flows
|
||||||
|
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [X] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Feature is small and well-bounded — two P1 stories (browse + navigate) form the core MVP; P2 (discoverability link) is a natural follow-on. No clarifications needed. Ready for `/speckit-plan`.
|
||||||
58
specs/007-tag-browser/contracts/tags-endpoint.md
Normal file
58
specs/007-tag-browser/contracts/tags-endpoint.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Contract: GET /api/v1/tags (enhanced)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Extends the existing tags list endpoint with two new optional query parameters. All existing behaviour is preserved when the new parameters are omitted.
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|------------|---------|----------|-------------|
|
||||||
|
| `q` | string | — | Filter tags by name prefix (existing) |
|
||||||
|
| `limit` | integer | 100 | Max items to return; capped at 200 (existing) |
|
||||||
|
| `offset` | integer | 0 | Pagination offset (existing) |
|
||||||
|
| `sort` | string | `name` | Sort order: `name` (alphabetical asc) or `count_desc` (image count descending, alphabetical secondary) |
|
||||||
|
| `min_count`| integer | 0 | Exclude tags with fewer than this many images. Use `1` to hide zero-count tags. |
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Not required. Public endpoint.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "id": "uuid", "name": "string", "image_count": 0 }
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"limit": 100,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No changes to the response shape.
|
||||||
|
|
||||||
|
## Tag Browser Usage
|
||||||
|
|
||||||
|
The tag browser component calls:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tags?sort=count_desc&min_count=1&limit=500
|
||||||
|
```
|
||||||
|
|
||||||
|
`limit=500` is a safe upper bound for a personal library. If `total` exceeds `limit` in the response, the component logs a warning but renders what it received (no pagination UI required at this scale).
|
||||||
|
|
||||||
|
## Library Autocomplete Usage (unchanged)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tags?q=<prefix>&limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses neither `sort` nor `min_count` — default behaviour is unchanged.
|
||||||
23
specs/007-tag-browser/data-model.md
Normal file
23
specs/007-tag-browser/data-model.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Data Model: Tag Browser
|
||||||
|
|
||||||
|
No schema changes are required for this feature. All data needed to power the tag browser already exists.
|
||||||
|
|
||||||
|
## Derived Entity: Tag with Count
|
||||||
|
|
||||||
|
The tag browser displays a **read-only, derived view** of existing data:
|
||||||
|
|
||||||
|
| Field | Source | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `name` | `tags.name` | Lowercase, normalised string |
|
||||||
|
| `image_count` | `COUNT(image_tags.image_id) WHERE image_tags.tag_id = tags.id` | Computed at query time |
|
||||||
|
|
||||||
|
This is exactly the shape already returned by `GET /api/v1/tags` as `{"id", "name", "image_count"}`.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
The query in `TagRepository.list_tags()` gains two optional behaviours:
|
||||||
|
|
||||||
|
1. **Sort by count descending** — adds `ORDER BY image_count DESC, name ASC` (count-desc primary, alphabetical secondary) instead of the current `ORDER BY name ASC`.
|
||||||
|
2. **Exclude zero-count tags** — adds `HAVING image_count > 0` (or equivalent `WHERE` on the subquery) when `min_count=1` is requested.
|
||||||
|
|
||||||
|
No new tables, columns, indexes, or migrations are needed.
|
||||||
96
specs/007-tag-browser/plan.md
Normal file
96
specs/007-tag-browser/plan.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Implementation Plan: Tag Browser
|
||||||
|
|
||||||
|
**Branch**: `007-tag-browser` | **Date**: 2026-05-06 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `specs/007-tag-browser/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a `/tags` page that lists every tag with its image count, sorted by popularity, each linking to the filtered library view. Requires: (1) two new query parameters on the existing `/api/v1/tags` endpoint to support sort-by-count and zero-count exclusion, (2) query-parameter-driven filtering on the library route so tag browser links deep-link correctly, (3) a new `TagBrowserComponent`, and (4) a navigation entry point from the library.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Python 3.12 (API), TypeScript strict / Angular 19 (UI)
|
||||||
|
**Primary Dependencies**: FastAPI, SQLAlchemy 2.x async, Angular standalone components
|
||||||
|
**Storage**: PostgreSQL (read-only for this feature — no schema changes)
|
||||||
|
**Testing**: pytest + httpx (API integration), Jasmine/Karma (Angular unit)
|
||||||
|
**Target Platform**: Web (same stack as all prior features)
|
||||||
|
**Project Type**: Web service + SPA
|
||||||
|
**Performance Goals**: Tag list page load perceived as instant (same bar as library)
|
||||||
|
**Constraints**: No schema changes; no new dependencies; counts must be accurate at page-load time
|
||||||
|
**Scale/Scope**: Personal library — tag count is bounded; no pagination UI needed for tag browser, but the API call uses existing paginated endpoint
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| §2.1 Strict separation of concerns | ✅ | UI calls API; API owns all DB logic |
|
||||||
|
| §2.5 Repository layer | ✅ | All query changes go in `TagRepository.list_tags()` |
|
||||||
|
| §2.6 No speculative abstraction | ✅ | No new interfaces; extends existing repo method |
|
||||||
|
| §3.1 API versioning `/api/v1/` | ✅ | Modifying existing versioned endpoint |
|
||||||
|
| §3.2 OpenAPI as contract | ✅ | New query params documented via FastAPI |
|
||||||
|
| §3.3 Error shape | ✅ | No new error paths |
|
||||||
|
| §3.4 Pagination | ✅ | Existing endpoint already paginates; tag browser fetches with `limit=500` (safe upper bound for a personal library) |
|
||||||
|
| §4.1 Tags lowercase normalised | ✅ | No change to tag creation/normalisation |
|
||||||
|
| §5.1 TDD non-negotiable | ✅ | Tests written before implementation in tasks |
|
||||||
|
| §5.3 Tests colocated | ✅ | API tests in `api/tests/`, Angular spec next to component |
|
||||||
|
| §6 Tech stack | ✅ | No new dependencies |
|
||||||
|
| §7.3 Linting/formatting enforced | ✅ | `ng lint` + `ruff` gates in tasks |
|
||||||
|
|
||||||
|
**Gate**: All principles pass. Phase 0 research not required — no unknowns.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/007-tag-browser/
|
||||||
|
├── plan.md ← this file
|
||||||
|
├── research.md ← not required (no unknowns)
|
||||||
|
├── data-model.md ← see below (derived data, no schema changes)
|
||||||
|
├── contracts/
|
||||||
|
│ └── tags-endpoint.md ← enhanced GET /api/v1/tags contract
|
||||||
|
└── tasks.md ← generated by /speckit-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code Changes
|
||||||
|
|
||||||
|
```text
|
||||||
|
api/
|
||||||
|
├── app/
|
||||||
|
│ ├── repositories/
|
||||||
|
│ │ └── tag_repo.py ← extend list_tags() with sort + min_count params
|
||||||
|
│ └── routers/
|
||||||
|
│ └── tags.py ← expose sort + min_count as query params
|
||||||
|
└── tests/
|
||||||
|
├── integration/
|
||||||
|
│ └── test_tags.py ← new tests: sort=count_desc, min_count=1
|
||||||
|
└── unit/
|
||||||
|
└── test_tags.py ← unit tests for repo sort/filter logic (if applicable)
|
||||||
|
|
||||||
|
ui/src/app/
|
||||||
|
├── tags/
|
||||||
|
│ ├── tags.component.ts ← new TagBrowserComponent
|
||||||
|
│ └── tags.component.spec.ts ← component tests
|
||||||
|
├── services/
|
||||||
|
│ └── tag.service.ts ← add sort param to list() method
|
||||||
|
├── library/
|
||||||
|
│ └── library.component.ts ← read ?tags= query param on init; add /tags nav link
|
||||||
|
└── app.routes.ts ← add /tags route (lazy-loaded)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
### API: extend existing endpoint rather than add new one
|
||||||
|
|
||||||
|
The `/api/v1/tags` endpoint already returns tags with `image_count`. Two new optional query parameters make it serve the tag browser without breaking existing callers (the library autocomplete uses the endpoint unchanged):
|
||||||
|
|
||||||
|
- `sort`: `name` (default, current behaviour) | `count_desc` (tag browser use case)
|
||||||
|
- `min_count`: integer, default `0` (all tags, current behaviour) | `1` (excludes zero-count tags)
|
||||||
|
|
||||||
|
### Library: query param deep-linking
|
||||||
|
|
||||||
|
The library component currently manages `activeFilters` in memory only. Adding `?tags=cat,funny` query parameter support (read on `ngOnInit` via `ActivatedRoute`) allows the tag browser to link directly to a pre-filtered library view. The library already uses `addFilter()` / `applyFilter()` internally — reading from query params simply pre-populates `activeFilters` before the initial `load()` call. Navigation from within the library that changes filters should update the URL to keep it shareable, but that is a polish concern — minimum requirement is that arriving at `/?tags=cat` shows the cat-filtered library.
|
||||||
|
|
||||||
|
### Tag browser UI layout
|
||||||
|
|
||||||
|
A responsive chip/card grid sorted by count descending. Each item shows the tag name and count. Each item is a `routerLink` to `/?tags=<name>`. Follows the existing design token system (`--surface`, `--accent`, `--chip` styles). Empty state if no tags exist.
|
||||||
45
specs/007-tag-browser/quickstart.md
Normal file
45
specs/007-tag-browser/quickstart.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Quickstart: Tag Browser
|
||||||
|
|
||||||
|
## Verifying the feature end-to-end
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker stack running (`docker compose up`)
|
||||||
|
- At least 3 images uploaded with different tags (e.g., `cat`, `funny`, `reaction`)
|
||||||
|
- At least one image with two tags (e.g., both `cat` and `funny`)
|
||||||
|
|
||||||
|
### Scenario 1 — Tag browser shows all tags with correct counts
|
||||||
|
|
||||||
|
1. Open the app (not logged in).
|
||||||
|
2. Navigate to `/tags`.
|
||||||
|
3. **Expected**: A list of tags is shown. Each tag displays the number of images with that tag. Tags are ordered from most images to fewest.
|
||||||
|
4. Verify: Count next to `cat` matches the number of images actually tagged `cat`.
|
||||||
|
5. Verify: Tags with zero images are not shown.
|
||||||
|
|
||||||
|
### Scenario 2 — Clicking a tag navigates to the filtered library
|
||||||
|
|
||||||
|
1. On the `/tags` page, click the `cat` tag.
|
||||||
|
2. **Expected**: Navigated to the library (`/`) showing only images tagged `cat`.
|
||||||
|
3. Verify: The active filter chip shows `cat` in the library.
|
||||||
|
|
||||||
|
### Scenario 3 — Library page links to tag browser
|
||||||
|
|
||||||
|
1. Navigate to `/` (library, logged in or out).
|
||||||
|
2. **Expected**: A link or button labelled "Browse by tag" (or similar) is visible.
|
||||||
|
3. Click it.
|
||||||
|
4. **Expected**: The tag browser page loads.
|
||||||
|
|
||||||
|
### Scenario 4 — Empty state
|
||||||
|
|
||||||
|
1. If the library has no images at all, navigate to `/tags`.
|
||||||
|
2. **Expected**: An empty state message is shown rather than a blank page or error.
|
||||||
|
|
||||||
|
### API verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sorted by count, zero-count tags excluded
|
||||||
|
curl http://localhost:8000/api/v1/tags?sort=count_desc&min_count=1
|
||||||
|
|
||||||
|
# Existing autocomplete behaviour unchanged
|
||||||
|
curl http://localhost:8000/api/v1/tags?q=ca&limit=10
|
||||||
|
```
|
||||||
95
specs/007-tag-browser/spec.md
Normal file
95
specs/007-tag-browser/spec.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Feature Specification: Tag Browser
|
||||||
|
|
||||||
|
**Feature Branch**: `007-tag-browser`
|
||||||
|
**Created**: 2026-05-06
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "A page that lists all tags with their image counts so that users don't have to guess at searches to find image categories/tags"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 — Browse All Tags (Priority: P1)
|
||||||
|
|
||||||
|
The owner (or any visitor) wants to know what categories of images exist in the library without having to type guesses into a search box. They navigate to the tag browser page and see every tag in the library alongside the number of images associated with it, sorted so the most-used tags appear first.
|
||||||
|
|
||||||
|
**Why this priority**: This is the entire purpose of the feature. A visitor who doesn't know what tags exist has no way to discover them otherwise — the tag filter on the library page only helps when you already know what to type.
|
||||||
|
|
||||||
|
**Independent Test**: Navigate to the tag browser page without being logged in. Confirm every tag in the library is shown with its image count, ordered from highest to lowest count.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the library contains images with various tags, **When** a visitor opens the tag browser page, **Then** every tag in the library is listed with the number of images that carry that tag.
|
||||||
|
2. **Given** the tag list is displayed, **When** the visitor looks at the ordering, **Then** tags with more images appear before tags with fewer images.
|
||||||
|
3. **Given** the visitor is not logged in, **When** they open the tag browser page, **Then** the page loads and displays tags without requiring authentication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 — Navigate from Tag to Library (Priority: P1)
|
||||||
|
|
||||||
|
A visitor sees a tag they are interested in and wants to view the images in that category. Clicking a tag on the tag browser page takes them directly to the library filtered to that tag, without requiring them to retype it.
|
||||||
|
|
||||||
|
**Why this priority**: The tag browser page has no value as a dead end. Each tag must be a link to the filtered library view — that is the core action the page enables. Treated as P1 because the browse and navigate actions together form the minimum useful feature.
|
||||||
|
|
||||||
|
**Independent Test**: Click any tag on the tag browser page. Confirm the library view opens showing only images carrying that tag.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the tag browser is showing a list of tags, **When** the visitor clicks a tag, **Then** they are taken to the library view filtered to show only images with that tag.
|
||||||
|
2. **Given** the visitor clicks a tag with a count of one, **When** the library loads, **Then** exactly one image is shown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 — Reach the Tag Browser from the Library (Priority: P2)
|
||||||
|
|
||||||
|
The owner is browsing the image library and wants to switch to the tag browser to explore by category. A navigation element on the library page makes the tag browser discoverable without requiring the visitor to type the URL directly.
|
||||||
|
|
||||||
|
**Why this priority**: The tag browser is only useful if visitors can find it. A direct entry point from the library is the most natural discovery path; however, the core value of browsing and navigating tags is independently deliverable without it.
|
||||||
|
|
||||||
|
**Independent Test**: Load the library page. Confirm a visible link or button leads to the tag browser and navigates correctly when clicked.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the visitor is on the library page, **When** they look for a way to browse by tag, **Then** a visible link or button leads them to the tag browser.
|
||||||
|
2. **Given** the visitor clicks that link, **When** the tag browser loads, **Then** all tags and counts are shown as expected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What if there are no tags in the library at all? The page displays an appropriate empty state message rather than a blank page or error.
|
||||||
|
- What if a tag has been removed from all images (count reaches zero)? Tags with a count of zero are not shown on the tag browser page.
|
||||||
|
- What if the library contains a very large number of distinct tags? The page renders all of them without truncation; pagination is not required at personal library scale.
|
||||||
|
- What if two tags share the same count? An alphabetical secondary sort is acceptable — no specific tie-breaking order was requested.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The application MUST provide a dedicated tag browser page accessible at a stable URL.
|
||||||
|
- **FR-002**: The tag browser page MUST display every tag that exists in the library with at least one associated image, each shown with its current image count.
|
||||||
|
- **FR-003**: Tags with an image count of zero MUST NOT appear on the tag browser page.
|
||||||
|
- **FR-004**: Tags MUST be ordered from highest image count to lowest image count.
|
||||||
|
- **FR-005**: Each tag on the tag browser page MUST be a navigable link that takes the visitor to the library view filtered to that tag.
|
||||||
|
- **FR-006**: The tag browser page MUST be publicly accessible without authentication.
|
||||||
|
- **FR-007**: The library page MUST include a discoverable navigation element leading to the tag browser page.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Tag with count**: A tag label paired with the number of images currently carrying that tag. No new stored data — counts are derived from existing image–tag relationships at read time.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Every tag present in the library with at least one image appears on the tag browser page — 0% omission rate.
|
||||||
|
- **SC-002**: The image count displayed next to each tag matches the actual number of images with that tag — 100% accuracy.
|
||||||
|
- **SC-003**: Clicking any tag on the tag browser navigates to the correctly filtered library view in 100% of cases.
|
||||||
|
- **SC-004**: The tag browser page loads successfully without authentication — verified by opening it while logged out.
|
||||||
|
- **SC-005**: A visitor can go from the library page to the tag browser and on to a filtered library view in three interactions or fewer.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Tags are already a first-class concept in the library — images can have multiple tags and the data needed to derive counts already exists. No schema changes are required.
|
||||||
|
- The library page already supports filtering by tag (via the existing search/filter mechanism); the tag browser links into that existing behaviour.
|
||||||
|
- Alphabetical secondary sort for equal-count tags is acceptable.
|
||||||
|
- Pagination of the tag list is out of scope for a personal image library.
|
||||||
|
- Creating, renaming, or deleting tags from the tag browser page is out of scope; it is a read-only view.
|
||||||
152
specs/007-tag-browser/tasks.md
Normal file
152
specs/007-tag-browser/tasks.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Tasks: Tag Browser
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/007-tag-browser/`
|
||||||
|
**Prerequisites**: plan.md ✅, spec.md ✅, data-model.md ✅, contracts/ ✅, quickstart.md ✅
|
||||||
|
|
||||||
|
**Tests**: TDD is non-negotiable (§5.1). Every implementation task is preceded by a failing-test task. Test tasks MUST be written and confirmed failing before the corresponding implementation task begins.
|
||||||
|
|
||||||
|
**Organization**: Foundational API + service changes first (block all stories), then one phase per user story.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel with other [P] tasks in the same phase
|
||||||
|
- **[Story]**: Which user story this task belongs to
|
||||||
|
- Exact file paths included in every task description
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
No new project structure required. The existing layout accommodates all changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational — API Enhancement & Service Update
|
||||||
|
|
||||||
|
**Purpose**: Extend `GET /api/v1/tags` with `sort` and `min_count` query parameters; update the Angular `TagService` to pass them. All three user stories depend on the API returning tags sorted by count with zero-count tags excluded.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T001 [P] Write failing API integration tests for `sort=count_desc` and `min_count=1` params in `api/tests/integration/test_tags.py` — assert response is ordered highest-count-first and excludes zero-count tags
|
||||||
|
- [X] T002 [P] Write failing spec for updated `TagService.list()` accepting `sort` and `minCount` params in `ui/src/app/services/tag.service.spec.ts` — final signature: `list(prefix = '', limit = 100, offset = 0, sort?: string, minCount?: number)`
|
||||||
|
- [X] T003 Extend `TagRepository.list_tags()` in `api/app/repositories/tag_repo.py` — add `sort: str = "name"` and `min_count: int = 0` params; apply `ORDER BY image_count DESC, name ASC` when `sort="count_desc"`; apply `HAVING image_count >= min_count` filter — run AFTER T001 (TDD)
|
||||||
|
- [X] T004 Expose `sort` and `min_count` as optional query params in `api/app/routers/tags.py` — pass through to `tag_repo.list_tags()` — run AFTER T003
|
||||||
|
- [X] T005 Update `TagService.list()` in `ui/src/app/services/tag.service.ts` — final signature: `list(prefix = '', limit = 100, offset = 0, sort?: string, minCount?: number)`; include `sort` and `min_count` in `HttpParams` when provided — run AFTER T002 (TDD)
|
||||||
|
|
||||||
|
**Execution order**: T001 ∥ T002 → T003 (after T001), T005 (after T002) → T004 (after T003)
|
||||||
|
|
||||||
|
**Checkpoint**: `GET /api/v1/tags?sort=count_desc&min_count=1` returns tags sorted by image count descending with zero-count tags excluded. `TagService.list()` passes the new params.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Browse All Tags (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: A `/tags` page that lists every tag (with count ≥ 1) sorted from most-used to least-used, with loading skeleton, empty state, and error state matching the existing design system.
|
||||||
|
|
||||||
|
**Independent Test**: Navigate to `/tags` while logged out. Confirm every tag with at least one image is shown with its count, ordered by count descending. Confirm the empty state appears when no tags exist.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T006 [US1] Write failing spec for `TagBrowserComponent` in `ui/src/app/tags/tags.component.spec.ts` covering: (a) skeleton shown while loading, (b) tag list rendered with name and count after load, (c) tags ordered by count descending, (d) empty state shown when tag list is empty, (e) error state shown on fetch failure with retry button, (f) each rendered tag element has an `href` of `/?tags=<tagname>` (FR-005 coverage), (g) component renders when `AuthService` is not present / user is unauthenticated (FR-006 coverage)
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T007 [US1] Create `TagBrowserComponent` in `ui/src/app/tags/tags.component.ts` — standalone component; on init call `tagService.list('', 500, 0, 'count_desc', 1)` (positional order matches T005 signature); display tag chips with name + count; each chip is a `routerLink="/"` with `[queryParams]="{tags: tag.name}"` so the href renders as `/?tags=<name>`; include skeleton loading state (reuse `.skeleton` class from global styles), empty state, and error state with retry; apply design tokens throughout
|
||||||
|
- [X] T008 [P] [US1] Add `/tags` lazy route to `ui/src/app/app.routes.ts` — load `TagBrowserComponent`; no auth guard (public route)
|
||||||
|
|
||||||
|
**Checkpoint**: `/tags` renders a sorted, filterable tag list visible without authentication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Navigate from Tag to Library (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Clicking a tag on the tag browser navigates to the library pre-filtered to that tag. Requires the library to read `?tags=<name>` from the URL on init and apply it as an active filter before the first image load.
|
||||||
|
|
||||||
|
**Independent Test**: Navigate directly to `/?tags=cat` in the browser. Confirm the library loads showing only images tagged `cat` and the `cat` chip appears in the active filter bar.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T009 [US2] Write failing spec for `LibraryComponent` reading `?tags=` query param in `ui/src/app/library/library.component.spec.ts` — assert that when the component initialises with `?tags=cat` in the URL, `activeFilters` contains `['cat']` and `imageService.list` is called with `['cat']`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T010 [US2] Update `LibraryComponent` in `ui/src/app/library/library.component.ts` — inject `ActivatedRoute`; in `ngOnInit`, read `snapshot.queryParamMap.get('tags')`; if present, split by comma, set `activeFilters` before calling `load()` so the first fetch is already filtered
|
||||||
|
|
||||||
|
**Checkpoint**: Navigating to `/?tags=cat` from the tag browser shows the correctly filtered library.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Tag Browser Discoverable from Library (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: A visible "Browse tags" link in the library page header navigates to `/tags`. Makes the tag browser discoverable without requiring the user to type the URL.
|
||||||
|
|
||||||
|
**Independent Test**: Load the library page. Confirm a link to `/tags` is visible in the header and navigates correctly when clicked.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T011 [US3] Write failing spec for library nav link to `/tags` in `ui/src/app/library/library.component.spec.ts` — assert a link element with `href="/tags"` is present in the rendered header
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T012 [US3] Add "Browse tags" `routerLink="/tags"` link to `LibraryComponent` header in `ui/src/app/library/library.component.ts` — place alongside the existing Upload button; style consistently with the existing header button pattern
|
||||||
|
|
||||||
|
**Checkpoint**: All three user stories are independently functional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [X] T013 [P] Run `ruff check api/app/ api/tests/` and fix any violations
|
||||||
|
- [X] T014 [P] Run `ng lint` in `ui/` — zero violations required
|
||||||
|
- [X] T015 Run `ng build` in `ui/` — zero errors required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 2 (Foundational)**: Blocks all user story phases — must complete first
|
||||||
|
- **Phase 3 (US1)**: Depends on Phase 2 — TagBrowserComponent needs the sorted tag endpoint
|
||||||
|
- **Phase 4 (US2)**: Depends on Phase 2 — library deep-link needs no API change, but should follow US1 for coherent testing
|
||||||
|
- **Phase 5 (US3)**: Depends on Phase 3 (needs the `/tags` route to exist for the link to be meaningful)
|
||||||
|
- **Phase 6 (Polish)**: Depends on all prior phases
|
||||||
|
|
||||||
|
### Within Phase 2
|
||||||
|
|
||||||
|
- T001 ∥ T002 (different repos, both write failing tests)
|
||||||
|
- T003 after T001 (TDD: failing test must exist first)
|
||||||
|
- T005 after T002 (TDD: failing test must exist first)
|
||||||
|
- T003 ∥ T005 (different repos, after their respective tests)
|
||||||
|
- T004 after T003 (router wraps repo)
|
||||||
|
|
||||||
|
### Execution Order (Phase 2)
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1 (parallel): T001, T002
|
||||||
|
Step 2 (parallel): T003 (after T001), T005 (after T002)
|
||||||
|
Step 3: T004 (after T003)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parallel Opportunities (Phases 3–5)
|
||||||
|
|
||||||
|
- T007 and T008 are parallel within Phase 3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP (US1 + US2 — both P1)
|
||||||
|
|
||||||
|
1. Complete Phase 2 (Foundational)
|
||||||
|
2. Complete Phase 3 (US1 — TagBrowserComponent)
|
||||||
|
3. Complete Phase 4 (US2 — library deep-link)
|
||||||
|
4. **Validate**: Navigate from tag browser → library → confirm pre-filtered results
|
||||||
|
5. Phases 5–6 add discoverability and polish
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
- After Phase 3: `/tags` page is live and usable (visitors can browse tags)
|
||||||
|
- After Phase 4: clicking a tag works end-to-end (browse → filtered library)
|
||||||
|
- After Phase 5: tag browser is discoverable from the library without typing the URL
|
||||||
|
- After Phase 6: lint and build clean, ready for merge
|
||||||
236
specs/008-postgres-integration-tests/plan.md
Normal file
236
specs/008-postgres-integration-tests/plan.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# Implementation Plan: PostgreSQL Integration Test Infrastructure
|
||||||
|
|
||||||
|
**Branch**: `master` | **Date**: 2026-05-06 | **Spec**: specs/008-postgres-integration-tests/spec.md
|
||||||
|
**Input**: Feature specification from `specs/008-postgres-integration-tests/spec.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Enforce the constitution's PostgreSQL mandate (§2.5, §5.2 v1.3.0) for integration tests. Three concrete deliverables: (1) a fast-fail guard in `conftest.py` that rejects non-PostgreSQL URLs before any test collects, (2) a `docker-compose.test.yml` that provides isolated `postgres-test` and `minio-test` services and an `api-test` runner, and (3) a `Makefile` + `.env.test.example` that document the canonical test commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Python 3.12, Docker Compose v2
|
||||||
|
**Primary Dependencies**: pytest, pytest-asyncio, asyncpg, SQLAlchemy 2.x (all already in `pyproject.toml [dev]`)
|
||||||
|
**Storage**: PostgreSQL 16-alpine (test instance), MinIO (test instance)
|
||||||
|
**Testing**: pytest — this feature *is* the test infrastructure change
|
||||||
|
**Target Platform**: Developer workstation (Linux/macOS) with Docker
|
||||||
|
**Project Type**: Infrastructure / developer-experience
|
||||||
|
**Performance Goals**: Guard exits in < 2 s; full integration suite continues to run in < 60 s
|
||||||
|
**Constraints**: Must not break the existing dev compose stack; no changes to application source code
|
||||||
|
**Scale/Scope**: One guard, one compose file, one Makefile, one env example
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| §2.5 Database abstraction — no alternative DB in integration tests | ✅ ENFORCED | This feature implements the enforcement |
|
||||||
|
| §5.1 TDD — failing test before implementation | ✅ | Guard itself is tested by running with a bad URL before adding the guard |
|
||||||
|
| §5.2 Test pyramid — integration tests use real PostgreSQL | ✅ ENFORCED | docker-compose.test.yml provides the real instance |
|
||||||
|
| §5.4 CI must pass before task is done | ✅ | Verified by running the full suite via compose |
|
||||||
|
| §6 Tech stack — asyncpg driver, Docker Compose | ✅ | No new technologies introduced |
|
||||||
|
| §7.1 One-command local start | ✅ | `docker compose -f docker-compose.test.yml run --rm api-test` |
|
||||||
|
| §7.2 Environment config via env vars | ✅ | .env.test.example documents all vars |
|
||||||
|
| §7.3 Linting not optional | ✅ | ruff will run as part of task validation |
|
||||||
|
|
||||||
|
No violations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/008-postgres-integration-tests/
|
||||||
|
├── plan.md ← this file
|
||||||
|
├── research.md ← decisions made above
|
||||||
|
├── spec.md ← feature specification
|
||||||
|
└── tasks.md ← generated by /speckit-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source changes
|
||||||
|
|
||||||
|
```text
|
||||||
|
# New files
|
||||||
|
docker-compose.test.yml ← isolated test services + api-test runner
|
||||||
|
.env.test.example ← documents test environment variables
|
||||||
|
Makefile ← test-unit / test-integration targets
|
||||||
|
|
||||||
|
# Modified files
|
||||||
|
api/tests/integration/conftest.py ← add postgresql+asyncpg:// dialect guard
|
||||||
|
```
|
||||||
|
|
||||||
|
No application source files (`api/app/`) are modified. No UI files are touched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Design
|
||||||
|
|
||||||
|
### 1. conftest.py — dialect guard
|
||||||
|
|
||||||
|
Add a module-level `pytest_configure` hook at the top of `api/tests/integration/conftest.py`. It resolves the database URL (same logic as the `engine` fixture: prefer `TEST_DATABASE_URL`, fall back to `settings.database_url`) and calls `pytest.exit()` if the scheme is not `postgresql+asyncpg`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def pytest_configure(config):
|
||||||
|
import os
|
||||||
|
db_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL", "")
|
||||||
|
if not db_url.startswith("postgresql+asyncpg://"):
|
||||||
|
pytest.exit(
|
||||||
|
"Integration tests require a PostgreSQL database "
|
||||||
|
"(postgresql+asyncpg://...). "
|
||||||
|
"Set TEST_DATABASE_URL or DATABASE_URL accordingly. "
|
||||||
|
f"Got: {db_url!r}",
|
||||||
|
returncode=1,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook runs before any fixture or collection, giving an immediate, unambiguous error.
|
||||||
|
|
||||||
|
**Note**: This guard goes in `api/tests/integration/conftest.py` only, not in `api/tests/conftest.py`, so that unit tests (which use no database) are unaffected.
|
||||||
|
|
||||||
|
### 2. docker-compose.test.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres-test:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: reactbin
|
||||||
|
POSTGRES_PASSWORD: reactbin
|
||||||
|
POSTGRES_DB: reactbin_test
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U reactbin"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio-test:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
- "9002:9000"
|
||||||
|
- "9003:9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio-init-test:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
minio-test:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
mc alias set local http://minio-test:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||||
|
mc mb --ignore-existing local/reactbin-test
|
||||||
|
"
|
||||||
|
|
||||||
|
api-test:
|
||||||
|
build:
|
||||||
|
context: ./api
|
||||||
|
environment:
|
||||||
|
TEST_DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres-test:5432/reactbin_test
|
||||||
|
DATABASE_URL: postgresql+asyncpg://reactbin:reactbin@postgres-test:5432/reactbin_test
|
||||||
|
S3_ENDPOINT_URL: http://minio-test:9000
|
||||||
|
S3_BUCKET_NAME: reactbin-test
|
||||||
|
S3_ACCESS_KEY_ID: minioadmin
|
||||||
|
S3_SECRET_ACCESS_KEY: minioadmin
|
||||||
|
S3_REGION: us-east-1
|
||||||
|
JWT_SECRET_KEY: test-secret-key-for-testing-only
|
||||||
|
OWNER_USERNAME: testowner
|
||||||
|
OWNER_PASSWORD: testpassword
|
||||||
|
API_BASE_URL: http://localhost:8000
|
||||||
|
MAX_UPLOAD_BYTES: "52428800"
|
||||||
|
depends_on:
|
||||||
|
postgres-test:
|
||||||
|
condition: service_healthy
|
||||||
|
minio-init-test:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
command: ["python", "-m", "pytest", "tests/", "-v"]
|
||||||
|
working_dir: /app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. .env.test.example
|
||||||
|
|
||||||
|
Documents the variables needed to run integration tests from the host (with postgres-test and minio-test already running via compose):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Integration test environment — used when running pytest directly on the host
|
||||||
|
# Start test services first: docker compose -f docker-compose.test.yml up -d postgres-test minio-test minio-init-test
|
||||||
|
|
||||||
|
TEST_DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@localhost:5433/reactbin_test
|
||||||
|
DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@localhost:5433/reactbin_test
|
||||||
|
S3_ENDPOINT_URL=http://localhost:9002
|
||||||
|
S3_BUCKET_NAME=reactbin-test
|
||||||
|
S3_ACCESS_KEY_ID=minioadmin
|
||||||
|
S3_SECRET_ACCESS_KEY=minioadmin
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
JWT_SECRET_KEY=test-secret-key-for-testing-only
|
||||||
|
OWNER_USERNAME=testowner
|
||||||
|
OWNER_PASSWORD=testpassword
|
||||||
|
API_BASE_URL=http://localhost:8000
|
||||||
|
MAX_UPLOAD_BYTES=52428800
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Makefile
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
.PHONY: test-unit test-integration
|
||||||
|
|
||||||
|
test-unit:
|
||||||
|
cd api && python -m pytest tests/unit/ -v
|
||||||
|
|
||||||
|
test-integration:
|
||||||
|
docker compose -f docker-compose.test.yml run --rm api-test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Breakdown
|
||||||
|
|
||||||
|
### Phase 1: Guard (FR-001) — US1
|
||||||
|
|
||||||
|
- Write a failing test: run `pytest api/tests/integration/` with `TEST_DATABASE_URL=sqlite+aiosqlite:///test.db` — confirm it does NOT exit early (test that the guard is absent)
|
||||||
|
- Add `pytest_configure` guard to `api/tests/integration/conftest.py`
|
||||||
|
- Verify: running with SQLite URL now exits immediately with the correct message
|
||||||
|
- Verify: running with a PostgreSQL URL proceeds normally
|
||||||
|
|
||||||
|
### Phase 2: Docker Compose test stack (FR-002, FR-003) — US2
|
||||||
|
|
||||||
|
- Write `docker-compose.test.yml` with `postgres-test`, `minio-test`, `minio-init-test`, `api-test`
|
||||||
|
- Run `docker compose -f docker-compose.test.yml run --rm api-test` — all tests pass
|
||||||
|
- Confirm dev stack (port 5432, 9000) is unaffected
|
||||||
|
|
||||||
|
### Phase 3: Documentation (FR-004, FR-005) — US3
|
||||||
|
|
||||||
|
- Write `.env.test.example`
|
||||||
|
- Write `Makefile` with `test-unit` and `test-integration`
|
||||||
|
- Verify `make test-unit` runs unit tests without Docker
|
||||||
|
- Verify `make test-integration` invokes the compose command
|
||||||
|
|
||||||
|
### Phase 4: Polish
|
||||||
|
|
||||||
|
- `ruff check api/app/ api/tests/` — zero violations
|
||||||
|
- `ng lint` is unaffected (no UI changes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No data model or API contracts
|
||||||
|
|
||||||
|
This feature touches only developer tooling. No new API endpoints, database schema changes, or UI components.
|
||||||
38
specs/008-postgres-integration-tests/quickstart.md
Normal file
38
specs/008-postgres-integration-tests/quickstart.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Quickstart: Integration Test Infrastructure
|
||||||
|
|
||||||
|
## Run the full integration test suite (Docker, recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.test.yml run --rm api-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Test services start automatically. The command exits with pytest's return code.
|
||||||
|
|
||||||
|
## Run unit tests only (no Docker required)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test-unit
|
||||||
|
# or directly:
|
||||||
|
cd api && python -m pytest tests/unit/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run integration tests from the host (test services must be running)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start test services
|
||||||
|
docker compose -f docker-compose.test.yml up -d postgres-test minio-test minio-init-test
|
||||||
|
|
||||||
|
# Copy and source test env vars
|
||||||
|
cp .env.test.example .env.test
|
||||||
|
export $(cat .env.test | xargs)
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cd api && python -m pytest tests/integration/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validate the guard works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TEST_DATABASE_URL=sqlite+aiosqlite:///test.db python -m pytest api/tests/integration/
|
||||||
|
# Expected: exits immediately with "Integration tests require postgresql+asyncpg://"
|
||||||
|
```
|
||||||
55
specs/008-postgres-integration-tests/research.md
Normal file
55
specs/008-postgres-integration-tests/research.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Research: PostgreSQL Integration Test Infrastructure
|
||||||
|
|
||||||
|
## Decision 1: How to enforce the PostgreSQL dialect in conftest.py
|
||||||
|
|
||||||
|
**Decision**: Add a `pytest_configure` hook (or a module-level guard in `conftest.py`) that calls `pytest.exit()` if the resolved database URL does not start with `postgresql+asyncpg://`.
|
||||||
|
|
||||||
|
**Rationale**: `pytest_configure` runs before collection, giving the clearest possible failure signal. A module-level assertion would also work but produces a less readable traceback. `pytest.exit()` with a human-readable message is the idiomatic approach.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- A custom pytest plugin in a separate file — unnecessary complexity for a one-liner guard.
|
||||||
|
- Raising an exception in the `engine` fixture — runs too late (after collection); developers see confusing fixture errors instead of a clear message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 2: Separate docker-compose.test.yml vs profiles in docker-compose.yml
|
||||||
|
|
||||||
|
**Decision**: Use a standalone `docker-compose.test.yml` at the repo root.
|
||||||
|
|
||||||
|
**Rationale**: Docker Compose profiles require the developer to remember `--profile test` on every command. A separate file is explicit and self-contained. The test file can define its own service names and ports without touching the dev compose file at all.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `docker-compose.yml` with a `test` profile — profile discovery is non-obvious; modifying the dev file risks breaking the dev stack.
|
||||||
|
- A `docker-compose.override.yml` — override files apply automatically to `docker compose up`, which is the opposite of what we want for tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 3: Port assignments for test services
|
||||||
|
|
||||||
|
**Decision**:
|
||||||
|
- `postgres-test`: host port 5433 (standard offset from dev 5432)
|
||||||
|
- `minio-test` API: host port 9002 (offset from dev 9000)
|
||||||
|
- `minio-test` console: host port 9003 (offset from dev 9001)
|
||||||
|
|
||||||
|
**Rationale**: Predictable offsets make it easy to remember. Developers running both stacks simultaneously won't hit port conflicts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 4: S3 isolation strategy for tests
|
||||||
|
|
||||||
|
**Decision**: The `api-test` service sets `S3_BUCKET_NAME=reactbin-test` pointing to the dedicated `minio-test` instance. The `minio-init-test` sidecar creates that bucket before tests run.
|
||||||
|
|
||||||
|
**Rationale**: The existing conftest already manages database isolation via `create_all` / `drop_all`. MinIO requires bucket pre-creation (same as dev). A dedicated test bucket on a dedicated test MinIO instance gives full isolation. No changes to application storage code are needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 5: Makefile vs shell scripts
|
||||||
|
|
||||||
|
**Decision**: A `Makefile` at the repo root with `test-unit` and `test-integration` targets.
|
||||||
|
|
||||||
|
**Rationale**: `make` is universally available on Linux/macOS developer machines. The targets are short wrappers that document the canonical test invocation. No build logic; just convenience aliases.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Shell scripts (`scripts/test.sh`) — no discoverability; `make help` is more ergonomic.
|
||||||
|
- `package.json` scripts — wrong tool for a Python/Docker project.
|
||||||
|
- `justfile` — not universally installed.
|
||||||
95
specs/008-postgres-integration-tests/spec.md
Normal file
95
specs/008-postgres-integration-tests/spec.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Feature Specification: PostgreSQL Integration Test Infrastructure
|
||||||
|
|
||||||
|
**Feature Branch**: `008-postgres-integration-tests`
|
||||||
|
**Created**: 2026-05-06
|
||||||
|
**Status**: Draft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Integration tests currently permit any SQLAlchemy-compatible database URL, including SQLite. This allowed a real production bug (incorrect `HAVING` without `GROUP BY`) to ship undetected because SQLite's permissive dialect did not reject it. The project constitution (§2.5, §5.2 v1.3.0) now explicitly mandates PostgreSQL for integration tests. This feature enforces that mandate with infrastructure and guardrails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Scenarios & Testing
|
||||||
|
|
||||||
|
### User Story 1 — Integration tests are enforced to run against PostgreSQL (Priority: P1)
|
||||||
|
|
||||||
|
A developer running `pytest` against a non-PostgreSQL database URL receives an immediate, descriptive failure before any test runs.
|
||||||
|
|
||||||
|
**Why this priority**: Directly addresses the production bug that prompted this feature. Without this, the constitution mandate has no teeth.
|
||||||
|
|
||||||
|
**Independent Test**: Set `TEST_DATABASE_URL=sqlite+aiosqlite:///test.db` and run `pytest api/tests/integration/`. Confirm pytest exits immediately with a message identifying the dialect problem and naming the required scheme.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** `TEST_DATABASE_URL` is set to a SQLite URL, **When** `pytest api/tests/integration/` is invoked, **Then** pytest exits before collecting any test with an error: `Integration tests require postgresql+asyncpg://`.
|
||||||
|
2. **Given** `DATABASE_URL` is unset and `TEST_DATABASE_URL` is unset, **When** pytest is invoked, **Then** pytest exits with a clear message about the missing database URL.
|
||||||
|
3. **Given** `TEST_DATABASE_URL` is a valid `postgresql+asyncpg://` URL, **When** pytest is invoked, **Then** tests collect and run normally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 — One-command integration test run against isolated services (Priority: P1)
|
||||||
|
|
||||||
|
A developer can run the entire integration test suite against dedicated, isolated PostgreSQL and MinIO instances with a single command.
|
||||||
|
|
||||||
|
**Why this priority**: Without this, the PostgreSQL requirement is mandated but impractical — developers have no easy way to satisfy it.
|
||||||
|
|
||||||
|
**Independent Test**: From the repo root with Docker available, run `docker compose -f docker-compose.test.yml run --rm api-test`. Confirm all integration tests pass, test containers start and stop cleanly, and dev database/bucket are untouched.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** Docker is running and dev services are stopped, **When** the test command is run, **Then** isolated `postgres-test` and `minio-test` services start, all tests run against them, and the command exits with pytest's return code.
|
||||||
|
2. **Given** dev services are running on their normal ports, **When** the test command is run, **Then** test services use different ports (5433, 9002/9003) and do not interfere with the dev stack.
|
||||||
|
3. **Given** any test data is written during the run, **When** the test run completes, **Then** all test schema is dropped (conftest teardown is unchanged).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 — Test infrastructure is documented (Priority: P2)
|
||||||
|
|
||||||
|
A developer new to the project can understand how to run unit tests vs integration tests without reading the source code.
|
||||||
|
|
||||||
|
**Independent Test**: Read `.env.test.example` and `Makefile`. Confirm all required environment variables are documented and `make test-unit` / `make test-integration` targets are present.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a fresh clone, **When** the developer reads `.env.test.example`, **Then** they see every variable needed to run integration tests outside Docker, with example values.
|
||||||
|
2. **Given** the Makefile, **When** the developer runs `make test-unit`, **Then** the pytest unit suite runs without requiring Docker.
|
||||||
|
3. **Given** the Makefile, **When** the developer runs `make test-integration`, **Then** the Docker Compose test command runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What if `TEST_DATABASE_URL` is set but malformed? — The guard should still catch a non-PostgreSQL scheme; asyncpg will raise its own error for a malformed URL.
|
||||||
|
- What if Docker is not available? — `make test-integration` fails at the Docker level with Docker's own error; the Makefile does not need to guard for this.
|
||||||
|
- What if the test PostgreSQL port (5433) is already in use? — Standard Docker port conflict error; no special handling needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: `conftest.py` MUST assert the resolved database URL starts with `postgresql+asyncpg://` and call `pytest.exit()` with a descriptive message before any test collects.
|
||||||
|
- **FR-002**: A `docker-compose.test.yml` MUST define isolated `postgres-test` (port 5433) and `minio-test` (ports 9002/9003) services and an `api-test` runner service.
|
||||||
|
- **FR-003**: The `api-test` service MUST set `TEST_DATABASE_URL` pointing to `postgres-test` and all S3 env vars pointing to `minio-test`.
|
||||||
|
- **FR-004**: A `.env.test.example` MUST document all environment variables required to run integration tests outside Docker.
|
||||||
|
- **FR-005**: A `Makefile` MUST provide `test-unit` and `test-integration` targets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- **SC-001**: Running `pytest api/tests/integration/` with a SQLite URL exits in under 2 seconds with a clear error message — no tests run.
|
||||||
|
- **SC-002**: `docker compose -f docker-compose.test.yml run --rm api-test` completes successfully with all integration tests passing.
|
||||||
|
- **SC-003**: Dev services (postgres on 5432, minio on 9000) are unaffected when the test command runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Docker Compose v2 (`docker compose`) is available in the developer environment.
|
||||||
|
- The existing `conftest.py` `engine` fixture (session-scoped `create_all` / `drop_all`) continues to handle schema lifecycle; no per-test transaction rollback mechanism is introduced.
|
||||||
|
- CI/CD pipeline configuration is out of scope for this feature.
|
||||||
113
specs/008-postgres-integration-tests/tasks.md
Normal file
113
specs/008-postgres-integration-tests/tasks.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Tasks: PostgreSQL Integration Test Infrastructure
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/008-postgres-integration-tests/`
|
||||||
|
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, quickstart.md ✅
|
||||||
|
|
||||||
|
**Tests**: TDD is non-negotiable (§5.1). For infrastructure tasks the "failing test" is a verification step that confirms the thing being built is absent before building it, then confirms it works after. Every user story has an explicit TDD red step before its implementation task.
|
||||||
|
|
||||||
|
**Organization**: No foundational blocking phase — all three user stories touch independent files and can proceed in order.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel with other [P] tasks in the same phase
|
||||||
|
- **[Story]**: Which user story this task belongs to
|
||||||
|
- Exact file paths included in every task description
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
No new project structure required. The existing layout accommodates all changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 — Dialect guard in conftest (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: `pytest api/tests/integration/` exits immediately with a clear message if the database URL is not `postgresql+asyncpg://`.
|
||||||
|
|
||||||
|
**Independent Test**: Run `TEST_DATABASE_URL=sqlite+aiosqlite:///test.db python -m pytest api/tests/integration/ -q` — command exits in < 2 s with the error message `Integration tests require postgresql+asyncpg://` and no tests are collected.
|
||||||
|
|
||||||
|
- [X] T001 [US1] Confirm guard is absent (TDD red): from `api/`, run `TEST_DATABASE_URL=sqlite+aiosqlite:///test.db python -m pytest tests/integration/ -q --co 2>&1 | head -20` — observe that tests ARE collected and note the count (guard not yet in place)
|
||||||
|
- [X] T002 [US1] Add `pytest_configure` hook to `api/tests/integration/conftest.py` — resolve URL via `os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL", "")`, call `pytest.exit("Integration tests require postgresql+asyncpg://...", returncode=1)` if URL does not start with `postgresql+asyncpg://`; place hook before any imports that depend on the database URL
|
||||||
|
- [X] T003 [US1] Verify guard works (TDD green): run `TEST_DATABASE_URL=sqlite+aiosqlite:///test.db python -m pytest api/tests/integration/ -q` — confirm immediate exit with the correct error message and zero tests collected; also confirm a valid `postgresql+asyncpg://` URL does not trigger the guard
|
||||||
|
|
||||||
|
**Checkpoint**: Dialect-mismatched test runs are blocked before any test collects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 — Docker Compose test stack (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: `docker compose -f docker-compose.test.yml run --rm api-test` runs the full integration suite against isolated PostgreSQL and MinIO services on different ports than the dev stack.
|
||||||
|
|
||||||
|
**Independent Test**: Run `docker compose -f docker-compose.test.yml run --rm api-test` from the repo root — all tests pass; verify `docker compose ps` shows dev services (if running) are unaffected on their original ports.
|
||||||
|
|
||||||
|
- [X] T004 [US2] Confirm compose file is absent (TDD red): run `test -f docker-compose.test.yml && echo EXISTS || echo ABSENT` — confirm output is `ABSENT`
|
||||||
|
- [X] T005 [US2] Create `docker-compose.test.yml` at the repo root with four services: `postgres-test` (image `postgres:16-alpine`, host port 5433, db `reactbin_test`), `minio-test` (image `minio/minio:latest`, host ports 9002/9003), `minio-init-test` (creates bucket `reactbin-test`, depends on `minio-test` healthy), and `api-test` (builds from `./api`, runs `python -m pytest tests/ -v`, depends on `postgres-test` healthy and `minio-init-test` completed, environment sets `TEST_DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@postgres-test:5432/reactbin_test`, `DATABASE_URL` to same value, and all S3 vars pointing to `minio-test:9000` with bucket `reactbin-test`) — follow exact design in `specs/008-postgres-integration-tests/plan.md`
|
||||||
|
- [X] T006 [US2] Verify compose stack (TDD green): run `docker compose -f docker-compose.test.yml run --rm api-test` — confirm all integration tests pass; confirm no errors about missing env vars or connection failures
|
||||||
|
|
||||||
|
**Checkpoint**: Full integration suite runs against real PostgreSQL via one command.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 3 — Test documentation (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: `.env.test.example` and `Makefile` document how to run both test tiers.
|
||||||
|
|
||||||
|
**Independent Test**: Read `.env.test.example` — all variables needed for integration tests are present with example values. Run `make test-unit` — pytest unit suite runs without Docker and passes.
|
||||||
|
|
||||||
|
- [X] T007 [P] [US3] Create `.env.test.example` at the repo root documenting all variables required to run integration tests outside Docker: `TEST_DATABASE_URL`, `DATABASE_URL`, `S3_ENDPOINT_URL`, `S3_BUCKET_NAME`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_REGION`, `JWT_SECRET_KEY`, `OWNER_USERNAME`, `OWNER_PASSWORD`, `API_BASE_URL`, `MAX_UPLOAD_BYTES` — with example values pointing to `localhost:5433` and `localhost:9002` (test service ports); include a comment explaining how to start test services first — follow exact design in `specs/008-postgres-integration-tests/plan.md`
|
||||||
|
- [X] T008 [P] [US3] Create `Makefile` at the repo root with `.PHONY: test-unit test-integration`, `test-unit` target running `cd api && python -m pytest tests/unit/ -v`, and `test-integration` target running `docker compose -f docker-compose.test.yml run --rm api-test`
|
||||||
|
- [X] T009 [US3] Verify `make test-unit` — unit tests pass without Docker (validates the Makefile target and confirms unit tests have no Docker dependency)
|
||||||
|
- [X] T010 Verify `make test-integration` — Docker integration suite passes end-to-end (cross-story verification: exercises the US2 compose stack via the US3 Makefile target)
|
||||||
|
|
||||||
|
**Checkpoint**: All three user stories independently functional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [X] T011 Run `ruff check api/app/ api/tests/` — zero violations (conftest change must pass ruff; fix any issues)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 2 (US1)**: No external dependencies — can start immediately
|
||||||
|
- **Phase 3 (US2)**: Depends on Phase 2 (guard must be in place so the compose stack run exercises it)
|
||||||
|
- **Phase 4 (US3)**: T007 and T008 are independent file writes (can run in parallel with each other after Phase 3); T009 requires T008; T010 requires T008 and T006
|
||||||
|
- **Phase 5 (Polish)**: Depends on all prior phases
|
||||||
|
|
||||||
|
### Within Phase 4
|
||||||
|
|
||||||
|
- T007 ∥ T008 (different files, no dependency)
|
||||||
|
- T009 after T008 (Makefile must exist)
|
||||||
|
- T010 after T008 and T006 (requires both Makefile and compose stack)
|
||||||
|
|
||||||
|
### Execution Order Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: T001, T002, T003 (sequential — TDD for guard)
|
||||||
|
Step 2: T004, T005, T006 (sequential — TDD for compose stack)
|
||||||
|
Step 3 (parallel): T007, T008
|
||||||
|
Step 4: T009 (after T008), T010 (after T008 + T006)
|
||||||
|
Step 5: T011
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP (US1 — the guard)
|
||||||
|
|
||||||
|
1. Complete T001–T003
|
||||||
|
2. **Validate**: SQLite URL is blocked; PostgreSQL URL proceeds
|
||||||
|
3. US2 and US3 add the infrastructure to make the mandate practical
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
- After Phase 2: Dialect bugs are caught immediately — core safety net is in place
|
||||||
|
- After Phase 3: Full integration suite runs against PostgreSQL via one Docker command
|
||||||
|
- After Phase 4: Both test tiers are documented and accessible via `make`
|
||||||
|
- After Phase 5: Lint clean, ready for merge
|
||||||
34
specs/009-login-rate-limiting/checklists/requirements.md
Normal file
34
specs/009-login-rate-limiting/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Login Brute-Force Protection
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-05-06
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [X] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [X] Focused on user value and business needs
|
||||||
|
- [X] Written for non-technical stakeholders
|
||||||
|
- [X] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [X] Requirements are testable and unambiguous
|
||||||
|
- [X] Success criteria are measurable
|
||||||
|
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [X] All acceptance scenarios are defined
|
||||||
|
- [X] Edge cases are identified
|
||||||
|
- [X] Scope is clearly bounded
|
||||||
|
- [X] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [X] All functional requirements have clear acceptance criteria
|
||||||
|
- [X] User scenarios cover primary flows
|
||||||
|
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [X] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Spec is ready for `/speckit-plan`.
|
||||||
85
specs/009-login-rate-limiting/contracts/auth.md
Normal file
85
specs/009-login-rate-limiting/contracts/auth.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# API Contract: Authentication
|
||||||
|
|
||||||
|
## POST /api/v1/auth/token
|
||||||
|
|
||||||
|
Authenticates the owner and returns a JWT access token.
|
||||||
|
|
||||||
|
**This endpoint is modified by feature 009** to enforce brute-force protection.
|
||||||
|
All previous behaviour is preserved. One new response code (429) is added.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/token
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "string",
|
||||||
|
"password": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responses
|
||||||
|
|
||||||
|
#### 200 OK — Credentials accepted
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "<jwt>",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 86400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Side effect: resets the failure counter for the caller's IP address.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 401 Unauthorized — Credentials rejected
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Invalid credentials",
|
||||||
|
"code": "invalid_credentials"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Side effect: increments the failure counter for the caller's IP address. If the
|
||||||
|
counter reaches `LOGIN_MAX_FAILURES`, subsequent requests from this IP will receive
|
||||||
|
429 until the cooldown expires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 429 Too Many Requests — Source blocked after repeated failures
|
||||||
|
|
||||||
|
**This response is new in feature 009.**
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 429 Too Many Requests
|
||||||
|
Retry-After: 900
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Too many failed login attempts. Please try again later.",
|
||||||
|
"code": "login_rate_limited"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Retry-After` header value is the configured cooldown duration in seconds (default: 900).
|
||||||
|
It reflects the maximum possible wait, not the exact remaining lockout time.
|
||||||
|
|
||||||
|
No credentials are verified when this response is returned — the request is
|
||||||
|
rejected before authentication is attempted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- The failure counter is per source IP address (TCP peer, not forwarded headers).
|
||||||
|
- Threshold values (`LOGIN_MAX_FAILURES`, `LOGIN_WINDOW_SECONDS`, `LOGIN_COOLDOWN_SECONDS`)
|
||||||
|
are not disclosed in any response.
|
||||||
|
- Counters are in-memory and reset on process restart.
|
||||||
53
specs/009-login-rate-limiting/data-model.md
Normal file
53
specs/009-login-rate-limiting/data-model.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Data Model: Login Brute-Force Protection
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature introduces no new database tables. The only data entity is a transient,
|
||||||
|
in-memory rate-limit record that does not survive process restarts. This is intentional
|
||||||
|
(see research.md Decision 3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity: Rate-Limit Record (in-memory only)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|----------------|---------|-----------------------------------------------------------------------------|
|
||||||
|
| `failures` | int | Count of consecutive failed login attempts in the current window |
|
||||||
|
| `window_start` | float | Unix timestamp marking when the current counting window began |
|
||||||
|
| `blocked_until`| float | Unix timestamp after which the source is no longer blocked; 0.0 if not blocked |
|
||||||
|
|
||||||
|
**Keyed by**: resolved client IP address string (e.g., `"192.168.1.1"`); see `get_client_ip()` in `rate_limiter.py` for resolution logic
|
||||||
|
|
||||||
|
**Lifecycle**:
|
||||||
|
1. Record is created on the first failed login from a source.
|
||||||
|
2. `failures` increments on each subsequent failure within the window.
|
||||||
|
3. When `failures >= LOGIN_MAX_FAILURES`, `blocked_until` is set to `now + LOGIN_COOLDOWN_SECONDS`.
|
||||||
|
4. When `blocked_until` has passed, the record is deleted on the next request from that source.
|
||||||
|
5. A successful login deletes the record immediately (failure counter reset).
|
||||||
|
6. If `now - window_start > LOGIN_WINDOW_SECONDS` without triggering lockout, the counter resets within the existing record.
|
||||||
|
|
||||||
|
**State machine**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[no record]
|
||||||
|
│ first failure
|
||||||
|
▼
|
||||||
|
[tracking] ──── failure N ≥ max ────► [blocked]
|
||||||
|
│ │
|
||||||
|
│ success / window expires │ cooldown expires
|
||||||
|
▼ ▼
|
||||||
|
[no record] ◄─────────────────────── [no record]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Entity: Rate-Limit Settings
|
||||||
|
|
||||||
|
Stored as environment variables; loaded via `app.config.Settings`:
|
||||||
|
|
||||||
|
| Env Var | Default | Description |
|
||||||
|
|----------------------------|---------|----------------------------------------------------------|
|
||||||
|
| `LOGIN_MAX_FAILURES` | `5` | Failures within window before lockout |
|
||||||
|
| `LOGIN_WINDOW_SECONDS` | `300` | Rolling window duration in seconds (5 minutes) |
|
||||||
|
| `LOGIN_COOLDOWN_SECONDS` | `900` | Lockout duration in seconds after threshold exceeded (15 minutes) |
|
||||||
|
| `LOGIN_TRUSTED_PROXY_IPS` | `""` | Comma-separated IPs/CIDRs of trusted upstream proxies (e.g., `10.0.0.0/8`); empty = disabled |
|
||||||
388
specs/009-login-rate-limiting/plan.md
Normal file
388
specs/009-login-rate-limiting/plan.md
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
# Implementation Plan: Login Brute-Force Protection
|
||||||
|
|
||||||
|
**Branch**: `009-login-rate-limiting` | **Date**: 2026-05-06 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `specs/009-login-rate-limiting/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add failure-counting brute-force protection to the login endpoint (`POST /api/v1/auth/token`).
|
||||||
|
After a configurable number of consecutive failed attempts from the same resolved client IP,
|
||||||
|
the endpoint returns HTTP 429 with a `Retry-After` header for a configurable cooldown period.
|
||||||
|
A successful login resets the counter. All thresholds are configurable via environment variables.
|
||||||
|
When deployed behind a reverse proxy (nginx, Kubernetes ingress), a `LOGIN_TRUSTED_PROXY_IPS`
|
||||||
|
setting enables extraction of the real client IP from `X-Forwarded-For`. No new infrastructure
|
||||||
|
(no Redis, no new DB table) — counters live in process memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Python 3.12+
|
||||||
|
**Primary Dependencies**: FastAPI, pydantic-settings (already in use); no new dependencies added
|
||||||
|
**Storage**: In-memory `dict` (no persistence across restarts — intentional)
|
||||||
|
**Testing**: pytest + pytest-asyncio (existing test infrastructure)
|
||||||
|
**Target Platform**: Linux server (Docker)
|
||||||
|
**Project Type**: Web service (API only — this feature has no UI surface)
|
||||||
|
**Performance Goals**: Rate limiter adds negligible overhead (dict lookup + lock acquisition; sub-millisecond)
|
||||||
|
**Constraints**: Must not add new runtime service dependencies; must not change any auth behaviour for non-blocked sources
|
||||||
|
**Scale/Scope**: Single process, single user; in-memory store is sufficient
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| §2.4 Auth abstraction (AuthProvider interface) | ✅ Pass | Rate limiter is a guard *before* `JWTAuthProvider.verify_credentials()`, not a bypass of the interface |
|
||||||
|
| §2.5 DB abstraction (repository layer) | ✅ Pass | No database access; in-memory only |
|
||||||
|
| §2.6 No speculative abstraction | ✅ Pass | Concrete `LoginRateLimiter` class, no interface; only one implementation planned |
|
||||||
|
| §3.3 Error envelope (`detail` + `code`) | ✅ Pass | 429 response uses `{"detail": "...", "code": "login_rate_limited"}` |
|
||||||
|
| §5.1 TDD | ✅ Required | Tasks follow red → green order |
|
||||||
|
| §5.2 Integration tests against PostgreSQL | ✅ Pass | Integration test for the login endpoint will run against the Docker PostgreSQL stack |
|
||||||
|
| §7.2 Environment configuration | ✅ Pass | `LOGIN_MAX_FAILURES`, `LOGIN_WINDOW_SECONDS`, `LOGIN_COOLDOWN_SECONDS`, `LOGIN_TRUSTED_PROXY_IPS` from env vars |
|
||||||
|
| §7.3 Linting (ruff) | ✅ Required | All new files must pass `ruff check` |
|
||||||
|
|
||||||
|
**Gate result**: No violations. Cleared to proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/009-login-rate-limiting/
|
||||||
|
├── plan.md ← this file
|
||||||
|
├── research.md ← decisions on approach
|
||||||
|
├── data-model.md ← rate-limit record entity
|
||||||
|
├── quickstart.md ← curl runbook
|
||||||
|
├── contracts/
|
||||||
|
│ └── auth.md ← updated POST /api/v1/auth/token with 429
|
||||||
|
└── tasks.md ← generated by /speckit-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code Changes
|
||||||
|
|
||||||
|
```text
|
||||||
|
api/
|
||||||
|
├── app/
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── rate_limiter.py ← NEW: LoginRateLimiter class
|
||||||
|
│ │ ├── jwt_provider.py (unchanged)
|
||||||
|
│ │ ├── noop.py (unchanged)
|
||||||
|
│ │ └── provider.py (unchanged)
|
||||||
|
│ ├── config.py ← add login_max_failures, login_window_seconds, login_cooldown_seconds, login_trusted_proxy_ips
|
||||||
|
│ ├── main.py ← init LoginRateLimiter in lifespan, attach to app.state
|
||||||
|
│ └── routers/
|
||||||
|
│ └── auth.py ← check rate limit before auth, record outcome
|
||||||
|
└── tests/
|
||||||
|
├── unit/
|
||||||
|
│ └── test_rate_limiter.py ← NEW: unit tests for LoginRateLimiter logic
|
||||||
|
└── integration/
|
||||||
|
└── test_login_rate_limit.py ← NEW: integration tests for 429 behaviour via HTTP
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Detail
|
||||||
|
|
||||||
|
### `api/app/auth/rate_limiter.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from ipaddress import IPv4Network, IPv6Network
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(
|
||||||
|
request: Request,
|
||||||
|
trusted_networks: list[IPv4Network | IPv6Network],
|
||||||
|
) -> str:
|
||||||
|
"""Return the resolved client IP, honouring X-Forwarded-For when the
|
||||||
|
TCP peer is a trusted upstream proxy. Falls back to the TCP peer address
|
||||||
|
when no trusted networks are configured or the peer is not in the list."""
|
||||||
|
peer = request.client.host if request.client else "unknown"
|
||||||
|
if trusted_networks and peer != "unknown":
|
||||||
|
try:
|
||||||
|
peer_addr = ipaddress.ip_address(peer)
|
||||||
|
if any(peer_addr in net for net in trusted_networks):
|
||||||
|
xff = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||||||
|
if xff:
|
||||||
|
return xff
|
||||||
|
real_ip = request.headers.get("X-Real-IP", "").strip()
|
||||||
|
if real_ip:
|
||||||
|
return real_ip
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Record:
|
||||||
|
failures: int = 0
|
||||||
|
window_start: float = field(default_factory=time.time)
|
||||||
|
blocked_until: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRateLimiter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
max_failures: int = 5,
|
||||||
|
window_seconds: int = 300,
|
||||||
|
cooldown_seconds: int = 900,
|
||||||
|
) -> None:
|
||||||
|
self._max = max_failures
|
||||||
|
self._window = window_seconds
|
||||||
|
self._cooldown = cooldown_seconds
|
||||||
|
self._store: dict[str, _Record] = {}
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cooldown_seconds(self) -> int:
|
||||||
|
return self._cooldown
|
||||||
|
|
||||||
|
def is_blocked(self, ip: str) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
rec = self._store.get(ip)
|
||||||
|
if rec is None:
|
||||||
|
return False
|
||||||
|
if rec.blocked_until > now:
|
||||||
|
return True
|
||||||
|
if rec.blocked_until > 0:
|
||||||
|
del self._store[ip]
|
||||||
|
return False
|
||||||
|
|
||||||
|
def record_failure(self, ip: str) -> None:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
rec = self._store.get(ip)
|
||||||
|
if rec is None:
|
||||||
|
rec = _Record(window_start=now)
|
||||||
|
self._store[ip] = rec
|
||||||
|
if now - rec.window_start > self._window:
|
||||||
|
rec.failures = 0
|
||||||
|
rec.window_start = now
|
||||||
|
rec.failures += 1
|
||||||
|
if rec.failures >= self._max:
|
||||||
|
rec.blocked_until = now + self._cooldown
|
||||||
|
logger.warning(
|
||||||
|
"Login blocked for %s after %d failures", ip, rec.failures
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_success(self, ip: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._store.pop(ip, None)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `api/app/config.py` additions
|
||||||
|
|
||||||
|
```python
|
||||||
|
login_max_failures: int = 5
|
||||||
|
login_window_seconds: int = 300
|
||||||
|
login_cooldown_seconds: int = 900
|
||||||
|
login_trusted_proxy_ips: str = "" # comma-separated IPs/CIDRs; empty = disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
### `api/app/main.py` lifespan update
|
||||||
|
|
||||||
|
```python
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
from app.auth.rate_limiter import LoginRateLimiter
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(application: FastAPI):
|
||||||
|
settings = get_settings()
|
||||||
|
application.state.login_rate_limiter = LoginRateLimiter(
|
||||||
|
max_failures=settings.login_max_failures,
|
||||||
|
window_seconds=settings.login_window_seconds,
|
||||||
|
cooldown_seconds=settings.login_cooldown_seconds,
|
||||||
|
)
|
||||||
|
trusted_networks = []
|
||||||
|
for part in settings.login_trusted_proxy_ips.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part:
|
||||||
|
try:
|
||||||
|
trusted_networks.append(ipaddress.ip_network(part, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
pass # invalid entry — skip silently
|
||||||
|
application.state.login_trusted_networks = trusted_networks
|
||||||
|
# ... existing DB setup unchanged
|
||||||
|
engine = get_engine()
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
await engine.dispose()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `api/app/routers/auth.py` update
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.auth.jwt_provider import JWTAuthProvider
|
||||||
|
from app.auth.rate_limiter import LoginRateLimiter, get_client_ip
|
||||||
|
from app.dependencies import get_jwt_auth
|
||||||
|
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/token", response_model=TokenResponse)
|
||||||
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
body: LoginRequest,
|
||||||
|
auth: JWTAuthProvider = Depends(get_jwt_auth),
|
||||||
|
):
|
||||||
|
limiter: LoginRateLimiter = request.app.state.login_rate_limiter
|
||||||
|
ip: str = get_client_ip(request, request.app.state.login_trusted_networks)
|
||||||
|
|
||||||
|
if limiter.is_blocked(ip):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={
|
||||||
|
"detail": "Too many failed login attempts. Please try again later.",
|
||||||
|
"code": "login_rate_limited",
|
||||||
|
},
|
||||||
|
headers={"Retry-After": str(limiter.cooldown_seconds)},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not auth.verify_credentials(body.username, body.password):
|
||||||
|
limiter.record_failure(ip)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
|
||||||
|
)
|
||||||
|
|
||||||
|
limiter.record_success(ip)
|
||||||
|
token = auth.create_token()
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
token_type="bearer",
|
||||||
|
expires_in=auth._expiry_seconds,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `api/tests/unit/test_rate_limiter.py` (representative cases)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
from app.auth.rate_limiter import LoginRateLimiter
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_blocked_initially():
|
||||||
|
limiter = LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=300)
|
||||||
|
assert limiter.is_blocked("1.2.3.4") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocked_after_threshold():
|
||||||
|
limiter = LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=300)
|
||||||
|
for _ in range(3):
|
||||||
|
limiter.record_failure("1.2.3.4")
|
||||||
|
assert limiter.is_blocked("1.2.3.4") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_clears_failures():
|
||||||
|
limiter = LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=300)
|
||||||
|
limiter.record_failure("1.2.3.4")
|
||||||
|
limiter.record_failure("1.2.3.4")
|
||||||
|
limiter.record_success("1.2.3.4")
|
||||||
|
assert limiter.is_blocked("1.2.3.4") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ips_are_isolated():
|
||||||
|
limiter = LoginRateLimiter(max_failures=2, window_seconds=60, cooldown_seconds=300)
|
||||||
|
limiter.record_failure("1.1.1.1")
|
||||||
|
limiter.record_failure("1.1.1.1")
|
||||||
|
assert limiter.is_blocked("2.2.2.2") is False
|
||||||
|
```
|
||||||
|
|
||||||
|
### `api/tests/integration/test_login_rate_limit.py` (representative cases)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
# Uses the 'client' fixture (NoOpAuthProvider) from conftest — sufficient for this
|
||||||
|
# endpoint since we're testing the rate-limit layer, not auth correctness.
|
||||||
|
# The login endpoint instantiates its own limiter via app.state, so we need
|
||||||
|
# the full ASGI app.
|
||||||
|
|
||||||
|
BAD_CREDS = {"username": "attacker", "password": "wrong"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repeated_failures_trigger_429(client: AsyncClient):
|
||||||
|
# Use a custom limiter with low threshold to avoid slow tests
|
||||||
|
# (the app.state.login_rate_limiter is set in lifespan; override for test)
|
||||||
|
from app.auth.rate_limiter import LoginRateLimiter
|
||||||
|
from app.main import app
|
||||||
|
original = app.state.login_rate_limiter
|
||||||
|
app.state.login_rate_limiter = LoginRateLimiter(
|
||||||
|
max_failures=3, window_seconds=60, cooldown_seconds=30
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
for _ in range(3):
|
||||||
|
await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||||
|
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
|
||||||
|
assert resp.status_code == 429
|
||||||
|
assert resp.json()["code"] == "login_rate_limited"
|
||||||
|
assert "Retry-After" in resp.headers
|
||||||
|
finally:
|
||||||
|
app.state.login_rate_limiter = original
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1 (MVP — P1): Blocking after repeated failures
|
||||||
|
|
||||||
|
1. Add `login_max_failures`, `login_window_seconds`, `login_cooldown_seconds`, `login_trusted_proxy_ips` to `api/app/config.py`
|
||||||
|
2. Create `api/app/auth/rate_limiter.py` with `LoginRateLimiter` and `get_client_ip()`
|
||||||
|
3. Initialize rate limiter and parse trusted networks in `api/app/main.py` lifespan; attach both to `app.state`
|
||||||
|
4. Update `api/app/routers/auth.py` to resolve client IP via `get_client_ip()`, then check + record outcomes
|
||||||
|
5. Unit tests: `api/tests/unit/test_rate_limiter.py`
|
||||||
|
6. Integration tests: `api/tests/integration/test_login_rate_limit.py`
|
||||||
|
|
||||||
|
### Phase 2 (US2 — observability): Logging and response hints
|
||||||
|
|
||||||
|
Delivered as part of Phase 1 (the `logger.warning(...)` call and `Retry-After` header
|
||||||
|
are embedded in the same implementation). No separate phase needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables to Add to `.env.example`
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# Login brute-force protection
|
||||||
|
LOGIN_MAX_FAILURES=5
|
||||||
|
LOGIN_WINDOW_SECONDS=300
|
||||||
|
LOGIN_COOLDOWN_SECONDS=900
|
||||||
|
# Comma-separated IPs/CIDRs of trusted upstream proxies (e.g. nginx ingress pod CIDR).
|
||||||
|
# Leave empty when not behind a reverse proxy.
|
||||||
|
LOGIN_TRUSTED_PROXY_IPS=
|
||||||
|
```
|
||||||
|
|
||||||
|
These are optional (have defaults) so existing `.env` files without them continue working.
|
||||||
112
specs/009-login-rate-limiting/quickstart.md
Normal file
112
specs/009-login-rate-limiting/quickstart.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Quickstart: Login Brute-Force Protection
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- API running (via `docker compose up` or locally with `.env` set)
|
||||||
|
- `curl` available
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 1: Trigger the rate limiter
|
||||||
|
|
||||||
|
Send 6 consecutive failed login attempts (default threshold is 5):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for i in $(seq 1 6); do
|
||||||
|
echo "Attempt $i:"
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" \
|
||||||
|
-X POST http://localhost:8000/api/v1/auth/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "wrong", "password": "wrong"}'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
Attempt 1: 401
|
||||||
|
Attempt 2: 401
|
||||||
|
Attempt 3: 401
|
||||||
|
Attempt 4: 401
|
||||||
|
Attempt 5: 401
|
||||||
|
Attempt 6: 429
|
||||||
|
```
|
||||||
|
|
||||||
|
The 6th attempt returns 429. Inspect the headers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -X POST http://localhost:8000/api/v1/auth/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "wrong", "password": "wrong"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected headers include:
|
||||||
|
```
|
||||||
|
HTTP/1.1 429 Too Many Requests
|
||||||
|
Retry-After: 900
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected body:
|
||||||
|
```json
|
||||||
|
{"detail": "Too many failed login attempts. Please try again later.", "code": "login_rate_limited"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 2: Successful login resets the counter
|
||||||
|
|
||||||
|
Make some failed attempts, then log in with valid credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fail twice
|
||||||
|
for i in 1 2; do
|
||||||
|
curl -s -o /dev/null -w "fail $i: %{http_code}\n" \
|
||||||
|
-X POST http://localhost:8000/api/v1/auth/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "wrong", "password": "wrong"}'
|
||||||
|
done
|
||||||
|
|
||||||
|
# Succeed — resets counter
|
||||||
|
curl -s -o /dev/null -w "success: %{http_code}\n" \
|
||||||
|
-X POST http://localhost:8000/api/v1/auth/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "'"$OWNER_USERNAME"'", "password": "'"$OWNER_PASSWORD"'"}'
|
||||||
|
|
||||||
|
# Now fail 5 more times — counter was reset, so no 429 yet
|
||||||
|
for i in $(seq 1 5); do
|
||||||
|
curl -s -o /dev/null -w "fail after reset $i: %{http_code}\n" \
|
||||||
|
-X POST http://localhost:8000/api/v1/auth/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "wrong", "password": "wrong"}'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all "fail after reset" lines return 401 (not 429), confirming the counter was reset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 3: Observe log output
|
||||||
|
|
||||||
|
While triggering the rate limiter (Scenario 1), watch API logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
After the threshold is crossed you should see a line like:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARNING app.auth.rate_limiter:rate_limiter.py:NN Login blocked for 172.18.0.1 after 5 failures
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment variable overrides
|
||||||
|
|
||||||
|
To test with a lower threshold without code changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LOGIN_MAX_FAILURES=2 LOGIN_WINDOW_SECONDS=60 LOGIN_COOLDOWN_SECONDS=30 \
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Then only 2 failures trigger the lockout, and it clears after 30 seconds.
|
||||||
67
specs/009-login-rate-limiting/research.md
Normal file
67
specs/009-login-rate-limiting/research.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Research: Login Brute-Force Protection
|
||||||
|
|
||||||
|
## Decision 1: Library vs. custom implementation
|
||||||
|
|
||||||
|
**Decision**: Custom in-memory failure tracker (no new library dependency)
|
||||||
|
|
||||||
|
**Rationale**: The requirement is to count *failed* login attempts specifically and reset on success — not to rate-limit all requests regardless of outcome. Popular libraries like `slowapi` count all requests to a route, which would break FR-004 (reset on success) without significant workarounds. A purpose-built 60-line class is simpler, more auditable, and has no dependency footprint.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `slowapi` (built on `limits`): Counts all requests, not failures. Requires patching the exception handler to decrement on success — fragile and non-obvious.
|
||||||
|
- `slowapi` with a custom key function: Could be done, but the library's storage model doesn't expose a "reset this key" API in a clean way.
|
||||||
|
- Redis-backed counter: Overkill for a single-user personal app with one instance. No new infrastructure justified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 2: Fixed window vs. sliding window
|
||||||
|
|
||||||
|
**Decision**: Fixed window with per-source reset on successful login
|
||||||
|
|
||||||
|
**Rationale**: Fixed window is simpler to implement correctly and sufficient for this use case. The main attack — rapid sequential guessing — is fully addressed. The known "burst at window boundary" weakness is irrelevant here because: (a) the cooldown period is separate from the counting window, and (b) a successful login resets the counter entirely.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Sliding window: More accurate, but adds complexity (requires storing timestamps of each request). The marginal security benefit doesn't justify the implementation cost for a personal single-user app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 3: In-memory backing store
|
||||||
|
|
||||||
|
**Decision**: Python `dict` keyed by source IP, protected by a threading `Lock`
|
||||||
|
|
||||||
|
**Rationale**: The application runs as a single process. In-memory storage means counters reset on restart — this is acceptable and matches the "fail open" assumption in the spec. No new infrastructure (Redis, database table) is required.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Database-backed counters: Persistent across restarts, but adds a DB round-trip to every login request (including successful ones). Not justified.
|
||||||
|
- Redis: Distributed-safe and persistent, but requires a new service dependency. Out of scope for a personal single-instance app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 4: Source identifier
|
||||||
|
|
||||||
|
**Decision**: `request.client.host` (the TCP peer address)
|
||||||
|
|
||||||
|
**Rationale**: The spec explicitly states not to trust `X-Forwarded-For` headers unless the app is known to be behind a trusted proxy. `request.client.host` in Starlette/FastAPI is the actual TCP peer IP — it cannot be spoofed by an attacker sending arbitrary headers.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `X-Forwarded-For` first value: Spoofable if the app is not behind a trusted proxy (attacker can set arbitrary header values).
|
||||||
|
- `X-Real-IP`: Same spoofing concern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 5: 429 response and Retry-After header
|
||||||
|
|
||||||
|
**Decision**: Return HTTP 429 with `{"detail": "...", "code": "login_rate_limited"}` and a `Retry-After` header set to the configured cooldown duration in seconds
|
||||||
|
|
||||||
|
**Rationale**: HTTP 429 is the standard "Too Many Requests" status. The `Retry-After` header is explicitly mentioned in the spec (US2 acceptance scenario) and is required by RFC 6585 for rate-limit responses. Setting it to the *configured* cooldown (not the exact remaining time) satisfies FR-005: it doesn't reveal precise expiry, just the maximum wait. The response body follows §3.3 of the constitution (error envelope with `detail` and `code`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 6: Default threshold values
|
||||||
|
|
||||||
|
**Decision**: `LOGIN_MAX_FAILURES=5`, `LOGIN_WINDOW_SECONDS=300` (5 min), `LOGIN_COOLDOWN_SECONDS=900` (15 min)
|
||||||
|
|
||||||
|
**Rationale**: Industry standard for web apps. 5 attempts is enough for legitimate typos but makes brute-force infeasible at human scale. A 5-minute counting window matches typical "I fat-fingered my password" retry patterns. A 15-minute cooldown is a meaningful deterrent without locking out a legitimate owner indefinitely.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- 3 failures / 60 s window / 300 s cooldown: More aggressive, but too likely to lock out the legitimate owner on a bad day.
|
||||||
|
- 10 failures: Too permissive for a brute-force defense.
|
||||||
84
specs/009-login-rate-limiting/spec.md
Normal file
84
specs/009-login-rate-limiting/spec.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Feature Specification: Login Brute-Force Protection
|
||||||
|
|
||||||
|
**Feature Branch**: `009-login-rate-limiting`
|
||||||
|
**Created**: 2026-05-06
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Login API endpoints should be rate limited or otherwise protected against brute force attacks"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Repeated failed logins are blocked (Priority: P1)
|
||||||
|
|
||||||
|
An attacker (or misconfigured client) sending many rapid login attempts with the wrong password is slowed or blocked before they can exhaustively guess credentials. After a threshold number of consecutive failures from the same source, the system refuses further attempts for a cooldown period and returns a clear, non-leaking error.
|
||||||
|
|
||||||
|
**Why this priority**: Directly prevents credential-stuffing and brute-force attacks against the sole privileged account. Without this, the owner account is exposed to automated password guessing with no friction.
|
||||||
|
|
||||||
|
**Independent Test**: Send more than the allowed number of failed login requests in quick succession and confirm that subsequent attempts are rejected with a rate-limit or lockout response — without knowing or changing the real password.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an attacker sends N+1 failed login attempts within the configured window, **When** the (N+1)th request arrives, **Then** the system returns an error response indicating the request is blocked (not the normal "invalid credentials" error) and does not process the login attempt.
|
||||||
|
2. **Given** a legitimate user has been temporarily blocked after too many failures, **When** the cooldown period elapses and they retry with the correct password, **Then** they are logged in successfully.
|
||||||
|
3. **Given** a legitimate user makes a few failed attempts and then waits beyond the cooldown window, **When** they retry within the next window, **Then** their failure counter resets and they are not blocked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Operators can observe and reason about blocking activity (Priority: P2)
|
||||||
|
|
||||||
|
When the protection triggers, the system produces enough observable signal (log entries, response metadata) that an operator can confirm the feature is working, diagnose false positives, and tune thresholds — without exposing sensitive details to the client.
|
||||||
|
|
||||||
|
**Why this priority**: Invisible security controls are unmanageable. Operators need to know the system is doing what it claims, and blocked legitimate users need a clear (but not exploitable) explanation.
|
||||||
|
|
||||||
|
**Independent Test**: Trigger the rate limiter and confirm that: (a) the response body or headers communicate that the request was blocked and when the client may retry; (b) the server logs an entry identifying the blocked source and the reason.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a source is blocked, **When** they receive the rejection response, **Then** the response indicates they should wait before retrying (e.g., a `Retry-After` hint) without disclosing the exact threshold values.
|
||||||
|
2. **Given** the rate limiter fires, **When** an operator inspects server logs, **Then** there is a log entry at WARNING level or above recording the blocked source and timestamp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when a distributed attacker rotates IPs to avoid per-IP limits?
|
||||||
|
- How does the system behave if the backing store for rate-limit counters is temporarily unavailable — does it fail open (allow all) or fail closed (block all)?
|
||||||
|
- Are IPv6 addresses and IPv4-mapped-IPv6 addresses treated consistently?
|
||||||
|
- Does a successful login reset the failure counter for that source?
|
||||||
|
- What happens if many legitimate users share a NAT/proxy IP (e.g., corporate network)?
|
||||||
|
- What if `TRUSTED_PROXY_IPS` is configured to include an IP that an external attacker controls? (An attacker could then spoof `X-Forwarded-For` and rotate fake source IPs to bypass the rate limiter — operators must only list genuinely trusted upstream infrastructure.)
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST enforce a maximum number of failed login attempts per source identifier (the resolved client IP address) within a rolling time window before blocking further attempts.
|
||||||
|
- **FR-002**: Once a source exceeds the failure threshold, the system MUST reject subsequent login requests for a configurable cooldown period, returning a distinct response (not the normal invalid-credentials response).
|
||||||
|
- **FR-003**: After the cooldown period expires, the system MUST permit the source to attempt login again, resetting its failure count.
|
||||||
|
- **FR-004**: A successful login MUST reset the failure counter for that source, preventing accumulation of old failures from blocking future legitimate access.
|
||||||
|
- **FR-005**: The rejection response MUST NOT reveal the specific threshold values or remaining lockout duration in a way that aids an attacker in timing their attempts, but MUST provide enough information (e.g., "try again later") for a legitimate user to understand the situation.
|
||||||
|
- **FR-006**: The system MUST log a structured warning event whenever a source is blocked, including the source identifier and timestamp.
|
||||||
|
- **FR-007**: Rate-limit thresholds (maximum attempts, window duration, cooldown duration) MUST be configurable without code changes.
|
||||||
|
- **FR-008**: The system MUST support a configurable list of trusted upstream proxy IP addresses and CIDR ranges. When the TCP peer address matches a trusted proxy, the resolved client IP MUST be extracted from the `X-Forwarded-For` request header (first entry) or, if absent, `X-Real-IP`. When no trusted proxies are configured, the TCP peer address MUST be used directly and forwarded-IP headers MUST be ignored.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Rate-limit record**: Tracks the number of consecutive failures and the window start time for a given source identifier; expires automatically after the cooldown period.
|
||||||
|
- **Source identifier**: The resolved client IP address used to key rate-limit records. When `LOGIN_TRUSTED_PROXY_IPS` is empty (default), this is the TCP peer address. When one or more proxy IPs/CIDRs are configured and the TCP peer matches, the first `X-Forwarded-For` entry (or `X-Real-IP`) is used instead.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: An automated script sending 100 consecutive failed login requests completes with at least 90 of those requests rejected after the threshold is crossed — verified in a controlled test environment.
|
||||||
|
- **SC-002**: A legitimate user who has been temporarily blocked can successfully log in within 5 minutes of the cooldown period expiring without any manual intervention.
|
||||||
|
- **SC-003**: Zero information about threshold values or exact lockout expiry is present in blocked response bodies or headers.
|
||||||
|
- **SC-004**: Every blocking event produces a corresponding log entry; 100% of triggered blocking events are observable in logs during testing.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The application has a single login endpoint used by all clients (the owner login introduced in feature 004).
|
||||||
|
- Source identification uses the resolved client IP address. By default (when `LOGIN_TRUSTED_PROXY_IPS` is empty) this is the TCP peer address. When one or more proxy IPs/CIDRs are configured, the first entry of `X-Forwarded-For` (or `X-Real-IP`) is used instead — but only when the TCP peer is in the trusted list, preventing header spoofing by external clients.
|
||||||
|
- If the rate-limit backing store is unavailable, the system fails open (allows the attempt through) rather than blocking all logins — this preserves the owner's access, which is critical for a single-user admin application.
|
||||||
|
- No CAPTCHA or multi-factor step is in scope; protection is purely count/time-based.
|
||||||
|
- The feature targets the login endpoint only; other endpoints are out of scope.
|
||||||
|
- The single-user nature of the app means IP-based identification is sufficient — there is no need for per-username lockout, and using IP (rather than username) avoids contributing to username enumeration risk.
|
||||||
120
specs/009-login-rate-limiting/tasks.md
Normal file
120
specs/009-login-rate-limiting/tasks.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Tasks: Login Brute-Force Protection
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/009-login-rate-limiting/`
|
||||||
|
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/auth.md ✅, quickstart.md ✅
|
||||||
|
|
||||||
|
**Tests**: TDD is non-negotiable (§5.1). Every test task appears before the implementation task it covers. For each red step, run the test and confirm it fails before proceeding to the implementation.
|
||||||
|
|
||||||
|
**Organization**: Phase 1 adds env vars; Phase 2 adds config fields (shared by both stories); Phase 3 implements the core blocking behaviour (US1 MVP); Phase 4 adds observability-specific test coverage (US2); Phase 5 is polish.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel with other [P] tasks in the same phase
|
||||||
|
- **[Story]**: Which user story this task belongs to
|
||||||
|
- Exact file paths included in every task description
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
- [X] T001 Add a `# Login brute-force protection` comment block with `LOGIN_MAX_FAILURES=5`, `LOGIN_WINDOW_SECONDS=300`, `LOGIN_COOLDOWN_SECONDS=900`, and `LOGIN_TRUSTED_PROXY_IPS=` (empty by default, with an inline comment explaining it accepts comma-separated IPs/CIDRs) to both `.env.example` and `.env.test.example` at the repo root
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational
|
||||||
|
|
||||||
|
**Purpose**: Add the three new settings fields — required before any story implementation.
|
||||||
|
|
||||||
|
- [X] T002 Add `login_max_failures: int = 5`, `login_window_seconds: int = 300`, `login_cooldown_seconds: int = 900`, `login_trusted_proxy_ips: str = ""` to the `Settings` class in `api/app/config.py` (append after `owner_password`)
|
||||||
|
|
||||||
|
**Checkpoint**: `api/app/config.py` accepts all three new env vars with defaults.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Repeated failed logins are blocked (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: After `LOGIN_MAX_FAILURES` consecutive failed login attempts from the same source IP within `LOGIN_WINDOW_SECONDS`, `POST /api/v1/auth/token` returns HTTP 429 for `LOGIN_COOLDOWN_SECONDS`. A successful login resets the counter.
|
||||||
|
|
||||||
|
**Independent Test**: `cd api && python -m pytest tests/unit/test_rate_limiter.py tests/integration/test_login_rate_limit.py::test_repeated_failures_trigger_429 tests/integration/test_login_rate_limit.py::test_success_resets_counter tests/integration/test_login_rate_limit.py::test_429_has_retry_after_header tests/integration/test_login_rate_limit.py::test_xff_header_ignored_when_no_trusted_networks -v` — all pass.
|
||||||
|
|
||||||
|
### Tests for User Story 1 (TDD red — write first, confirm failure before T005)
|
||||||
|
|
||||||
|
- [X] T003 [P] [US1] Create `api/tests/unit/test_rate_limiter.py` with ten failing unit tests — import `LoginRateLimiter` and `get_client_ip` from `app.auth.rate_limiter`; for `LoginRateLimiter` (instantiate with `max_failures=3, window_seconds=60, cooldown_seconds=300`): `test_not_blocked_initially`, `test_blocked_after_threshold`, `test_success_clears_failures`, `test_ips_are_isolated`, `test_window_resets_after_expiry`, `test_log_warning_on_lockout` (caplog at WARNING level: call `record_failure()` until threshold, assert `"Login blocked" in caplog.text` and IP in log output); for `get_client_ip` (construct a mock using `from unittest.mock import MagicMock` and `from starlette.requests import Request`: `req = MagicMock(spec=Request); req.client.host = "10.0.0.1"; req.headers = {"X-Forwarded-For": "203.0.113.5"}`): `test_get_client_ip_no_trusted_networks_returns_peer` (empty `trusted_networks=[]` → returns `req.client.host`), `test_get_client_ip_trusted_peer_uses_xff` (peer `"10.0.0.1"` in trusted CIDR `"10.0.0.0/8"` → returns `"203.0.113.5"`), `test_get_client_ip_untrusted_peer_ignores_xff` (peer `"8.8.8.8"` not in trusted CIDR `"10.0.0.0/8"` → returns `"8.8.8.8"` despite XFF), `test_get_client_ip_trusted_peer_falls_back_to_real_ip` (peer trusted, no XFF header, `X-Real-IP: "203.0.113.9"` → returns `"203.0.113.9"`); run `python -m pytest tests/unit/test_rate_limiter.py -v` and confirm `ImportError` or `ModuleNotFoundError` (red)
|
||||||
|
- [X] T004 [P] [US1] Create `api/tests/integration/test_login_rate_limit.py` with four failing integration tests; each must override both `app.state.login_rate_limiter` (fresh `LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=30)`) and `app.state.login_trusted_networks` (set to `[]` for all four tests — the `ASGITransport` peer is `"testclient"`, not a valid IP, so trusted-network matching can't be exercised here; proxy extraction is fully covered by T003 unit tests) via try/finally: (1) `test_repeated_failures_trigger_429` — POST three bad-credential requests then assert fourth returns 429 with `resp.json()["code"] == "login_rate_limited"`; (2) `test_success_resets_counter` — two failures → one valid login using `{"username": os.environ["OWNER_USERNAME"], "password": os.environ["OWNER_PASSWORD"]}` (matching conftest.py defaults: `testowner`/`testpassword`) → three more failures → assert all three return 401, not 429; (3) `test_429_has_retry_after_header` — trigger lockout (three failures), then assert `"Retry-After" in resp.headers` and `int(resp.headers["Retry-After"]) > 0`; (4) `test_xff_header_ignored_when_no_trusted_networks` — send three bad-cred requests with `headers={"X-Forwarded-For": "1.2.3.4"}` then a fourth with `headers={"X-Forwarded-For": "9.9.9.9"}` — assert the fourth returns 429 (not 401), proving the limiter tracked the real peer `"testclient"` for all requests and XFF was ignored; run `python -m pytest tests/integration/test_login_rate_limit.py -v` and confirm failure (red)
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T005 [US1] Create `api/app/auth/rate_limiter.py` with two exports: (1) `get_client_ip(request: Request, trusted_networks: list[IPv4Network | IPv6Network]) -> str` — imports `ipaddress`, `from ipaddress import IPv4Network, IPv6Network`, `from starlette.requests import Request`; extracts `peer = request.client.host if request.client else "unknown"`; if `trusted_networks` is non-empty and peer is parseable as an IP address and falls within any trusted network, returns first `X-Forwarded-For` entry (strip whitespace) or `X-Real-IP` value, otherwise returns `peer`; wraps `ipaddress.ip_address(peer)` in `try/except ValueError` and falls back to `peer` on error; (2) `LoginRateLimiter` class: `__init__(self, max_failures: int = 5, window_seconds: int = 300, cooldown_seconds: int = 900)` storing params as `_max`, `_window`, `_cooldown`; `_store: dict[str, _Record]` and `_lock: threading.Lock`; `@dataclass _Record` with `failures: int = 0`, `window_start: float = field(default_factory=time.time)`, `blocked_until: float = 0.0`; `is_blocked(ip: str) -> bool`, `record_failure(ip: str) -> None` (logs WARNING on lockout), `record_success(ip: str) -> None`, `cooldown_seconds` property; stdlib imports: `import ipaddress, logging, time`, `from dataclasses import dataclass, field`, `from threading import Lock`
|
||||||
|
- [X] T006 [US1] Update `api/app/main.py` lifespan: add `import ipaddress` at top; import `LoginRateLimiter` from `app.auth.rate_limiter`; inside `lifespan` before `engine = get_engine()`, consolidate to `settings = get_settings()` (remove the existing bare `get_settings()` call), then set `application.state.login_rate_limiter = LoginRateLimiter(max_failures=settings.login_max_failures, window_seconds=settings.login_window_seconds, cooldown_seconds=settings.login_cooldown_seconds)`; then parse `settings.login_trusted_proxy_ips` — split on `","`, strip each part, skip empty strings, call `ipaddress.ip_network(part, strict=False)` inside a `try/except ValueError` (skip invalid entries silently), collect results into `trusted_networks: list`; set `application.state.login_trusted_networks = trusted_networks`
|
||||||
|
- [X] T007 [US1] Update `api/app/routers/auth.py` login endpoint: add `Request` to FastAPI imports and add `from fastapi.responses import JSONResponse`; add `from app.auth.rate_limiter import LoginRateLimiter, get_client_ip`; add `request: Request` as first parameter to `login()`; extract `limiter: LoginRateLimiter = request.app.state.login_rate_limiter` and `ip: str = get_client_ip(request, request.app.state.login_trusted_networks)`; add guard block — if `limiter.is_blocked(ip)`: return `JSONResponse(status_code=429, content={"detail": "Too many failed login attempts. Please try again later.", "code": "login_rate_limited"}, headers={"Retry-After": str(limiter.cooldown_seconds)})`; after `verify_credentials` returns False: call `limiter.record_failure(ip)` before the existing `HTTPException`; after `auth.create_token()`: call `limiter.record_success(ip)` before returning `TokenResponse`
|
||||||
|
- [X] T008 [US1] Verify TDD green: run `cd api && python -m pytest tests/unit/test_rate_limiter.py -v` — all 10 pass; run `make test-integration` — all tests pass including `test_repeated_failures_trigger_429`, `test_success_resets_counter`, `test_429_has_retry_after_header`, and `test_xff_header_ignored_when_no_trusted_networks`
|
||||||
|
|
||||||
|
**Checkpoint**: Brute-force blocking is live. Automated repeated failures are stopped after threshold; the owner can still log in after cooldown; unit and integration tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Operators can observe blocking activity (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: The 429 response includes a `Retry-After` header with a positive integer; the response body `code` is `"login_rate_limited"` and contains no threshold numeric values; server logs a WARNING when blocking triggers.
|
||||||
|
|
||||||
|
**Independent Test**: Trigger the rate limiter (already works from Phase 3) and assert `Retry-After` header is present in the response and `code` field is `"login_rate_limited"`.
|
||||||
|
|
||||||
|
### Tests for User Story 2 (TDD red — extend existing file)
|
||||||
|
|
||||||
|
- [X] T009 [US2] Add one test to `api/tests/integration/test_login_rate_limit.py` targeting observability properties not yet covered: `test_429_body_shape` — override `app.state.login_rate_limiter` with a fresh `LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=30)` via try/finally (same isolation pattern as T004), trigger lockout (three failures), then assert `resp.json() == {"detail": "Too many failed login attempts. Please try again later.", "code": "login_rate_limited"}` (exact match — confirms no threshold values leak and shape is correct); confirm this test is green immediately against the US1 implementation (T007 already returns this exact body)
|
||||||
|
|
||||||
|
**Checkpoint**: US2 observability properties are explicitly exercised by integration tests; a future regression in the Retry-After header or code field will be caught.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [X] T010 Run `cd api && ruff check app/auth/rate_limiter.py app/routers/auth.py app/config.py app/main.py tests/unit/test_rate_limiter.py tests/integration/test_login_rate_limit.py` — fix any violations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1 (Setup)**: No external dependencies — can start immediately
|
||||||
|
- **Phase 2 (Foundational)**: No external dependencies — can start immediately (parallel with Phase 1)
|
||||||
|
- **Phase 3 (US1)**: Depends on Phase 2 (T002 must exist before T006 can use `settings.login_max_failures`)
|
||||||
|
- **Phase 4 (US2)**: Depends on Phase 3 (tests verify behaviour implemented in T007)
|
||||||
|
- **Phase 5 (Polish)**: Depends on all prior phases
|
||||||
|
|
||||||
|
### Within Phase 3
|
||||||
|
|
||||||
|
- T003 ∥ T004 (different files, no dependency — write tests in parallel)
|
||||||
|
- T005 after T003, T004 (implement after tests confirm they fail)
|
||||||
|
- T006 ∥ T007 after T005 (both import from `rate_limiter.py`; write to different files — `main.py` and `auth.py`; T006 sets `app.state.login_trusted_networks` which T007's router reads)
|
||||||
|
- T008 after T005, T006, T007 (verify all pass)
|
||||||
|
|
||||||
|
### Execution Order Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: T001 ∥ T002 (setup + foundational — parallel, different files)
|
||||||
|
Step 2: T003 ∥ T004 (write failing tests — parallel)
|
||||||
|
Step 3: T005 (implement LoginRateLimiter — after red tests confirmed)
|
||||||
|
Step 4: T006 ∥ T007 (wire limiter into app — parallel, different files)
|
||||||
|
Step 5: T008 (verify green)
|
||||||
|
Step 6: T009 (US2 observability tests — verify green immediately)
|
||||||
|
Step 7: T010 (ruff clean)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP (US1 — the blocker)
|
||||||
|
|
||||||
|
1. Complete T001–T002 (config setup)
|
||||||
|
2. Complete T003–T008 (core blocking)
|
||||||
|
3. **Validate**: Run `make test-integration` — all 88 existing tests still pass; 2 new rate-limit tests pass
|
||||||
|
4. US2 adds verification coverage for already-implemented observability features
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
- After Phase 3: Brute-force attacks on the login endpoint are blocked — core security net is in place
|
||||||
|
- After Phase 4: Observability properties are explicitly tested — regressions in headers/logs will be caught
|
||||||
|
- After Phase 5: Lint clean, ready for merge
|
||||||
4
ui/.prettierignore
Normal file
4
ui/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
package-lock.json
|
||||||
@@ -4,6 +4,9 @@ const tseslint = require("typescript-eslint");
|
|||||||
const angular = require("angular-eslint");
|
const angular = require("angular-eslint");
|
||||||
|
|
||||||
module.exports = tseslint.config(
|
module.exports = tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ["dist/", "node_modules/", "coverage/", "*.min.js"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ["**/*.ts"],
|
files: ["**/*.ts"],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
@@ -1,25 +1,95 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter, Router } from '@angular/router';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
import { AuthService } from './auth/auth.service';
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
describe('AppComponent', () => {
|
||||||
|
let authSpy: jasmine.SpyObj<AuthService>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
authSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated', 'logout']);
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AppComponent],
|
imports: [AppComponent],
|
||||||
providers: [provideRouter(routes)],
|
providers: [
|
||||||
|
provideRouter(routes),
|
||||||
|
{ provide: AuthService, useValue: authSpy },
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create the app', () => {
|
it('should create the app', () => {
|
||||||
|
authSpy.isAuthenticated.and.returnValue(false);
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
const app = fixture.componentInstance;
|
expect(fixture.componentInstance).toBeTruthy();
|
||||||
expect(app).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have title reactbin-ui', () => {
|
it('should have title reactbin-ui', () => {
|
||||||
|
authSpy.isAuthenticated.and.returnValue(false);
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
const app = fixture.componentInstance;
|
expect(fixture.componentInstance.title).toEqual('reactbin-ui');
|
||||||
expect(app.title).toEqual('reactbin-ui');
|
});
|
||||||
|
|
||||||
|
it('header is present when authenticated', () => {
|
||||||
|
authSpy.isAuthenticated.and.returnValue(true);
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const header = (fixture.nativeElement as HTMLElement).querySelector('header.app-header');
|
||||||
|
expect(header).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('header is present when not authenticated', () => {
|
||||||
|
authSpy.isAuthenticated.and.returnValue(false);
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const header = (fixture.nativeElement as HTMLElement).querySelector('header.app-header');
|
||||||
|
expect(header).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sign-out button is visible when authenticated', () => {
|
||||||
|
authSpy.isAuthenticated.and.returnValue(true);
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const btn = (fixture.nativeElement as HTMLElement).querySelector('.logout-btn');
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sign-out button is absent when not authenticated', () => {
|
||||||
|
authSpy.isAuthenticated.and.returnValue(false);
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const btn = (fixture.nativeElement as HTMLElement).querySelector('.logout-btn');
|
||||||
|
expect(btn).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onLogout calls auth.logout and navigates to / (grid)', () => {
|
||||||
|
authSpy.isAuthenticated.and.returnValue(true);
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
spyOn(router, 'navigate');
|
||||||
|
fixture.componentInstance.onLogout();
|
||||||
|
expect(authSpy.logout).toHaveBeenCalled();
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('header app-name is a link to /', () => {
|
||||||
|
authSpy.isAuthenticated.and.returnValue(false);
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const link = (fixture.nativeElement as HTMLElement).querySelector('a.app-name') as HTMLAnchorElement;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link.getAttribute('href')).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('header height is 48px', () => {
|
||||||
|
authSpy.isAuthenticated.and.returnValue(true);
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const header = (fixture.nativeElement as HTMLElement).querySelector('header.app-header') as HTMLElement;
|
||||||
|
// The CSS declares height: 48px; we verify the class is applied correctly via the element presence
|
||||||
|
// (actual computed styles require a real browser/DOM environment)
|
||||||
|
expect(header.classList.contains('app-header')).toBeTrue();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,42 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router, RouterOutlet } from '@angular/router';
|
import { Router, RouterLink, RouterOutlet } from '@angular/router';
|
||||||
import { AuthService } from './auth/auth.service';
|
import { AuthService } from './auth/auth.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterOutlet],
|
imports: [CommonModule, RouterLink, RouterOutlet],
|
||||||
template: `
|
template: `
|
||||||
<header class="app-header" *ngIf="auth.isAuthenticated()">
|
<header class="app-header">
|
||||||
<button class="logout-btn" (click)="onLogout()">Sign out</button>
|
<a routerLink="/" class="app-name">Reactbin</a>
|
||||||
|
<button *ngIf="auth.isAuthenticated()" class="logout-btn" (click)="onLogout()">Sign out</button>
|
||||||
</header>
|
</header>
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.app-header { display: flex; justify-content: flex-end; padding: 8px 16px; background: #1a1a1a; border-bottom: 1px solid #333; }
|
:host { display: block; }
|
||||||
.logout-btn { background: none; border: 1px solid #555; color: #aaa; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 0.9rem; }
|
.app-header {
|
||||||
.logout-btn:hover { border-color: #aaa; color: #e0e0e0; }
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.app-name { font-weight: 600; font-size: 1rem; color: var(--text); letter-spacing: 0.02em; text-decoration: none; }
|
||||||
|
.logout-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: border-color var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
.logout-btn:hover { border-color: var(--border-focus); color: var(--text); }
|
||||||
`],
|
`],
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
@@ -26,6 +46,6 @@ export class AppComponent {
|
|||||||
|
|
||||||
onLogout(): void {
|
onLogout(): void {
|
||||||
this.auth.logout();
|
this.auth.logout();
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./upload/upload.component').then((m) => m.UploadComponent),
|
import('./upload/upload.component').then((m) => m.UploadComponent),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'tags',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./tags/tags.component').then((m) => m.TagsComponent),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'images/:id',
|
path: 'images/:id',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { AuthService } from './auth.service';
|
|||||||
|
|
||||||
describe('authGuard', () => {
|
describe('authGuard', () => {
|
||||||
let authService: jasmine.SpyObj<AuthService>;
|
let authService: jasmine.SpyObj<AuthService>;
|
||||||
let router: Router;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authService = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
|
authService = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
|
||||||
@@ -18,8 +17,6 @@ describe('authGuard', () => {
|
|||||||
{ provide: AuthService, useValue: authService },
|
{ provide: AuthService, useValue: authService },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
router = TestBed.inject(Router);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects to login when not authenticated', () => {
|
it('redirects to login when not authenticated', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { HttpClient, HttpErrorResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { authInterceptor } from './auth.interceptor';
|
import { authInterceptor } from './auth.interceptor';
|
||||||
@@ -48,6 +48,7 @@ describe('authInterceptor', () => {
|
|||||||
|
|
||||||
it('redirects to login on 401 response', () => {
|
it('redirects to login on 401 response', () => {
|
||||||
authService.getToken.and.returnValue('test-token');
|
authService.getToken.and.returnValue('test-token');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
http.get('/api/v1/images').subscribe({ error: () => {} });
|
http.get('/api/v1/images').subscribe({ error: () => {} });
|
||||||
const req = httpMock.expectOne('/api/v1/images');
|
const req = httpMock.expectOne('/api/v1/images');
|
||||||
req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
|
req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
|
||||||
|
|||||||
@@ -2,27 +2,19 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
|
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { of } from 'rxjs';
|
import { of, throwError, Subject } from 'rxjs';
|
||||||
import { DetailComponent } from './detail.component';
|
import { DetailComponent } from './detail.component';
|
||||||
import { ImageService } from '../services/image.service';
|
import { ImageService } from '../services/image.service';
|
||||||
import { routes } from '../app.routes';
|
import { routes } from '../app.routes';
|
||||||
|
|
||||||
const MOCK_IMAGE = {
|
const MOCK_IMAGE = {
|
||||||
id: 'img-1',
|
id: 'img-1', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
|
||||||
hash: 'abc',
|
size_bytes: 100, width: 10, height: 10, storage_key: 'abc',
|
||||||
filename: 'test.jpg',
|
thumbnail_key: null, created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
|
||||||
mime_type: 'image/jpeg',
|
|
||||||
size_bytes: 100,
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
storage_key: 'abc',
|
|
||||||
thumbnail_key: null,
|
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
|
||||||
tags: ['cat', 'funny'],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('DetailComponent', () => {
|
describe('DetailComponent', () => {
|
||||||
function setup(imageId = 'img-1') {
|
function setup(imageId = 'img-1', imageResponse = of(MOCK_IMAGE)) {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [DetailComponent],
|
imports: [DetailComponent],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -33,13 +25,13 @@ describe('DetailComponent', () => {
|
|||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const fixture = TestBed.createComponent(DetailComponent);
|
const fixture = TestBed.createComponent(DetailComponent);
|
||||||
const component = fixture.componentInstance;
|
|
||||||
const imgSvc = TestBed.inject(ImageService);
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
spyOn(imgSvc, 'get').and.returnValue(of(MOCK_IMAGE));
|
spyOn(imgSvc, 'get').and.returnValue(imageResponse);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
return { fixture, component, imgSvc };
|
return { fixture, component: fixture.componentInstance, imgSvc };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Existing tests preserved
|
||||||
it('should call PATCH with removed tag absent when chip × is clicked', () => {
|
it('should call PATCH with removed tag absent when chip × is clicked', () => {
|
||||||
const { component, imgSvc } = setup();
|
const { component, imgSvc } = setup();
|
||||||
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
|
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
|
||||||
@@ -80,4 +72,74 @@ describe('DetailComponent', () => {
|
|||||||
component.goBack();
|
component.goBack();
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// New polish tests
|
||||||
|
|
||||||
|
it('skeleton is visible while loading is true', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [DetailComponent],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(), provideHttpClientTesting(), provideRouter(routes),
|
||||||
|
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 'img-1' } } } },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(DetailComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
// Don't emit — keep loading state
|
||||||
|
spyOn(imgSvc, 'get').and.returnValue(new Subject());
|
||||||
|
fixture.componentInstance.loading = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.image-skeleton')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error card shown when error is true and loading is false', () => {
|
||||||
|
const fixture = (() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [DetailComponent],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(), provideHttpClientTesting(), provideRouter(routes),
|
||||||
|
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 'img-1' } } } },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
return TestBed.createComponent(DetailComponent);
|
||||||
|
})();
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'get').and.returnValue(throwError(() => ({ status: 500 })));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.fetch-error-card')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('not-found card shown when image is null, loading is false, error is false', () => {
|
||||||
|
const { fixture, component } = setup('img-1', of(MOCK_IMAGE));
|
||||||
|
component.image = null;
|
||||||
|
component.loading = false;
|
||||||
|
component.error = false;
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.not-found-card')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tag error element uses danger styling class', () => {
|
||||||
|
const { fixture, component } = setup();
|
||||||
|
component.tagError = 'Invalid tag: special characters not allowed';
|
||||||
|
fixture.detectChanges();
|
||||||
|
const errEl = (fixture.nativeElement as HTMLElement).querySelector('.tag-error');
|
||||||
|
expect(errEl).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onImgError sets src to placeholder SVG', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
|
const imgEl = document.createElement('img');
|
||||||
|
imgEl.src = 'http://example.com/image.jpg';
|
||||||
|
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
|
||||||
|
expect(imgEl.src).toContain('data:image/svg+xml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onImgError does not recurse when src already is a data URI', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
|
const imgEl = document.createElement('img');
|
||||||
|
imgEl.src = 'data:image/svg+xml,placeholder';
|
||||||
|
const before = imgEl.src;
|
||||||
|
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
|
||||||
|
expect(imgEl.src).toBe(before);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,58 @@
|
|||||||
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { ImageRecord, ImageService } from '../services/image.service';
|
import { ImageRecord, ImageService } from '../services/image.service';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
|
||||||
|
const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500"><rect width="800" height="500" fill="%23111"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="48" fill="%23444">🔗</text></svg>`;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-detail',
|
selector: 'app-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [CommonModule, FormsModule, RouterLink],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="detail-page" *ngIf="image">
|
<!-- Loading skeleton -->
|
||||||
<button class="back-btn" (click)="goBack()">← Back</button>
|
<div class="detail-page" *ngIf="loading">
|
||||||
|
<div class="skeleton image-skeleton"></div>
|
||||||
|
<div class="chip-row-skeleton">
|
||||||
|
<div *ngFor="let _ of skeletonChips" class="skeleton chip-skeleton"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chip-row-skeleton">
|
||||||
|
<div *ngFor="let _ of skeletonChips" class="skeleton chip-skeleton short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network error state -->
|
||||||
|
<div class="fetch-error-card" *ngIf="error && !loading">
|
||||||
|
<span class="error-icon">⚠</span>
|
||||||
|
<p>Failed to load image. Please check your connection.</p>
|
||||||
|
<div class="error-actions">
|
||||||
|
<button class="retry-btn" (click)="retry()">Retry</button>
|
||||||
|
<a routerLink="/" class="back-link">Back to library</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not-found state -->
|
||||||
|
<div class="not-found-card" *ngIf="!image && !loading && !error">
|
||||||
|
<span class="not-found-icon">✦</span>
|
||||||
|
<h2>Image not found</h2>
|
||||||
|
<p>This image may have been deleted or the URL is incorrect.</p>
|
||||||
|
<a routerLink="/" class="back-btn">Back to library</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="detail-page" *ngIf="image && !loading">
|
||||||
|
<button class="back-btn-inline" (click)="goBack()">← Back</button>
|
||||||
<h2>{{ image.filename }}</h2>
|
<h2>{{ image.filename }}</h2>
|
||||||
|
|
||||||
<img class="full-image" [src]="imageService.getFileUrl(image.id)" [alt]="image.filename" />
|
<img
|
||||||
|
class="full-image"
|
||||||
|
[src]="imageService.getFileUrl(image.id)"
|
||||||
|
[alt]="image.filename"
|
||||||
|
(error)="onImgError($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
<section class="tags-section">
|
<section class="tags-section">
|
||||||
<h3>Tags</h3>
|
<h3>Tags</h3>
|
||||||
@@ -24,7 +61,11 @@ import { AuthService } from '../auth/auth.service';
|
|||||||
{{ tag }} <button *ngIf="auth.isAuthenticated()" (click)="removeTag(tag)">×</button>
|
{{ tag }} <button *ngIf="auth.isAuthenticated()" (click)="removeTag(tag)">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="add-tag" *ngIf="auth.isAuthenticated()">
|
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="owner-actions" *ngIf="auth.isAuthenticated()">
|
||||||
|
<div class="add-tag">
|
||||||
<input
|
<input
|
||||||
[(ngModel)]="newTagInput"
|
[(ngModel)]="newTagInput"
|
||||||
placeholder="Add tag…"
|
placeholder="Add tag…"
|
||||||
@@ -32,11 +73,9 @@ import { AuthService } from '../auth/auth.service';
|
|||||||
(blur)="onBlur()"
|
(blur)="onBlur()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p>
|
<button class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<button *ngIf="auth.isAuthenticated()" class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
|
|
||||||
|
|
||||||
<div class="dialog-overlay" *ngIf="showDeleteDialog">
|
<div class="dialog-overlay" *ngIf="showDeleteDialog">
|
||||||
<div class="dialog">
|
<div class="dialog">
|
||||||
<p>Permanently delete this image?</p>
|
<p>Permanently delete this image?</p>
|
||||||
@@ -45,34 +84,78 @@ import { AuthService } from '../auth/auth.service';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p *ngIf="!image && !loading" class="not-found">Image not found.</p>
|
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.detail-page { max-width: 900px; margin: 32px auto; padding: 0 16px; }
|
.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; }
|
/* Skeleton */
|
||||||
|
.image-skeleton { width: 100%; height: 400px; margin-bottom: 16px; }
|
||||||
|
.chip-row-skeleton { display: flex; gap: 8px; margin-bottom: 10px; padding: 0 16px; }
|
||||||
|
.chip-skeleton { width: 64px; height: 28px; border-radius: var(--radius-chip); }
|
||||||
|
.chip-skeleton.short { width: 48px; }
|
||||||
|
|
||||||
|
/* Network error card */
|
||||||
|
.fetch-error-card {
|
||||||
|
max-width: 520px; margin: 80px auto; text-align: center;
|
||||||
|
padding: 40px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.error-icon { display: block; font-size: 2rem; color: var(--danger); margin-bottom: 12px; }
|
||||||
|
.error-actions { display: flex; justify-content: center; gap: 16px; margin-top: 20px; }
|
||||||
|
.retry-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; }
|
||||||
|
.retry-btn:hover { border-color: var(--border-focus); }
|
||||||
|
.back-link { color: var(--accent); text-decoration: none; line-height: 2.2; }
|
||||||
|
|
||||||
|
/* Not-found card */
|
||||||
|
.not-found-card {
|
||||||
|
max-width: 480px; margin: 80px auto; text-align: center;
|
||||||
|
padding: 48px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.not-found-icon { display: block; font-size: 2.5rem; color: var(--text-muted); margin-bottom: 16px; }
|
||||||
|
.not-found-card h2 { margin-bottom: 8px; }
|
||||||
|
.not-found-card p { color: var(--text-muted); margin-bottom: 24px; }
|
||||||
|
|
||||||
|
/* Main detail */
|
||||||
|
.back-btn-inline { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 1rem; margin-bottom: 16px; padding: 0; }
|
||||||
|
.back-btn {
|
||||||
|
display: inline-block; margin-top: 16px; padding: 10px 24px;
|
||||||
|
background: var(--surface-raised); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; text-decoration: none;
|
||||||
|
}
|
||||||
|
.back-btn:hover { border-color: var(--border-focus); }
|
||||||
|
.full-image { width: 100%; max-height: 70vh; object-fit: contain; background: #111; border-radius: var(--radius); display: block; }
|
||||||
.tags-section { margin-top: 24px; }
|
.tags-section { margin-top: 24px; }
|
||||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
.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 { background: var(--surface-raised); padding: 4px 12px; border-radius: var(--radius-chip); display: flex; align-items: center; gap: 6px; }
|
||||||
.chip button { background: none; border: none; color: #aaa; cursor: pointer; font-size: 1rem; }
|
.chip button { background: none; border: none; color: var(--text-muted); 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: var(--danger); font-size: 0.85rem; margin-top: 6px; border-left: 3px solid var(--danger); padding-left: 10px; }
|
||||||
.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; }
|
/* Owner actions panel */
|
||||||
|
.owner-actions {
|
||||||
|
margin-top: 24px; padding: 20px;
|
||||||
|
background: var(--surface); border-top: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.add-tag input { padding: 8px; background: var(--surface-raised); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); width: 200px; }
|
||||||
|
.add-tag input:focus { outline: none; border-color: var(--border-focus); }
|
||||||
|
.delete-btn { padding: 10px 24px; background: var(--danger); color: var(--danger-text); border: none; border-radius: var(--radius); cursor: pointer; margin-left: auto; }
|
||||||
|
|
||||||
|
/* Delete dialog */
|
||||||
.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-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 { background: var(--surface); 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 { margin: 12px 8px 0; padding: 8px 20px; border: none; border-radius: var(--radius); cursor: pointer; }
|
||||||
.dialog button:first-of-type { background: #c0392b; color: #fff; }
|
.dialog button:first-of-type { background: var(--danger); color: var(--danger-text); }
|
||||||
.dialog button:last-of-type { background: #444; color: #e0e0e0; }
|
.dialog button:last-of-type { background: var(--surface-raised); color: var(--text); }
|
||||||
.not-found { text-align: center; color: #666; padding: 60px; }
|
|
||||||
`],
|
`],
|
||||||
})
|
})
|
||||||
export class DetailComponent implements OnInit {
|
export class DetailComponent implements OnInit {
|
||||||
image: ImageRecord | null = null;
|
image: ImageRecord | null = null;
|
||||||
loading = true;
|
loading = true;
|
||||||
|
error = false;
|
||||||
newTagInput = '';
|
newTagInput = '';
|
||||||
tagError = '';
|
tagError = '';
|
||||||
showDeleteDialog = false;
|
showDeleteDialog = false;
|
||||||
|
readonly skeletonChips = Array(4).fill(null);
|
||||||
|
private currentId = '';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public imageService: ImageService,
|
public imageService: ImageService,
|
||||||
@@ -85,9 +168,35 @@ export class DetailComponent implements OnInit {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const id = this.route.snapshot.paramMap.get('id');
|
const id = this.route.snapshot.paramMap.get('id');
|
||||||
if (!id) { this.loading = false; return; }
|
if (!id) { this.loading = false; return; }
|
||||||
|
this.currentId = id;
|
||||||
|
this.fetchImage(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(): void {
|
||||||
|
this.error = false;
|
||||||
|
this.loading = true;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
this.fetchImage(this.currentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchImage(id: string): void {
|
||||||
this.imageService.get(id).subscribe({
|
this.imageService.get(id).subscribe({
|
||||||
next: (img) => { this.image = img; this.loading = false; this.cdr.markForCheck(); },
|
next: (img) => {
|
||||||
error: () => { this.loading = false; this.cdr.markForCheck(); },
|
this.image = img;
|
||||||
|
this.loading = false;
|
||||||
|
this.error = false;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.loading = false;
|
||||||
|
if (err?.status === 404) {
|
||||||
|
this.image = null;
|
||||||
|
this.error = false;
|
||||||
|
} else {
|
||||||
|
this.error = true;
|
||||||
|
}
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,4 +236,11 @@ export class DetailComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
goBack(): void { this.router.navigate(['/']); }
|
goBack(): void { this.router.navigate(['/']); }
|
||||||
|
|
||||||
|
onImgError(event: Event): void {
|
||||||
|
const img = event.target as HTMLImageElement;
|
||||||
|
if (!img.src.startsWith('data:')) {
|
||||||
|
img.src = PLACEHOLDER_SVG;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter, ActivatedRoute } from '@angular/router';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { LibraryComponent } from './library.component';
|
import { LibraryComponent } from './library.component';
|
||||||
import { ImageService } from '../services/image.service';
|
import { ImageService } from '../services/image.service';
|
||||||
import { routes } from '../app.routes';
|
import { routes } from '../app.routes';
|
||||||
|
|
||||||
|
function makeActivatedRoute(queryParams: Record<string, string> = {}) {
|
||||||
|
return {
|
||||||
|
snapshot: {
|
||||||
|
queryParamMap: {
|
||||||
|
get: (key: string) => queryParams[key] ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_PAGE = { items: [], total: 0, limit: 50, offset: 0 };
|
||||||
|
const ONE_IMAGE = {
|
||||||
|
items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, created_at: '' }],
|
||||||
|
total: 1, limit: 50, offset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
describe('LibraryComponent', () => {
|
describe('LibraryComponent', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -17,33 +33,116 @@ describe('LibraryComponent', () => {
|
|||||||
|
|
||||||
it('should render image grid from service response', () => {
|
it('should render image grid from service response', () => {
|
||||||
const fixture = TestBed.createComponent(LibraryComponent);
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
const component = fixture.componentInstance;
|
|
||||||
const imgSvc = TestBed.inject(ImageService);
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
spyOn(imgSvc, 'list').and.returnValue(
|
spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE));
|
||||||
of({
|
|
||||||
items: [
|
|
||||||
{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, created_at: '' },
|
|
||||||
],
|
|
||||||
total: 1,
|
|
||||||
limit: 50,
|
|
||||||
offset: 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const de = fixture.nativeElement as HTMLElement;
|
expect((fixture.nativeElement as HTMLElement).querySelectorAll('.image-card').length).toBe(1);
|
||||||
expect(de.querySelectorAll('.image-card').length).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should trigger new API call with tags param on filter change', () => {
|
it('should trigger new API call with tags param on filter change', () => {
|
||||||
const fixture = TestBed.createComponent(LibraryComponent);
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
const component = fixture.componentInstance;
|
|
||||||
const imgSvc = TestBed.inject(ImageService);
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
const listSpy = spyOn(imgSvc, 'list').and.returnValue(
|
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
|
||||||
of({ items: [], total: 0, limit: 50, offset: 0 })
|
|
||||||
);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.applyFilter(['cat', 'funny']);
|
||||||
component.applyFilter(['cat', 'funny']);
|
|
||||||
expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number));
|
expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('showSpinner is false initially', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.componentInstance.showSpinner).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders 8 skeleton cards while showSpinner is true', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
fixture.componentInstance.showSpinner = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
const skeletons = (fixture.nativeElement as HTMLElement).querySelectorAll('.card-skeleton');
|
||||||
|
expect(skeletons.length).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error is false initially', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.componentInstance.error).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error card when error is true', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
fixture.componentInstance.error = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.error-card')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error card has retry button that calls load()', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
fixture.componentInstance.error = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
spyOn(fixture.componentInstance, 'load');
|
||||||
|
const retryBtn = (fixture.nativeElement as HTMLElement).querySelector('.error-card .retry-btn') as HTMLButtonElement;
|
||||||
|
expect(retryBtn).not.toBeNull();
|
||||||
|
retryBtn.click();
|
||||||
|
expect(fixture.componentInstance.load).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty state contains routerLink to /upload', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const link = (fixture.nativeElement as HTMLElement).querySelector('.empty-state a[href="/upload"]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onImgError sets src to placeholder SVG', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgEl = document.createElement('img');
|
||||||
|
imgEl.src = 'http://example.com/image.jpg';
|
||||||
|
const event = { target: imgEl } as unknown as Event;
|
||||||
|
fixture.componentInstance.onImgError(event);
|
||||||
|
expect(imgEl.src).toContain('data:image/svg+xml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onImgError does not recurse when src already contains placeholder', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgEl = document.createElement('img');
|
||||||
|
imgEl.src = 'data:image/svg+xml,placeholder';
|
||||||
|
const originalSrc = imgEl.src;
|
||||||
|
const event = { target: imgEl } as unknown as Event;
|
||||||
|
fixture.componentInstance.onImgError(event);
|
||||||
|
expect(imgEl.src).toBe(originalSrc);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-populates activeFilters from ?tags= query param on init', () => {
|
||||||
|
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ tags: 'cat,funny' }) });
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.componentInstance.activeFilters).toEqual(['cat', 'funny']);
|
||||||
|
expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set activeFilters when no ?tags= param present', () => {
|
||||||
|
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute() });
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.componentInstance.activeFilters).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('header contains a link to /tags', () => {
|
||||||
|
const fixture = TestBed.createComponent(LibraryComponent);
|
||||||
|
const imgSvc = TestBed.inject(ImageService);
|
||||||
|
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const link = (fixture.nativeElement as HTMLElement).querySelector('a[href="/tags"]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,21 +5,27 @@ import {
|
|||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
|
||||||
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
|
import { Subject, debounceTime, distinctUntilChanged, share, timer } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
import { ImageRecord, ImageService } from '../services/image.service';
|
import { ImageRecord, ImageService } from '../services/image.service';
|
||||||
import { TagService } from '../services/tag.service';
|
import { TagService } from '../services/tag.service';
|
||||||
|
|
||||||
|
const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="160" viewBox="0 0 200 160"><rect width="200" height="160" fill="%23252525"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="32" fill="%23555">🖼</text></svg>`;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-library',
|
selector: 'app-library',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule, RouterLink],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="library">
|
<div class="library">
|
||||||
<header>
|
<header>
|
||||||
<h1>Reactbin</h1>
|
<h1>Reactbin</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a routerLink="/tags" class="tags-link">Browse tags</a>
|
||||||
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
|
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
@@ -34,49 +40,86 @@ import { TagService } from '../services/tag.service';
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="suggestions" *ngIf="suggestions.length">
|
<ul class="suggestions" *ngIf="suggestions.length">
|
||||||
<li *ngFor="let s of suggestions" (click)="addFilter(s.name)">{{ s.name }} ({{ s.image_count }})</li>
|
<li *ngFor="let s of suggestions" (click)="addFilter(s.name)" (keydown.enter)="addFilter(s.name)" tabindex="0" role="option" [attr.aria-selected]="false">{{ s.name }} ({{ s.image_count }})</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="images.length === 0 && !loading" class="empty-state">
|
<!-- Skeleton loading grid -->
|
||||||
<p>{{ activeFilters.length ? 'No images match these filters.' : 'No images yet. Upload your first!' }}</p>
|
<div *ngIf="showSpinner" class="grid">
|
||||||
|
<div *ngFor="let _ of skeletonItems" class="image-card skeleton card-skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid">
|
<!-- Error state -->
|
||||||
|
<div *ngIf="error && !showSpinner" class="error-card">
|
||||||
|
<p>Failed to load images. Please check your connection.</p>
|
||||||
|
<button class="retry-btn" (click)="load()">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div *ngIf="images.length === 0 && !showSpinner && !error" class="empty-state">
|
||||||
|
<span class="empty-icon">✦</span>
|
||||||
|
<p *ngIf="activeFilters.length">No images match these filters.</p>
|
||||||
|
<p *ngIf="!activeFilters.length">No images yet.</p>
|
||||||
|
<a *ngIf="!activeFilters.length" routerLink="/upload" class="upload-link">Upload your first image</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image grid -->
|
||||||
|
<div *ngIf="!showSpinner && !error" class="grid">
|
||||||
<div
|
<div
|
||||||
*ngFor="let img of images"
|
*ngFor="let img of images"
|
||||||
class="image-card"
|
class="image-card"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
(click)="router.navigate(['/images', img.id])"
|
(click)="router.navigate(['/images', img.id])"
|
||||||
|
(keydown.enter)="router.navigate(['/images', img.id])"
|
||||||
>
|
>
|
||||||
<img [src]="imageService.getThumbnailUrl(img.id)" [alt]="img.filename" loading="lazy" />
|
<img
|
||||||
|
[src]="imageService.getThumbnailUrl(img.id)"
|
||||||
|
[alt]="img.filename"
|
||||||
|
loading="lazy"
|
||||||
|
(error)="onImgError($event)"
|
||||||
|
/>
|
||||||
<div class="tag-row">
|
<div class="tag-row">
|
||||||
<span *ngFor="let tag of img.tags" class="chip small">{{ tag }}</span>
|
<span *ngFor="let tag of img.tags" class="chip small">{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button *ngIf="hasMore" class="load-more" (click)="loadMore()">Load more</button>
|
<button *ngIf="hasMore && !showSpinner && !error" class="load-more" (click)="loadMore()">Load more</button>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.library { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
|
.library { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
|
||||||
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
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; }
|
.header-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.tags-link { color: var(--text-muted); text-decoration: none; font-size: 0.9rem; transition: color var(--transition); }
|
||||||
|
.tags-link:hover { color: var(--text); }
|
||||||
|
.upload-btn { padding: 8px 20px; background: var(--accent); color: var(--accent-text); border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600; }
|
||||||
.filter-bar { position: relative; margin-bottom: 24px; }
|
.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; }
|
.filter-bar input { width: 100%; padding: 10px; background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); }
|
||||||
|
.filter-bar input:focus { outline: none; border-color: var(--border-focus); }
|
||||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
.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 { background: var(--surface-raised); padding: 3px 10px; border-radius: var(--radius-chip); font-size: 0.85rem; display: flex; align-items: center; gap: 4px; }
|
||||||
.chip.small { font-size: 0.75rem; padding: 2px 8px; }
|
.chip.small { font-size: 0.75rem; padding: 2px 8px; }
|
||||||
.chip button { background: none; border: none; color: #aaa; cursor: pointer; padding: 0; font-size: 1rem; }
|
.chip button { background: none; border: none; color: var(--text-muted); 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 { position: absolute; z-index: 10; background: var(--surface); border: 1px solid var(--border); list-style: none; width: 100%; max-height: 200px; overflow-y: auto; border-radius: 0 0 var(--radius) var(--radius); }
|
||||||
.suggestions li { padding: 8px 12px; cursor: pointer; }
|
.suggestions li { padding: 8px 12px; cursor: pointer; }
|
||||||
.suggestions li:hover { background: #2a2a2a; }
|
.suggestions li:hover { background: var(--surface-raised); }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
|
||||||
.image-card { cursor: pointer; background: #1a1a1a; border-radius: 8px; overflow: hidden; }
|
.image-card { cursor: pointer; background: var(--surface); border-radius: var(--radius); overflow: hidden; transition: transform var(--transition), box-shadow var(--transition); }
|
||||||
|
.image-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
|
||||||
.image-card img { width: 100%; height: 160px; object-fit: cover; display: block; }
|
.image-card img { width: 100%; height: 160px; object-fit: cover; display: block; }
|
||||||
|
.card-skeleton { height: 200px; }
|
||||||
.tag-row { padding: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
|
.tag-row { padding: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
.empty-state { text-align: center; padding: 60px 0; color: #666; }
|
.empty-state { text-align: center; padding: 60px 0; color: var(--text-muted); }
|
||||||
.load-more { display: block; margin: 24px auto; padding: 10px 32px; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 6px; cursor: pointer; }
|
.empty-icon { display: block; font-size: 2rem; margin-bottom: 12px; }
|
||||||
|
.upload-link { display: inline-block; margin-top: 16px; color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||||
|
.upload-link:hover { text-decoration: underline; }
|
||||||
|
.error-card { text-align: center; padding: 40px; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border); }
|
||||||
|
.error-card p { color: var(--text-muted); margin-bottom: 16px; }
|
||||||
|
.retry-btn { padding: 8px 24px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
|
||||||
|
.retry-btn:hover { border-color: var(--border-focus); }
|
||||||
|
.load-more { display: block; margin: 24px auto; padding: 10px 32px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; }
|
||||||
`],
|
`],
|
||||||
})
|
})
|
||||||
export class LibraryComponent implements OnInit {
|
export class LibraryComponent implements OnInit {
|
||||||
@@ -84,8 +127,10 @@ export class LibraryComponent implements OnInit {
|
|||||||
activeFilters: string[] = [];
|
activeFilters: string[] = [];
|
||||||
tagSearch = '';
|
tagSearch = '';
|
||||||
suggestions: { name: string; image_count: number }[] = [];
|
suggestions: { name: string; image_count: number }[] = [];
|
||||||
loading = false;
|
showSpinner = false;
|
||||||
|
error = false;
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
|
readonly skeletonItems = Array(8).fill(null);
|
||||||
private offset = 0;
|
private offset = 0;
|
||||||
private readonly limit = 50;
|
private readonly limit = 50;
|
||||||
private readonly filterChange$ = new Subject<string>();
|
private readonly filterChange$ = new Subject<string>();
|
||||||
@@ -95,10 +140,15 @@ export class LibraryComponent implements OnInit {
|
|||||||
private tagService: TagService,
|
private tagService: TagService,
|
||||||
public router: Router,
|
public router: Router,
|
||||||
private cdr: ChangeDetectorRef,
|
private cdr: ChangeDetectorRef,
|
||||||
|
private route: ActivatedRoute,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadImages();
|
const tagsParam = this.route.snapshot.queryParamMap.get('tags');
|
||||||
|
if (tagsParam) {
|
||||||
|
this.activeFilters = tagsParam.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
|
||||||
|
}
|
||||||
|
this.load();
|
||||||
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
|
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
|
||||||
if (q) {
|
if (q) {
|
||||||
this.tagService.list(q, 10).subscribe((r) => {
|
this.tagService.list(q, 10).subscribe((r) => {
|
||||||
@@ -112,6 +162,29 @@ export class LibraryComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.error = false;
|
||||||
|
const req$ = this.imageService.list(this.activeFilters, this.limit, this.offset).pipe(share());
|
||||||
|
timer(150).pipe(takeUntil(req$)).subscribe(() => {
|
||||||
|
this.showSpinner = true;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
});
|
||||||
|
req$.subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.images = [...this.images, ...res.items];
|
||||||
|
this.offset += res.items.length;
|
||||||
|
this.hasMore = this.offset < res.total;
|
||||||
|
this.showSpinner = false;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.showSpinner = false;
|
||||||
|
this.error = true;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onTagInput(event: Event): void {
|
onTagInput(event: Event): void {
|
||||||
const val = (event.target as HTMLInputElement).value;
|
const val = (event.target as HTMLInputElement).value;
|
||||||
this.tagSearch = val;
|
this.tagSearch = val;
|
||||||
@@ -136,21 +209,17 @@ export class LibraryComponent implements OnInit {
|
|||||||
this.activeFilters = tags;
|
this.activeFilters = tags;
|
||||||
this.offset = 0;
|
this.offset = 0;
|
||||||
this.images = [];
|
this.images = [];
|
||||||
this.loadImages();
|
this.load();
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
loadMore(): void {
|
||||||
this.loadImages();
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onImgError(event: Event): void {
|
||||||
|
const img = event.target as HTMLImageElement;
|
||||||
|
if (!img.src.startsWith('data:')) {
|
||||||
|
img.src = PLACEHOLDER_SVG;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,4 +60,48 @@ describe('LoginComponent', () => {
|
|||||||
tick();
|
tick();
|
||||||
expect(authService.login).not.toHaveBeenCalled();
|
expect(authService.login).not.toHaveBeenCalled();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// New polish tests
|
||||||
|
|
||||||
|
it('submit button shows "Signing in…" and is disabled while loading', () => {
|
||||||
|
const fixture = TestBed.createComponent(LoginComponent);
|
||||||
|
const comp = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
comp.loading = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
const btn = (fixture.nativeElement as HTMLElement).querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||||
|
expect(btn.textContent?.trim()).toContain('Signing in');
|
||||||
|
expect(btn.disabled).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('field-level validation error shown for empty username on touched', () => {
|
||||||
|
const fixture = TestBed.createComponent(LoginComponent);
|
||||||
|
const comp = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
comp.form.get('username')!.markAsTouched();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const err = (fixture.nativeElement as HTMLElement).querySelector('.validation-error');
|
||||||
|
expect(err).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errorMessage paragraph is visible when errorMessage is set', fakeAsync(() => {
|
||||||
|
authService.login.and.returnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
|
||||||
|
component.form.setValue({ username: 'owner', password: 'wrong' });
|
||||||
|
component.onSubmit();
|
||||||
|
tick();
|
||||||
|
const fixture = TestBed.createComponent(LoginComponent);
|
||||||
|
fixture.componentInstance.errorMessage = component.errorMessage;
|
||||||
|
fixture.detectChanges();
|
||||||
|
const errPara = (fixture.nativeElement as HTMLElement).querySelector('.error-message');
|
||||||
|
expect(errPara).not.toBeNull();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('fields retain their values after a failed login', fakeAsync(() => {
|
||||||
|
authService.login.and.returnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
|
||||||
|
component.form.setValue({ username: 'owner', password: 'wrong' });
|
||||||
|
component.onSubmit();
|
||||||
|
tick();
|
||||||
|
expect(component.form.value.username).toBe('owner');
|
||||||
|
expect(component.form.value.password).toBe('wrong');
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,18 +10,19 @@ import { AuthService } from '../auth/auth.service';
|
|||||||
imports: [CommonModule, ReactiveFormsModule],
|
imports: [CommonModule, ReactiveFormsModule],
|
||||||
template: `
|
template: `
|
||||||
<div class="login-page">
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
<h1>Sign In</h1>
|
<h1>Sign In</h1>
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
|
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input id="username" type="text" formControlName="username" />
|
<input id="username" type="text" formControlName="username" autocomplete="username" />
|
||||||
<span *ngIf="form.get('username')?.invalid && form.get('username')?.touched" class="validation-error">
|
<span *ngIf="form.get('username')?.invalid && form.get('username')?.touched" class="validation-error">
|
||||||
Username is required
|
Username is required
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input id="password" type="password" formControlName="password" />
|
<input id="password" type="password" formControlName="password" autocomplete="current-password" />
|
||||||
<span *ngIf="form.get('password')?.invalid && form.get('password')?.touched" class="validation-error">
|
<span *ngIf="form.get('password')?.invalid && form.get('password')?.touched" class="validation-error">
|
||||||
Password is required
|
Password is required
|
||||||
</span>
|
</span>
|
||||||
@@ -32,7 +33,46 @@ import { AuthService } from '../auth/auth.service';
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`,
|
`,
|
||||||
|
styles: [`
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 40px 32px;
|
||||||
|
}
|
||||||
|
h1 { margin-bottom: 28px; font-size: 1.5rem; }
|
||||||
|
.field { margin-bottom: 20px; }
|
||||||
|
label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: var(--text-muted); }
|
||||||
|
input[type="text"], input[type="password"] {
|
||||||
|
width: 100%; padding: 10px 12px;
|
||||||
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
color: var(--text); border-radius: var(--radius);
|
||||||
|
font-size: 1rem; transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
input:focus { outline: none; border-color: var(--border-focus); }
|
||||||
|
.validation-error { display: block; margin-top: 4px; font-size: 0.8rem; color: var(--danger); }
|
||||||
|
.error-message { margin-bottom: 16px; color: var(--danger); font-size: 0.9rem; }
|
||||||
|
button[type="submit"] {
|
||||||
|
width: 100%; padding: 11px;
|
||||||
|
background: var(--accent); color: var(--accent-text);
|
||||||
|
border: none; border-radius: var(--radius);
|
||||||
|
font-size: 1rem; font-weight: 600; cursor: pointer;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
button[type="submit"]:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
`],
|
||||||
})
|
})
|
||||||
export class LoginComponent {
|
export class LoginComponent {
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
@@ -53,6 +93,7 @@ export class LoginComponent {
|
|||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.form.invalid) {
|
if (this.form.invalid) {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|||||||
@@ -30,4 +30,26 @@ describe('TagService', () => {
|
|||||||
expect(req.request.params.has('q')).toBeFalse();
|
expect(req.request.params.has('q')).toBeFalse();
|
||||||
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
|
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should include sort param when provided', () => {
|
||||||
|
service.list('', 100, 0, 'count_desc').subscribe();
|
||||||
|
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
|
||||||
|
expect(req.request.params.get('sort')).toBe('count_desc');
|
||||||
|
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include min_count param when minCount is provided', () => {
|
||||||
|
service.list('', 500, 0, 'count_desc', 1).subscribe();
|
||||||
|
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
|
||||||
|
expect(req.request.params.get('min_count')).toBe('1');
|
||||||
|
req.flush({ items: [], total: 0, limit: 500, offset: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should omit sort and min_count when not provided', () => {
|
||||||
|
service.list('cat').subscribe();
|
||||||
|
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
|
||||||
|
expect(req.request.params.has('sort')).toBeFalse();
|
||||||
|
expect(req.request.params.has('min_count')).toBeFalse();
|
||||||
|
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,11 +21,17 @@ export class TagService {
|
|||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
list(prefix?: string, limit = 100, offset = 0): Observable<TagListResponse> {
|
list(prefix = '', limit = 100, offset = 0, sort?: string, minCount?: number): Observable<TagListResponse> {
|
||||||
let params = new HttpParams().set('limit', limit).set('offset', offset);
|
let params = new HttpParams().set('limit', limit).set('offset', offset);
|
||||||
if (prefix) {
|
if (prefix) {
|
||||||
params = params.set('q', prefix);
|
params = params.set('q', prefix);
|
||||||
}
|
}
|
||||||
|
if (sort) {
|
||||||
|
params = params.set('sort', sort);
|
||||||
|
}
|
||||||
|
if (minCount !== undefined) {
|
||||||
|
params = params.set('min_count', minCount);
|
||||||
|
}
|
||||||
return this.http.get<TagListResponse>(`${this.base}/tags`, { params });
|
return this.http.get<TagListResponse>(`${this.base}/tags`, { params });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
ui/src/app/tags/tags.component.spec.ts
Normal file
102
ui/src/app/tags/tags.component.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { Subject, of, throwError } from 'rxjs';
|
||||||
|
import { TagsComponent } from './tags.component';
|
||||||
|
import { TagService, TagListResponse } from '../services/tag.service';
|
||||||
|
import { routes } from '../app.routes';
|
||||||
|
|
||||||
|
const TAGS_PAGE = (items: { name: string; image_count: number }[]): TagListResponse => ({
|
||||||
|
items: items.map((t, i) => ({ id: String(i), ...t })),
|
||||||
|
total: items.length,
|
||||||
|
limit: 500,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TagsComponent', () => {
|
||||||
|
let tagSvc: jasmine.SpyObj<TagService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tagSvc = jasmine.createSpyObj('TagService', ['list']);
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [TagsComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: TagService, useValue: tagSvc },
|
||||||
|
provideRouter(routes),
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows skeleton while loading', () => {
|
||||||
|
// list() never resolves during this test
|
||||||
|
tagSvc.list.and.returnValue(new Subject<never>().asObservable());
|
||||||
|
const fixture = TestBed.createComponent(TagsComponent);
|
||||||
|
fixture.componentInstance.showSpinner = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.skeleton')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders tag list with name and count after load', () => {
|
||||||
|
tagSvc.list.and.returnValue(of(TAGS_PAGE([
|
||||||
|
{ name: 'cat', image_count: 5 },
|
||||||
|
{ name: 'dog', image_count: 2 },
|
||||||
|
])));
|
||||||
|
const fixture = TestBed.createComponent(TagsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const items = (fixture.nativeElement as HTMLElement).querySelectorAll('.tag-item');
|
||||||
|
expect(items.length).toBe(2);
|
||||||
|
expect(items[0].textContent).toContain('cat');
|
||||||
|
expect(items[0].textContent).toContain('5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags are ordered by count descending (service is called with count_desc)', () => {
|
||||||
|
tagSvc.list.and.returnValue(of(TAGS_PAGE([])));
|
||||||
|
const fixture = TestBed.createComponent(TagsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tagSvc.list).toHaveBeenCalledWith('', 500, 0, 'count_desc', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when tag list is empty', () => {
|
||||||
|
tagSvc.list.and.returnValue(of(TAGS_PAGE([])));
|
||||||
|
const fixture = TestBed.createComponent(TagsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.empty-state')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state on fetch failure', () => {
|
||||||
|
tagSvc.list.and.returnValue(throwError(() => new Error('network')));
|
||||||
|
const fixture = TestBed.createComponent(TagsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.error-card')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retry button in error state calls load again', () => {
|
||||||
|
tagSvc.list.and.returnValue(throwError(() => new Error('network')));
|
||||||
|
const fixture = TestBed.createComponent(TagsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
spyOn(fixture.componentInstance, 'load');
|
||||||
|
const btn = (fixture.nativeElement as HTMLElement).querySelector('.error-card .retry-btn') as HTMLButtonElement;
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
btn.click();
|
||||||
|
expect(fixture.componentInstance.load).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each tag item links to /?tags=<tagname>', () => {
|
||||||
|
tagSvc.list.and.returnValue(of(TAGS_PAGE([
|
||||||
|
{ name: 'funny', image_count: 3 },
|
||||||
|
])));
|
||||||
|
const fixture = TestBed.createComponent(TagsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const link = (fixture.nativeElement as HTMLElement).querySelector('.tag-item a') as HTMLAnchorElement;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link.getAttribute('href')).toBe('/?tags=funny');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without requiring authentication', () => {
|
||||||
|
tagSvc.list.and.returnValue(of(TAGS_PAGE([{ name: 'test', image_count: 1 }])));
|
||||||
|
// No AuthService injected — component must not depend on it
|
||||||
|
const fixture = TestBed.createComponent(TagsComponent);
|
||||||
|
expect(() => fixture.detectChanges()).not.toThrow();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.tag-item')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
96
ui/src/app/tags/tags.component.ts
Normal file
96
ui/src/app/tags/tags.component.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { TagRecord, TagService } from '../services/tag.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tags',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div class="tags-page">
|
||||||
|
<header class="tags-header">
|
||||||
|
<h1>Browse Tags</h1>
|
||||||
|
<a routerLink="/" class="back-link">← Library</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Skeleton -->
|
||||||
|
<div *ngIf="showSpinner" class="tag-grid">
|
||||||
|
<div *ngFor="let _ of skeletonItems" class="tag-item skeleton tag-skeleton"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div *ngIf="error && !showSpinner" class="error-card">
|
||||||
|
<p>Failed to load tags. Please check your connection.</p>
|
||||||
|
<button class="retry-btn" (click)="load()">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div *ngIf="!showSpinner && !error && tags.length === 0" class="empty-state">
|
||||||
|
<span class="empty-icon">✦</span>
|
||||||
|
<p>No tags yet. Upload some images and add tags to get started.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tag grid -->
|
||||||
|
<div *ngIf="!showSpinner && !error && tags.length > 0" class="tag-grid">
|
||||||
|
<div *ngFor="let tag of tags" class="tag-item">
|
||||||
|
<a [routerLink]="['/']" [queryParams]="{ tags: tag.name }" class="tag-link">
|
||||||
|
<span class="tag-name">{{ tag.name }}</span>
|
||||||
|
<span class="tag-count">{{ tag.image_count }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.tags-page { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
|
||||||
|
.tags-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||||
|
.tags-header h1 { margin: 0; }
|
||||||
|
.back-link { color: var(--text-muted); text-decoration: none; font-size: 0.9rem; }
|
||||||
|
.back-link:hover { color: var(--text); }
|
||||||
|
.tag-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
|
||||||
|
.tag-item { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); transition: border-color var(--transition); }
|
||||||
|
.tag-item:hover { border-color: var(--border-focus); }
|
||||||
|
.tag-skeleton { height: 56px; }
|
||||||
|
.tag-link { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; text-decoration: none; color: var(--text); }
|
||||||
|
.tag-name { font-size: 0.95rem; font-weight: 500; }
|
||||||
|
.tag-count { font-size: 0.8rem; color: var(--text-muted); background: var(--surface-raised); padding: 2px 8px; border-radius: var(--radius-chip); }
|
||||||
|
.empty-state { text-align: center; padding: 60px 0; color: var(--text-muted); }
|
||||||
|
.empty-icon { display: block; font-size: 2rem; margin-bottom: 12px; }
|
||||||
|
.error-card { text-align: center; padding: 40px; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border); }
|
||||||
|
.error-card p { color: var(--text-muted); margin-bottom: 16px; }
|
||||||
|
.retry-btn { padding: 8px 24px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
|
||||||
|
.retry-btn:hover { border-color: var(--border-focus); }
|
||||||
|
`],
|
||||||
|
})
|
||||||
|
export class TagsComponent implements OnInit {
|
||||||
|
tags: TagRecord[] = [];
|
||||||
|
showSpinner = false;
|
||||||
|
error = false;
|
||||||
|
readonly skeletonItems = Array(12).fill(null);
|
||||||
|
|
||||||
|
constructor(private tagService: TagService, private cdr: ChangeDetectorRef) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.error = false;
|
||||||
|
this.showSpinner = true;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
this.tagService.list('', 500, 0, 'count_desc', 1).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.tags = res.items;
|
||||||
|
this.showSpinner = false;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.showSpinner = false;
|
||||||
|
this.error = true;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,12 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { provideRouter, Router } from '@angular/router';
|
import { provideRouter, Router } from '@angular/router';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { of, throwError } from 'rxjs';
|
|
||||||
import { UploadComponent } from './upload.component';
|
import { UploadComponent } from './upload.component';
|
||||||
import { ImageService } from '../services/image.service';
|
|
||||||
import { routes } from '../app.routes';
|
import { routes } from '../app.routes';
|
||||||
|
|
||||||
describe('UploadComponent', () => {
|
describe('UploadComponent', () => {
|
||||||
let component: UploadComponent;
|
let component: UploadComponent;
|
||||||
|
|
||||||
function makeImageService(overrides: Partial<ImageService> = {}): jasmine.SpyObj<ImageService> {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return jasmine.createSpyObj<ImageService>('ImageService', { upload: of({} as any), ...overrides } as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [UploadComponent],
|
imports: [UploadComponent],
|
||||||
@@ -26,22 +19,17 @@ describe('UploadComponent', () => {
|
|||||||
const fixture = TestBed.createComponent(UploadComponent);
|
const fixture = TestBed.createComponent(UploadComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
component.tagInput = 'CAT, Funny reaction';
|
component.tagInput = 'CAT, Funny reaction';
|
||||||
const parsed = component.parseTagInput(component.tagInput);
|
expect(component.parseTagInput(component.tagInput)).toEqual(['cat', 'funny', 'reaction']);
|
||||||
expect(parsed).toEqual(['cat', 'funny', 'reaction']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should split on commas', () => {
|
it('should split on commas', () => {
|
||||||
const fixture = TestBed.createComponent(UploadComponent);
|
const fixture = TestBed.createComponent(UploadComponent);
|
||||||
component = fixture.componentInstance;
|
expect(fixture.componentInstance.parseTagInput('a,b,c')).toEqual(['a', 'b', 'c']);
|
||||||
const parsed = component.parseTagInput('a,b,c');
|
|
||||||
expect(parsed).toEqual(['a', 'b', 'c']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter empty tokens', () => {
|
it('should filter empty tokens', () => {
|
||||||
const fixture = TestBed.createComponent(UploadComponent);
|
const fixture = TestBed.createComponent(UploadComponent);
|
||||||
component = fixture.componentInstance;
|
expect(fixture.componentInstance.parseTagInput(' ,, cat ,,')).toEqual(['cat']);
|
||||||
const parsed = component.parseTagInput(' ,, cat ,,');
|
|
||||||
expect(parsed).toEqual(['cat']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('on duplicate response: shows toast and navigates to detail', async () => {
|
it('on duplicate response: shows toast and navigates to detail', async () => {
|
||||||
@@ -49,13 +37,7 @@ describe('UploadComponent', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
const router = TestBed.inject(Router);
|
const router = TestBed.inject(Router);
|
||||||
spyOn(router, 'navigate');
|
spyOn(router, 'navigate');
|
||||||
|
await component.handleUploadResponse({ id: 'abc', duplicate: true } as Parameters<typeof component.handleUploadResponse>[0]);
|
||||||
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(component.toastMessage).toContain('library');
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']);
|
expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']);
|
||||||
});
|
});
|
||||||
@@ -65,8 +47,7 @@ describe('UploadComponent', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
const router = TestBed.inject(Router);
|
const router = TestBed.inject(Router);
|
||||||
spyOn(router, 'navigate');
|
spyOn(router, 'navigate');
|
||||||
|
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
|
||||||
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as any);
|
|
||||||
expect(component.toastMessage).toBeTruthy();
|
expect(component.toastMessage).toBeTruthy();
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']);
|
expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']);
|
||||||
});
|
});
|
||||||
@@ -76,9 +57,66 @@ describe('UploadComponent', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
const router = TestBed.inject(Router);
|
const router = TestBed.inject(Router);
|
||||||
spyOn(router, 'navigate');
|
spyOn(router, 'navigate');
|
||||||
|
|
||||||
component.handleUploadError({ status: 422, error: { detail: 'bad file', code: 'invalid_mime_type' } });
|
component.handleUploadError({ status: 422, error: { detail: 'bad file', code: 'invalid_mime_type' } });
|
||||||
expect(component.errorMessage).toBeTruthy();
|
expect(component.errorMessage).toBeTruthy();
|
||||||
expect(router.navigate).not.toHaveBeenCalled();
|
expect(router.navigate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// New polish tests
|
||||||
|
|
||||||
|
it('submit button is disabled when no file is selected', () => {
|
||||||
|
const fixture = TestBed.createComponent(UploadComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
const btn = (fixture.nativeElement as HTMLElement).querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||||
|
expect(btn.disabled).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submit button shows "Uploading…" label while uploading is true', () => {
|
||||||
|
const fixture = TestBed.createComponent(UploadComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.uploading = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
const btn = (fixture.nativeElement as HTMLElement).querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||||
|
expect(btn.textContent).toContain('Uploading');
|
||||||
|
expect(btn.disabled).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showSuccess banner is hidden initially', () => {
|
||||||
|
const fixture = TestBed.createComponent(UploadComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.success-banner')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showSuccess banner is visible when showSuccess is true', () => {
|
||||||
|
const fixture = TestBed.createComponent(UploadComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.showSuccess = true;
|
||||||
|
component.uploadedFilename = 'photo.jpg';
|
||||||
|
fixture.detectChanges();
|
||||||
|
const banner = (fixture.nativeElement as HTMLElement).querySelector('.success-banner');
|
||||||
|
expect(banner).not.toBeNull();
|
||||||
|
expect(banner!.textContent).toContain('photo.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows validation error message for 422 response', () => {
|
||||||
|
const fixture = TestBed.createComponent(UploadComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.handleUploadError({ status: 422, error: { detail: 'Unsupported file type', code: 'invalid_mime_type' } });
|
||||||
|
fixture.detectChanges();
|
||||||
|
const err = (fixture.nativeElement as HTMLElement).querySelector('.error');
|
||||||
|
expect(err!.textContent).toContain('Unsupported file type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows generic error message for network error', () => {
|
||||||
|
const fixture = TestBed.createComponent(UploadComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.handleUploadError({ status: 500, error: null });
|
||||||
|
fixture.detectChanges();
|
||||||
|
const err = (fixture.nativeElement as HTMLElement).querySelector('.error');
|
||||||
|
expect(err!.textContent).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { ImageRecord, ImageService } from '../services/image.service';
|
import { ImageRecord, ImageService } from '../services/image.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-upload',
|
selector: 'app-upload',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [CommonModule, FormsModule, RouterLink],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="upload-page">
|
<div class="upload-page">
|
||||||
@@ -15,45 +15,91 @@ import { ImageRecord, ImageService } from '../services/image.service';
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="drop-zone"
|
class="drop-zone"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
[class.drag-over]="isDragOver"
|
[class.drag-over]="isDragOver"
|
||||||
(dragover)="onDragOver($event)"
|
(dragover)="onDragOver($event)"
|
||||||
(dragleave)="isDragOver = false"
|
(dragleave)="isDragOver = false"
|
||||||
(drop)="onDrop($event)"
|
(drop)="onDrop($event)"
|
||||||
(click)="fileInput.click()"
|
(click)="fileInput.click()"
|
||||||
|
(keydown.enter)="fileInput.click()"
|
||||||
|
(keydown.space)="fileInput.click()"
|
||||||
>
|
>
|
||||||
|
<span class="drop-icon">📁</span>
|
||||||
<p>{{ selectedFile ? selectedFile.name : 'Drag & drop or click to browse' }}</p>
|
<p>{{ selectedFile ? selectedFile.name : 'Drag & drop or click to browse' }}</p>
|
||||||
<input #fileInput type="file" accept="image/*" hidden (change)="onFileChange($event)" />
|
<input #fileInput type="file" accept="image/*" hidden (change)="onFileChange($event)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tag-input" *ngIf="selectedFile">
|
<div class="tag-input" *ngIf="selectedFile">
|
||||||
<label>Tags (comma or space separated)</label>
|
<label for="tag-input">Tags (comma or space separated)</label>
|
||||||
<input [(ngModel)]="tagInput" placeholder="cat, funny, reaction" />
|
<input id="tag-input" [(ngModel)]="tagInput" placeholder="cat, funny, reaction" />
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
<span *ngFor="let tag of parseTagInput(tagInput)" class="chip">{{ tag }}</span>
|
<span *ngFor="let tag of parseTagInput(tagInput)" class="chip">{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button [disabled]="!selectedFile || uploading" (click)="submit()">
|
<button type="submit" [disabled]="!selectedFile || uploading" (click)="submit()">
|
||||||
|
<span *ngIf="uploading" class="spinner"></span>
|
||||||
{{ uploading ? 'Uploading…' : 'Upload' }}
|
{{ uploading ? 'Uploading…' : 'Upload' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p class="toast" *ngIf="toastMessage">{{ toastMessage }}</p>
|
<!-- Success banner -->
|
||||||
|
<div class="success-banner" *ngIf="showSuccess">
|
||||||
|
<span class="success-icon">✔</span>
|
||||||
|
<span>{{ uploadedFilename }} uploaded.</span>
|
||||||
|
<span class="banner-links">
|
||||||
|
<button type="button" class="link" (click)="resetForm()">Upload another</button>
|
||||||
|
<a routerLink="/" class="link">View in library</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="toast" *ngIf="toastMessage && !showSuccess">{{ toastMessage }}</p>
|
||||||
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.upload-page { max-width: 600px; margin: 40px auto; padding: 0 16px; }
|
.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; }
|
h1 { margin-bottom: 24px; }
|
||||||
.drop-zone.drag-over { border-color: #fff; background: #1a1a1a; }
|
.drop-zone {
|
||||||
|
border: 2px dashed color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface);
|
||||||
|
transition: border-color var(--transition), background var(--transition);
|
||||||
|
}
|
||||||
|
.drop-zone.drag-over { border-color: var(--accent); background: var(--surface-raised); }
|
||||||
|
.drop-icon { font-size: 2rem; display: block; margin-bottom: 8px; }
|
||||||
.tag-input { margin: 16px 0; }
|
.tag-input { margin: 16px 0; }
|
||||||
label { display: block; margin-bottom: 4px; font-size: 0.9rem; color: #aaa; }
|
label { display: block; margin-bottom: 4px; font-size: 0.9rem; color: var(--text-muted); }
|
||||||
input[type=text], input:not([type]) { width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 4px; }
|
input[type=text], input:not([type]) { width: 100%; padding: 8px; background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); }
|
||||||
|
input:focus { outline: none; border-color: var(--border-focus); }
|
||||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||||
.chip { background: #333; padding: 2px 10px; border-radius: 12px; font-size: 0.85rem; }
|
.chip { background: var(--surface-raised); padding: 2px 10px; border-radius: var(--radius-chip); font-size: 0.85rem; }
|
||||||
button { padding: 10px 24px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
|
button[type="submit"] {
|
||||||
button:disabled { opacity: 0.5; cursor: default; }
|
display: flex; align-items: center; gap: 8px;
|
||||||
.toast { color: #4a9eff; margin-top: 12px; }
|
padding: 10px 24px; background: var(--accent); color: var(--accent-text);
|
||||||
.error { color: #ff6b6b; margin-top: 12px; }
|
border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
button[type="submit"]:disabled { opacity: 0.5; cursor: default; color: var(--text-muted); }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--accent-text); border-top-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; }
|
||||||
|
.success-banner {
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
margin-top: 16px; padding: 12px 16px;
|
||||||
|
background: color-mix(in srgb, #2ecc71 12%, var(--surface));
|
||||||
|
border: 1px solid color-mix(in srgb, #2ecc71 30%, transparent);
|
||||||
|
border-radius: var(--radius); color: var(--text);
|
||||||
|
}
|
||||||
|
.success-icon { color: #2ecc71; font-size: 1.1rem; }
|
||||||
|
.banner-links { display: flex; gap: 12px; margin-left: auto; }
|
||||||
|
.link { color: var(--accent); cursor: pointer; text-decoration: none; font-size: 0.9rem; }
|
||||||
|
.link:hover { text-decoration: underline; }
|
||||||
|
.toast { color: var(--accent); margin-top: 12px; }
|
||||||
|
.error { color: var(--danger); margin-top: 12px; }
|
||||||
`],
|
`],
|
||||||
})
|
})
|
||||||
export class UploadComponent {
|
export class UploadComponent {
|
||||||
@@ -63,6 +109,9 @@ export class UploadComponent {
|
|||||||
toastMessage = '';
|
toastMessage = '';
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
isDragOver = false;
|
isDragOver = false;
|
||||||
|
showSuccess = false;
|
||||||
|
uploadedFilename = '';
|
||||||
|
private dismissTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private imageService: ImageService,
|
private imageService: ImageService,
|
||||||
@@ -101,6 +150,7 @@ export class UploadComponent {
|
|||||||
this.uploading = true;
|
this.uploading = true;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.toastMessage = '';
|
this.toastMessage = '';
|
||||||
|
this.showSuccess = false;
|
||||||
|
|
||||||
const tags = this.parseTagInput(this.tagInput);
|
const tags = this.parseTagInput(this.tagInput);
|
||||||
this.imageService.upload(this.selectedFile, tags).subscribe({
|
this.imageService.upload(this.selectedFile, tags).subscribe({
|
||||||
@@ -121,13 +171,36 @@ export class UploadComponent {
|
|||||||
if (res.duplicate) {
|
if (res.duplicate) {
|
||||||
this.toastMessage = 'Already in your library';
|
this.toastMessage = 'Already in your library';
|
||||||
} else {
|
} else {
|
||||||
this.toastMessage = 'Image uploaded successfully!';
|
this.uploadedFilename = this.selectedFile?.name ?? res.id;
|
||||||
|
this.showSuccess = true;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
if (this.dismissTimer) clearTimeout(this.dismissTimer);
|
||||||
|
this.dismissTimer = setTimeout(() => {
|
||||||
|
this.showSuccess = false;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}, 4000);
|
||||||
}
|
}
|
||||||
await this.router.navigate(['/images', res.id]);
|
await this.router.navigate(['/images', res.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleUploadError(err: any): void {
|
handleUploadError(err: unknown): void {
|
||||||
const apiError = err?.error;
|
const httpErr = err as { status?: number; error?: { detail?: string } };
|
||||||
this.errorMessage = apiError?.detail ?? 'Upload failed. Please try again.';
|
const status = httpErr?.status;
|
||||||
|
const detail = httpErr?.error?.detail;
|
||||||
|
if (status === 422 && detail) {
|
||||||
|
this.errorMessage = detail;
|
||||||
|
} else {
|
||||||
|
this.errorMessage = 'Upload failed. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm(): void {
|
||||||
|
this.selectedFile = null;
|
||||||
|
this.tagInput = '';
|
||||||
|
this.toastMessage = '';
|
||||||
|
this.errorMessage = '';
|
||||||
|
this.showSuccess = false;
|
||||||
|
if (this.dismissTimer) clearTimeout(this.dismissTimer);
|
||||||
|
this.cdr.markForCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,32 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f0f0f;
|
||||||
|
--surface: #1a1a1a;
|
||||||
|
--surface-raised: #252525;
|
||||||
|
--border: #333;
|
||||||
|
--border-focus: #555;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-muted: #777;
|
||||||
|
--accent: #4a9eff;
|
||||||
|
--accent-text: #000;
|
||||||
|
--danger: #c0392b;
|
||||||
|
--danger-text: #fff;
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-chip: 12px;
|
||||||
|
--transition: 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
from { background-position: -200% 0; }
|
||||||
|
to { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-raised) 50%, var(--surface) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -6,7 +35,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
background: #0f0f0f;
|
background: var(--bg);
|
||||||
color: #e0e0e0;
|
color: var(--text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user