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,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']}"
|
||||
Reference in New Issue
Block a user