mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-20 16:51:10 +00:00
506 lines
20 KiB
Python
506 lines
20 KiB
Python
from django.test import TestCase
|
|
from django.contrib.auth import get_user_model
|
|
from django.utils import timezone
|
|
from datetime import timedelta
|
|
from rest_framework.test import APIClient
|
|
from rest_framework import status
|
|
|
|
from apps.channels.models import Channel, ChannelGroup, ChannelOverride
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class ChannelBulkEditAPITests(TestCase):
|
|
def setUp(self):
|
|
# Create a test admin user (user_level >= 10) and authenticate
|
|
self.user = User.objects.create_user(username="testuser", password="testpass123")
|
|
self.user.user_level = 10 # Set admin level
|
|
self.user.save()
|
|
self.client = APIClient()
|
|
self.client.force_authenticate(user=self.user)
|
|
self.bulk_edit_url = "/api/channels/channels/edit/bulk/"
|
|
|
|
# Create test channel group
|
|
self.group1 = ChannelGroup.objects.create(name="Test Group 1")
|
|
self.group2 = ChannelGroup.objects.create(name="Test Group 2")
|
|
|
|
# Create test channels
|
|
self.channel1 = Channel.objects.create(
|
|
channel_number=1.0,
|
|
name="Channel 1",
|
|
tvg_id="channel1",
|
|
channel_group=self.group1
|
|
)
|
|
self.channel2 = Channel.objects.create(
|
|
channel_number=2.0,
|
|
name="Channel 2",
|
|
tvg_id="channel2",
|
|
channel_group=self.group1
|
|
)
|
|
self.channel3 = Channel.objects.create(
|
|
channel_number=3.0,
|
|
name="Channel 3",
|
|
tvg_id="channel3"
|
|
)
|
|
|
|
def test_bulk_edit_success(self):
|
|
"""Test successful bulk update of multiple channels"""
|
|
data = [
|
|
{"id": self.channel1.id, "name": "Updated Channel 1"},
|
|
{"id": self.channel2.id, "name": "Updated Channel 2", "channel_number": 22.0},
|
|
]
|
|
|
|
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
|
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.data["message"], "Successfully updated 2 channels")
|
|
self.assertEqual(len(response.data["channels"]), 2)
|
|
|
|
# Verify database changes
|
|
self.channel1.refresh_from_db()
|
|
self.channel2.refresh_from_db()
|
|
self.assertEqual(self.channel1.name, "Updated Channel 1")
|
|
self.assertEqual(self.channel2.name, "Updated Channel 2")
|
|
self.assertEqual(self.channel2.channel_number, 22.0)
|
|
|
|
def test_bulk_edit_with_empty_validated_data_first(self):
|
|
"""
|
|
Test the bug fix: when first channel has empty validated_data.
|
|
This was causing: ValueError: Field names must be given to bulk_update()
|
|
"""
|
|
# Create a channel with data that will be "unchanged" (empty validated_data)
|
|
# We'll send the same data it already has
|
|
data = [
|
|
# First channel: no actual changes (this would create empty validated_data)
|
|
{"id": self.channel1.id},
|
|
# Second channel: has changes
|
|
{"id": self.channel2.id, "name": "Updated Channel 2"},
|
|
]
|
|
|
|
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
|
|
|
# Should not crash with ValueError
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.data["message"], "Successfully updated 2 channels")
|
|
|
|
# Verify the channel with changes was updated
|
|
self.channel2.refresh_from_db()
|
|
self.assertEqual(self.channel2.name, "Updated Channel 2")
|
|
|
|
def test_bulk_edit_all_empty_updates(self):
|
|
"""Test when all channels have empty updates (no actual changes)"""
|
|
data = [
|
|
{"id": self.channel1.id},
|
|
{"id": self.channel2.id},
|
|
]
|
|
|
|
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
|
|
|
# Should succeed without calling bulk_update
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.data["message"], "Successfully updated 2 channels")
|
|
|
|
def test_bulk_edit_mixed_fields(self):
|
|
"""Test bulk update where different channels update different fields"""
|
|
data = [
|
|
{"id": self.channel1.id, "name": "New Name 1"},
|
|
{"id": self.channel2.id, "channel_number": 99.0},
|
|
{"id": self.channel3.id, "tvg_id": "new_tvg_id", "name": "New Name 3"},
|
|
]
|
|
|
|
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
|
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.data["message"], "Successfully updated 3 channels")
|
|
|
|
# Verify all updates
|
|
self.channel1.refresh_from_db()
|
|
self.channel2.refresh_from_db()
|
|
self.channel3.refresh_from_db()
|
|
|
|
self.assertEqual(self.channel1.name, "New Name 1")
|
|
self.assertEqual(self.channel2.channel_number, 99.0)
|
|
self.assertEqual(self.channel3.tvg_id, "new_tvg_id")
|
|
self.assertEqual(self.channel3.name, "New Name 3")
|
|
|
|
def test_bulk_edit_with_channel_group(self):
|
|
"""Test bulk update with channel_group_id changes"""
|
|
data = [
|
|
{"id": self.channel1.id, "channel_group_id": self.group2.id},
|
|
{"id": self.channel3.id, "channel_group_id": self.group1.id},
|
|
]
|
|
|
|
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
|
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
|
|
# Verify group changes
|
|
self.channel1.refresh_from_db()
|
|
self.channel3.refresh_from_db()
|
|
self.assertEqual(self.channel1.channel_group, self.group2)
|
|
self.assertEqual(self.channel3.channel_group, self.group1)
|
|
|
|
def test_bulk_edit_nonexistent_channel(self):
|
|
"""Test bulk update with a channel that doesn't exist"""
|
|
nonexistent_id = 99999
|
|
data = [
|
|
{"id": nonexistent_id, "name": "Should Fail"},
|
|
{"id": self.channel1.id, "name": "Should Still Update"},
|
|
]
|
|
|
|
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
|
|
|
# Should return 400 with errors
|
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
self.assertIn("errors", response.data)
|
|
self.assertEqual(len(response.data["errors"]), 1)
|
|
self.assertEqual(response.data["errors"][0]["channel_id"], nonexistent_id)
|
|
self.assertEqual(response.data["errors"][0]["error"], "Channel not found")
|
|
|
|
# The valid channel should still be updated
|
|
self.assertEqual(response.data["updated_count"], 1)
|
|
|
|
def test_bulk_edit_validation_error(self):
|
|
"""Test bulk update with invalid data (validation error)"""
|
|
data = [
|
|
{"id": self.channel1.id, "channel_number": "invalid_number"},
|
|
]
|
|
|
|
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
|
|
|
# Should return 400 with validation errors
|
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
self.assertIn("errors", response.data)
|
|
self.assertEqual(len(response.data["errors"]), 1)
|
|
self.assertIn("channel_number", response.data["errors"][0]["errors"])
|
|
|
|
def test_bulk_edit_empty_channel_updates(self):
|
|
"""Test bulk update with empty list"""
|
|
data = []
|
|
|
|
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
|
|
|
# Empty list is accepted and returns success with 0 updates
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.data["message"], "Successfully updated 0 channels")
|
|
|
|
def test_bulk_edit_missing_channel_updates(self):
|
|
"""Test bulk update without proper format (dict instead of list)"""
|
|
data = {"channel_updates": {}}
|
|
|
|
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
|
|
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
self.assertEqual(response.data["error"], "Expected a list of channel updates")
|
|
|
|
def test_bulk_edit_preserves_other_fields(self):
|
|
"""Test that bulk update only changes specified fields"""
|
|
original_channel_number = self.channel1.channel_number
|
|
original_tvg_id = self.channel1.tvg_id
|
|
|
|
data = [
|
|
{"id": self.channel1.id, "name": "Only Name Changed"},
|
|
]
|
|
|
|
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
|
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
|
|
# Verify only name changed, other fields preserved
|
|
self.channel1.refresh_from_db()
|
|
self.assertEqual(self.channel1.name, "Only Name Changed")
|
|
self.assertEqual(self.channel1.channel_number, original_channel_number)
|
|
self.assertEqual(self.channel1.tvg_id, original_tvg_id)
|
|
|
|
def test_bulk_swap_clear_and_assign_same_number(self):
|
|
# User clears channel A's override (which currently pins #10) and
|
|
# in the same bulk request sets channel B's override.channel_number
|
|
# to #10. Both halves of the swap must succeed; the resulting
|
|
# state has A unpinned and B pinned at #10.
|
|
auto_a = Channel.objects.create(
|
|
channel_number=1.0,
|
|
name="Auto A",
|
|
tvg_id="auto_a",
|
|
channel_group=self.group1,
|
|
auto_created=True,
|
|
)
|
|
ChannelOverride.objects.create(channel=auto_a, channel_number=10.0)
|
|
auto_b = Channel.objects.create(
|
|
channel_number=2.0,
|
|
name="Auto B",
|
|
tvg_id="auto_b",
|
|
channel_group=self.group1,
|
|
auto_created=True,
|
|
)
|
|
|
|
data = [
|
|
{"id": auto_a.id, "override": None},
|
|
{"id": auto_b.id, "override": {"channel_number": 10.0}},
|
|
]
|
|
response = self.client.patch(self.bulk_edit_url, data, format="json")
|
|
|
|
self.assertEqual(
|
|
response.status_code,
|
|
status.HTTP_200_OK,
|
|
f"Expected 200; got {response.status_code} body={response.data}",
|
|
)
|
|
self.assertFalse(
|
|
ChannelOverride.objects.filter(channel=auto_a).exists()
|
|
)
|
|
b_override = ChannelOverride.objects.get(channel=auto_b)
|
|
self.assertEqual(b_override.channel_number, 10.0)
|
|
|
|
|
|
class ChannelSummaryEffectiveValuesTests(TestCase):
|
|
"""
|
|
The /api/channels/channels/summary/ endpoint feeds the TV Guide.
|
|
Like every downstream output surface, it must reflect the user's
|
|
overrides (name, channel_number, logo_id, epg_data_id,
|
|
channel_group_id) instead of the raw provider values, otherwise
|
|
the in-app guide would silently disagree with HDHR / M3U / EPG /
|
|
XC clients on the same channel set.
|
|
"""
|
|
|
|
def setUp(self):
|
|
from django.contrib.auth import get_user_model
|
|
from rest_framework.test import APIClient
|
|
from apps.channels.models import ChannelOverride
|
|
|
|
User = get_user_model()
|
|
self.user = User.objects.create_user(
|
|
username="summary_admin", password="x"
|
|
)
|
|
self.user.user_level = 10
|
|
self.user.save()
|
|
self.client = APIClient()
|
|
self.client.force_authenticate(user=self.user)
|
|
|
|
self.group = ChannelGroup.objects.create(name="Summary Group")
|
|
self.other_group = ChannelGroup.objects.create(name="Other")
|
|
self.channel = Channel.objects.create(
|
|
channel_number=10.0,
|
|
name="Provider Name",
|
|
channel_group=self.group,
|
|
auto_created=True,
|
|
)
|
|
ChannelOverride.objects.create(
|
|
channel=self.channel,
|
|
name="Override Name",
|
|
channel_number=99.0,
|
|
channel_group=self.other_group,
|
|
)
|
|
|
|
def test_summary_returns_effective_values(self):
|
|
response = self.client.get("/api/channels/channels/summary/")
|
|
self.assertEqual(response.status_code, 200)
|
|
row = next(r for r in response.data if r["id"] == self.channel.id)
|
|
self.assertEqual(row["name"], "Override Name")
|
|
self.assertEqual(row["channel_number"], 99.0)
|
|
self.assertEqual(row["channel_group_id"], self.other_group.id)
|
|
|
|
|
|
class ChannelManagerEffectiveValuesTests(TestCase):
|
|
"""
|
|
The chainable ``Channel.objects.with_effective_values()`` shortcut
|
|
must return rows with the same ``effective_*`` annotations the
|
|
module-level helper produces, since both forms are documented
|
|
entry points and a divergence would silently change output for
|
|
one set of callers.
|
|
"""
|
|
|
|
def test_manager_shortcut_matches_module_helper(self):
|
|
from apps.channels.managers import with_effective_values
|
|
|
|
group = ChannelGroup.objects.create(name="Manager Test")
|
|
channel = Channel.objects.create(
|
|
channel_number=42.0,
|
|
name="Original Name",
|
|
channel_group=group,
|
|
auto_created=True,
|
|
)
|
|
ChannelOverride.objects.create(
|
|
channel=channel,
|
|
name="Renamed",
|
|
channel_number=99.0,
|
|
)
|
|
|
|
helper_row = with_effective_values(
|
|
Channel.objects.filter(id=channel.id)
|
|
).get()
|
|
shortcut_row = (
|
|
Channel.objects.with_effective_values()
|
|
.filter(id=channel.id)
|
|
.get()
|
|
)
|
|
|
|
self.assertEqual(helper_row.effective_name, "Renamed")
|
|
self.assertEqual(shortcut_row.effective_name, "Renamed")
|
|
self.assertEqual(helper_row.effective_channel_number, 99.0)
|
|
self.assertEqual(shortcut_row.effective_channel_number, 99.0)
|
|
self.assertEqual(
|
|
helper_row.effective_channel_group_id,
|
|
shortcut_row.effective_channel_group_id,
|
|
)
|
|
|
|
|
|
class SeriesRuleAPITests(TestCase):
|
|
"""API tests for series rule CRUD and bulk-remove endpoints."""
|
|
|
|
def setUp(self):
|
|
User = get_user_model()
|
|
self.admin = User.objects.create_user(username="admin_sr", password="pass")
|
|
self.admin.user_level = 10
|
|
self.admin.save()
|
|
self.client = APIClient()
|
|
self.client.force_authenticate(user=self.admin)
|
|
|
|
from core.models import CoreSettings
|
|
CoreSettings.set_dvr_series_rules([])
|
|
|
|
self.rules_url = "/api/channels/series-rules/"
|
|
self.bulk_remove_url = "/api/channels/series-rules/bulk-remove/"
|
|
|
|
# --- POST (create/upsert) ---
|
|
|
|
def test_create_rule_with_tvg_id(self):
|
|
resp = self.client.post(self.rules_url, {
|
|
"tvg_id": "some.channel", "title": "My Show", "mode": "all",
|
|
}, format="json")
|
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(len(resp.data["rules"]), 1)
|
|
self.assertEqual(resp.data["rules"][0]["tvg_id"], "some.channel")
|
|
|
|
def test_create_title_only_rule_no_tvg_id(self):
|
|
"""A rule with no tvg_id (title-only) is accepted when title is provided."""
|
|
resp = self.client.post(self.rules_url, {
|
|
"tvg_id": "", "title": "Untethered Show", "mode": "all",
|
|
}, format="json")
|
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
rule = resp.data["rules"][0]
|
|
self.assertEqual(rule["tvg_id"], "")
|
|
self.assertEqual(rule["title"], "Untethered Show")
|
|
|
|
def test_create_rule_requires_title_or_description(self):
|
|
resp = self.client.post(self.rules_url, {
|
|
"tvg_id": "some.channel", "title": "", "description": "",
|
|
}, format="json")
|
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
|
|
|
def test_upsert_key_is_tvg_id_and_title(self):
|
|
"""Two POST requests with same tvg_id but different titles create two rules."""
|
|
self.client.post(self.rules_url, {
|
|
"tvg_id": "ch.1", "title": "Show A", "mode": "all",
|
|
}, format="json")
|
|
self.client.post(self.rules_url, {
|
|
"tvg_id": "ch.1", "title": "Show B", "mode": "all",
|
|
}, format="json")
|
|
resp = self.client.get(self.rules_url)
|
|
self.assertEqual(len(resp.data["rules"]), 2)
|
|
|
|
def test_upsert_updates_existing_rule(self):
|
|
"""POSTing with an existing (tvg_id, title) pair updates in place."""
|
|
self.client.post(self.rules_url, {
|
|
"tvg_id": "ch.1", "title": "Show A", "mode": "all",
|
|
}, format="json")
|
|
self.client.post(self.rules_url, {
|
|
"tvg_id": "ch.1", "title": "Show A", "mode": "new",
|
|
}, format="json")
|
|
resp = self.client.get(self.rules_url)
|
|
self.assertEqual(len(resp.data["rules"]), 1)
|
|
self.assertEqual(resp.data["rules"][0]["mode"], "new")
|
|
|
|
# --- DELETE (query params) ---
|
|
|
|
def test_delete_rule_by_tvg_id_and_title(self):
|
|
self.client.post(self.rules_url, {
|
|
"tvg_id": "ch.1", "title": "Show A", "mode": "all",
|
|
}, format="json")
|
|
resp = self.client.delete(
|
|
self.rules_url + "?tvg_id=ch.1&title=Show+A"
|
|
)
|
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(resp.data["rules"], [])
|
|
|
|
def test_delete_title_only_rule(self):
|
|
"""Title-only rules (tvg_id='') are deleted via empty tvg_id query param."""
|
|
self.client.post(self.rules_url, {
|
|
"tvg_id": "", "title": "Untethered Show", "mode": "all",
|
|
}, format="json")
|
|
resp = self.client.delete(
|
|
self.rules_url + "?tvg_id=&title=Untethered+Show"
|
|
)
|
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(resp.data["rules"], [])
|
|
|
|
def test_delete_only_removes_matching_rule(self):
|
|
"""Delete by (tvg_id, title) leaves other rules intact."""
|
|
self.client.post(self.rules_url, {"tvg_id": "ch.1", "title": "Show A", "mode": "all"}, format="json")
|
|
self.client.post(self.rules_url, {"tvg_id": "ch.1", "title": "Show B", "mode": "all"}, format="json")
|
|
self.client.delete(self.rules_url + "?tvg_id=ch.1&title=Show+A")
|
|
resp = self.client.get(self.rules_url)
|
|
self.assertEqual(len(resp.data["rules"]), 1)
|
|
self.assertEqual(resp.data["rules"][0]["title"], "Show B")
|
|
|
|
def test_delete_removes_future_recordings(self):
|
|
"""DELETE cleans up future recordings that matched the rule."""
|
|
from apps.channels.models import Recording
|
|
|
|
group = ChannelGroup.objects.create(name="G")
|
|
channel = Channel.objects.create(channel_number=1, name="Ch", channel_group=group)
|
|
now = timezone.now()
|
|
Recording.objects.create(
|
|
channel=channel,
|
|
start_time=now + timedelta(hours=1),
|
|
end_time=now + timedelta(hours=2),
|
|
custom_properties={"program": {"tvg_id": "ch.1", "title": "Show A"}},
|
|
)
|
|
|
|
self.client.post(self.rules_url, {"tvg_id": "ch.1", "title": "Show A", "mode": "all"}, format="json")
|
|
self.client.delete(self.rules_url + "?tvg_id=ch.1&title=Show+A")
|
|
self.assertEqual(Recording.objects.count(), 0)
|
|
|
|
# --- POST bulk-remove ---
|
|
|
|
def test_bulk_remove_with_tvg_id(self):
|
|
from apps.channels.models import Recording
|
|
|
|
group = ChannelGroup.objects.create(name="G2")
|
|
channel = Channel.objects.create(channel_number=2, name="Ch2", channel_group=group)
|
|
now = timezone.now()
|
|
Recording.objects.create(
|
|
channel=channel,
|
|
start_time=now + timedelta(hours=1),
|
|
end_time=now + timedelta(hours=2),
|
|
custom_properties={"program": {"tvg_id": "ch.x", "title": "Show X"}},
|
|
)
|
|
resp = self.client.post(self.bulk_remove_url, {"tvg_id": "ch.x"}, format="json")
|
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(resp.data["removed"], 1)
|
|
self.assertEqual(Recording.objects.count(), 0)
|
|
|
|
def test_bulk_remove_title_only_no_tvg_id(self):
|
|
"""Bulk-remove accepts title alone (no tvg_id) for title-only rules."""
|
|
from apps.channels.models import Recording
|
|
|
|
group = ChannelGroup.objects.create(name="G3")
|
|
channel = Channel.objects.create(channel_number=3, name="Ch3", channel_group=group)
|
|
now = timezone.now()
|
|
Recording.objects.create(
|
|
channel=channel,
|
|
start_time=now + timedelta(hours=1),
|
|
end_time=now + timedelta(hours=2),
|
|
custom_properties={"program": {"tvg_id": "ch.a", "title": "Cross Show"}},
|
|
)
|
|
Recording.objects.create(
|
|
channel=channel,
|
|
start_time=now + timedelta(hours=3),
|
|
end_time=now + timedelta(hours=4),
|
|
custom_properties={"program": {"tvg_id": "ch.b", "title": "Cross Show"}},
|
|
)
|
|
resp = self.client.post(self.bulk_remove_url, {"title": "Cross Show"}, format="json")
|
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(resp.data["removed"], 2)
|
|
|
|
def test_bulk_remove_requires_tvg_id_or_title(self):
|
|
resp = self.client.post(self.bulk_remove_url, {}, format="json")
|
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|