Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled

This commit is contained in:
root
2026-05-09 21:24:50 +02:00
commit f56b088643
721 changed files with 177870 additions and 0 deletions
View File
+195
View File
@@ -0,0 +1,195 @@
import os
import tempfile
from django.test import TestCase
from apps.epg.tasks import (
_NAMED_ENTITY_RE,
_detect_xml_encoding,
_replace_html_entity,
_resolve_html_entities,
)
class ReplaceHtmlEntityTests(TestCase):
"""Tests for the regex callback that resolves individual HTML entities."""
def _sub(self, text):
return _NAMED_ENTITY_RE.sub(_replace_html_entity, text)
def test_french_accented(self):
self.assertEqual(self._sub("Chaîne Télé"), "Chaîne Télé")
def test_german_umlauts(self):
self.assertEqual(self._sub("München Übersicht ß"), "München Übersicht ß")
def test_spanish(self):
self.assertEqual(self._sub("España ¿Qué?"), "España ¿Qué?")
def test_portuguese(self):
self.assertEqual(self._sub("Comunicação"), "Comunicação")
def test_scandinavian(self):
self.assertEqual(self._sub("Norsk ø å æ"), "Norsk ø å æ")
def test_greek_letters(self):
self.assertEqual(self._sub("αβγ"), "αβγ")
def test_currency_and_symbols(self):
self.assertEqual(self._sub("© € £ ¥"), "© € £ ¥")
def test_preserves_xml_amp(self):
self.assertEqual(self._sub("A & B"), "A & B")
def test_preserves_xml_lt_gt(self):
self.assertEqual(self._sub("<tag>"), "<tag>")
def test_preserves_xml_quot_apos(self):
self.assertEqual(self._sub(""hello'"), ""hello'")
def test_preserves_uppercase_xml_entities(self):
"""&, <, >, " resolve to XML-special chars; must not be replaced."""
self.assertEqual(self._sub("&"), "&")
self.assertEqual(self._sub("<"), "<")
self.assertEqual(self._sub(">"), ">")
self.assertEqual(self._sub("""), """)
def test_partial_entity_match_preserved(self):
"""html.unescape can partially match &amp inside &ampersand; — must not corrupt."""
self.assertEqual(self._sub("&ampersand;"), "&ampersand;")
def test_mixed_html_and_xml_entities(self):
self.assertEqual(
self._sub("Résumé & Co <test>"),
"Résumé & Co <test>",
)
def test_plain_ascii_unchanged(self):
self.assertEqual(self._sub("Plain ASCII text"), "Plain ASCII text")
def test_direct_utf8_unchanged(self):
self.assertEqual(self._sub("日本語テレビ"), "日本語テレビ")
def test_unknown_entity_preserved(self):
self.assertEqual(self._sub("&zzfakeentity;"), "&zzfakeentity;")
class ResolveHtmlEntitiesFileTests(TestCase):
"""Tests for the file-level preprocessing function."""
def _make_file(self, content):
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
return path
def test_resolves_entities_in_file(self):
path = self._make_file(
'<?xml version="1.0"?>\n<tv><channel><display-name>T&eacute;l&eacute;</display-name></channel></tv>'
)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("Télé", content)
self.assertNotIn("&eacute;", content)
def test_preserves_xml_entities_in_file(self):
path = self._make_file("<tv><desc>A &amp; B &lt;C&gt;</desc></tv>")
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("&amp;", content)
self.assertIn("&lt;", content)
self.assertIn("&gt;", content)
def test_no_temp_file_left_on_success(self):
path = self._make_file("<tv>test</tv>")
_resolve_html_entities(path)
self.assertFalse(os.path.exists(path + ".entity_tmp"))
def test_plain_file_unchanged(self):
original = '<?xml version="1.0"?>\n<tv><channel><display-name>Plain</display-name></channel></tv>'
path = self._make_file(original)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
def test_utf8_content_preserved(self):
original = "<tv><channel><display-name>日本語テレビ</display-name></channel></tv>"
path = self._make_file(original)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("日本語テレビ", content)
def test_iso_8859_1_encoding(self):
"""Files declaring ISO-8859-1 should be read in that encoding."""
xml = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>Cha&icirc;ne</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(xml.encode("iso-8859-1"))
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
_resolve_html_entities(path)
with open(path, "r", encoding="iso-8859-1") as f:
content = f.read()
self.assertIn("Cha\u00eene", content)
self.assertNotIn("&icirc;", content)
def test_detect_encoding_utf8_default(self):
"""Headers without an encoding declaration default to UTF-8."""
self.assertEqual(_detect_xml_encoding(b'<?xml version="1.0"?>'), "utf-8")
def test_detect_encoding_iso_8859_1(self):
"""Encoding is read from the XML declaration."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="ISO-8859-1"?>'),
"ISO-8859-1",
)
def test_detect_encoding_single_quotes(self):
"""Encoding detection works with single-quoted attributes."""
self.assertEqual(
_detect_xml_encoding(b"<?xml version='1.0' encoding='windows-1252'?>"),
"windows-1252",
)
def test_detect_encoding_unknown_falls_back(self):
"""Unrecognized encoding falls back to UTF-8."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="x-fake-codec"?>'),
"utf-8",
)
def test_iso_8859_1_with_entities_roundtrip(self):
"""ISO-8859-1 file with entities: resolved without corrupting existing accented chars."""
# Mix of direct ISO-8859-1 chars and HTML entities
xml_str = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>D\xe9j\xe0 &eacute;mission</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(xml_str.encode("iso-8859-1"))
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
_resolve_html_entities(path)
with open(path, "r", encoding="iso-8859-1") as f:
content = f.read()
self.assertIn("D\xe9j\xe0", content, "Existing accented chars should be preserved")
self.assertIn("\xe9mission", content, "Entity should be resolved")
self.assertNotIn("&eacute;", content)
def test_mismatched_encoding_leaves_file_untouched(self):
"""File declaring UTF-8 but containing Latin-1 bytes is left alone."""
# \xe9 is valid ISO-8859-1 but invalid as a standalone UTF-8 byte
raw = b'<?xml version="1.0" encoding="UTF-8"?>\n<tv><channel><display-name>\xe9</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(raw)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
original_bytes = raw # save for comparison
_resolve_html_entities(path)
with open(path, "rb") as f:
result_bytes = f.read()
self.assertEqual(result_bytes, original_bytes, "File should be untouched on decode error")
+655
View File
@@ -0,0 +1,655 @@
from django.test import TestCase
from django.utils import timezone
from apps.epg.models import EPGData, EPGSource, ProgramData
from apps.epg.serializers import ProgramDataSerializer, ProgramDetailSerializer
from apps.epg.utils import extract_season_episode, extract_season_episode_from_description
class ProgramDataSerializerTests(TestCase):
"""Tests for ProgramDataSerializer season/episode extraction from custom_properties."""
def setUp(self):
self.epg_source = EPGSource.objects.create(
name="Test Source", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
)
self.now = timezone.now()
def _create_program(self, **kwargs):
defaults = {
"epg": self.epg,
"start_time": self.now,
"end_time": self.now + timezone.timedelta(hours=1),
"title": "Test Program",
}
defaults.update(kwargs)
return ProgramData.objects.create(**defaults)
def test_season_and_episode_from_custom_properties(self):
"""Season and episode should be extracted from custom_properties."""
program = self._create_program(
custom_properties={"season": 3, "episode": 5}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 3)
self.assertEqual(data["episode"], 5)
def test_season_only_from_custom_properties(self):
"""Season should be returned even when episode is absent."""
program = self._create_program(custom_properties={"season": 2})
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 2)
self.assertIsNone(data["episode"])
def test_episode_only_from_custom_properties(self):
"""Episode should be returned even when season is absent."""
program = self._create_program(custom_properties={"episode": 10})
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertEqual(data["episode"], 10)
def test_season_episode_null_when_custom_properties_is_none(self):
"""Both should be None when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_season_episode_null_when_custom_properties_is_empty(self):
"""Both should be None when custom_properties is an empty dict."""
program = self._create_program(custom_properties={})
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_season_episode_null_when_keys_absent(self):
"""Both should be None when custom_properties has other keys but no season/episode."""
program = self._create_program(
custom_properties={"categories": ["Drama"], "rating": "TV-14"}
)
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_sub_title_included_in_serialized_data(self):
"""sub_title field should be present in serialized output."""
program = self._create_program(sub_title="The Pilot")
data = ProgramDataSerializer(program).data
self.assertEqual(data["sub_title"], "The Pilot")
def test_sub_title_null_when_not_set(self):
"""sub_title should be None when not set."""
program = self._create_program()
data = ProgramDataSerializer(program).data
self.assertIsNone(data["sub_title"])
def test_all_expected_fields_present(self):
"""Serialized output should contain all expected fields."""
program = self._create_program(
sub_title="Episode Title",
custom_properties={"season": 1, "episode": 1},
)
data = ProgramDataSerializer(program).data
expected_fields = {
"id", "start_time", "end_time", "title", "sub_title",
"description", "tvg_id", "season", "episode",
"is_new", "is_live", "is_premiere", "is_finale",
}
self.assertEqual(set(data.keys()), expected_fields)
def test_season_episode_from_onscreen_episode(self):
"""Season and episode should be parsed from onscreen_episode string."""
program = self._create_program(
custom_properties={"onscreen_episode": "S12 E6"}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 12)
self.assertEqual(data["episode"], 6)
def test_onscreen_episode_no_space(self):
"""Should parse onscreen_episode without space between S and E."""
program = self._create_program(
custom_properties={"onscreen_episode": "S3E21"}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 3)
self.assertEqual(data["episode"], 21)
def test_onscreen_episode_with_part(self):
"""Should parse season/episode even when part info follows."""
program = self._create_program(
custom_properties={"onscreen_episode": "S8 E8 P2/2"}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 8)
self.assertEqual(data["episode"], 8)
def test_direct_season_episode_takes_priority_over_onscreen(self):
"""Direct season/episode keys should take priority over onscreen parsing."""
program = self._create_program(
custom_properties={
"season": 1, "episode": 2,
"onscreen_episode": "S99 E99",
}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 1)
self.assertEqual(data["episode"], 2)
def test_onscreen_episode_invalid_format(self):
"""Should return None for onscreen_episode that doesn't match S/E pattern."""
program = self._create_program(
custom_properties={"onscreen_episode": "Episode 5"}
)
data = ProgramDataSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
def test_bulk_serialization_with_mixed_data(self):
"""Serializer should handle a mix of programs with and without metadata."""
p1 = self._create_program(
title="Show A",
sub_title="Ep 1",
custom_properties={"season": 1, "episode": 1},
)
p2 = self._create_program(
title="Movie B",
custom_properties=None,
)
p3 = self._create_program(
title="Show C",
custom_properties={},
)
data = ProgramDataSerializer([p1, p2, p3], many=True).data
self.assertEqual(len(data), 3)
self.assertEqual(data[0]["season"], 1)
self.assertEqual(data[0]["episode"], 1)
self.assertIsNone(data[1]["season"])
self.assertIsNone(data[1]["episode"])
self.assertIsNone(data[2]["season"])
self.assertIsNone(data[2]["episode"])
def test_is_new_true_when_flag_set(self):
"""is_new should be True when custom_properties has 'new' flag."""
program = self._create_program(custom_properties={"new": True})
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_new"])
def test_is_live_true_when_flag_set(self):
"""is_live should be True when custom_properties has 'live' flag."""
program = self._create_program(custom_properties={"live": True})
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_live"])
def test_is_premiere_true_when_flag_set(self):
"""is_premiere should be True when custom_properties has 'premiere' flag."""
program = self._create_program(custom_properties={"premiere": True})
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_premiere"])
def test_flags_false_when_not_set(self):
"""All boolean flags should be False when not in custom_properties."""
program = self._create_program(custom_properties={"season": 1})
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_new"])
self.assertFalse(data["is_live"])
self.assertFalse(data["is_premiere"])
def test_flags_false_when_custom_properties_none(self):
"""All boolean flags should be False when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_new"])
self.assertFalse(data["is_live"])
self.assertFalse(data["is_premiere"])
def test_flags_false_when_custom_properties_empty(self):
"""All boolean flags should be False when custom_properties is empty."""
program = self._create_program(custom_properties={})
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_new"])
self.assertFalse(data["is_live"])
self.assertFalse(data["is_premiere"])
def test_multiple_flags_set(self):
"""Multiple flags can be true simultaneously."""
program = self._create_program(
custom_properties={"new": True, "live": True, "premiere": True}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_new"])
self.assertTrue(data["is_live"])
self.assertTrue(data["is_premiere"])
def test_flags_with_season_episode(self):
"""Flags should work alongside season/episode data."""
program = self._create_program(
custom_properties={"season": 5, "episode": 1, "new": True, "premiere": True}
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 5)
self.assertEqual(data["episode"], 1)
self.assertTrue(data["is_new"])
self.assertFalse(data["is_live"])
self.assertTrue(data["is_premiere"])
def test_is_finale_from_premiere_text_season_finale(self):
"""is_finale should be True when premiere_text contains 'Season Finale'."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "Season Finale"}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_finale"])
def test_is_finale_from_premiere_text_series_finale(self):
"""is_finale should be True when premiere_text contains 'Series Finale'."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "Series Finale"}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_finale"])
def test_is_finale_case_insensitive(self):
"""is_finale detection should be case-insensitive."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "SEASON FINALE"}
)
data = ProgramDataSerializer(program).data
self.assertTrue(data["is_finale"])
def test_is_finale_false_for_premiere_text(self):
"""is_finale should be False when premiere_text is 'Season Premiere'."""
program = self._create_program(
custom_properties={"premiere": True, "premiere_text": "Season Premiere"}
)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_finale"])
def test_is_finale_false_when_no_premiere_text(self):
"""is_finale should be False when premiere_text is absent."""
program = self._create_program(
custom_properties={"premiere": True}
)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_finale"])
def test_is_finale_false_when_custom_properties_none(self):
"""is_finale should be False when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDataSerializer(program).data
self.assertFalse(data["is_finale"])
class ExtractSeasonEpisodeHelperTests(TestCase):
"""Tests for the shared extract_season_episode helper function."""
def test_both_present(self):
season, episode = extract_season_episode({"season": 3, "episode": 5})
self.assertEqual(season, 3)
self.assertEqual(episode, 5)
def test_fallback_to_onscreen(self):
season, episode = extract_season_episode({"onscreen_episode": "S12 E6"})
self.assertEqual(season, 12)
self.assertEqual(episode, 6)
def test_direct_values_override_onscreen(self):
season, episode = extract_season_episode({
"season": 1, "episode": 2, "onscreen_episode": "S99 E99"
})
self.assertEqual(season, 1)
self.assertEqual(episode, 2)
def test_empty_dict(self):
season, episode = extract_season_episode({})
self.assertIsNone(season)
self.assertIsNone(episode)
def test_partial_with_onscreen_fill(self):
"""Direct season + onscreen episode fills the gap."""
season, episode = extract_season_episode({
"season": 5, "onscreen_episode": "S5E10"
})
self.assertEqual(season, 5)
self.assertEqual(episode, 10)
def test_description_fallback_s_e_format(self):
"""S01E01 in description should be used as third-tier fallback."""
season, episode = extract_season_episode({}, description="S2 E31 The Episode Title")
self.assertEqual(season, 2)
self.assertEqual(episode, 31)
def test_description_fallback_season_episode_format(self):
season, episode = extract_season_episode({}, description="Season 3 Episode 12 Some Title")
self.assertEqual(season, 3)
self.assertEqual(episode, 12)
def test_description_fallback_nxnn_format(self):
season, episode = extract_season_episode({}, description="5x03 Episode Name")
self.assertEqual(season, 5)
self.assertEqual(episode, 3)
def test_description_not_used_when_cp_has_both(self):
"""Description fallback should not override existing custom_properties values."""
season, episode = extract_season_episode(
{"season": 1, "episode": 2}, description="S99 E99 Fake"
)
self.assertEqual(season, 1)
self.assertEqual(episode, 2)
def test_description_not_used_when_onscreen_provides_both(self):
season, episode = extract_season_episode(
{"onscreen_episode": "S3E5"}, description="S99 E99 Fake"
)
self.assertEqual(season, 3)
self.assertEqual(episode, 5)
def test_description_fills_gap_after_partial_onscreen(self):
"""If onscreen provides only season, description can fill episode."""
# onscreen_episode "S5" doesn't match the S/E pattern, so no values from onscreen
# description provides both
season, episode = extract_season_episode(
{"season": 5}, description="S5 E10 Title"
)
self.assertEqual(season, 5)
self.assertEqual(episode, 10)
def test_description_none_is_safe(self):
season, episode = extract_season_episode({}, description=None)
self.assertIsNone(season)
self.assertIsNone(episode)
def test_description_empty_string_is_safe(self):
season, episode = extract_season_episode({}, description="")
self.assertIsNone(season)
self.assertIsNone(episode)
class ExtractSeasonEpisodeFromDescriptionTests(TestCase):
"""Tests for extract_season_episode_from_description() in tasks.py."""
def test_s_e_compact(self):
s, e, cleaned = extract_season_episode_from_description("S2E31 The Kevin Episode")
self.assertEqual(s, 2)
self.assertEqual(e, 31)
self.assertEqual(cleaned, "The Kevin Episode")
def test_s_e_with_space(self):
s, e, cleaned = extract_season_episode_from_description("S2 E31 The Kevin Episode")
self.assertEqual(s, 2)
self.assertEqual(e, 31)
self.assertEqual(cleaned, "The Kevin Episode")
def test_season_episode_words(self):
s, e, cleaned = extract_season_episode_from_description("Season 3 Episode 12 Title Here")
self.assertEqual(s, 3)
self.assertEqual(e, 12)
self.assertEqual(cleaned, "Title Here")
def test_nxnn_format(self):
s, e, cleaned = extract_season_episode_from_description("5x03 Episode Name")
self.assertEqual(s, 5)
self.assertEqual(e, 3)
self.assertEqual(cleaned, "Episode Name")
def test_leading_dash(self):
s, e, cleaned = extract_season_episode_from_description("- S1E5 Title")
self.assertEqual(s, 1)
self.assertEqual(e, 5)
self.assertEqual(cleaned, "Title")
def test_case_insensitive(self):
s, e, cleaned = extract_season_episode_from_description("s10e20 Lower Case")
self.assertEqual(s, 10)
self.assertEqual(e, 20)
self.assertEqual(cleaned, "Lower Case")
def test_no_match_returns_original(self):
s, e, cleaned = extract_season_episode_from_description("Just a normal description")
self.assertIsNone(s)
self.assertIsNone(e)
self.assertEqual(cleaned, "Just a normal description")
def test_none_input(self):
s, e, cleaned = extract_season_episode_from_description(None)
self.assertIsNone(s)
self.assertIsNone(e)
self.assertIsNone(cleaned)
def test_empty_string(self):
s, e, cleaned = extract_season_episode_from_description("")
self.assertIsNone(s)
self.assertIsNone(e)
self.assertEqual(cleaned, "")
def test_mid_string_s_e_not_matched(self):
"""S/E in middle of description should NOT be matched (anchored to start)."""
s, e, cleaned = extract_season_episode_from_description("Some intro text S1E5 title")
self.assertIsNone(s)
self.assertIsNone(e)
self.assertEqual(cleaned, "Some intro text S1E5 title")
class ProgramDataSerializerDescriptionFallbackTests(TestCase):
"""Integration tests: serializer uses description fallback for S/E."""
def setUp(self):
self.epg_source = EPGSource.objects.create(
name="Test Source", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
)
self.now = timezone.now()
def _create_program(self, **kwargs):
defaults = {
"epg": self.epg,
"start_time": self.now,
"end_time": self.now + timezone.timedelta(hours=1),
"title": "Test Program",
}
defaults.update(kwargs)
return ProgramData.objects.create(**defaults)
def test_se_from_description_when_no_cp(self):
program = self._create_program(
custom_properties={},
description="S2 E5 The Episode Title",
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 2)
self.assertEqual(data["episode"], 5)
def test_se_from_description_not_used_when_cp_has_values(self):
program = self._create_program(
custom_properties={"season": 1, "episode": 1},
description="S99 E99 Fake",
)
data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 1)
self.assertEqual(data["episode"], 1)
class ProgramDetailSerializerTests(TestCase):
"""Tests for ProgramDetailSerializer — rich field extraction from custom_properties."""
def setUp(self):
self.epg_source = EPGSource.objects.create(
name="Test Source", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source
)
self.now = timezone.now()
def _create_program(self, **kwargs):
defaults = {
"epg": self.epg,
"start_time": self.now,
"end_time": self.now + timezone.timedelta(hours=1),
"title": "Test Program",
}
defaults.update(kwargs)
return ProgramData.objects.create(**defaults)
def test_all_detail_fields_present(self):
"""Detail serializer should include all expected fields."""
program = self._create_program(custom_properties={"season": 1, "episode": 1})
data = ProgramDetailSerializer(program).data
expected_fields = {
"id", "start_time", "end_time", "title", "sub_title", "description", "tvg_id",
"season", "episode", "is_new", "is_live", "is_premiere", "is_finale",
"categories", "rating", "rating_system", "star_ratings",
"credits", "video_quality", "aspect_ratio", "stereo", "is_previously_shown",
"country", "language", "production_date", "original_air_date",
"imdb_id", "tmdb_id", "tvdb_id", "icon", "images",
}
self.assertEqual(set(data.keys()), expected_fields)
def test_categories_extraction(self):
program = self._create_program(
custom_properties={"categories": ["Drama", "Thriller"]}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["categories"], ["Drama", "Thriller"])
def test_categories_empty_when_absent(self):
program = self._create_program(custom_properties={})
data = ProgramDetailSerializer(program).data
self.assertEqual(data["categories"], [])
def test_rating_extraction(self):
program = self._create_program(
custom_properties={"rating": "TV-14", "rating_system": "VCHIP"}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["rating"], "TV-14")
self.assertEqual(data["rating_system"], "VCHIP")
def test_star_ratings_extraction(self):
program = self._create_program(
custom_properties={"star_ratings": [{"value": "8.5/10", "system": "IMDB"}]}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(len(data["star_ratings"]), 1)
self.assertEqual(data["star_ratings"][0]["value"], "8.5/10")
def test_credits_extraction(self):
program = self._create_program(
custom_properties={
"credits": {
"actor": [{"name": "Bryan Cranston", "role": "Walter White"}],
"director": ["Rian Johnson"],
"writer": ["Moira Walley-Beckett"],
}
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(len(data["credits"]["actors"]), 1)
self.assertEqual(data["credits"]["actors"][0]["name"], "Bryan Cranston")
self.assertEqual(data["credits"]["directors"], ["Rian Johnson"])
self.assertEqual(data["credits"]["writers"], ["Moira Walley-Beckett"])
def test_credits_empty_when_absent(self):
program = self._create_program(custom_properties={})
data = ProgramDetailSerializer(program).data
self.assertEqual(data["credits"]["actors"], [])
self.assertEqual(data["credits"]["directors"], [])
def test_video_quality_extraction(self):
program = self._create_program(
custom_properties={"video": {"quality": "HDTV", "aspect": "16:9"}}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["video_quality"], "HDTV")
self.assertEqual(data["aspect_ratio"], "16:9")
def test_audio_extraction(self):
program = self._create_program(
custom_properties={"audio": {"stereo": "Dolby Digital"}}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["stereo"], "Dolby Digital")
def test_geographic_fields(self):
program = self._create_program(
custom_properties={"country": "US", "language": "en"}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["country"], "US")
self.assertEqual(data["language"], "en")
def test_original_air_date(self):
program = self._create_program(
custom_properties={
"previously_shown_details": {"start": "2013-09-15"}
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["original_air_date"], "2013-09-15")
def test_external_ids(self):
program = self._create_program(
custom_properties={
"imdb.com_id": "tt0903747",
"themoviedb.org_id": "1396",
"thetvdb.com_id": "81189",
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["imdb_id"], "tt0903747")
self.assertEqual(data["tmdb_id"], "1396")
self.assertEqual(data["tvdb_id"], "81189")
def test_images_extraction(self):
program = self._create_program(
custom_properties={
"icon": "https://example.com/icon.png",
"images": [{"url": "https://example.com/poster.jpg", "type": "poster"}],
}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["icon"], "https://example.com/icon.png")
self.assertEqual(len(data["images"]), 1)
def test_null_custom_properties_returns_safe_defaults(self):
"""All enriched fields should be null/empty when custom_properties is None."""
program = self._create_program(custom_properties=None)
data = ProgramDetailSerializer(program).data
self.assertIsNone(data["season"])
self.assertIsNone(data["episode"])
self.assertEqual(data["categories"], [])
self.assertIsNone(data["rating"])
self.assertEqual(data["star_ratings"], [])
self.assertEqual(data["credits"]["actors"], [])
self.assertIsNone(data["video_quality"])
self.assertIsNone(data["country"])
self.assertIsNone(data["imdb_id"])
self.assertEqual(data["images"], [])
def test_season_episode_uses_shared_helper(self):
"""Detail serializer should use the same onscreen_episode fallback."""
program = self._create_program(
custom_properties={"onscreen_episode": "S5E14"}
)
data = ProgramDetailSerializer(program).data
self.assertEqual(data["season"], 5)
self.assertEqual(data["episode"], 14)
def test_status_flags_match_slim_serializer(self):
"""Status flags should produce identical results as ProgramDataSerializer."""
program = self._create_program(
custom_properties={
"new": True, "live": True, "premiere": True,
"premiere_text": "Season Finale",
}
)
slim = ProgramDataSerializer(program).data
detail = ProgramDetailSerializer(program).data
self.assertEqual(slim["is_new"], detail["is_new"])
self.assertEqual(slim["is_live"], detail["is_live"])
self.assertEqual(slim["is_premiere"], detail["is_premiere"])
self.assertEqual(slim["is_finale"], detail["is_finale"])