7b49f94dcb
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>
148 lines
5.4 KiB
Python
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"
|