Files
yunke/yunke-server/tests/test_api_media.py
T
浅念 7b49f94dcb feat: media downloader + serve api
M2 Task 2.6 (media downloader) + Task 2.8 (media serve API).

Downloader (app/scraper/media.py):
- download_url(client, url): fetches bytes via the YunkeClient's underlying
  httpx client (carries session cookies; respx-mockable; follows redirects).
- download_message_media(client, msg, store, session): URL-driven fetcher that
  picks the best http(s) URL (file_url -> file_hd_url -> file_th_url), dedups
  by md5 (skips the network when a Media row with local file already exists),
  falls back to sha256(bytes) when no md5, stores via ObjectStore.put_by_hash,
  and upserts a Media row. Skips type-10 system messages (non-media per
  phase0-findings §2) and type-3 opaque-id / text (no http url). Resilient:
  download errors are logged and return None (no raise).
- media_type_from_message / guess_mime_type helpers.

Repo (app/storage/repos.py): upsert_media — idempotent Core upsert into Media by
media_hash. Volatile download artifacts (local_path/size_bytes/mime_type) use
COALESCE(incoming, existing) on conflict so a re-scrape that did not re-download
does not clobber previously stored values. Exported from app.storage.

Serve API (app/api/routers/media.py): GET /api/media/{media_hash} streams the
local file via FileResponse (handles HTTP Range / 206). 404 when the row is
absent, has no local file, or the file is missing on disk. Content-type from the
row's mime_type, falling back to ext-derived guess.

Tests (+19, suite 130 -> 149, all green; ruff clean):
- test_media_downloader.py: image/video download + upsert; md5 dedup skips
  second network hit; sha256 fallback when no md5; type-3/text/system -> None;
  download failure -> None (no raise); upsert_media idempotency +
  null-local_path preservation + 3x-same-key-one-row.
- test_api_media.py: 200 + content-type + bytes; 404 unknown hash; 404 missing
  file on disk; Range -> 206 partial; ext-derived content-type when mime NULL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 14:13:50 +08:00

148 lines
5.4 KiB
Python

"""Tests for GET /api/media/{media_hash} — media serve API (Task 2.8).
Drives the FastAPI app over an in-process httpx transport (``ASGITransport``) —
no real network socket. The ``get_session`` dependency is overridden to an
isolated in-memory async SQLite session (``StaticPool`` so every request sees
the same seeded schema/rows). Media blobs are written to a real ``tmp_path``
file and referenced by absolute path from the seeded ``Media`` row.
Coverage:
* ``GET /api/media/{hash}`` for a seeded row -> 200, correct ``Content-Type``,
and the exact bytes streamed back.
* Unknown hash -> 404.
* Row exists but the underlying file is missing on disk -> 404.
* HTTP Range request -> ``206`` with the requested partial bytes (FileResponse
handles Range), proving the endpoint supports range / partial-content.
* A row whose ``mime_type`` is NULL falls back to an ext-derived content type.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.api import deps
from app.api.main import app
from app.db.base import Base
from app.db.models import Media
PNG_BYTES = b"\x89PNG\r\n\x1a\n fake image body"
@pytest.fixture
async def client(tmp_path: Path):
"""Yield an ``AsyncClient`` against ``app`` with a seeded in-memory DB.
Seeds one ``Media`` row (``abc123``) pointing at a real PNG-bytes file
under ``tmp_path``, plus a second row (``nopng``) whose ``local_path`` is
deliberately missing on disk (to exercise the file-missing 404 path), plus
a third (``nomime``) whose ``mime_type`` is NULL (to exercise the
ext-derived content-type fallback).
"""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
media_file = tmp_path / "abc123.png"
media_file.write_bytes(PNG_BYTES)
# A second file with no mime_type recorded, to test ext-derived fallback.
fallback_file = tmp_path / "fallback.webp"
fallback_file.write_bytes(b"WEBPBYTES")
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
session.add_all(
[
Media(
media_hash="abc123",
channel="wechat",
media_type="image",
original_url="https://wx.oss.yunkecn.com/x/abc.png",
local_path=str(media_file),
size_bytes=len(PNG_BYTES),
mime_type="image/png",
),
Media(
media_hash="nopng",
channel="wechat",
media_type="image",
local_path=str(tmp_path / "does-not-exist.png"),
size_bytes=1,
mime_type="image/png",
),
Media(
media_hash="nomime",
channel="wechat",
media_type="image",
local_path=str(fallback_file),
size_bytes=len(b"WEBPBYTES"),
mime_type=None,
),
]
)
await session.commit()
async def _override_session():
async with SessionLocal() as s:
yield s
app.dependency_overrides[deps.get_session] = _override_session
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
finally:
app.dependency_overrides.clear()
await engine.dispose()
async def test_get_media_returns_bytes_with_content_type(client: AsyncClient):
"""Seeded hash -> 200, image/png content-type, exact bytes streamed back."""
r = await client.get("/api/media/abc123")
assert r.status_code == 200
assert r.headers["content-type"].startswith("image/png")
assert r.content == PNG_BYTES
async def test_get_media_unknown_hash_returns_404(client: AsyncClient):
"""An unknown media_hash -> 404."""
r = await client.get("/api/media/does-not-exist")
assert r.status_code == 404
async def test_get_media_missing_file_on_disk_returns_404(client: AsyncClient):
"""Row present but file absent from disk -> 404 (defensive)."""
r = await client.get("/api/media/nopng")
assert r.status_code == 404
async def test_get_media_supports_http_range(client: AsyncClient):
"""Range request -> 206 Partial Content with the requested byte slice.
Starlette's ``FileResponse`` honors ``Range``; this pins the behavior so a
future swap to a hand-rolled StreamingResponse would have to keep it.
"""
r = await client.get("/api/media/abc123", headers={"Range": "bytes=0-3"})
assert r.status_code == 206
assert r.content == PNG_BYTES[:4]
assert r.headers["content-length"] == "4"
assert r.headers["content-range"].startswith("bytes 0-3/")
async def test_get_media_falls_back_to_ext_when_mime_null(client: AsyncClient):
"""mime_type NULL on the row -> content-type guessed from the .webp ext."""
r = await client.get("/api/media/nomime")
assert r.status_code == 200
assert r.headers["content-type"].startswith("image/webp")
assert r.content == b"WEBPBYTES"