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:
2026-05-04 22:16:39 +10:00
commit 2309e9f43a
80 changed files with 6339 additions and 0 deletions
View File
+105
View File
@@ -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
+72
View File
@@ -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