feat(main): commit

This commit is contained in:
2026-03-08 16:48:58 -04:00
parent 567766eaed
commit f37382d2b8
29 changed files with 3735 additions and 223 deletions

View File

@@ -1,6 +1,9 @@
import pytest
from django.test import Client
from core.models import AppUser, Library, Channel
from core.models import AppUser, Library, Channel, MediaSource, MediaItem, Airing
from django.utils import timezone
from datetime import timedelta
import uuid
@pytest.fixture
def client():
@@ -63,5 +66,68 @@ def test_create_channel(client, user, library):
assert data["slug"] == "new-api-ch"
# Verify it hit the DB
assert Channel.objects.count() == 1
assert Channel.objects.get(id=data["id"]).name == "New API Channel"
@pytest.fixture
def media_source(db, library):
return MediaSource.objects.create(
library=library,
name="Test Source",
source_type="local_directory",
uri="/mock/test"
)
@pytest.fixture
def media_item_youtube(db, media_source):
return MediaItem.objects.create(
media_source=media_source,
title="YT Test Video",
item_kind="video",
runtime_seconds=600,
file_path="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
)
@pytest.mark.django_db
def test_channel_now_playing_uncached_media_path(client, channel, media_item_youtube):
now = timezone.now()
Airing.objects.create(
channel=channel,
media_item=media_item_youtube,
starts_at=now - timedelta(minutes=5),
ends_at=now + timedelta(minutes=5),
slot_kind="program",
status="playing",
generation_batch_uuid=uuid.uuid4()
)
response = client.get(f"/api/channel/{channel.id}/now")
assert response.status_code == 200
data = response.json()
assert data["media_item_title"] == "YT Test Video"
# Should use the raw file_path since there is no cached_file_path
assert data["media_item_path"] == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
@pytest.mark.django_db
def test_channel_now_playing_cached_media_path(client, channel, media_item_youtube):
from django.conf import settings
import os
media_item_youtube.cached_file_path = os.path.join(settings.MEDIA_ROOT, "dQw4w9WgXcQ.mp4")
media_item_youtube.save()
now = timezone.now()
Airing.objects.create(
channel=channel,
media_item=media_item_youtube,
starts_at=now - timedelta(minutes=5),
ends_at=now + timedelta(minutes=5),
slot_kind="program",
status="playing",
generation_batch_uuid=uuid.uuid4()
)
response = client.get(f"/api/channel/{channel.id}/now")
assert response.status_code == 200
data = response.json()
assert data["media_item_title"] == "YT Test Video"
# Should resolve the cached_file_path to a web-accessible MEDIA_URL
assert data["media_item_path"] == "/dQw4w9WgXcQ.mp4"

292
api/tests/test_sources.py Normal file
View File

