/
/
/
1"""Tests for the AirPlay pairing session (cliairplay pair-setup handling)."""
2
3from __future__ import annotations
4
5import logging
6from unittest.mock import AsyncMock, MagicMock
7
8import pytest
9from music_assistant_models.errors import PlayerCommandFailed
10
11from music_assistant.providers.airplay.constants import StreamingProtocol
12from music_assistant.providers.airplay.pairing import AirPlayPairing
13
14# stderr of a pair-setup run that hit the ATV's pairing rate limit (HAP backoff)
15BACKOFF_STDERR = [
16 "[01:08:00.786] ap2_hap_create:608 [HAP] Created context without credentials",
17 "[01:08:00.786] ap2_hap_pair_setup_pin:1197 [HAP] Starting HomeKit pair-setup (PIN)...",
18 "[01:08:00.796] ap2_hap_pair_setup_pin:1215 [HAP] /pair-pin-start -> 200",
19 "[01:08:00.927] ap2_hap_pair_setup_pin:1236 [HAP] Pair-setup M2 error tag: 3",
20 "Pairing failed.",
21]
22
23
24def _make_pairing() -> AirPlayPairing:
25 """Build an AirPlay 2 pairing session without starting anything."""
26 return AirPlayPairing(
27 address="192.168.68.60",
28 name="Apple TV",
29 protocol=StreamingProtocol.AIRPLAY2,
30 logger=logging.getLogger("test.airplay.pairing"),
31 port=7000,
32 device_id="ABCDEF0123456789",
33 )
34
35
36def _attach_proc(pairing: AirPlayPairing, **overrides: object) -> MagicMock:
37 """Attach a fake live pair-setup process to the pairing session."""
38 proc = MagicMock()
39 proc.closed = False
40 proc.write = AsyncMock()
41 proc.read = AsyncMock(return_value=b"")
42 proc.wait_with_timeout = AsyncMock(return_value=1)
43 proc.kill = AsyncMock()
44 for name, value in overrides.items():
45 setattr(proc, name, value)
46 pairing._pair_proc = proc
47 return proc
48
49
50def test_pair_setup_error_prefers_specific_line_over_trailer() -> None:
51 """The generic "Pairing failed." trailer must not hide the specific error line."""
52 pairing = _make_pairing()
53 pairing._pair_proc_stderr = list(BACKOFF_STDERR)
54 assert pairing._pair_setup_error() == "Pair-setup M2 error tag: 3"
55
56
57def test_pair_setup_error_falls_back_to_trailer() -> None:
58 """With no specific line, the generic trailer is still better than nothing."""
59 pairing = _make_pairing()
60 pairing._pair_proc_stderr = ["Enter the PIN shown on the device: ", "Pairing failed."]
61 assert pairing._pair_setup_error() == "Pairing failed."
62
63
64async def test_backoff_failure_carries_specific_translation() -> None:
65 """A device in HAP backoff (error tag 3) surfaces as the pairing_backoff error."""
66 pairing = _make_pairing()
67 _attach_proc(pairing, write=AsyncMock(side_effect=BrokenPipeError))
68 pairing._pair_proc_stderr = list(BACKOFF_STDERR)
69
70 with pytest.raises(PlayerCommandFailed) as excinfo:
71 await pairing.finish_pairing(pin="1234")
72
73 assert excinfo.value.translation_key == "pairing_backoff"
74 assert "error tag: 3" in str(excinfo.value)
75
76
77@pytest.mark.parametrize(
78 ("tag", "translation_key"),
79 [(2, "pairing_wrong_pin"), (3, "pairing_backoff"), (5, "pairing_backoff")],
80)
81async def test_hap_error_tags_map_to_specific_translations(tag: int, translation_key: str) -> None:
82 """Actionable HAP error tags surface as their specific error translation."""
83 pairing = _make_pairing()
84 _attach_proc(pairing)
85 pairing._pair_proc_stderr = [
86 f"[10:00:01.000] ap2_hap_pair_setup_pin:1310 [HAP] Pair-setup M4 error tag: {tag}",
87 "Pairing failed.",
88 ]
89
90 with pytest.raises(PlayerCommandFailed) as excinfo:
91 await pairing.finish_pairing(pin="0000")
92
93 assert excinfo.value.translation_key == translation_key
94
95
96async def test_error_line_glued_to_pin_prompt_still_surfaces() -> None:
97 """
98 Surface the error even when it arrives glued to the PIN prompt.
99
100 The binary writes its PIN prompt without a newline, so the line-based stderr
101 reader glues the prompt to the front of the next line - which on a post-PIN
102 failure is the error line itself.
103 """
104 pairing = _make_pairing()
105 _attach_proc(pairing)
106 pairing._pair_proc_stderr = [
107 "[10:00:00.000] ap2_hap_pair_setup_pin:1272 [HAP] Pair-setup M2 OK - waiting for the PIN entry",
108 "Enter the PIN shown on the device: [10:00:05.000] ap2_hap_pair_setup_pin:1313 "
109 "[HAP] Pair-setup M4 error tag: 2 (authentication failed - wrong PIN or key mismatch)",
110 "Pairing failed.",
111 ]
112
113 with pytest.raises(PlayerCommandFailed) as excinfo:
114 await pairing.finish_pairing(pin="0000")
115
116 assert excinfo.value.translation_key == "pairing_wrong_pin"
117 assert "error tag: 2" in str(excinfo.value)
118 assert "M2 OK" not in str(excinfo.value)
119
120
121async def test_unclassified_failure_uses_generic_pairing_translation() -> None:
122 """Failures without a HAP error tag surface as the generic pairing error."""
123 pairing = _make_pairing()
124 _attach_proc(pairing)
125 pairing._pair_proc_stderr = ["Cannot connect to 192.168.68.60:7000"]
126
127 with pytest.raises(PlayerCommandFailed) as excinfo:
128 await pairing.finish_pairing(pin="1234")
129
130 assert excinfo.value.translation_key == "pairing_failed"
131 assert "Cannot connect" in str(excinfo.value)
132