feat: support audio & vidéo — lecteurs HTML5 intégrés #109
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
# tests/test_media_stream.py — Audio/video streaming & HTTP Range (roadmap #109-A/F1)
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# 4 KiB of pseudo-ID3 bytes — the endpoint never decodes the payload, it only
|
||||
# has to serve bytes with the right MIME and honour Range.
|
||||
MEDIA_BYTES = b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
|
||||
PDF_BYTES = b"%PDF-1.4\n" + b"x" * 2048 + b"\n%%EOF"
|
||||
|
||||
|
||||
def _write(vault_dir: str, name: str, content: bytes) -> None:
|
||||
(Path(vault_dir) / name).write_bytes(content)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/file — audio/video metadata returned before read_text (#109-A4)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMediaFileView:
|
||||
def test_audio_metadata(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "note-vocale.mp3", MEDIA_BYTES)
|
||||
resp = client.get("/api/file/TestVault", params={"path": "note-vocale.mp3"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_audio"] is True
|
||||
assert data["is_video"] is not True
|
||||
assert data["media_mime"] == "audio/mpeg"
|
||||
assert data["stream_url"] == "/api/media/TestVault?path=note-vocale.mp3"
|
||||
assert data["size_bytes"] == len(MEDIA_BYTES)
|
||||
assert data["unsupported"] is not True
|
||||
|
||||
def test_video_metadata(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "clip.mp4", MEDIA_BYTES)
|
||||
resp = client.get("/api/file/TestVault", params={"path": "clip.mp4"})
|
||||
data = resp.json()
|
||||
assert data["is_video"] is True
|
||||
assert data["is_audio"] is not True
|
||||
assert data["media_mime"] == "video/mp4"
|
||||
assert data["stream_url"] == "/api/media/TestVault?path=clip.mp4"
|
||||
|
||||
def test_large_media_falls_back_to_download(self, client, test_vault_dir, monkeypatch):
|
||||
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
|
||||
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
|
||||
resp = client.get("/api/file/TestVault", params={"path": "huge.mp3"})
|
||||
data = resp.json()
|
||||
assert data["unsupported"] is True
|
||||
assert data["media_too_large"] is True
|
||||
assert not data.get("is_audio")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/media — byte-range streaming (#109-A2/F1)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMediaStreamRange:
|
||||
def test_no_range_returns_200_and_accept_ranges(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get("/api/media/TestVault", params={"path": "song.mp3"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
assert resp.headers["content-type"].startswith("audio/mpeg")
|
||||
assert resp.content == MEDIA_BYTES
|
||||
|
||||
def test_first_kibibyte_returns_206(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get(
|
||||
"/api/media/TestVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Range": "bytes=0-1023"},
|
||||
)
|
||||
assert resp.status_code == 206
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
assert resp.headers["content-range"] == f"bytes 0-1023/{len(MEDIA_BYTES)}"
|
||||
assert len(resp.content) == 1024
|
||||
assert resp.content == MEDIA_BYTES[:1024]
|
||||
|
||||
def test_suffix_range(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get(
|
||||
"/api/media/TestVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Range": "bytes=-100"},
|
||||
)
|
||||
assert resp.status_code == 206
|
||||
assert resp.content == MEDIA_BYTES[-100:]
|
||||
|
||||
def test_unsatisfiable_range_returns_416(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get(
|
||||
"/api/media/TestVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Range": f"bytes={len(MEDIA_BYTES) + 10}-"},
|
||||
)
|
||||
assert resp.status_code == 416
|
||||
assert resp.headers["content-range"] == f"bytes */{len(MEDIA_BYTES)}"
|
||||
|
||||
def test_mov_mime_override(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "clip.mov", MEDIA_BYTES)
|
||||
resp = client.get("/api/media/TestVault", params={"path": "clip.mov"})
|
||||
assert resp.headers["content-type"].startswith("video/quicktime")
|
||||
|
||||
def test_rejects_non_media(self, client, test_vault_dir):
|
||||
resp = client.get("/api/media/TestVault", params={"path": "note1.md"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_missing_media_404(self, client):
|
||||
resp = client.get("/api/media/TestVault", params={"path": "nope.mp3"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_unknown_vault_404(self, client):
|
||||
resp = client.get("/api/media/NoSuchVault", params={"path": "song.mp3"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_path_traversal_rejected(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get("/api/media/TestVault", params={"path": "../../../etc/passwd.mp3"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_media_too_large_returns_413(self, client, test_vault_dir, monkeypatch):
|
||||
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
|
||||
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
|
||||
resp = client.get("/api/media/TestVault", params={"path": "huge.mp3"})
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Vault access control (#109-A3)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMediaAccess:
|
||||
def test_normaluser_cannot_stream_other_vault(self, admin_client):
|
||||
resp = admin_client.post(
|
||||
"/api/auth/login", json={"username": "normaluser", "password": "normal123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
resp = admin_client.get(
|
||||
"/api/media/OtherVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Regression: pdf/stream unchanged after extracting the shared helper (#109-A2)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPdfStreamRegression:
|
||||
def test_pdf_range_still_works(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
|
||||
resp = client.get(
|
||||
"/api/file/TestVault/pdf/stream",
|
||||
params={"path": "doc.pdf"},
|
||||
headers={"Range": "bytes=0-9"},
|
||||
)
|
||||
assert resp.status_code == 206
|
||||
assert resp.headers["content-type"].startswith("application/pdf")
|
||||
assert resp.headers["content-range"] == f"bytes 0-9/{len(PDF_BYTES)}"
|
||||
assert resp.content == PDF_BYTES[:10]
|
||||
|
||||
def test_pdf_without_range_returns_200(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
|
||||
resp = client.get("/api/file/TestVault/pdf/stream", params={"path": "doc.pdf"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
Reference in New Issue
Block a user