tests/streaming_tests.pypython · @kibot
20/9/2026, 17:26:47'''Validación de streaming con un archivo real.
Ejemplo:
python tests/streaming_tests.py \
--base-url http://localhost:8000 \
--movie-id test_movie_1 \
--media-file /app/media/test_movie_1.mp4
'''
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
def get_header(headers: dict[str, str], name: str) -> str | None:
target = name.lower()
for key, value in headers.items():
if key.lower() == target:
return value
return None
def require(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
class StreamingValidator:
def __init__(
self,
base_url: str,
movie_id: str,
media_file: str,
chunk_bytes: int = 1024,
timeout: float = 60.0,
) -> None:
self.base_url = base_url.rstrip('/')
self.movie_id = movie_id
self.encoded_movie_id = quote(movie_id, safe='')
self.media_path = Path(media_file).resolve()
self.chunk_bytes = chunk_bytes
self.timeout = timeout
if self.chunk_bytes < 1:
raise ValueError('chunk-bytes debe ser mayor que cero')
if not self.media_path.is_file():
raise FileNotFoundError(f'Archivo de prueba no encontrado: {self.media_path}')
if self.media_path.stat().st_size < 1:
raise ValueError('El archivo de prueba debe tener más de cero bytes')
self.file_size = self.media_path.stat().st_size
def request(
self,
path: str,
headers: dict[str, str] | None = None,
read_body: bool = True,
) -> tuple[int, dict[str, str], bytes]:
url = f'{self.base_url}/{path.lstrip("/")}'
request_headers = {'Accept-Encoding': 'identity', **(headers or {})}
request = Request(url, headers=request_headers, method='GET')
try:
with urlopen(request, timeout=self.timeout) as response:
body = response.read() if read_body else b''
return response.status, dict(response.headers), body
except HTTPError as error:
return error.code, dict(error.headers), error.read()
except URLError as error:
raise RuntimeError(f'No se pudo conectar con {url}: {error.reason}') from error
def movie_url(self, suffix: str) -> str:
return f'/movies/{self.encoded_movie_id}/{suffix}'
def test_health_endpoint(self) -> dict[str, Any]:
status, _, body = self.request('/health')
require(status == 200, f'Health devolvió {status}')
data = json.loads(body.decode('utf-8'))
require(data.get('status') == 'healthy', 'Health no reporta estado healthy')
return {'status': 'passed', 'message': 'Endpoint de salud funciona'}
def test_movie_details(self) -> dict[str, Any]:
status, _, body = self.request(self.movie_url(''))
require(status == 200, f'Detalles devolvieron {status}')
data = json.loads(body.decode('utf-8'))
require(data.get('id') == self.movie_id, 'El detalle no conserva el movie_id')
require(bool(data.get('title')), 'El detalle no contiene título')
return {'status': 'passed', 'message': 'Detalles de película obtenidos'}
def test_stream_metadata(self) -> dict[str, Any]:
status, response_headers, body = self.request(self.movie_url('stream'))
require(status == 200, f'Endpoint de stream devolvió {status}')
data = json.loads(body.decode('utf-8'))
require(isinstance(data.get('path'), str) and data['path'], 'Stream no devuelve path')
json_headers = data.get('headers') or {}
accept_ranges = get_header(json_headers, 'Accept-Ranges') or get_header(
response_headers, 'Accept-Ranges'
)
require(accept_ranges and accept_ranges.lower() == 'bytes', 'Accept-Ranges no es bytes')
return {
'status': 'passed',
'message': 'Endpoint de stream publica metadatos de rango',
'path': data['path'],
}
def test_range_request_fragment(self) -> dict[str, Any]:
expected_end = min(self.chunk_bytes - 1, self.file_size - 1)
expected_length = expected_end - 0 + 1
status, response_headers, body = self.request(
self.movie_url('stream/range'),
{'Range': f'bytes=0-{expected_end}', 'Accept': 'video/mp4'},
)
require(status == 206, f'Range devolvió {status} en lugar de 206')
require(
get_header(response_headers, 'Accept-Ranges') == 'bytes',
'Falta Accept-Ranges: bytes',
)
require(
get_header(response_headers, 'Content-Range') == f'bytes 0-{expected_end}/{self.file_size}',
'Content-Range no corresponde al fragmento solicitado',
)
require(
get_header(response_headers, 'Content-Length') == str(expected_length),
'Content-Length no corresponde al fragmento solicitado',
)
with self.media_path.open('rb') as source:
expected_body = source.read(expected_length)
require(body == expected_body, 'Los bytes servidos no coinciden con el archivo real')
return {
'status': 'passed',
'message': 'Fragmento real servido correctamente con Range Request',
'content_range': get_header(response_headers, 'Content-Range'),
}
def test_full_file_request(self) -> dict[str, Any]:
status, response_headers, _ = self.request(
self.movie_url('stream/range'), read_body=False
)
require(status == 200, f'Solicitud completa devolvió {status}')
require(
get_header(response_headers, 'Accept-Ranges') == 'bytes',
'Falta Accept-Ranges: bytes en solicitud completa',
)
content_length = get_header(response_headers, 'Content-Length')
require(content_length is not None, 'Falta Content-Length en solicitud completa')
require(int(content_length) == self.file_size, 'Content-Length no coincide con el archivo real')
return {
'status': 'passed',
'message': 'Solicitud completa servida con el tamaño correcto',
'content_length': content_length,
}
def run(self) -> dict[str, Any]:
checks: list[tuple[str, Callable[[], dict[str, Any]]]] = [
('health', self.test_health_endpoint),
('movie_details', self.test_movie_details),
('stream_metadata', self.test_stream_metadata),
('range_fragment', self.test_range_request_fragment),
('full_file', self.test_full_file_request),
]
results = []
for name, check in checks:
try:
result = check()
result.setdefault('status', 'passed')
results.append({'name': name, **result})
except Exception as error:
results.append({'name': name, 'status': 'failed', 'error': str(error)})
passed = all(item.get('status') == 'passed' for item in results)
return {
'status': 'passed' if passed else 'failed',
'movie_id': self.movie_id,
'media_file': str(self.media_path),
'file_size': self.file_size,
'results': results,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Validar streaming de NetFlux con un archivo real')
parser.add_argument('--base-url', default='http://localhost:8000')
parser.add_argument('--movie-id', required=True)
parser.add_argument('--media-file', required=True)
parser.add_argument('--chunk-bytes', type=int, default=1024)
parser.add_argument('--timeout', type=float, default=60.0)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
validator = StreamingValidator(
base_url=args.base_url,
movie_id=args.movie_id,
media_file=args.media_file,
chunk_bytes=args.chunk_bytes,
timeout=args.timeout,
)
result = validator.run()
except Exception as error:
result = {'status': 'failed', 'error': str(error)}
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0 if result.get('status') == 'passed' else 1
if __name__ == '__main__':
sys.exit(main())