Started (incomplete) implementation of stream verification scheduler and endpoints
All checks were successful
AWS Deploy on Push / build (push) Successful in 5m18s

This commit is contained in:
2025-06-17 17:12:39 -05:00
parent abb467749b
commit a42d4c30a6
14 changed files with 1066 additions and 33 deletions

43
tests/routers/mocks.py Normal file
View File

@@ -0,0 +1,43 @@
from unittest.mock import Mock
from fastapi import Request
from app.iptv.scheduler import StreamScheduler
class MockScheduler:
"""Base mock APScheduler instance"""
running = True
start = Mock()
shutdown = Mock()
add_job = Mock()
remove_job = Mock()
get_job = Mock(return_value=None)
def __init__(self, running=True):
self.running = running
def create_trigger_mock(triggered_ref: dict) -> callable:
"""Create a mock trigger function that updates a reference when called"""
def trigger_mock():
triggered_ref["value"] = True
return trigger_mock
async def mock_get_scheduler(
request: Request, scheduler_class=MockScheduler, running=True, **kwargs
) -> StreamScheduler:
"""Mock dependency for get_scheduler with customization options"""
scheduler = StreamScheduler()
mock_scheduler = scheduler_class(running=running)
# Apply any additional attributes/methods
for key, value in kwargs.items():
setattr(mock_scheduler, key, value)
scheduler.scheduler = mock_scheduler
return scheduler

View File

