tests/validate_range_requests.pypython · @kibot
20/9/2026, 17:37:08'''"
Validación local de Range Requests para NetFlux.
Prueba el endpoint /movies/{movie_id}/stream/range contra el contrato
HTTP Range Requests (RFC 7233). Ejecutar contra un backend local:
python tests/validate_range_requests.py --base-url http://localhost:8000 --movie-id test_movie_1 --media-file /app/media/test_movie_1.mp4
O en modo autónomo con servidor embebido para pruebas rápidas:
python tests/validate_range_requests.py --self-contained
'''
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
from urllib.parse import quote
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 RangeRequestValidator:
"""Valida que el endpoint /stream/range cumple el contrato HTTP Range Requests."""
def __init__(
self,
base_url: str,
movie_id: str,
media_file: str,
chunk_bytes: int = 1024,
timeout: float = 30.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 = urllib.request.Request(url, headers=request_headers, method='GET')
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
body = response.read() if read_body else b''
return response.status, dict(response.headers), body
except urllib.error.HTTPError as error:
return error.code, dict(error.headers), error.read()
except urllib.error.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]:
"""Verifica que el backend esté activo."""
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]:
"""Verifica que los detalles de la película son accesibles."""
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_range_partial_content(self) -> dict[str, Any]:
"""Prueba una solicitud de rango parcial válida (206 Partial Content)."""
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}',
f'Content-Range no corresponde: {get_header(response_headers, "Content-Range")}',
)
require(
get_header(response_headers, 'Content-Length') == str(expected_length),
'Content-Length no corresponde al fragmento solicitado',
)
# Verificar que los bytes servidos coinciden con el archivo real
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 parcial servido correctamente',
'content_range': get_header(response_headers, 'Content-Range'),
'content_length': get_header(response_headers, 'Content-Length'),
'bytes_received': len(body),
}
def test_full_file_without_range(self) -> dict[str, Any]:
"""Prueba solicitud completa sin header Range (200 OK)."""
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 tamaño correcto',
'content_length': content_length,
}
def test_range_not_satisfiable(self) -> dict[str, Any]:
"""Prueba un rango fuera de los límites del archivo (416 Range Not Satisfiable)."""
status, response_headers, body = self.request(
self.movie_url('stream/range'),
{'Range': f'bytes={self.file_size + 1000}-{self.file_size + 2000}'},
)
require(status == 416, f'Rango inválido devolvió {status} en lugar de 416')
return {
'status': 'passed',
'message': 'Rango fuera de límites correctamente rechazado con 416',
}
def test_multiple_consecutive_ranges(self) -> dict[str, Any]:
"""Prueba múltiples solicitudes de rango consecutivas para verificar consistencia."""
results = []
num_requests = 3
for i in range(num_requests):
start = i * self.chunk_bytes
end = min(start + self.chunk_bytes - 1, self.file_size - 1)
if start >= self.file_size:
break
expected_length = end - start + 1
status, response_headers, body = self.request(
self.movie_url('stream/range'),
{'Range': f'bytes={start}-{end}'},
)
require(status == 206, f'Solicitud {i+1} devolvió {status} en lugar de 206')
require(
get_header(response_headers, 'Content-Range') == f'bytes {start}-{end}/{self.file_size}',
f'Solicitud {i+1}: Content-Range incorrecto',
)
require(
get_header(response_headers, 'Content-Length') == str(expected_length),
f'Solicitud {i+1}: Content-Length incorrecto',
)
with self.media_path.open('rb') as source:
source.seek(start)
expected_body = source.read(expected_length)
require(body == expected_body, f'Solicitud {i+1}: bytes no coinciden')
results.append({
'request': i + 1,
'range': f'bytes={start}-{end}',
'status': 'passed',
})
return {
'status': 'passed',
'message': f'{len(results)} solicitudes consecutivas verificadas correctamente',
'requests': results,
}
def test_overlapping_ranges(self) -> dict[str, Any]:
"""Prueba rangos solapados para verificar que cada respuesta es independiente."""
mid = self.file_size // 2
chunk = min(self.chunk_bytes, mid)
start1 = max(0, mid - chunk // 2)
end1 = start1 + chunk - 1
start2 = mid
end2 = min(start2 + chunk - 1, self.file_size - 1)
status1, headers1, body1 = self.request(
self.movie_url('stream/range'),
{'Range': f'bytes={start1}-{end1}'},
)
status2, headers2, body2 = self.request(
self.movie_url('stream/range'),
{'Range': f'bytes={start2}-{end2}'},
)
require(status1 == 206, f'Primer rango devolvió {status1}')
require(status2 == 206, f'Segundo rango devolvió {status2}')
require(body1 != body2, 'Los cuerpos de rangos solapados deberían diferir')
return {
'status': 'passed',
'message': 'Rangos solapados responden independientemente',
'range1': f'bytes={start1}-{end1}',
'range2': f'bytes={start2}-{end2}',
}
def test_streaming_endpoint_metadata(self) -> dict[str, Any]:
"""Verifica que el endpoint /stream expone metadatos de Range Requests."""
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 correctamente',
'path': data['path'],
}
def run(self) -> dict[str, Any]:
"""Ejecuta todas las validaciones y devuelve el reporte consolidado."""
checks: list[tuple[str, callable[[], dict[str, Any]]]] = [
('health', self.test_health_endpoint),
('movie_details', self.test_movie_details),
('stream_metadata', self.test_streaming_endpoint_metadata),
('range_partial', self.test_range_partial_content),
('full_file', self.test_full_file_without_range),
('range_not_satisfiable', self.test_range_not_satisfiable),
('multiple_ranges', self.test_multiple_consecutive_ranges),
('overlapping_ranges', self.test_overlapping_ranges),
]
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,
'total_checks': len(results),
'passed_checks': sum(1 for r in results if r.get('status') == 'passed'),
'results': results,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description='Validar Range Requests del backend NetFlux'
)
parser.add_argument('--base-url', default='http://localhost:8000')
parser.add_argument('--movie-id', default='test_movie_1')
parser.add_argument('--media-file', default='/app/media/test_movie_1.mp4')
parser.add_argument('--chunk-bytes', type=int, default=1024)
parser.add_argument('--timeout', type=float, default=30.0)
parser.add_argument(
'--self-contained',
action='store_true',
help='Ejecutar con servidor embebido y archivo de prueba generado automáticamente',
)
return parser.parse_args()
def create_test_video(output_path: str) -> None:
"""Genera un video de prueba con FFmpeg si no existe."""
import subprocess
if Path(output_path).is_file() and Path(output_path).stat().st_size > 0:
return
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
cmd = [
'ffmpeg', '-y', '-f', 'lavfi',
'-i', 'testsrc=duration=5:size=640x360:rate=30',
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '28',
'-c:a', 'aac', '-b:a', '128k',
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
raise RuntimeError(f'Error generando video de prueba: {result.stderr}')
def run_self_contained() -> int:
"""Ejecuta validación con servidor embebido y archivo de prueba."""
import uvicorn
from backend.main import app
test_video = '/tmp/test_movie_validate.mp4'
create_test_video(test_video)
# Iniciar servidor en hilo separado
config = uvicorn.Config(app, host='127.0.0.1', port=8765, log_level='error')
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
# Esperar a que el servidor esté listo
max_wait = 10
for _ in range(max_wait):
try:
urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=1)
break
except Exception:
time.sleep(0.5)
else:
print('ERROR: No se pudo iniciar el servidor de prueba')
return 1
# Ejecutar validaciones
validator = RangeRequestValidator(
base_url='http://127.0.0.1:8765',
movie_id='test_movie_1',
media_file=test_video,
chunk_bytes=1024,
timeout=15.0,
)
result = validator.run()
print(json.dumps(result, indent=2, ensure_ascii=False))
server.should_exit = True
return 0 if result.get('status') == 'passed' else 1
def main() -> int:
args = parse_args()
if args.self_contained:
return run_self_contained()
try:
validator = RangeRequestValidator(
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())