Ctrl K
ComunidadGuías…
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())

Comentarios de código