@@ -1,43 +1,261 @@
import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from fastapi import status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
# Import the router we're testing
from app.routers.playlist import (
ProcessStatus,
ValidationProcessResponse,
ValidationResultResponse,
router,
validation_processes,
)
from app.utils.database import get_db
# Import mocks and fixtures
from tests.utils.auth_test_fixtures import (
admin_user_client,
db_session,
non_admin_user_client,
)
from tests.utils.db_mocks import MockChannelDB
# --- Test Fixtures ---
def test_protected_route_admin_access(db_session, admin_user_client):
"""Test that admin users can access the protected route"""
response = admin_user_client.get("/playlist/protected")
@pytest.fixture
def mock_stream_manager():
with patch("app.routers.playlist.StreamManager") as mock:
yield mock
# --- Test Cases For Stream Validation ---
def test_start_stream_validation_success(
db_session: Session, admin_user_client, mock_stream_manager
):
"""Test starting a stream validation process"""
mock_instance = mock_stream_manager.return_value
mock_instance.validate_and_select_stream.return_value = "http://valid.stream.url"
response = admin_user_client.post(
"/playlist/validate-streams", json={"channel_id": "test-channel"}
)
assert response.status_code == status.HTTP_202_ACCEPTED
data = response.json()
assert "process_id" in data
assert data["status"] == ProcessStatus.PENDING
assert data["message"] == "Validation process started"
# Verify process was added to tracking
process_id = data["process_id"]
assert process_id in validation_processes
# In test environment, background tasks run synchronously so status may be COMPLETED
assert validation_processes[process_id]["status"] in [
ProcessStatus.PENDING,
ProcessStatus.COMPLETED,
]
assert validation_processes[process_id]["channel_id"] == "test-channel"
def test_get_validation_status_pending(db_session: Session, admin_user_client):
"""Test checking status of pending validation"""
process_id = str(uuid.uuid4())
validation_processes[process_id] = {
"status": ProcessStatus.PENDING,
"channel_id": "test-channel",
}
response = admin_user_client.get(f"/playlist/validate-streams/{process_id}")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert "access to support resources" in data["message"]
assert "testadmin" in data["message"]
assert data["process_id"] == process_id
assert data["status"] == ProcessStatus.PENDING
assert data["working_streams"] is None
assert data["error"] is None
def test_protected_route_non_admin_access(db_session, non_admin_user_client):
"""Test that non-admin users can access the protected route
(just requires authentication)"""
response = non_admin_user_client.get("/playlist/protected")
def test_get_validation_status_completed(db_session: Session, admin_user_client):
"""Test checking status of completed validation"""
process_id = str(uuid.uuid4())
validation_processes[process_id] = {
"status": ProcessStatus.COMPLETED,
"channel_id": "test-channel",
"result": {
"working_streams": [
{"channel_id": "test-channel", "stream_url": "http://valid.stream.url"}
]
},
}
response = admin_user_client.get(f"/playlist/validate-streams/{process_id}")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert "access to support resources" in data["message"]
assert "testuser" in data["message"]
assert data["process_id"] == process_id
assert data["status"] == ProcessStatus.COMPLETED
assert len(data["working_streams"]) == 1
assert data["working_streams"][0]["channel_id"] == "test-channel"
assert data["working_streams"][0]["stream_url"] == "http://valid.stream.url"
assert data["error"] is None
def test_protected_route_no_auth():
"""Test that unauthenticated users cannot access the protected route"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
def test_get_validation_status_completed_with_error(
db_session: Session, admin_user_client
):
"""Test checking status of completed validation with error"""
process_id = str(uuid.uuid4())
validation_processes[process_id] = {
"status": ProcessStatus.COMPLETED,
"channel_id": "test-channel",
"error": "No working streams found for channel test-channel",
}
from app.routers.playlist import router as playlist_router
response = admin_user_client.get(f"/playlist/validate-streams/{process_id}")
app = FastAPI()
app.include_router(playlist_router)
client = TestClient(app)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["process_id"] == process_id
assert data["status"] == ProcessStatus.COMPLETED
assert data["working_streams"] is None
assert data["error"] == "No working streams found for channel test-channel"
response = client.get("/playlist/protected")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "Not authenticated" in response.json()["detail"]
def test_get_validation_status_failed(db_session: Session, admin_user_client):
"""Test checking status of failed validation"""
process_id = str(uuid.uuid4())
validation_processes[process_id] = {
"status": ProcessStatus.FAILED,
"channel_id": "test-channel",
"error": "Validation error occurred",
}
response = admin_user_client.get(f"/playlist/validate-streams/{process_id}")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["process_id"] == process_id
assert data["status"] == ProcessStatus.FAILED
assert data["working_streams"] is None
assert data["error"] == "Validation error occurred"
def test_get_validation_status_not_found(db_session: Session, admin_user_client):
"""Test checking status of non-existent process"""
random_uuid = str(uuid.uuid4())
response = admin_user_client.get(f"/playlist/validate-streams/{random_uuid}")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert "Process not found" in response.json()["detail"]
def test_run_stream_validation_success(mock_stream_manager, db_session):
"""Test the background validation task success case"""
process_id = str(uuid.uuid4())
validation_processes[process_id] = {
"status": ProcessStatus.PENDING,
"channel_id": "test-channel",
}
mock_instance = mock_stream_manager.return_value
mock_instance.validate_and_select_stream.return_value = "http://valid.stream.url"
from app.routers.playlist import run_stream_validation
run_stream_validation(process_id, "test-channel", db_session)
assert validation_processes[process_id]["status"] == ProcessStatus.COMPLETED
assert len(validation_processes[process_id]["result"]["working_streams"]) == 1
assert (
validation_processes[process_id]["result"]["working_streams"][0].channel_id
== "test-channel"
)
assert (
validation_processes[process_id]["result"]["working_streams"][0].stream_url
== "http://valid.stream.url"
)
def test_run_stream_validation_failure(mock_stream_manager, db_session):
"""Test the background validation task failure case"""
process_id = str(uuid.uuid4())
validation_processes[process_id] = {
"status": ProcessStatus.PENDING,
"channel_id": "test-channel",
}
mock_instance = mock_stream_manager.return_value
mock_instance.validate_and_select_stream.return_value = None
from app.routers.playlist import run_stream_validation
run_stream_validation(process_id, "test-channel", db_session)
assert validation_processes[process_id]["status"] == ProcessStatus.COMPLETED
assert "error" in validation_processes[process_id]
assert "No working streams found" in validation_processes[process_id]["error"]
def test_run_stream_validation_exception(mock_stream_manager, db_session):
"""Test the background validation task exception case"""
process_id = str(uuid.uuid4())
validation_processes[process_id] = {
"status": ProcessStatus.PENDING,
"channel_id": "test-channel",
}
mock_instance = mock_stream_manager.return_value
mock_instance.validate_and_select_stream.side_effect = Exception("Test error")
from app.routers.playlist import run_stream_validation
run_stream_validation(process_id, "test-channel", db_session)
assert validation_processes[process_id]["status"] == ProcessStatus.FAILED
assert "error" in validation_processes[process_id]
assert "Test error" in validation_processes[process_id]["error"]
def test_start_stream_validation_no_channel_id(
db_session: Session, admin_user_client, mock_stream_manager
):
"""Test starting validation without channel_id"""
response = admin_user_client.post("/playlist/validate-streams", json={})
assert response.status_code == status.HTTP_202_ACCEPTED
data = response.json()
assert "process_id" in data
assert data["status"] == ProcessStatus.PENDING
# Verify process was added to tracking
process_id = data["process_id"]
assert process_id in validation_processes
assert validation_processes[process_id]["status"] in [
ProcessStatus.PENDING,
ProcessStatus.COMPLETED,
]
assert validation_processes[process_id]["channel_id"] is None
assert "not yet implemented" in validation_processes[process_id].get("error", "")
def test_run_stream_validation_no_channel_id(mock_stream_manager, db_session):
"""Test background validation without channel_id"""
process_id = str(uuid.uuid4())
validation_processes[process_id] = {"status": ProcessStatus.PENDING}
from app.routers.playlist import run_stream_validation
run_stream_validation(process_id, None, db_session)
assert validation_processes[process_id]["status"] == ProcessStatus.COMPLETED
assert "error" in validation_processes[process_id]
assert "not yet implemented" in validation_processes[process_id]["error"]

View File

@@ -0,0 +1,287 @@
from datetime import datetime, timezone
from unittest.mock import Mock
from fastapi import HTTPException, Request, status
from app.iptv.scheduler import StreamScheduler
from app.routers.scheduler import get_scheduler
from app.routers.scheduler import router as scheduler_router
from app.utils.database import get_db
from tests.routers.mocks import MockScheduler, create_trigger_mock, mock_get_scheduler
from tests.utils.auth_test_fixtures import (
admin_user_client,
db_session,
non_admin_user_client,
)
from tests.utils.db_mocks import mock_get_db
# Scheduler Health Check Tests
def test_scheduler_health_success(admin_user_client, monkeypatch):
"""
Test case for successful scheduler health check when accessed by an admin user.
It mocks the scheduler to be running and have a next scheduled job.
"""
# Define the expected next run time for the scheduler job.
next_run = datetime.now(timezone.utc)
# Create a mock job object that simulates an APScheduler job.
mock_job = Mock()
mock_job.next_run_time = next_run
# Mock the `get_job` method to return our mock_job for a specific ID.
def mock_get_job(job_id):
if job_id == "daily_stream_validation":
return mock_job
return None
# Create a custom mock for `get_scheduler` dependency.
async def custom_mock_get_scheduler(request: Request) -> StreamScheduler:
return await mock_get_scheduler(
request,
running=True,
get_job=Mock(side_effect=mock_get_job), # Use the custom mock_get_job
)
# Include the scheduler router in the test application.
admin_user_client.app.include_router(scheduler_router)
# Override dependencies for the test.
admin_user_client.app.dependency_overrides[get_scheduler] = (
custom_mock_get_scheduler
)
admin_user_client.app.dependency_overrides[get_db] = mock_get_db
# Make the request to the scheduler health endpoint.
response = admin_user_client.get("/scheduler/health")
# Assert the response status code and content.
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["status"] == "running"
assert data["next_run"] == str(next_run)
def test_scheduler_health_stopped(admin_user_client, monkeypatch):
"""
Test case for scheduler health check when the scheduler is in a stopped state.
Ensures the API returns the correct status and no next run time.
"""
# Create a custom mock for `get_scheduler` dependency,
# simulating a stopped scheduler.
async def custom_mock_get_scheduler(request: Request) -> StreamScheduler:
return await mock_get_scheduler(
request,
running=False,
)
# Include the scheduler router in the test application.
admin_user_client.app.include_router(scheduler_router)
# Override dependencies for the test.
admin_user_client.app.dependency_overrides[get_scheduler] = (
custom_mock_get_scheduler
)
admin_user_client.app.dependency_overrides[get_db] = mock_get_db
# Make the request to the scheduler health endpoint.
response = admin_user_client.get("/scheduler/health")
# Assert the response status code and content.
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["status"] == "stopped"
assert data["next_run"] is None
def test_scheduler_health_forbidden_for_non_admin(non_admin_user_client, monkeypatch):
"""
Test case to ensure that non-admin users are forbidden from accessing
the scheduler health endpoint.
"""
# Create a custom mock for `get_scheduler` dependency.
async def custom_mock_get_scheduler(request: Request) -> StreamScheduler:
return await mock_get_scheduler(
request,
running=False,
)
# Include the scheduler router in the test application.
non_admin_user_client.app.include_router(scheduler_router)
# Override dependencies for the test.
non_admin_user_client.app.dependency_overrides[get_scheduler] = (
custom_mock_get_scheduler
)
non_admin_user_client.app.dependency_overrides[get_db] = mock_get_db
# Make the request to the scheduler health endpoint.
response = non_admin_user_client.get("/scheduler/health")
# Assert the response status code and error detail.
assert response.status_code == status.HTTP_403_FORBIDDEN
assert "required roles" in response.json()["detail"]
def test_scheduler_health_check_exception(admin_user_client, monkeypatch):
"""
Test case for handling exceptions during the scheduler health check.
Ensures the API returns a 500 Internal Server Error when an exception occurs.
"""
# Create a custom mock for `get_scheduler` dependency that raises an exception.
async def custom_mock_get_scheduler(request: Request) -> StreamScheduler:
return await mock_get_scheduler(
request, running=True, get_job=Mock(side_effect=Exception("Test exception"))
)
# Include the scheduler router in the test application.
admin_user_client.app.include_router(scheduler_router)
# Override dependencies for the test.
admin_user_client.app.dependency_overrides[get_scheduler] = (
custom_mock_get_scheduler
)
admin_user_client.app.dependency_overrides[get_db] = mock_get_db
# Make the request to the scheduler health endpoint.
response = admin_user_client.get("/scheduler/health")
# Assert the response status code and error detail.
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert "Failed to check scheduler health" in response.json()["detail"]
# Scheduler Trigger Tests
def test_trigger_validation_success(admin_user_client, monkeypatch):
"""
Test case for successful manual triggering
of stream validation by an admin user.
It verifies that the trigger method is called and
the API returns a 202 Accepted status.
"""
# Use a mutable reference to check if the trigger method was called.
triggered_ref = {"value": False}
# Initialize a custom mock scheduler.
custom_scheduler = MockScheduler(running=True)
custom_scheduler.get_job = Mock(return_value=None)
# Create a custom mock for `get_scheduler` dependency,
# overriding `trigger_manual_validation`.
async def custom_mock_get_scheduler(request: Request) -> StreamScheduler:
scheduler = await mock_get_scheduler(
request,
running=True,
)
# Replace the actual trigger method with our mock to track calls.
scheduler.trigger_manual_validation = create_trigger_mock(
triggered_ref=triggered_ref
)
return scheduler
# Include the scheduler router in the test application.
admin_user_client.app.include_router(scheduler_router)
# Override dependencies for the test.
admin_user_client.app.dependency_overrides[get_scheduler] = (
custom_mock_get_scheduler
)
admin_user_client.app.dependency_overrides[get_db] = mock_get_db
# Make the request to trigger stream validation.
response = admin_user_client.post("/scheduler/trigger")
# Assert the response status code, message, and that the trigger was called.
assert response.status_code == status.HTTP_202_ACCEPTED
assert response.json()["message"] == "Stream validation triggered"
assert triggered_ref["value"] is True
def test_trigger_validation_forbidden_for_non_admin(non_admin_user_client, monkeypatch):
"""
Test case to ensure that non-admin users are
forbidden from manually triggering stream validation.
"""
# Create a custom mock for `get_scheduler` dependency.
async def custom_mock_get_scheduler(request: Request) -> StreamScheduler:
return await mock_get_scheduler(
request,
running=True,
)
# Include the scheduler router in the test application.
non_admin_user_client.app.include_router(scheduler_router)
# Override dependencies for the test.
non_admin_user_client.app.dependency_overrides[get_scheduler] = (
custom_mock_get_scheduler
)
non_admin_user_client.app.dependency_overrides[get_db] = mock_get_db
# Make the request to trigger stream validation.
response = non_admin_user_client.post("/scheduler/trigger")
# Assert the response status code and error detail.
assert response.status_code == status.HTTP_403_FORBIDDEN
assert "required roles" in response.json()["detail"]
def test_scheduler_initialized_in_app_state(admin_user_client):
"""
Test case for when the scheduler is initialized in the app state but its internal
scheduler attribute is not set, which should still allow health check.
"""
scheduler = StreamScheduler()
# Set the scheduler instance in the test client's app state.
admin_user_client.app.state.scheduler = scheduler
# Include the scheduler router in the test application.
admin_user_client.app.include_router(scheduler_router)
# Override only get_db, allowing the real get_scheduler to be tested.
admin_user_client.app.dependency_overrides[get_db] = mock_get_db
# Make the request to the scheduler health endpoint.
response = admin_user_client.get("/scheduler/health")
# Assert the response status code.
assert response.status_code == status.HTTP_200_OK
def test_scheduler_not_initialized_in_app_state(admin_user_client):
"""
Test case for when the scheduler is not properly initialized in the app state.
This simulates a scenario where the internal scheduler attribute is missing,
leading to a 500 Internal Server Error on health check.
"""
scheduler = StreamScheduler()
del (
scheduler.scheduler
) # Simulate uninitialized scheduler by deleting the attribute
# Set the scheduler instance in the test client's app state.
admin_user_client.app.state.scheduler = scheduler
# Include the scheduler router in the test application.
admin_user_client.app.include_router(scheduler_router)
# Override only get_db, allowing the real get_scheduler to be tested.
admin_user_client.app.dependency_overrides[get_db] = mock_get_db
# Make the request to the scheduler health endpoint.
response = admin_user_client.get("/scheduler/health")
# Assert the response status code and error detail.
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert "Scheduler not initialized" in response.json()["detail"]