Initial commit — StreamStack v1
Five-service streaming platform: auth, catalogue, streaming, ingest, thumbnailer. Includes React frontend served by nginx, NATS JetStream event bus, aiobotocore async S3, PyAV video metadata + thumbnail extraction, service-to-service JWT auth, and a full unit + e2e test suite.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from streamstack.streaming.app import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nats_client():
|
||||
nc = AsyncMock()
|
||||
js = AsyncMock()
|
||||
kv = AsyncMock()
|
||||
nc.jetstream.return_value = js
|
||||
js.key_value.return_value = kv
|
||||
return nc, kv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_resolve_token_valid():
|
||||
with patch(
|
||||
"streamstack.streaming.service.resolve_stream_token",
|
||||
new=AsyncMock(return_value=("media-uuid-abc", "user-1")),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_resolve_token_invalid():
|
||||
with patch(
|
||||
"streamstack.streaming.service.resolve_stream_token",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3_full():
|
||||
s3 = MagicMock()
|
||||
s3.head_object.return_value = {
|
||||
"ContentLength": 1024,
|
||||
"ContentType": "video/mp4",
|
||||
}
|
||||
body = MagicMock()
|
||||
body.read.side_effect = [b"x" * 512, b"x" * 512, b""]
|
||||
s3.get_object.return_value = {"Body": body}
|
||||
with patch("streamstack.streaming.service.get_s3_client", return_value=s3):
|
||||
yield s3
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nats_global(mock_nats_client):
|
||||
nc, _ = mock_nats_client
|
||||
with patch("streamstack.streaming.router.get_nats", return_value=nc):
|
||||
yield nc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
resp = await ac.get("/v1/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_not_found(mock_resolve_token_invalid, mock_nats_global):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
resp = await ac.get("/v1/stream/invalid-token")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_response(mock_resolve_token_valid, mock_nats_global, mock_s3_full):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
resp = await ac.get("/v1/stream/valid-token")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
assert resp.headers["content-length"] == "1024"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_range_request(mock_resolve_token_valid, mock_nats_global):
|
||||
s3 = MagicMock()
|
||||
s3.head_object.return_value = {"ContentLength": 10000, "ContentType": "video/mp4"}
|
||||
body = MagicMock()
|
||||
body.read.side_effect = [b"y" * 500, b""]
|
||||
s3.get_object.return_value = {"Body": body}
|
||||
with patch("streamstack.streaming.service.get_s3_client", return_value=s3):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
resp = await ac.get("/v1/stream/valid-token", headers={"Range": "bytes=0-499"})
|
||||
assert resp.status_code == 206
|
||||
assert "content-range" in resp.headers
|
||||
assert resp.headers["content-range"] == "bytes 0-499/10000"
|
||||
assert resp.headers["content-length"] == "500"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_invalid_range_header(mock_resolve_token_valid, mock_nats_global):
|
||||
s3 = MagicMock()
|
||||
s3.head_object.return_value = {"ContentLength": 10000, "ContentType": "video/mp4"}
|
||||
with patch("streamstack.streaming.service.get_s3_client", return_value=s3):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
resp = await ac.get("/v1/stream/valid-token", headers={"Range": "bytes=abc-def"})
|
||||
assert resp.status_code == 400
|
||||
@@ -0,0 +1,72 @@
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from streamstack.streaming.tokens import (
|
||||
TOKEN_TTL_SECONDS,
|
||||
create_stream_token,
|
||||
resolve_stream_token,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nc():
|
||||
nc = AsyncMock()
|
||||
js = AsyncMock()
|
||||
kv = AsyncMock()
|
||||
# jetstream() is a synchronous call in nats.py
|
||||
nc.jetstream = MagicMock(return_value=js)
|
||||
js.key_value = AsyncMock(return_value=kv)
|
||||
return nc, kv
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_returns_token(mock_nc):
|
||||
nc, kv = mock_nc
|
||||
token = await create_stream_token(nc, "media-uuid", "user-123")
|
||||
assert len(token) > 20
|
||||
kv.put.assert_called_once()
|
||||
key_used = kv.put.call_args[0][0]
|
||||
assert key_used == token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_valid_token(mock_nc):
|
||||
nc, kv = mock_nc
|
||||
now = int(time.time())
|
||||
entry = MagicMock()
|
||||
entry.value = f"media-uuid|user-123|{now}".encode()
|
||||
kv.get.return_value = entry
|
||||
result = await resolve_stream_token(nc, "some-token")
|
||||
assert result == ("media-uuid", "user-123")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_expired_token(mock_nc):
|
||||
nc, kv = mock_nc
|
||||
expired_time = int(time.time()) - TOKEN_TTL_SECONDS - 1
|
||||
entry = MagicMock()
|
||||
entry.value = f"media-uuid|user-123|{expired_time}".encode()
|
||||
kv.get.return_value = entry
|
||||
result = await resolve_stream_token(nc, "some-token")
|
||||
assert result is None
|
||||
kv.delete.assert_called_once_with("some-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_missing_token(mock_nc):
|
||||
nc, kv = mock_nc
|
||||
kv.get.side_effect = Exception("not found")
|
||||
result = await resolve_stream_token(nc, "nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_malformed_token(mock_nc):
|
||||
nc, kv = mock_nc
|
||||
entry = MagicMock()
|
||||
entry.value = b"bad-data"
|
||||
kv.get.return_value = entry
|
||||
result = await resolve_stream_token(nc, "some-token")
|
||||
assert result is None
|
||||
Reference in New Issue
Block a user