@@ -0,0 +1,292 @@
"""
Tests for the /api/sources/ router.
Covers:
- Listing sources
- Creating local and YouTube-type sources
- Syncing a YouTube source (mocked yt-dlp call)
- Deleting a source
- Attempting to sync a non-YouTube source (should 400)
"""
import pytest
from unittest.mock import patch, MagicMock
from django.test import Client
from core.models import AppUser, Library, MediaSource, MediaItem
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def client():
return Client()
@pytest.fixture
def user(db):
return AppUser.objects.create_user(
username="srcuser", email="src@pytv.local", password="password"
)
@pytest.fixture
def library(db, user):
return Library.objects.create(
owner_user=user,
name="Source Test Library",
visibility="public",
)
@pytest.fixture
def local_source(db, library):
return MediaSource.objects.create(
library=library,
name="Local Movies",
source_type=MediaSource.SourceType.LOCAL_DIRECTORY,
uri="/mnt/movies",
)
@pytest.fixture
def youtube_source(db, library):
return MediaSource.objects.create(
library=library,
name="ABC News",
source_type=MediaSource.SourceType.YOUTUBE_CHANNEL,
uri="https://www.youtube.com/@ABCNews",
)
# ---------------------------------------------------------------------------
# Listing
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_list_sources_empty(client):
"""Listing when no sources exist returns an empty array."""
response = client.get("/api/sources/")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.django_db
def test_list_sources_returns_all(client, local_source, youtube_source):
response = client.get("/api/sources/")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
names = {s["name"] for s in data}
assert names == {"Local Movies", "ABC News"}
# ---------------------------------------------------------------------------
# Creation
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_create_local_source(client, library):
payload = {
"library_id": library.id,
"name": "My Movies",
"source_type": "local_directory",
"uri": "/mnt/media/movies",
"is_active": True,
}
response = client.post(
"/api/sources/", data=payload, content_type="application/json"
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "My Movies"
assert data["source_type"] == "local_directory"
assert MediaSource.objects.filter(id=data["id"]).exists()
@pytest.mark.django_db
def test_create_youtube_channel_source(client, library):
"""Registering a YouTube channel URL as a media source."""
payload = {
"library_id": library.id,
"name": "ABC News",
"source_type": "youtube_channel",
"uri": "https://www.youtube.com/@ABCNews",
"is_active": True,
}
response = client.post(
"/api/sources/", data=payload, content_type="application/json"
)
assert response.status_code == 201
data = response.json()
assert data["source_type"] == "youtube_channel"
assert "@ABCNews" in data["uri"]
# Verify it's in the DB with correct type
src = MediaSource.objects.get(id=data["id"])
assert src.source_type == MediaSource.SourceType.YOUTUBE_CHANNEL
@pytest.mark.django_db
def test_create_youtube_playlist_source(client, library):
"""Registering a YouTube playlist URL as a media source."""
payload = {
"library_id": library.id,
"name": "Tech Talks",
"source_type": "youtube_playlist",
"uri": "https://www.youtube.com/playlist?list=PLFgquLnL59akA2PflFpeQG9L01VFg90wS",
"is_active": True,
}
response = client.post(
"/api/sources/", data=payload, content_type="application/json"
)
assert response.status_code == 201
data = response.json()
assert data["source_type"] == "youtube_playlist"
@pytest.mark.django_db
def test_create_source_invalid_library(client):
"""Creating a source with a non-existent library should 404."""
payload = {
"library_id": 99999,
"name": "Orphan",
"source_type": "local_directory",
"uri": "/nope",
}
response = client.post(
"/api/sources/", data=payload, content_type="application/json"
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Retrieval
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_get_source(client, youtube_source):
response = client.get(f"/api/sources/{youtube_source.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == youtube_source.id
assert data["source_type"] == "youtube_channel"
@pytest.mark.django_db
def test_get_source_not_found(client):
response = client.get("/api/sources/99999")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Deletion
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_delete_source(client, local_source):
source_id = local_source.id
response = client.delete(f"/api/sources/{source_id}")
assert response.status_code == 204
assert not MediaSource.objects.filter(id=source_id).exists()
@pytest.mark.django_db
def test_delete_source_not_found(client):
response = client.delete("/api/sources/99999")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Sync (mocked yt-dlp)
# ---------------------------------------------------------------------------
MOCK_YT_ENTRIES = [
{
"id": "abc123",
"title": "Breaking News: Test Story",
"duration": 180,
"thumbnail": "https://i.ytimg.com/vi/abc123/hqdefault.jpg",
"description": "A breaking news segment.",
"upload_date": "20240315",
"url": "https://www.youtube.com/watch?v=abc123",
"uploader": "ABC News",
},
{
"id": "def456",
"title": "Weather Report: Sunny with a chance of clouds",
"duration": 90,
"thumbnail": "https://i.ytimg.com/vi/def456/hqdefault.jpg",
"description": "Evening weather.",
"upload_date": "20240316",
"url": "https://www.youtube.com/watch?v=def456",
"uploader": "ABC News",
},
]
@pytest.mark.django_db
def test_sync_youtube_channel_creates_media_items(client, youtube_source):
"""
Syncing a YouTube channel should call yt-dlp (mocked) and upsert
MediaItem rows for each discovered video.
"""
with patch("core.services.youtube._extract_playlist_info", return_value=MOCK_YT_ENTRIES):
response = client.post(f"/api/sources/{youtube_source.id}/sync")
assert response.status_code == 200
data = response.json()
# Two new videos → created=2
assert data["created"] == 2
assert data["updated"] == 0
assert data["skipped"] == 0
# MediaItems should now exist in the DB
items = MediaItem.objects.filter(media_source=youtube_source)
assert items.count() == 2
titles = {i.title for i in items}
assert "Breaking News: Test Story" in titles
assert "Weather Report: Sunny with a chance of clouds" in titles
# Verify youtube_video_id is populated
item = items.get(youtube_video_id="abc123")
assert item.runtime_seconds == 180
assert item.release_year == 2024
@pytest.mark.django_db
def test_sync_youtube_channel_updates_existing(client, youtube_source):
"""Re-syncing the same source updates existing rows rather than duplicating."""
with patch("core.services.youtube._extract_playlist_info", return_value=MOCK_YT_ENTRIES):
client.post(f"/api/sources/{youtube_source.id}/sync")
# Second sync — same entries, should update not create
with patch(
"core.services.youtube._extract_playlist_info",
return_value=[{**MOCK_YT_ENTRIES[0], "title": "Updated Title"}],
):
response = client.post(f"/api/sources/{youtube_source.id}/sync")
assert response.status_code == 200
data = response.json()
assert data["created"] == 0
assert data["updated"] == 1
item = MediaItem.objects.get(youtube_video_id="abc123")
assert item.title == "Updated Title"
@pytest.mark.django_db
def test_sync_non_youtube_source_returns_400(client, local_source):
"""Syncing a non-YouTube source type should return HTTP 400."""
response = client.post(f"/api/sources/{local_source.id}/sync")
assert response.status_code == 400
@pytest.mark.django_db
def test_sync_source_not_found(client):
response = client.post("/api/sources/99999/sync")
assert response.status_code == 404