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,22 @@
|
||||
from streamstack.auth.hasher import hash_password, verify_password
|
||||
|
||||
|
||||
def test_hash_and_verify():
|
||||
plain = "super-secret-password"
|
||||
hashed = hash_password(plain)
|
||||
assert hashed != plain
|
||||
assert verify_password(plain, hashed)
|
||||
|
||||
|
||||
def test_wrong_password_fails():
|
||||
hashed = hash_password("correct")
|
||||
assert not verify_password("wrong", hashed)
|
||||
|
||||
|
||||
def test_different_hashes_for_same_password():
|
||||
plain = "same-password"
|
||||
hash1 = hash_password(plain)
|
||||
hash2 = hash_password(plain)
|
||||
assert hash1 != hash2
|
||||
assert verify_password(plain, hash1)
|
||||
assert verify_password(plain, hash2)
|
||||
@@ -0,0 +1,127 @@
|
||||
import subprocess
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from streamstack.auth.app import app
|
||||
from streamstack.auth.models import Base
|
||||
from streamstack.core.db import get_db
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rsa_key_pair(tmp_path_factory):
|
||||
tmp = tmp_path_factory.mktemp("keys")
|
||||
priv = tmp / "private.pem"
|
||||
pub = tmp / "public.pem"
|
||||
subprocess.run(
|
||||
["openssl", "genrsa", "-out", str(priv), "2048"], check=True, capture_output=True
|
||||
)
|
||||
subprocess.run(
|
||||
["openssl", "rsa", "-in", str(priv), "-pubout", "-out", str(pub)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return str(priv), str(pub)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nats():
|
||||
nc = AsyncMock()
|
||||
js = AsyncMock()
|
||||
kv = AsyncMock()
|
||||
nc.jetstream.return_value = js
|
||||
js.key_value.return_value = kv
|
||||
with patch("streamstack.auth.router.get_nats", return_value=nc):
|
||||
yield nc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_client(db_session, mock_nats, rsa_key_pair):
|
||||
priv, pub = rsa_key_pair
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
with (
|
||||
patch("streamstack.core.auth.settings") as ms,
|
||||
patch("streamstack.auth.service.settings") as rs,
|
||||
):
|
||||
ms.jwt_private_key_path = priv
|
||||
ms.jwt_public_key_path = pub
|
||||
ms.jwt_algorithm = "RS256"
|
||||
ms.jwt_expire_minutes = 30
|
||||
ms.jwt_refresh_expire_days = 7
|
||||
rs.jwt_public_key_path = pub
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register(test_client):
|
||||
resp = await test_client.post(
|
||||
"/v1/auth/users/",
|
||||
json={"email": "new@example.com", "password": "password123"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["email"] == "new@example.com"
|
||||
assert "id" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_duplicate_email(test_client):
|
||||
creds = {"email": "dup@example.com", "password": "pass"}
|
||||
await test_client.post("/v1/auth/users/", json=creds)
|
||||
resp = await test_client.post("/v1/auth/users/", json=creds)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(test_client):
|
||||
await test_client.post(
|
||||
"/v1/auth/users/", json={"email": "login@example.com", "password": "correct"}
|
||||
)
|
||||
resp = await test_client.post(
|
||||
"/v1/auth/token",
|
||||
data={"username": "login@example.com", "password": "correct"},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "access_token" in body
|
||||
assert "refresh_token" in body
|
||||
assert body["token_type"] == "bearer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(test_client):
|
||||
await test_client.post(
|
||||
"/v1/auth/users/", json={"email": "wp@example.com", "password": "correct"}
|
||||
)
|
||||
resp = await test_client.post(
|
||||
"/v1/auth/token",
|
||||
data={"username": "wp@example.com", "password": "wrong"},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_unknown_user(test_client):
|
||||
resp = await test_client.post(
|
||||
"/v1/auth/token",
|
||||
data={"username": "nobody@example.com", "password": "pass"},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
@@ -0,0 +1,104 @@
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from streamstack.catalogue.models import Base, MediaItem, Movie, TvSeries, YoutubeShow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_base_media_item(db_session: AsyncSession):
|
||||
item = MediaItem(title="Generic Media", s3_key="media/generic.mp4")
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(item)
|
||||
assert item.id is not None
|
||||
assert item.media_type == "media"
|
||||
assert item.is_published is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_movie(db_session: AsyncSession):
|
||||
movie = Movie(
|
||||
title="Inception",
|
||||
s3_key="media/inception.mp4",
|
||||
director="Christopher Nolan",
|
||||
release_year=2010,
|
||||
mpaa_rating="PG-13",
|
||||
imdb_id="tt1375666",
|
||||
)
|
||||
db_session.add(movie)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(movie)
|
||||
assert movie.media_type == "movie"
|
||||
assert movie.director == "Christopher Nolan"
|
||||
assert movie.release_year == 2010
|
||||
assert movie.mpaa_rating == "PG-13"
|
||||
assert movie.imdb_id == "tt1375666"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tv_series(db_session: AsyncSession):
|
||||
episode = TvSeries(
|
||||
title="Breaking Bad S01E01",
|
||||
s3_key="media/bb-s01e01.mp4",
|
||||
show_name="Breaking Bad",
|
||||
season=1,
|
||||
episode=1,
|
||||
episode_title="Pilot",
|
||||
network="AMC",
|
||||
)
|
||||
db_session.add(episode)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(episode)
|
||||
assert episode.media_type == "tv_series"
|
||||
assert episode.show_name == "Breaking Bad"
|
||||
assert episode.season == 1
|
||||
assert episode.episode == 1
|
||||
assert episode.episode_title == "Pilot"
|
||||
assert episode.network == "AMC"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_youtube_show(db_session: AsyncSession):
|
||||
video = YoutubeShow(
|
||||
title="Python Tutorial",
|
||||
s3_key="media/yt-python.mp4",
|
||||
youtube_video_id="dQw4w9WgXcQ",
|
||||
youtube_channel_id="UCxxxxxx",
|
||||
channel_name="TechChannel",
|
||||
)
|
||||
db_session.add(video)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(video)
|
||||
assert video.media_type == "youtube_show"
|
||||
assert video.youtube_video_id == "dQw4w9WgXcQ"
|
||||
assert video.channel_name == "TechChannel"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_polymorphic_query_returns_correct_types(db_session: AsyncSession):
|
||||
db_session.add(Movie(title="Movie A", s3_key="media/a.mp4", is_published=True))
|
||||
db_session.add(TvSeries(title="Episode B", s3_key="media/b.mp4", is_published=True))
|
||||
db_session.add(YoutubeShow(title="Video C", s3_key="media/c.mp4", is_published=True))
|
||||
await db_session.commit()
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db_session.execute(select(MediaItem))
|
||||
items = result.scalars().all()
|
||||
assert len(items) == 3
|
||||
types = {i.media_type for i in items}
|
||||
assert types == {"movie", "tv_series", "youtube_show"}
|
||||
assert any(isinstance(i, Movie) for i in items)
|
||||
assert any(isinstance(i, TvSeries) for i in items)
|
||||
assert any(isinstance(i, YoutubeShow) for i in items)
|
||||
@@ -0,0 +1,192 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from streamstack.catalogue.app import app
|
||||
from streamstack.catalogue.models import Base, MediaItem
|
||||
from streamstack.core.db import get_db
|
||||
from streamstack.core.middleware import UserClaims, verify_jwt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user():
|
||||
return UserClaims(sub="admin-uuid", email="admin@test.com", roles=["admin"], jti="test-jti")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def viewer_user():
|
||||
return UserClaims(sub="viewer-uuid", email="viewer@test.com", roles=["viewer"], jti="test-jti")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nats():
|
||||
nc = AsyncMock()
|
||||
with patch("streamstack.catalogue.service.get_nats", return_value=nc):
|
||||
yield nc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_client(db_session, mock_nats):
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(test_client):
|
||||
resp = await test_client.get("/v1/health")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_empty(test_client):
|
||||
resp = await test_client.get("/v1/catalogue/")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["items"] == []
|
||||
assert body["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_requires_admin(test_client, viewer_user, db_session):
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[verify_jwt] = lambda: viewer_user
|
||||
try:
|
||||
resp = await test_client.post(
|
||||
"/v1/catalogue/",
|
||||
json={"media_type": "media", "title": "Test", "s3_key": "media/test.mp4"},
|
||||
headers={"Authorization": "Bearer fake"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(verify_jwt, None)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_movie(test_client, admin_user, db_session):
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[verify_jwt] = lambda: admin_user
|
||||
try:
|
||||
resp = await test_client.post(
|
||||
"/v1/catalogue/",
|
||||
json={
|
||||
"media_type": "movie",
|
||||
"title": "Inception",
|
||||
"s3_key": "media/inception.mp4",
|
||||
"director": "Christopher Nolan",
|
||||
"release_year": 2010,
|
||||
"is_published": True,
|
||||
},
|
||||
headers={"Authorization": "Bearer fake"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(verify_jwt, None)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["media_type"] == "movie"
|
||||
assert body["director"] == "Christopher Nolan"
|
||||
assert body["release_year"] == 2010
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tv_series(test_client, admin_user, db_session):
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[verify_jwt] = lambda: admin_user
|
||||
try:
|
||||
resp = await test_client.post(
|
||||
"/v1/catalogue/",
|
||||
json={
|
||||
"media_type": "tv_series",
|
||||
"title": "Breaking Bad S01E01",
|
||||
"s3_key": "media/bb-s01e01.mp4",
|
||||
"show_name": "Breaking Bad",
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"is_published": True,
|
||||
},
|
||||
headers={"Authorization": "Bearer fake"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(verify_jwt, None)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["media_type"] == "tv_series"
|
||||
assert body["show_name"] == "Breaking Bad"
|
||||
assert body["season"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_youtube_show(test_client, admin_user, db_session):
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[verify_jwt] = lambda: admin_user
|
||||
try:
|
||||
resp = await test_client.post(
|
||||
"/v1/catalogue/",
|
||||
json={
|
||||
"media_type": "youtube_show",
|
||||
"title": "Python Tutorial",
|
||||
"s3_key": "media/yt-python.mp4",
|
||||
"youtube_video_id": "dQw4w9WgXcQ",
|
||||
"channel_name": "TechChannel",
|
||||
"is_published": True,
|
||||
},
|
||||
headers={"Authorization": "Bearer fake"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(verify_jwt, None)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["media_type"] == "youtube_show"
|
||||
assert body["youtube_video_id"] == "dQw4w9WgXcQ"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_filtered_by_media_type(test_client, admin_user, db_session):
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[verify_jwt] = lambda: admin_user
|
||||
try:
|
||||
await test_client.post(
|
||||
"/v1/catalogue/",
|
||||
json={"media_type": "movie", "title": "M1", "s3_key": "m/m1.mp4", "is_published": True},
|
||||
headers={"Authorization": "Bearer fake"},
|
||||
)
|
||||
await test_client.post(
|
||||
"/v1/catalogue/",
|
||||
json={
|
||||
"media_type": "tv_series",
|
||||
"title": "T1",
|
||||
"s3_key": "m/t1.mp4",
|
||||
"is_published": True,
|
||||
},
|
||||
headers={"Authorization": "Bearer fake"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(verify_jwt, None)
|
||||
resp = await test_client.get("/v1/catalogue/?media_type=movie")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["media_type"] == "movie"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unpublished_returns_404(test_client, db_session):
|
||||
item = MediaItem(title="Hidden", s3_key="media/hidden.mp4", is_published=False)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
resp = await test_client.get(f"/v1/catalogue/{item.id}")
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,50 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from streamstack.core.db import Base
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def db_engine():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def db_session(db_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
session_factory = async_sessionmaker(db_engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nats():
|
||||
nc = AsyncMock()
|
||||
js = AsyncMock()
|
||||
kv = AsyncMock()
|
||||
nc.jetstream.return_value = js
|
||||
js.key_value.return_value = kv
|
||||
js.create_key_value.return_value = kv
|
||||
js.find_key_value.return_value = kv
|
||||
return nc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3_client():
|
||||
client = MagicMock()
|
||||
client.head_object.return_value = {
|
||||
"ContentLength": 1024 * 1024,
|
||||
"ContentType": "video/mp4",
|
||||
}
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_content() -> bytes:
|
||||
return b"fake video content " * 1000
|
||||
@@ -0,0 +1,60 @@
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from streamstack.core.auth import create_access_token, create_refresh_token, decode_token
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rsa_key_pair(tmp_path_factory):
|
||||
tmp = tmp_path_factory.mktemp("keys")
|
||||
priv = tmp / "private.pem"
|
||||
pub = tmp / "public.pem"
|
||||
subprocess.run(
|
||||
["openssl", "genrsa", "-out", str(priv), "2048"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["openssl", "rsa", "-in", str(priv), "-pubout", "-out", str(pub)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return str(priv), str(pub)
|
||||
|
||||
|
||||
def test_access_token_roundtrip(rsa_key_pair):
|
||||
priv, pub = rsa_key_pair
|
||||
with patch("streamstack.core.auth.settings") as mock_settings:
|
||||
mock_settings.jwt_private_key_path = priv
|
||||
mock_settings.jwt_public_key_path = pub
|
||||
mock_settings.jwt_algorithm = "RS256"
|
||||
mock_settings.jwt_expire_minutes = 30
|
||||
token = create_access_token("user-123", "test@example.com", ["viewer"])
|
||||
with patch("streamstack.core.auth.settings") as mock_settings:
|
||||
mock_settings.jwt_public_key_path = pub
|
||||
mock_settings.jwt_algorithm = "RS256"
|
||||
payload = decode_token(token)
|
||||
assert payload["sub"] == "user-123"
|
||||
assert payload["email"] == "test@example.com"
|
||||
assert payload["roles"] == ["viewer"]
|
||||
assert "jti" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
|
||||
def test_refresh_token_has_type(rsa_key_pair):
|
||||
priv, pub = rsa_key_pair
|
||||
with patch("streamstack.core.auth.settings") as mock_settings:
|
||||
mock_settings.jwt_private_key_path = priv
|
||||
mock_settings.jwt_public_key_path = pub
|
||||
mock_settings.jwt_algorithm = "RS256"
|
||||
mock_settings.jwt_expire_minutes = 30
|
||||
mock_settings.jwt_refresh_expire_days = 7
|
||||
token = create_refresh_token("user-123")
|
||||
with patch("streamstack.core.auth.settings") as mock_settings:
|
||||
mock_settings.jwt_public_key_path = pub
|
||||
mock_settings.jwt_algorithm = "RS256"
|
||||
payload = decode_token(token)
|
||||
assert payload["type"] == "refresh"
|
||||
assert payload["sub"] == "user-123"
|
||||
@@ -0,0 +1,105 @@
|
||||
import io
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
from moto import mock_aws
|
||||
|
||||
from streamstack.core.s3 import S3SeekableFile, open_s3_buffered
|
||||
|
||||
BUCKET = "test-bucket"
|
||||
KEY = "test/video.mp4"
|
||||
CONTENT = b"Hello World! This is seekable S3 content for testing purposes."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_object():
|
||||
with mock_aws():
|
||||
s3 = boto3.client("s3", region_name="us-east-1")
|
||||
s3.create_bucket(Bucket=BUCKET)
|
||||
s3.put_object(Bucket=BUCKET, Key=KEY, Body=CONTENT)
|
||||
yield s3
|
||||
|
||||
|
||||
def test_readable_and_seekable(s3_object):
|
||||
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
|
||||
assert f.readable() is True
|
||||
assert f.seekable() is True
|
||||
|
||||
|
||||
def test_size(s3_object):
|
||||
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
|
||||
assert f.size == len(CONTENT)
|
||||
|
||||
|
||||
def test_initial_position(s3_object):
|
||||
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
|
||||
assert f.tell() == 0
|
||||
|
||||
|
||||
def test_read_from_start(s3_object):
|
||||
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
|
||||
data = f.read(5)
|
||||
assert data == CONTENT[:5]
|
||||
assert f.tell() == 5
|
||||
|
||||
|
||||
def test_seek_set(s3_object):
|
||||
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
|
||||
pos = f.seek(7)
|
||||
assert pos == 7
|
||||
assert f.tell() == 7
|
||||
data = f.read(5)
|
||||
assert data == CONTENT[7:12]
|
||||
|
||||
|
||||
def test_seek_cur(s3_object):
|
||||
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
|
||||
f.read(5)
|
||||
f.seek(2, io.SEEK_CUR)
|
||||
assert f.tell() == 7
|
||||
|
||||
|
||||
def test_seek_end(s3_object):
|
||||
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
|
||||
pos = f.seek(-5, io.SEEK_END)
|
||||
assert pos == len(CONTENT) - 5
|
||||
data = f.read()
|
||||
assert data == CONTENT[-5:]
|
||||
|
||||
|
||||
def test_seek_beyond_end_clamps(s3_object):
|
||||
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
|
||||
pos = f.seek(len(CONTENT) + 100)
|
||||
assert pos == len(CONTENT)
|
||||
|
||||
|
||||
def test_read_at_eof_returns_empty(s3_object):
|
||||
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
|
||||
f.seek(0, io.SEEK_END)
|
||||
buf = bytearray(10)
|
||||
n = f.readinto(buf)
|
||||
assert n == 0
|
||||
|
||||
|
||||
def test_full_read(s3_object):
|
||||
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
|
||||
data = f.read()
|
||||
assert data == CONTENT
|
||||
|
||||
|
||||
def test_open_s3_buffered(s3_object):
|
||||
with mock_aws():
|
||||
s3 = boto3.client("s3", region_name="us-east-1")
|
||||
s3.create_bucket(Bucket=BUCKET)
|
||||
s3.put_object(Bucket=BUCKET, Key=KEY, Body=CONTENT)
|
||||
with patch_s3_client(s3):
|
||||
buf = open_s3_buffered(BUCKET, KEY)
|
||||
assert isinstance(buf, io.BufferedReader)
|
||||
data = buf.read(5)
|
||||
assert data == CONTENT[:5]
|
||||
|
||||
|
||||
def patch_s3_client(client):
|
||||
from unittest.mock import patch
|
||||
|
||||
return patch("streamstack.core.s3.get_s3_client", return_value=client)
|
||||
@@ -0,0 +1,104 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
import boto3
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
GATEWAY_URL = os.environ.get("GATEWAY_URL", "http://localhost:8080")
|
||||
S3_ENDPOINT = os.environ.get("S3_ENDPOINT_URL", "http://localhost:9000")
|
||||
S3_ACCESS_KEY = os.environ.get("S3_ACCESS_KEY", "minioadmin")
|
||||
S3_SECRET_KEY = os.environ.get("S3_SECRET_KEY", "minioadmin")
|
||||
S3_BUCKET = os.environ.get("S3_BUCKET_MEDIA", "media")
|
||||
|
||||
TESTDATA = os.path.join(os.path.dirname(__file__), "..", "..", "testdata")
|
||||
|
||||
TEST_VIDEOS = [
|
||||
{
|
||||
"filename": "Big_Buck_Bunny_1080_10s_30MB.mp4",
|
||||
"s3_key": "media/big_buck_bunny.mp4",
|
||||
"media_type": "movie",
|
||||
"title": "Big Buck Bunny",
|
||||
"director": "Sacha Goedegebure",
|
||||
"release_year": 2008,
|
||||
"is_published": True,
|
||||
},
|
||||
{
|
||||
"filename": "Jellyfish_1080_10s_30MB.mkv",
|
||||
"s3_key": "media/jellyfish.mkv",
|
||||
"media_type": "movie",
|
||||
"title": "Jellyfish",
|
||||
"director": "Blender Foundation",
|
||||
"release_year": 2015,
|
||||
"is_published": True,
|
||||
},
|
||||
]
|
||||
|
||||
ADMIN_EMAIL = "e2e-admin@streamstack.test"
|
||||
ADMIN_PASSWORD = "e2etestpassword"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def s3():
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=S3_ENDPOINT,
|
||||
aws_access_key_id=S3_ACCESS_KEY,
|
||||
aws_secret_access_key=S3_SECRET_KEY,
|
||||
region_name="us-east-1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def admin_token():
|
||||
with httpx.Client(base_url=GATEWAY_URL) as client:
|
||||
# Register; ignore 409 if already exists from a previous run
|
||||
client.post(
|
||||
"/api/v1/auth/users/",
|
||||
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD, "roles": ["admin", "viewer"]},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={"username": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def seeded_media(s3, admin_token):
|
||||
items = []
|
||||
with httpx.Client(base_url=GATEWAY_URL) as client:
|
||||
for video in TEST_VIDEOS:
|
||||
local_path = os.path.join(TESTDATA, video["filename"])
|
||||
s3.upload_file(local_path, S3_BUCKET, video["s3_key"])
|
||||
|
||||
payload = {k: v for k, v in video.items() if k != "filename"}
|
||||
resp = client.post(
|
||||
"/api/v1/catalogue/",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
items.append(resp.json())
|
||||
return items
|
||||
|
||||
|
||||
def wait_for_service(url: str, timeout: int = 60) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
r = httpx.get(url, timeout=5)
|
||||
if r.status_code < 500:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
raise RuntimeError(f"Service not ready: {url}")
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
wait_for_service(f"{GATEWAY_URL}/api/v1/auth/health")
|
||||
wait_for_service(f"{GATEWAY_URL}/api/v1/catalogue/health")
|
||||
wait_for_service(f"{GATEWAY_URL}/api/v1/stream/health")
|
||||
@@ -0,0 +1,101 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
GATEWAY = os.environ.get("GATEWAY_URL", "http://nginx:80")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_catalogue_lists_seeded_items(seeded_media):
|
||||
with httpx.Client(base_url=GATEWAY) as client:
|
||||
resp = client.get("/api/v1/catalogue/")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] >= 2
|
||||
titles = {item["title"] for item in body["items"]}
|
||||
assert "Big Buck Bunny" in titles
|
||||
assert "Jellyfish" in titles
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_catalogue_get_by_id(seeded_media):
|
||||
with httpx.Client(base_url=GATEWAY) as client:
|
||||
for item in seeded_media:
|
||||
resp = client.get(f"/api/v1/catalogue/{item['id']}")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["id"] == item["id"]
|
||||
assert body["title"] == item["title"]
|
||||
assert body["media_type"] == item["media_type"]
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_catalogue_filter_by_media_type(seeded_media):
|
||||
with httpx.Client(base_url=GATEWAY) as client:
|
||||
resp = client.get("/api/v1/catalogue/?media_type=movie")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert all(i["media_type"] == "movie" for i in body["items"])
|
||||
assert body["total"] >= 2
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_stream_token_issued(seeded_media, admin_token):
|
||||
item = seeded_media[0]
|
||||
with httpx.Client(base_url=GATEWAY) as client:
|
||||
resp = client.post(
|
||||
f"/api/v1/catalogue/{item['id']}/stream-token",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "stream_url" in body
|
||||
assert body["stream_url"].startswith("/api/v1/stream/")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_stream_full_response(seeded_media, admin_token):
|
||||
item = seeded_media[0]
|
||||
with httpx.Client(base_url=GATEWAY) as client:
|
||||
token_resp = client.post(
|
||||
f"/api/v1/catalogue/{item['id']}/stream-token",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
token_resp.raise_for_status()
|
||||
stream_url = token_resp.json()["stream_url"]
|
||||
resp = client.get(stream_url, timeout=30)
|
||||
assert resp.status_code == 200
|
||||
assert "accept-ranges" in resp.headers
|
||||
assert int(resp.headers["content-length"]) > 0
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_stream_range_request(seeded_media, admin_token):
|
||||
item = seeded_media[0]
|
||||
with httpx.Client(base_url=GATEWAY) as client:
|
||||
token_resp = client.post(
|
||||
f"/api/v1/catalogue/{item['id']}/stream-token",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
token_resp.raise_for_status()
|
||||
stream_url = token_resp.json()["stream_url"]
|
||||
resp = client.get(stream_url, headers={"Range": "bytes=0-65535"}, timeout=30)
|
||||
assert resp.status_code == 206
|
||||
assert "content-range" in resp.headers
|
||||
assert resp.headers["content-length"] == "65536"
|
||||
assert len(resp.content) == 65536
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_stream_both_videos(seeded_media, admin_token):
|
||||
with httpx.Client(base_url=GATEWAY) as client:
|
||||
for item in seeded_media:
|
||||
token_resp = client.post(
|
||||
f"/api/v1/catalogue/{item['id']}/stream-token",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert token_resp.status_code == 200
|
||||
stream_url = token_resp.json()["stream_url"]
|
||||
resp = client.get(stream_url, headers={"Range": "bytes=0-1023"}, timeout=30)
|
||||
assert resp.status_code == 206, f"Failed for {item['title']}"
|
||||
@@ -0,0 +1,129 @@
|
||||
import io
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from streamstack.core.middleware import UserClaims, verify_jwt
|
||||
from streamstack.ingest.app import app
|
||||
|
||||
ADMIN = UserClaims(sub="admin-uuid", email="admin@test.com", roles=["admin"], jti="jti")
|
||||
VIEWER = UserClaims(sub="viewer-uuid", email="viewer@test.com", roles=["viewer"], jti="jti")
|
||||
|
||||
MOCK_CATALOGUE_ITEM = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"media_type": "movie",
|
||||
"title": "Test Movie",
|
||||
"s3_key": "media/test.mp4",
|
||||
"bucket": "media",
|
||||
"content_type": "video/mp4",
|
||||
"duration_seconds": 10.0,
|
||||
"size_bytes": 1024,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"codec": "h264",
|
||||
"thumbnail_s3_key": "thumbnails/test.jpg",
|
||||
"is_published": True,
|
||||
"tags": [],
|
||||
"director": None,
|
||||
"release_year": None,
|
||||
"mpaa_rating": None,
|
||||
"imdb_id": None,
|
||||
"description": None,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
MOCK_META = {
|
||||
"duration": 10.0,
|
||||
"streams": [
|
||||
{"type": "video", "codec": "h264", "width": 1920, "height": 1080, "fps": 30.0},
|
||||
{"type": "audio", "codec": "aac", "sample_rate": 44100, "channels": 2},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3():
|
||||
s3 = MagicMock()
|
||||
s3.upload_fileobj.return_value = None
|
||||
s3.head_object.return_value = {"ContentLength": 1024}
|
||||
s3.put_object.return_value = None
|
||||
with patch("streamstack.ingest.service.get_s3_client", return_value=s3):
|
||||
yield s3
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_metadata():
|
||||
with patch("streamstack.ingest.service.extract_metadata", return_value=MOCK_META):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_thumbnail():
|
||||
with patch("streamstack.ingest.service._extract_thumbnail", return_value="thumbnails/test.jpg"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_catalogue():
|
||||
http_response = MagicMock()
|
||||
http_response.json.return_value = MOCK_CATALOGUE_ITEM
|
||||
http_response.raise_for_status.return_value = None
|
||||
|
||||
async_client = MagicMock()
|
||||
async_client.__aenter__ = AsyncMock(return_value=async_client)
|
||||
async_client.__aexit__ = AsyncMock(return_value=False)
|
||||
async_client.post = AsyncMock(return_value=http_response)
|
||||
|
||||
with patch("streamstack.ingest.service.httpx.AsyncClient", return_value=async_client):
|
||||
yield async_client
|
||||
|
||||
|
||||
async def _post_upload(ac, user):
|
||||
app.dependency_overrides[verify_jwt] = lambda: user
|
||||
try:
|
||||
return await ac.post(
|
||||
"/v1/ingest/upload",
|
||||
files={"file": ("test.mp4", io.BytesIO(b"fake video"), "video/mp4")},
|
||||
data={"media_type": "movie", "title": "Test Movie", "is_published": "true"},
|
||||
headers={"Authorization": "Bearer fake"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(verify_jwt, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_success(mock_s3, mock_metadata, mock_thumbnail, mock_catalogue):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
resp = await _post_upload(ac, ADMIN)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["media_type"] == "movie"
|
||||
assert body["title"] == "Test Movie"
|
||||
assert body["duration_seconds"] == 10.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_requires_admin(mock_s3, mock_metadata, mock_thumbnail, mock_catalogue):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
resp = await _post_upload(ac, VIEWER)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_calls_catalogue_api(mock_s3, mock_metadata, mock_thumbnail, mock_catalogue):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
await _post_upload(ac, ADMIN)
|
||||
mock_catalogue.post.assert_called_once()
|
||||
call_kwargs = mock_catalogue.post.call_args
|
||||
payload = call_kwargs.kwargs["json"]
|
||||
assert payload["s3_key"].startswith("media/")
|
||||
assert payload["s3_key"].endswith(".mp4")
|
||||
assert payload["duration_seconds"] == 10.0
|
||||
assert payload["width"] == 1920
|
||||
assert payload["height"] == 1080
|
||||
assert payload["codec"] == "h264"
|
||||
assert payload["thumbnail_s3_key"] == "thumbnails/test.jpg"
|
||||
@@ -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