Merge pull request 'Click-to-listen on the live wall + HTTP Range on /media (#233)' (#253) from feat/233-wall-audio into main
This commit was merged in pull request #253.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
HTTP Range on /media/calls/{id}/audio (server-26#233).
|
||||
|
||||
The GCS reads are replaced by an in-memory object: ``audio_object_size`` and
|
||||
``download_audio_range`` are patched where routers/media.py looks them up, and
|
||||
every span read is recorded so the tests can assert the route never asks GCS
|
||||
for more than the requested range (plus at most one STREAM_CHUNK_BYTES span).
|
||||
"""
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.internal import storage
|
||||
from app.routers import media
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(media.router)
|
||||
client = TestClient(app)
|
||||
|
||||
CALL_ID = "call-abc"
|
||||
GCS_URI = "gs://bucket/calls/call-abc.flac"
|
||||
|
||||
|
||||
class FakeObject:
|
||||
"""A stored object plus a log of every (start, end) span read from it."""
|
||||
|
||||
def __init__(self, data: bytes):
|
||||
self.data = data
|
||||
self.reads: list[tuple[int, int]] = []
|
||||
self.size_calls = 0
|
||||
|
||||
async def size(self, gcs_uri):
|
||||
self.size_calls += 1
|
||||
return len(self.data)
|
||||
|
||||
async def read(self, gcs_uri, start, end):
|
||||
self.reads.append((start, end))
|
||||
return self.data[start:end + 1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signed(monkeypatch):
|
||||
monkeypatch.setattr(storage.settings, "service_key", "test-key", raising=False)
|
||||
exp = int(time.time()) + 600
|
||||
return {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)}
|
||||
|
||||
|
||||
def _run(obj: FakeObject, params, headers=None, doc=None):
|
||||
doc_get = AsyncMock(return_value=doc if doc is not None else {"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})
|
||||
with patch.object(media.fstore, "doc_get", doc_get), \
|
||||
patch.object(media, "audio_object_size", obj.size), \
|
||||
patch.object(media, "download_audio_range", obj.read):
|
||||
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=params, headers=headers or {})
|
||||
return resp, doc_get
|
||||
|
||||
|
||||
def test_bounded_range_returns_206_with_exact_bytes(signed):
|
||||
obj = FakeObject(bytes(range(256)) * 40) # 10240 bytes
|
||||
resp, _ = _run(obj, signed, {"Range": "bytes=100-199"})
|
||||
assert resp.status_code == 206
|
||||
assert resp.content == obj.data[100:200]
|
||||
assert resp.headers["content-range"] == f"bytes 100-199/{len(obj.data)}"
|
||||
assert resp.headers["content-length"] == "100"
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
assert resp.headers["content-type"] == "audio/flac"
|
||||
assert resp.headers["cache-control"] == "private, max-age=3600"
|
||||
# Only the requested span was read from GCS — not the whole object.
|
||||
assert obj.reads == [(100, 199)]
|
||||
|
||||
|
||||
def test_open_ended_range(signed):
|
||||
obj = FakeObject(b"x" * 5000 + b"tail")
|
||||
resp, _ = _run(obj, signed, {"Range": "bytes=5000-"})
|
||||
assert resp.status_code == 206
|
||||
assert resp.content == b"tail"
|
||||
assert resp.headers["content-range"] == "bytes 5000-5003/5004"
|
||||
|
||||
|
||||
def test_suffix_range(signed):
|
||||
obj = FakeObject(b"0123456789")
|
||||
resp, _ = _run(obj, signed, {"Range": "bytes=-3"})
|
||||
assert resp.status_code == 206
|
||||
assert resp.content == b"789"
|
||||
assert resp.headers["content-range"] == "bytes 7-9/10"
|
||||
|
||||
|
||||
def test_suffix_longer_than_object_returns_whole_object(signed):
|
||||
obj = FakeObject(b"0123456789")
|
||||
resp, _ = _run(obj, signed, {"Range": "bytes=-500"})
|
||||
assert resp.status_code == 206
|
||||
assert resp.content == obj.data
|
||||
assert resp.headers["content-range"] == "bytes 0-9/10"
|
||||
|
||||
|
||||
def test_end_past_object_is_clamped(signed):
|
||||
obj = FakeObject(b"0123456789")
|
||||
resp, _ = _run(obj, signed, {"Range": "bytes=8-999"})
|
||||
assert resp.status_code == 206
|
||||
assert resp.content == b"89"
|
||||
assert resp.headers["content-range"] == "bytes 8-9/10"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rng", ["bytes=10-", "bytes=50-60", "bytes=-0"])
|
||||
def test_unsatisfiable_range_is_416(signed, rng):
|
||||
obj = FakeObject(b"0123456789")
|
||||
resp, _ = _run(obj, signed, {"Range": rng})
|
||||
assert resp.status_code == 416
|
||||
assert resp.headers["content-range"] == "bytes */10"
|
||||
assert obj.reads == []
|
||||
|
||||
|
||||
def test_no_range_is_200_full_body_and_advertises_ranges(signed):
|
||||
obj = FakeObject(b"abcdef" * 100)
|
||||
resp, _ = _run(obj, signed)
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == obj.data
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
assert resp.headers["content-length"] == str(len(obj.data))
|
||||
assert "content-range" not in resp.headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rng", ["bytes=0-1,4-5", "items=0-5", "bytes=5-2", "garbage"])
|
||||
def test_ignored_range_headers_fall_back_to_200(signed, rng):
|
||||
obj = FakeObject(b"0123456789")
|
||||
resp, _ = _run(obj, signed, {"Range": rng})
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == obj.data
|
||||
|
||||
|
||||
def test_long_object_is_streamed_in_bounded_spans(signed):
|
||||
# A ~13 MB "600 s FLAC": a seek near the end reads only from there on, in
|
||||
# spans no larger than STREAM_CHUNK_BYTES, and the first span is all the
|
||||
# route waits on before the response starts.
|
||||
size = 13 * 1024 * 1024
|
||||
obj = FakeObject(bytes(size))
|
||||
start = size - 1_500_000
|
||||
resp, _ = _run(obj, signed, {"Range": f"bytes={start}-"})
|
||||
assert resp.status_code == 206
|
||||
assert len(resp.content) == 1_500_000
|
||||
assert obj.reads[0][0] == start
|
||||
assert all(e - s + 1 <= media.STREAM_CHUNK_BYTES for s, e in obj.reads)
|
||||
assert sum(e - s + 1 for s, e in obj.reads) == 1_500_000
|
||||
|
||||
|
||||
def test_bad_signature_is_403_before_any_lookup(signed):
|
||||
obj = FakeObject(b"0123456789")
|
||||
resp, doc_get = _run(obj, {"exp": signed["exp"], "sig": "0" * 64}, {"Range": "bytes=0-3"})
|
||||
assert resp.status_code == 403
|
||||
doc_get.assert_not_called()
|
||||
assert obj.size_calls == 0
|
||||
assert obj.reads == []
|
||||
|
||||
|
||||
def test_expired_link_is_403(signed, monkeypatch):
|
||||
exp = int(time.time()) - 5
|
||||
obj = FakeObject(b"0123456789")
|
||||
resp, doc_get = _run(obj, {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)})
|
||||
assert resp.status_code == 403
|
||||
doc_get.assert_not_called()
|
||||
|
||||
|
||||
def test_missing_object_is_404(signed):
|
||||
obj = FakeObject(b"")
|
||||
|
||||
async def no_size(gcs_uri):
|
||||
return None
|
||||
|
||||
with patch.object(media.fstore, "doc_get", AsyncMock(return_value={"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})), \
|
||||
patch.object(media, "audio_object_size", no_size), \
|
||||
patch.object(media, "download_audio_range", obj.read):
|
||||
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=signed)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_legacy_mp3_keeps_its_content_type(signed):
|
||||
obj = FakeObject(b"ID3" + b"\0" * 50)
|
||||
resp, _ = _run(obj, signed, {"Range": "bytes=0-2"}, doc={"call_id": CALL_ID, "audio_gcs_uri": "gs://b/calls/x.mp3"})
|
||||
assert resp.status_code == 206
|
||||
assert resp.headers["content-type"] == "audio/mpeg"
|
||||
assert resp.content == b"ID3"
|
||||
|
||||
|
||||
# ── storage.py: the ranged reads never pull the whole object ────────────────
|
||||
def _fake_client(monkeypatch, size=1234, payload=b"span"):
|
||||
from unittest.mock import MagicMock
|
||||
client = MagicMock()
|
||||
blob = client.bucket.return_value.blob.return_value
|
||||
blob.size = size
|
||||
blob.download_as_bytes.return_value = payload
|
||||
monkeypatch.setattr(storage, "_gcs_client_cache", client)
|
||||
return client, blob
|
||||
|
||||
|
||||
def test_storage_size_reloads_metadata_only(monkeypatch):
|
||||
import asyncio
|
||||
client, blob = _fake_client(monkeypatch, size=13_000_000)
|
||||
assert asyncio.run(storage.audio_object_size(GCS_URI)) == 13_000_000
|
||||
client.bucket.assert_called_with("bucket")
|
||||
client.bucket.return_value.blob.assert_called_with("calls/call-abc.flac")
|
||||
blob.reload.assert_called_once()
|
||||
blob.download_as_bytes.assert_not_called()
|
||||
|
||||
|
||||
def test_storage_range_read_passes_inclusive_bounds(monkeypatch):
|
||||
import asyncio
|
||||
_, blob = _fake_client(monkeypatch)
|
||||
assert asyncio.run(storage.download_audio_range(GCS_URI, 100, 199)) == b"span"
|
||||
blob.download_as_bytes.assert_called_once_with(start=100, end=199)
|
||||
|
||||
|
||||
def test_storage_range_read_failure_returns_none(monkeypatch):
|
||||
import asyncio
|
||||
_, blob = _fake_client(monkeypatch)
|
||||
blob.download_as_bytes.side_effect = RuntimeError("gcs down")
|
||||
assert asyncio.run(storage.download_audio_range(GCS_URI, 0, 10)) is None
|
||||
blob.reload.side_effect = RuntimeError("404")
|
||||
assert asyncio.run(storage.audio_object_size(GCS_URI)) is None
|
||||
Reference in New Issue
Block a user