fa23568214
Implement `GET /api/v1/storage`, replacing the stub. Returns aggregate library facts (track/artist/album counts, total footprint, playtime, per-format / per-source / metadata-status breakdowns, top genres) plus the real capacity of the backing volume. - domain: `LibraryStats`, `FormatBreakdown`, `DiskUsage` value objects - ports: `FileStorage.disk_usage()` (local = shutil.disk_usage walking up to the nearest existing ancestor; S3 returns None — no fixed disk) - repo: `TrackRepository.library_stats()` (single set of GROUP BYs) - tests: storage stats API (auth, empty library, upload counting) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
4.5 KiB
Python
139 lines
4.5 KiB
Python
"""Integration tests for the storage statistics endpoint (§A6).
|
|
|
|
Requires a reachable Postgres; skips otherwise.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from app.core.config import get_settings
|
|
from app.infrastructure.db import Base, dispose_engine, get_engine, session_scope
|
|
from app.infrastructure.db.repositories import (
|
|
SqlAlchemyRefreshTokenRepository,
|
|
SqlAlchemyUserRepository,
|
|
)
|
|
from asgi_lifespan import LifespanManager
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
_db_reachable_cache: bool | None = None
|
|
|
|
|
|
async def _db_reachable() -> bool:
|
|
global _db_reachable_cache
|
|
if _db_reachable_cache is not None:
|
|
return _db_reachable_cache
|
|
|
|
from sqlalchemy import text
|
|
|
|
try:
|
|
async with asyncio.timeout(3):
|
|
async with get_engine().connect() as conn:
|
|
await conn.execute(text("SELECT 1"))
|
|
_db_reachable_cache = True
|
|
except Exception:
|
|
_db_reachable_cache = False
|
|
return _db_reachable_cache
|
|
|
|
|
|
@pytest.fixture
|
|
async def api(tmp_path: Path) -> AsyncIterator[AsyncClient]:
|
|
if not await _db_reachable():
|
|
pytest.skip("Postgres not reachable — integration test skipped.")
|
|
|
|
os.environ["MEDIA_PATH"] = str(tmp_path)
|
|
get_settings.cache_clear()
|
|
|
|
import app.infrastructure.storage.provider as _storage_provider
|
|
|
|
_storage_provider._storage = None
|
|
|
|
try:
|
|
async with get_engine().begin() as conn:
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
from app.application.user_service import UserService
|
|
from app.core.security import Argon2PasswordHasher
|
|
|
|
async with session_scope() as session:
|
|
await UserService(
|
|
users=SqlAlchemyUserRepository(session),
|
|
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
|
|
hasher=Argon2PasswordHasher(),
|
|
).create_user(username="testuser", password="testpass1", is_superuser=False)
|
|
|
|
from app.main import create_app
|
|
|
|
app = create_app()
|
|
async with LifespanManager(app):
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
yield client
|
|
|
|
async with get_engine().begin() as conn:
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
await dispose_engine()
|
|
finally:
|
|
_storage_provider._storage = None
|
|
os.environ.pop("MEDIA_PATH", None)
|
|
get_settings.cache_clear()
|
|
|
|
|
|
async def _login(api: AsyncClient) -> str:
|
|
resp = await api.post(
|
|
"/api/v1/auth/login", json={"username": "testuser", "password": "testpass1"}
|
|
)
|
|
assert resp.status_code == 200
|
|
return str(resp.json()["access_token"])
|
|
|
|
|
|
async def _upload(api: AsyncClient, token: str, *, name: str) -> None:
|
|
# Vary the bytes per file so dedup (by content) keeps them distinct.
|
|
audio = (f"fake audio bytes for storage stats test {name}".encode()) * 10
|
|
resp = await api.post(
|
|
"/api/v1/upload",
|
|
files={"file": (name, audio, "audio/mpeg")},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
async def test_storage_stats_requires_auth(api: AsyncClient) -> None:
|
|
resp = await api.get("/api/v1/storage")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_storage_stats_empty_library(api: AsyncClient) -> None:
|
|
token = await _login(api)
|
|
resp = await api.get("/api/v1/storage", headers={"Authorization": f"Bearer {token}"})
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["total_tracks"] == 0
|
|
assert body["total_size"] == 0
|
|
assert body["by_format"] == []
|
|
# Local backend reports a real disk in the test environment.
|
|
assert body["disk"] is not None
|
|
assert body["disk"]["total"] > 0
|
|
|
|
|
|
async def test_storage_stats_counts_uploads(api: AsyncClient) -> None:
|
|
token = await _login(api)
|
|
await _upload(api, token, name="one.mp3")
|
|
await _upload(api, token, name="two.mp3")
|
|
|
|
resp = await api.get("/api/v1/storage", headers={"Authorization": f"Bearer {token}"})
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["total_tracks"] == 2
|
|
assert body["total_size"] > 0
|
|
assert body["total_artists"] >= 1
|
|
fmt = {f["file_format"]: f for f in body["by_format"]}
|
|
assert "mp3" in fmt
|
|
assert fmt["mp3"]["track_count"] == 2
|
|
assert sum(body["by_source"].values()) == 2
|