/
/
/
1"""Tests for parsing Overcast's extended OPML export."""
2
3from __future__ import annotations
4
5from datetime import UTC, datetime, timedelta, timezone
6from pathlib import Path
7
8import pytest
9from music_assistant_models.errors import InvalidDataError
10
11from music_assistant.providers.overcast.helpers import (
12 OvercastSubscription,
13 match_episode_state,
14 parse_extended_opml,
15)
16
17_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "extended_export.opml"
18
19
20@pytest.fixture(name="subscriptions")
21def subscriptions_fixture() -> dict[str, OvercastSubscription]:
22 """Parse the extended OPML fixture."""
23 return parse_extended_opml(_FIXTURE_PATH.read_text())
24
25
26def test_only_subscribed_feeds_are_returned(
27 subscriptions: dict[str, OvercastSubscription],
28) -> None:
29 """Playlists and unsubscribed feeds are excluded, feeds are keyed by feed url."""
30 assert set(subscriptions) == {
31 "https://example.com/feed1.xml",
32 "https://example.com/feed2.xml",
33 }
34 subscription = subscriptions["https://example.com/feed1.xml"]
35 assert subscription.title == "Feed One"
36 assert subscription.overcast_id == "111"
37
38
39def test_episode_states(subscriptions: dict[str, OvercastSubscription]) -> None:
40 """Episode playback attributes are parsed, absent attributes yield defaults."""
41 episode_a, episode_b, episode_c = subscriptions["https://example.com/feed1.xml"].episodes
42
43 assert episode_a.overcast_id == "1001"
44 assert episode_a.enclosure_url == "https://cdn.example.com/ep-a.mp3?token=xyz"
45 assert episode_a.progress_s == 123
46 assert episode_a.played is False
47 assert episode_a.user_updated_at == datetime(
48 2026, 3, 1, 8, 0, 0, tzinfo=timezone(timedelta(hours=-7))
49 )
50 assert episode_a.pub_date == datetime(2026, 2, 27, 12, 0, 0, tzinfo=UTC)
51
52 assert episode_b.played is True
53 assert episode_b.progress_s is None
54
55 assert episode_c.played is False
56 assert episode_c.progress_s is None
57 assert episode_c.user_updated_at is None
58
59
60def test_single_episode_and_lenient_attribute_parsing(
61 subscriptions: dict[str, OvercastSubscription],
62) -> None:
63 """A feed with one episode outline still parses; bad dates/ints become None."""
64 (episode,) = subscriptions["https://example.com/feed2.xml"].episodes
65 assert episode.pub_date is None
66 assert episode.progress_s is None
67 assert episode.played is True
68 # a datetime without utc offset is normalized to an aware datetime
69 assert episode.user_updated_at == datetime(2026, 3, 2, 0, 0, 0, tzinfo=UTC)
70
71
72def test_empty_body_yields_no_subscriptions() -> None:
73 """A structurally valid document without feeds parses to an empty result."""
74 assert parse_extended_opml("<opml><body/></opml>") == {}
75
76
77def test_malformed_document_raises_invalid_data_error() -> None:
78 """A malformed XML document raises InvalidDataError."""
79 with pytest.raises(InvalidDataError):
80 parse_extended_opml("<opml><body>")
81
82
83def test_match_episode_state(subscriptions: dict[str, OvercastSubscription]) -> None:
84 """States match on exact enclosure url, with a query-stripped fallback."""
85 subscription = subscriptions["https://example.com/feed1.xml"]
86
87 exact = match_episode_state(subscription, "https://cdn.example.com/ep-b.mp3")
88 assert exact is not None
89 assert exact.overcast_id == "1002"
90
91 # the fixture state carries ?token=xyz; a rotated/absent query still matches
92 stripped = match_episode_state(subscription, "https://cdn.example.com/ep-a.mp3?token=other")
93 assert stripped is not None
94 assert stripped.overcast_id == "1001"
95
96 assert match_episode_state(subscription, "https://cdn.example.com/unknown.mp3") is None
97