Feat: Add tag browser page at /tags with count-sorted tag list and library deep-link

- Extends GET /api/v1/tags with sort=count_desc and min_count query params
- New TagsComponent at /tags (public, no auth guard) shows all tags sorted by image count
- Clicking a tag navigates to /?tags=<name> for a pre-filtered library view
- LibraryComponent reads ?tags= query param on init to support deep-linking from tag browser
- Library header gains a "Browse tags" link to /tags for discoverability
- All 15 TDD tasks complete; ruff, ng lint, and ng build clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:40:06 +00:00
parent 6092a4454e
commit 355014f975
32 changed files with 908 additions and 38 deletions

View File

@@ -1,19 +1,19 @@
import os
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
# 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("OWNER_USERNAME", "testowner")
os.environ.setdefault("OWNER_PASSWORD", "testpassword")
from app.main import app
from app.auth.jwt_provider import JWTAuthProvider
from app.config import get_settings
from app.database import Base
from app.dependencies import get_db, get_storage, get_auth
from app.auth.jwt_provider import JWTAuthProvider
from app.dependencies import get_auth, get_db, get_storage
from app.main import app
# Bust the LRU cache so get_settings() picks up the env vars set above
get_settings.cache_clear()
@@ -48,8 +48,8 @@ async def db_session(engine):
@pytest_asyncio.fixture
async def client(db_session):
from app.storage.s3_backend import S3StorageBackend
from app.auth.noop import NoOpAuthProvider
from app.storage.s3_backend import S3StorageBackend
storage = S3StorageBackend()
auth = NoOpAuthProvider()

View File

@@ -44,7 +44,6 @@ async def test_delete_removes_storage_object(client):
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
storage_key = upload.json()["hash"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
assert delete_resp.status_code == 204

View File

@@ -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.
"""
import io
import uuid
import pytest

View File

@@ -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
"""
import io
import pytest

View File

@@ -5,13 +5,17 @@ T057 — PATCH replaces tags, old tags unlinked, new tags upserted
T058 — PATCH with invalid tag → 422 invalid_tag
T073 — GET /api/v1/tags returns all tags alphabetically with correct image_count
T074 — GET /api/v1/tags?q=ca returns only tags prefixed "ca"
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 pytest
def _minimal_png() -> bytes:
import struct, zlib
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)
@@ -130,3 +134,64 @@ async def test_list_tags_prefix_filter(client):
for item in body["items"]:
assert item["name"].startswith("cat")
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(client):
# 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"},
)
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(client):
# 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"},
)
# 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

View File

@@ -79,6 +79,7 @@ async def test_upload_invalid_mime_type_returns_422(client):
@pytest.mark.asyncio
async def test_upload_oversized_file_returns_422(client):
import os
from app.config import get_settings
os.environ["MAX_UPLOAD_BYTES"] = "10"

View File

@@ -1,5 +1,3 @@
import os
import pytest
_BASE_ENV = {
@@ -26,6 +24,7 @@ def test_settings_load_from_env(monkeypatch):
# Import inside test to pick up monkeypatched env
import importlib
import app.config as 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"})
import importlib
import app.config as config_module
importlib.reload(config_module)
@@ -53,6 +53,7 @@ def test_settings_jwt_expiry_override(monkeypatch):
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
import importlib
import app.config as config_module
importlib.reload(config_module)

View File

@@ -1,4 +1,5 @@
import hashlib
from app.utils import compute_sha256

View File

@@ -1,6 +1,5 @@
import time
import pytest
import jwt as pyjwt
import pytest
from fastapi import HTTPException
from app.auth.jwt_provider import JWTAuthProvider

View File

@@ -3,6 +3,7 @@ T037 — tag normalisation: uppercase → lowercase, whitespace stripped
T038 — tag validation: rejects names > 64 chars, invalid chars
"""
import pytest
from app.repositories.tag_repo import TagRepository

View File

@@ -1,5 +1,6 @@
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"]
REJECTED_TYPES = ["application/pdf", "video/mp4", "text/plain", "application/octet-stream"]