/
/
/
1# mypy: disable-error-code="attr-defined,unreachable"
2"""Tests for the Ynison WebSocket client."""
3
4from __future__ import annotations
5
6import asyncio
7import json
8from contextlib import suppress
9from typing import Any
10from unittest.mock import AsyncMock, MagicMock, patch
11
12import aiohttp
13import pytest
14from music_assistant_models.errors import LoginFailed
15from ya_passport_auth import SecretStr
16
17from music_assistant.providers.yandex_ynison.constants import (
18 DEFAULT_APP_NAME,
19 DEVICE_TYPE_WEB,
20 YNISON_ORIGIN,
21)
22from music_assistant.providers.yandex_ynison.ynison_client import (
23 YnisonClient,
24 YnisonDeviceInfo,
25 YnisonSendError,
26 YnisonState,
27 generate_device_id,
28 make_version_block,
29)
30
31
32@pytest.fixture
33def device_info() -> YnisonDeviceInfo:
34 """Create test device info."""
35 return YnisonDeviceInfo(
36 device_id="test-device-id",
37 title="Test Device",
38 )
39
40
41@pytest.fixture
42def mock_state_callback() -> AsyncMock:
43 """Create a mock callback for state updates."""
44 return AsyncMock()
45
46
47@pytest.fixture
48def client(
49 device_info: YnisonDeviceInfo,
50 mock_state_callback: AsyncMock,
51) -> YnisonClient:
52 """Create a YnisonClient instance for testing."""
53 return YnisonClient(
54 token=SecretStr("test-token"),
55 device_info=device_info,
56 on_state_update=mock_state_callback,
57 logger=MagicMock(),
58 )
59
60
61# ------------------------------------------------------------------
62# YnisonDeviceInfo
63# ------------------------------------------------------------------
64
65
66class TestYnisonDeviceInfo:
67 """Tests for YnisonDeviceInfo dataclass."""
68
69 def test_defaults(self) -> None:
70 """Default type is WEB and app_name is set."""
71 info = YnisonDeviceInfo(device_id="abc", title="My Speaker")
72 assert info.type == DEVICE_TYPE_WEB
73 assert info.app_name == DEFAULT_APP_NAME
74
75 def test_custom_values(self) -> None:
76 """Custom values override defaults."""
77 info = YnisonDeviceInfo(
78 device_id="xyz",
79 title="Custom",
80 type="TV",
81 app_name="CustomApp",
82 app_version="2.0",
83 )
84 assert info.type == "TV"
85 assert info.app_version == "2.0"
86
87
88# ------------------------------------------------------------------
89# YnisonState
90# ------------------------------------------------------------------
91
92
93class TestYnisonState:
94 """Tests for YnisonState dataclass."""
95
96 def test_empty_state(self) -> None:
97 """Empty state returns safe defaults."""
98 state = YnisonState()
99 assert state.current_track_id is None
100 assert state.is_paused is True
101 assert state.progress_ms == 0
102 assert state.duration_ms == 0
103
104 def test_current_track_id(self) -> None:
105 """Extracts track ID from playable list by index."""
106 state = YnisonState(
107 player_state={
108 "player_queue": {
109 "current_playable_index": 1,
110 "playable_list": [
111 {"playable_id": "track1"},
112 {"playable_id": "track2"},
113 {"playable_id": "track3"},
114 ],
115 }
116 }
117 )
118 assert state.current_track_id == "track2"
119
120 def test_current_track_id_out_of_bounds(self) -> None:
121 """Returns None when index exceeds playable list."""
122 state = YnisonState(
123 player_state={
124 "player_queue": {
125 "current_playable_index": 10,
126 "playable_list": [{"playable_id": "track1"}],
127 }
128 }
129 )
130 assert state.current_track_id is None
131
132 def test_is_paused(self) -> None:
133 """Reads paused status from player state."""
134 state = YnisonState(player_state={"status": {"paused": False}})
135 assert state.is_paused is False
136
137 def test_progress_and_duration(self) -> None:
138 """Reads progress and duration from player state."""
139 state = YnisonState(
140 player_state={
141 "status": {
142 "progress_ms": 30000,
143 "duration_ms": 180000,
144 }
145 }
146 )
147 assert state.progress_ms == 30000
148 assert state.duration_ms == 180000
149
150
151# ------------------------------------------------------------------
152# YnisonClient internals
153# ------------------------------------------------------------------
154
155
156class TestYnisonClientBuildMethods:
157 """Tests for YnisonClient helper/build methods."""
158
159 def test_build_headers(self, client: YnisonClient) -> None:
160 """Headers include auth, origin, and protocol."""
161 headers = client._build_headers()
162 assert headers["Authorization"] == "OAuth test-token"
163 assert headers["Origin"] == YNISON_ORIGIN
164 assert "Sec-WebSocket-Protocol" in headers
165
166 def test_build_headers_with_ticket(self, client: YnisonClient) -> None:
167 """Headers include redirect ticket and session ID when provided."""
168 headers = client._build_headers(redirect_ticket="ticket123", session_id=42)
169 proto = headers["Sec-WebSocket-Protocol"]
170 assert "Ynison-Redirect-Ticket" in proto
171 assert "ticket123" in proto
172 assert "42" in proto
173
174 def test_build_ws_protocol_header(self, client: YnisonClient) -> None:
175 """Protocol header contains device ID and info."""
176 proto = client._build_ws_protocol_header()
177 assert proto.startswith("Bearer, v2, ")
178 data = json.loads(proto[len("Bearer, v2, ") :])
179 assert data["Ynison-Device-Id"] == "test-device-id"
180 device_info = json.loads(data["Ynison-Device-Info"])
181 assert device_info["app_name"] == DEFAULT_APP_NAME
182
183 def test_build_device_dict(self, client: YnisonClient) -> None:
184 """Device dict includes capabilities and info."""
185 device = client._build_device_dict()
186 assert device["info"]["device_id"] == "test-device-id"
187 assert device["capabilities"]["can_be_player"] is True
188 assert device["capabilities"]["can_be_remote_controller"] is False
189
190 def test_build_initial_state(self, client: YnisonClient) -> None:
191 """Initial state is paused with empty queue."""
192 state = client._build_initial_state()
193 assert state["status"]["paused"] is True
194 assert state["player_queue"]["playable_list"] == []
195
196 def test_build_initial_state_string_fields(self, client: YnisonClient) -> None:
197 """Ynison rejects integer timestamps; all time/version fields must be str."""
198 state = client._build_initial_state()
199 for block in (state["status"], state["player_queue"]):
200 version = block["version"]
201 assert version["device_id"] == "test-device-id"
202 assert isinstance(version["version"], str)
203 assert version["version"].isdigit()
204 assert version["timestamp_ms"] == "0"
205 assert state["status"]["progress_ms"] == "0"
206 assert state["status"]["duration_ms"] == "0"
207
208 def test_device_id_property(self, client: YnisonClient) -> None:
209 """device_id property exposes the registered device id."""
210 assert client.device_id == "test-device-id"
211
212
213class TestMakeVersionBlock:
214 """Tests for the module-level make_version_block helper."""
215
216 def test_fields_are_strings(self) -> None:
217 """Version and timestamp_ms must be strings (Ynison 500s on ints)."""
218 block = make_version_block("dev-42")
219 assert block["device_id"] == "dev-42"
220 assert isinstance(block["version"], str)
221 assert block["version"].isdigit()
222 assert block["timestamp_ms"] == "0"
223
224
225# ------------------------------------------------------------------
226# YnisonClient parse state
227# ------------------------------------------------------------------
228
229
230class TestYnisonClientParseState:
231 """Tests for state parsing."""
232
233 def test_parse_state(self, client: YnisonClient) -> None:
234 """Parses full state response into YnisonState."""
235 data: dict[str, Any] = {
236 "player_state": {
237 "status": {"paused": False, "progress_ms": 5000, "duration_ms": 200000},
238 "player_queue": {
239 "current_playable_index": 0,
240 "playable_list": [{"playable_id": "track42"}],
241 },
242 },
243 "active_device_id_optional": "test-device-id",
244 "devices": [{"info": {"device_id": "test-device-id"}}],
245 }
246 client._parse_state(data)
247 assert client.state.current_track_id == "track42"
248 assert client.state.active_device_id == "test-device-id"
249 assert client.state.is_paused is False
250
251 def test_parse_state_partial(self, client: YnisonClient) -> None:
252 """Partial updates should preserve existing state."""
253 client.state.active_device_id = "old-device"
254 client._parse_state({"player_state": {"status": {"paused": True}}})
255 assert client.state.active_device_id == "old-device"
256
257 def test_echo_flag_true_only_when_both_authors_ours(self, client: YnisonClient) -> None:
258 """AND-logic (1.9.1): both queue.version AND status.version must be ours."""
259 client._parse_state(
260 {
261 "player_state": {
262 "player_queue": {
263 "playable_list": [{"playable_id": "t1"}],
264 "current_playable_index": 0,
265 "version": {
266 "device_id": "test-device-id",
267 "version": "42",
268 "timestamp_ms": "0",
269 },
270 },
271 "status": {
272 "paused": False,
273 "progress_ms": "1000",
274 "duration_ms": "5000",
275 "version": {
276 "device_id": "test-device-id",
277 "version": "43",
278 "timestamp_ms": "0",
279 },
280 },
281 },
282 }
283 )
284 assert client.state.last_update_is_echo is True
285
286 def test_echo_flag_false_when_only_queue_is_ours(self, client: YnisonClient) -> None:
287 """
288 Status authored by peer â NOT echo, even if queue.version is ours.
289
290 Regression for the OR-logic bug: a peer toggling pause produced
291 status.version=peer + our stale queue.version=ours, which the old
292 OR-rule wrongly classified as echo and silenced the user action.
293 """
294 client._parse_state(
295 {
296 "player_state": {
297 "player_queue": {
298 "playable_list": [{"playable_id": "t1"}],
299 "current_playable_index": 0,
300 "version": {
301 "device_id": "test-device-id",
302 "version": "42",
303 "timestamp_ms": "0",
304 },
305 },
306 "status": {
307 "paused": True,
308 "progress_ms": "1000",
309 "duration_ms": "5000",
310 "version": {
311 "device_id": "peer-device",
312 "version": "44",
313 "timestamp_ms": "0",
314 },
315 },
316 },
317 }
318 )
319 assert client.state.last_update_is_echo is False
320
321 def test_echo_flag_false_when_only_status_is_ours(self, client: YnisonClient) -> None:
322 """
323 Queue authored by peer â NOT echo, even if status.version is ours.
324
325 The mirror case: our heartbeat just stamped status.version=ours, but
326 the peer changed the queue. Under AND-logic the peer change is not
327 silenced.
328 """
329 client._parse_state(
330 {
331 "player_state": {
332 "player_queue": {
333 "playable_list": [{"playable_id": "new-track"}],
334 "current_playable_index": 0,
335 "version": {
336 "device_id": "peer-device",
337 "version": "100",
338 "timestamp_ms": "0",
339 },
340 },
341 "status": {
342 "paused": False,
343 "progress_ms": "0",
344 "duration_ms": "5000",
345 "version": {
346 "device_id": "test-device-id",
347 "version": "99",
348 "timestamp_ms": "0",
349 },
350 },
351 },
352 }
353 )
354 assert client.state.last_update_is_echo is False
355
356 def test_echo_flag_false_on_foreign_author(self, client: YnisonClient) -> None:
357 """Both authors are peer â not an echo."""
358 client._parse_state(
359 {
360 "player_state": {
361 "player_queue": {
362 "playable_list": [{"playable_id": "t1"}],
363 "current_playable_index": 0,
364 "version": {
365 "device_id": "some-other-device",
366 "version": "42",
367 "timestamp_ms": "0",
368 },
369 },
370 },
371 }
372 )
373 assert client.state.last_update_is_echo is False
374
375 def test_echo_flag_false_when_version_missing(self, client: YnisonClient) -> None:
376 """
377 No version block at all â not an echo (safe default).
378
379 AND-logic treats missing version-block as "not ours" â matches the
380 previous safe default. Without a version-block we can't claim
381 ownership, so we let the update reach handlers.
382 """
383 client._parse_state(
384 {
385 "player_state": {
386 "player_queue": {"playable_list": [], "current_playable_index": -1},
387 },
388 }
389 )
390 assert client.state.last_update_is_echo is False
391
392 def test_echo_flag_false_when_player_state_missing(self, client: YnisonClient) -> None:
393 """status-only or non-player_state updates cannot be echoes."""
394 client.state.last_update_is_echo = True # sticky from a prior update
395 client._parse_state({"active_device_id_optional": "some-device"})
396 assert client.state.last_update_is_echo is False
397
398 def test_parse_state_coerces_int_timestamps_to_strings(self, client: YnisonClient) -> None:
399 """
400 Inbound int timestamps are stringified so outbound echoes stay safe.
401
402 Guards the reconnect path (send_full_state echoes self.state.player_state)
403 and queue-mutating update_player_state calls that shallow-copy status.
404 """
405 client._parse_state(
406 {
407 "player_state": {
408 "status": {
409 "paused": False,
410 "progress_ms": 1234,
411 "duration_ms": 56789,
412 "player_action_timestamp_ms": 111,
413 "version": {
414 "device_id": "peer",
415 "version": 42,
416 "timestamp_ms": 0,
417 },
418 },
419 "player_queue": {
420 "current_playable_index": 0,
421 "playable_list": [],
422 "version": {
423 "device_id": "peer",
424 "version": 99,
425 "timestamp_ms": 0,
426 },
427 },
428 }
429 }
430 )
431 status = client.state.player_state["status"]
432 assert status["progress_ms"] == "1234"
433 assert status["duration_ms"] == "56789"
434 assert status["player_action_timestamp_ms"] == "111"
435 assert status["version"]["version"] == "42"
436 assert status["version"]["timestamp_ms"] == "0"
437 queue_version = client.state.player_state["player_queue"]["version"]
438 assert queue_version["version"] == "99"
439 assert queue_version["timestamp_ms"] == "0"
440
441
442# ------------------------------------------------------------------
443# YnisonClient send methods
444# ------------------------------------------------------------------
445
446
447class TestYnisonClientSend:
448 """Tests for send methods."""
449
450 @pytest.fixture(autouse=True)
451 def _setup_ws(self, client: YnisonClient) -> None:
452 """Set up a mock WebSocket."""
453 self.mock_ws = AsyncMock()
454 self.mock_ws.closed = False
455 client._ws = self.mock_ws
456 client._connected = True
457
458 async def test_update_playing_status(self, client: YnisonClient) -> None:
459 """Sends correct playing status message with string-typed timestamps."""
460 await client.update_playing_status(1000, 5000, paused=False)
461 call_args = self.mock_ws.send_str.call_args[0][0]
462 msg = json.loads(call_args)
463 status = msg["update_playing_status"]["playing_status"]
464 # Ynison expects strings for timestamp fields (integers trigger 500)
465 assert status["progress_ms"] == "1000"
466 assert status["duration_ms"] == "5000"
467 assert status["paused"] is False
468
469 async def test_update_active_device(self, client: YnisonClient) -> None:
470 """Sends active device update message."""
471 await client.update_active_device("device-123")
472 msg = json.loads(self.mock_ws.send_str.call_args[0][0])
473 assert msg["update_active_device"]["device_id_optional"] == "device-123"
474
475 async def test_send_not_connected(self, client: YnisonClient) -> None:
476 """Should silently skip when not connected."""
477 client._ws = None
478 await client.update_active_device("test")
479 # No exception raised
480
481
482# ------------------------------------------------------------------
483# generate_device_id
484# ------------------------------------------------------------------
485
486
487class TestGenerateDeviceId:
488 """Tests for generate_device_id."""
489
490 def test_format(self) -> None:
491 """Device ID is 16-char lowercase alphanumeric."""
492 device_id = generate_device_id()
493 assert len(device_id) == 16
494 assert device_id.isalnum()
495 assert device_id.islower() or device_id.isdigit()
496
497 def test_uniqueness(self) -> None:
498 """Generated IDs are unique."""
499 ids = {generate_device_id() for _ in range(10)}
500 assert len(ids) == 10
501
502
503# ------------------------------------------------------------------
504# YnisonClient disconnect
505# ------------------------------------------------------------------
506
507
508class TestYnisonClientDisconnect:
509 """Tests for disconnect handling."""
510
511 async def test_disconnect_closes_ws(self, client: YnisonClient) -> None:
512 """Disconnect closes WebSocket and clears state."""
513 mock_ws = AsyncMock()
514 mock_ws.closed = False
515 client._ws = mock_ws
516 client._connected = True
517
518 await client.disconnect()
519
520 mock_ws.close.assert_called_once()
521 assert client._connected is False
522 assert client._ws is None
523
524 async def test_disconnect_cancels_tasks(self, client: YnisonClient) -> None:
525 """Disconnect cancels running message task."""
526
527 # Create a real task that we can cancel
528 async def _forever() -> None:
529 await asyncio.Event().wait()
530
531 task = asyncio.ensure_future(_forever())
532 client._message_task = task
533
534 await client.disconnect()
535
536 assert task.cancelled()
537
538 async def test_disconnect_when_not_connected(self, client: YnisonClient) -> None:
539 """Should not raise when already disconnected."""
540 await client.disconnect()
541
542
543# ------------------------------------------------------------------
544# Reconnect session ownership
545# ------------------------------------------------------------------
546
547
548class TestReconnectSessionOwnership:
549 """Tests for _reconnect respecting external session ownership."""
550
551 async def test_reconnect_reuses_external_session(self) -> None:
552 """Reconnect reuses a still-open external session instead of creating a new one."""
553 on_state = AsyncMock()
554 ext_session = MagicMock(spec=aiohttp.ClientSession)
555 ext_session.closed = False
556
557 client = YnisonClient(
558 token=SecretStr("test-token"),
559 device_info=YnisonDeviceInfo(device_id="dev1", title="Test"),
560 on_state_update=on_state,
561 logger=MagicMock(),
562 http_session=ext_session,
563 )
564 client._session = None # simulate session lost
565 client._stop_event.clear()
566
567 def stop_after_session_select() -> None:
568 client._stop_event.set()
569 msg = "stop after session selection"
570 raise RuntimeError(msg)
571
572 sleep_path = "music_assistant.providers.yandex_ynison.ynison_client.asyncio.sleep"
573 with (
574 patch(sleep_path, new_callable=AsyncMock),
575 patch.object(
576 client,
577 "_get_redirect_ticket",
578 new_callable=AsyncMock,
579 ) as mock_redir,
580 ):
581 mock_redir.side_effect = stop_after_session_select
582 await client._reconnect()
583
584 assert mock_redir.await_count == 1
585 assert client._session is ext_session
586
587 async def test_reconnect_retries_on_closed_external_session_until_stopped(
588 self,
589 ) -> None:
590 """Reconnect with closed external session retries until stop_event is set."""
591 on_state = AsyncMock()
592 ext_session = MagicMock(spec=aiohttp.ClientSession)
593 ext_session.closed = True
594
595 client = YnisonClient(
596 token=SecretStr("test-token"),
597 device_info=YnisonDeviceInfo(device_id="dev1", title="Test"),
598 on_state_update=on_state,
599 logger=MagicMock(),
600 http_session=ext_session,
601 )
602 client._session = None # simulate session lost
603
604 # Simulate an operator calling disconnect() after a few failures â
605 # without this the reconnect loop would retry forever.
606 sleep_calls = 0
607
608 async def stop_after_n_sleeps(*_args: object, **_kw: object) -> None:
609 nonlocal sleep_calls
610 sleep_calls += 1
611 if sleep_calls >= 3:
612 client._stop_event.set()
613
614 sleep_path = "music_assistant.providers.yandex_ynison.ynison_client.asyncio.sleep"
615 with (
616 patch(sleep_path, side_effect=stop_after_n_sleeps),
617 patch.object(client, "_get_redirect_ticket", new_callable=AsyncMock) as mock_redir,
618 ):
619 mock_redir.side_effect = AssertionError("should not reach here")
620 await client._reconnect()
621
622 # Never reached the redirect step because session is closed.
623 mock_redir.assert_not_awaited()
624 assert not client._connected
625
626 async def test_connect_raises_on_closed_external_session(self) -> None:
627 """connect() raises RuntimeError if external session is already closed."""
628 on_state = AsyncMock()
629 ext_session = MagicMock(spec=aiohttp.ClientSession)
630 ext_session.closed = True
631
632 client = YnisonClient(
633 token=SecretStr("test-token"),
634 device_info=YnisonDeviceInfo(device_id="dev1", title="Test"),
635 on_state_update=on_state,
636 logger=MagicMock(),
637 http_session=ext_session,
638 )
639
640 with pytest.raises(RuntimeError, match="closed"):
641 await client.connect()
642
643
644# ------------------------------------------------------------------
645# connect() transient error â reconnect
646# ------------------------------------------------------------------
647
648
649class TestConnectTransientError:
650 """Tests for connect() scheduling reconnect on transient errors."""
651
652 async def test_connect_transient_schedules_reconnect(self) -> None:
653 """Non-auth error during connect schedules _reconnect task."""
654 on_state = AsyncMock()
655 client = YnisonClient(
656 token=SecretStr("test-token"),
657 device_info=YnisonDeviceInfo(device_id="d1", title="T"),
658 on_state_update=on_state,
659 logger=MagicMock(),
660 )
661 with (
662 patch.object(
663 client,
664 "_get_redirect_ticket",
665 new_callable=AsyncMock,
666 side_effect=ConnectionError("network down"),
667 ),
668 patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_reconnect,
669 ):
670 await client.connect()
671 await asyncio.sleep(0) # let ensure_future task run
672
673 assert client._connected is False
674 assert client._ws is None
675 assert client._reconnect_task is not None
676 mock_reconnect.assert_awaited_once()
677
678 async def test_connect_transient_closes_ws_and_session(self) -> None:
679 """Transient connect error closes stale ws and owned session."""
680 on_state = AsyncMock()
681 client = YnisonClient(
682 token=SecretStr("test-token"),
683 device_info=YnisonDeviceInfo(device_id="d1", title="T"),
684 on_state_update=on_state,
685 logger=MagicMock(),
686 )
687 mock_ws = AsyncMock()
688 mock_ws.closed = False
689
690 async def fake_redirect() -> None:
691 # Simulate ws being set before the error
692 client._ws = mock_ws
693 raise OSError("timeout")
694
695 with (
696 patch.object(
697 client,
698 "_get_redirect_ticket",
699 new_callable=AsyncMock,
700 side_effect=fake_redirect,
701 ),
702 patch.object(client, "_reconnect", new_callable=AsyncMock),
703 ):
704 await client.connect()
705
706 mock_ws.close.assert_awaited_once()
707 assert client._session is None
708
709
710# ------------------------------------------------------------------
711# disconnect() â reconnect task cancellation
712# ------------------------------------------------------------------
713
714
715class TestDisconnectReconnectCancellation:
716 """Tests for disconnect() cancelling a running reconnect task."""
717
718 async def test_disconnect_cancels_reconnect_task(self) -> None:
719 """disconnect() cancels and awaits pending reconnect task."""
720 on_state = AsyncMock()
721 client = YnisonClient(
722 token=SecretStr("test-token"),
723 device_info=YnisonDeviceInfo(device_id="d1", title="T"),
724 on_state_update=on_state,
725 logger=MagicMock(),
726 )
727
728 async def _forever() -> None:
729 await asyncio.Event().wait()
730
731 task = asyncio.ensure_future(_forever())
732 client._reconnect_task = task
733
734 await client.disconnect()
735
736 assert task.cancelled()
737
738
739# ------------------------------------------------------------------
740# Message building methods
741# ------------------------------------------------------------------
742
743
744class TestMessageBuildingMethods:
745 """Tests for sync_state_from_eov, update_player_state, send_full_state."""
746
747 @pytest.fixture(autouse=True)
748 def _setup_ws(self, client: YnisonClient) -> None:
749 """Set up a mock WebSocket."""
750 self.mock_ws = AsyncMock()
751 self.mock_ws.closed = False
752 client._ws = self.mock_ws
753 client._connected = True
754
755 async def test_sync_state_from_eov(self, client: YnisonClient) -> None:
756 """sync_state_from_eov builds correct message structure."""
757 await client.sync_state_from_eov(actual_queue_id="q123")
758 call_data = json.loads(self.mock_ws.send_str.call_args[0][0])
759 assert call_data["sync_state_from_eov"]["actual_queue_id"] == "q123"
760 assert "rid" in call_data
761 assert call_data["activity_interception_type"] == "DO_NOT_INTERCEPT_BY_DEFAULT"
762 # Ynison expects string-typed timestamps (integers cause 500s)
763 assert isinstance(call_data["player_action_timestamp_ms"], str)
764 assert call_data["player_action_timestamp_ms"].isdigit()
765
766 async def test_update_player_state(self, client: YnisonClient) -> None:
767 """update_player_state builds correct message and logs queue info."""
768 ps = {
769 "player_queue": {
770 "current_playable_index": 2,
771 "playable_list": [{"id": "a"}, {"id": "b"}, {"id": "c"}],
772 "entity_type": "ALBUM",
773 }
774 }
775 await client.update_player_state(ps)
776 call_data = json.loads(self.mock_ws.send_str.call_args[0][0])
777 assert call_data["update_player_state"]["player_state"] == ps
778 assert "rid" in call_data
779 assert call_data["activity_interception_type"] == "DO_NOT_INTERCEPT_BY_DEFAULT"
780
781 async def test_send_full_state_default(self, client: YnisonClient) -> None:
782 """send_full_state with no args sends initial state and device dict."""
783 await client.send_full_state()
784 call_data = json.loads(self.mock_ws.send_str.call_args[0][0])
785 ufs = call_data["update_full_state"]
786 assert ufs["device"]["info"]["device_id"] == "test-device-id"
787 assert ufs["player_state"]["status"]["paused"] is True
788 assert ufs["is_currently_active"] is False
789 assert "rid" in call_data
790
791 async def test_send_full_state_custom(self, client: YnisonClient) -> None:
792 """send_full_state with custom player_state uses it."""
793 custom_state = {"status": {"paused": False, "progress_ms": 42}}
794 await client.send_full_state(player_state=custom_state)
795 call_data = json.loads(self.mock_ws.send_str.call_args[0][0])
796 assert call_data["update_full_state"]["player_state"] == custom_state
797
798
799# ------------------------------------------------------------------
800# _get_redirect_ticket
801# ------------------------------------------------------------------
802
803
804class TestGetRedirectTicket:
805 """Tests for _get_redirect_ticket."""
806
807 async def test_success(self, client: YnisonClient) -> None:
808 """Returns (host, ticket, session_id) on success."""
809 mock_msg = MagicMock()
810 mock_msg.type = aiohttp.WSMsgType.TEXT
811 mock_msg.data = json.dumps(
812 {
813 "host": "ynison-node.yandex.net",
814 "redirect_ticket": "ticket-abc",
815 "session_id": 42,
816 }
817 )
818
819 mock_ws = AsyncMock()
820 mock_ws.receive = AsyncMock(return_value=mock_msg)
821 mock_ws.close = AsyncMock()
822
823 mock_session = AsyncMock()
824 mock_session.ws_connect = AsyncMock(return_value=mock_ws)
825 client._session = mock_session
826
827 host, ticket, sid = await client._get_redirect_ticket()
828
829 assert host == "ynison-node.yandex.net"
830 assert ticket == "ticket-abc"
831 assert sid == 42
832 mock_ws.close.assert_awaited_once()
833
834 async def test_auth_failure_401(self, client: YnisonClient) -> None:
835 """401 WSServerHandshakeError raises LoginFailed."""
836 err = aiohttp.WSServerHandshakeError(
837 request_info=MagicMock(),
838 history=(),
839 status=401,
840 message="Unauthorized",
841 headers=MagicMock(),
842 )
843 mock_session = AsyncMock()
844 mock_session.ws_connect = AsyncMock(side_effect=err)
845 client._session = mock_session
846
847 with pytest.raises(LoginFailed):
848 await client._get_redirect_ticket()
849
850 async def test_auth_failure_403(self, client: YnisonClient) -> None:
851 """403 WSServerHandshakeError raises LoginFailed."""
852 err = aiohttp.WSServerHandshakeError(
853 request_info=MagicMock(),
854 history=(),
855 status=403,
856 message="Forbidden",
857 headers=MagicMock(),
858 )
859 mock_session = AsyncMock()
860 mock_session.ws_connect = AsyncMock(side_effect=err)
861 client._session = mock_session
862
863 with pytest.raises(LoginFailed):
864 await client._get_redirect_ticket()
865
866 async def test_network_error_500(self, client: YnisonClient) -> None:
867 """500 WSServerHandshakeError re-raises (not LoginFailed)."""
868 err = aiohttp.WSServerHandshakeError(
869 request_info=MagicMock(),
870 history=(),
871 status=500,
872 message="Server Error",
873 headers=MagicMock(),
874 )
875 mock_session = AsyncMock()
876 mock_session.ws_connect = AsyncMock(side_effect=err)
877 client._session = mock_session
878
879 with pytest.raises(aiohttp.WSServerHandshakeError):
880 await client._get_redirect_ticket()
881
882 async def test_missing_host_ticket(self, client: YnisonClient) -> None:
883 """Missing host/ticket in response raises ConnectionError."""
884 mock_msg = MagicMock()
885 mock_msg.type = aiohttp.WSMsgType.TEXT
886 mock_msg.data = json.dumps({"host": "", "redirect_ticket": ""})
887
888 mock_ws = AsyncMock()
889 mock_ws.receive = AsyncMock(return_value=mock_msg)
890 mock_ws.close = AsyncMock()
891
892 mock_session = AsyncMock()
893 mock_session.ws_connect = AsyncMock(return_value=mock_ws)
894 client._session = mock_session
895
896 with pytest.raises(ConnectionError, match="missing host or ticket"):
897 await client._get_redirect_ticket()
898
899 async def test_unexpected_msg_type(self, client: YnisonClient) -> None:
900 """Non-TEXT/BINARY message type raises ConnectionError."""
901 mock_msg = MagicMock()
902 mock_msg.type = aiohttp.WSMsgType.CLOSE
903 mock_msg.data = None
904
905 mock_ws = AsyncMock()
906 mock_ws.receive = AsyncMock(return_value=mock_msg)
907 mock_ws.close = AsyncMock()
908
909 mock_session = AsyncMock()
910 mock_session.ws_connect = AsyncMock(return_value=mock_ws)
911 client._session = mock_session
912
913 with pytest.raises(ConnectionError, match="Unexpected message type"):
914 await client._get_redirect_ticket()
915
916 async def test_no_session_raises_runtime_error(self, client: YnisonClient) -> None:
917 """Raises RuntimeError when session is None."""
918 client._session = None
919 with pytest.raises(RuntimeError, match="session not initialized"):
920 await client._get_redirect_ticket()
921
922
923# ------------------------------------------------------------------
924# _connect_state
925# ------------------------------------------------------------------
926
927
928class TestConnectState:
929 """Tests for _connect_state."""
930
931 async def test_success(self, client: YnisonClient) -> None:
932 """Successful connect sets _connected, calls send_full_state, starts loop."""
933 mock_ws = AsyncMock()
934
935 mock_session = AsyncMock()
936 mock_session.ws_connect = AsyncMock(return_value=mock_ws)
937 client._session = mock_session
938
939 with patch.object(client, "send_full_state", new_callable=AsyncMock) as mock_sfs:
940 await client._connect_state("host.yandex.net", "ticket", 42)
941
942 assert client._connected is True
943 # Cold start: send_full_state called with no args (blank state)
944 mock_sfs.assert_awaited_once_with()
945 assert client._has_connected_once is True
946 assert client._message_task is not None
947 # Clean up the task
948 client._message_task.cancel()
949 with suppress(asyncio.CancelledError):
950 await client._message_task
951
952 async def test_reconnect_sends_fresh_state_no_stale_replay(self, client: YnisonClient) -> None:
953 """
954 v2.0: reconnect sends a fresh initial state â no stale replay.
955
956 Replaying the last known state (which after a heartbeat could carry
957 `paused=True`) caused the server to broadcast it back and trigger
958 an unintended pause on the still-running player.
959 """
960 mock_ws = AsyncMock()
961 mock_session = AsyncMock()
962 mock_session.ws_connect = AsyncMock(return_value=mock_ws)
963 client._session = mock_session
964
965 # Simulate prior connection with stale paused state cached.
966 client._has_connected_once = True
967 client.state.player_state = {
968 "status": {"paused": True, "progress_ms": 120000, "duration_ms": 300000},
969 "player_queue": {
970 "current_playable_index": 3,
971 "playable_list": [{"playable_id": "t1"}],
972 },
973 }
974
975 with patch.object(client, "send_full_state", new_callable=AsyncMock) as mock_sfs:
976 await client._connect_state("host.yandex.net", "ticket", 42)
977
978 # send_full_state must be called WITHOUT player_state â it falls
979 # back to a fresh _build_initial_state() internally.
980 mock_sfs.assert_awaited_once_with()
981 # Settle window armed for ~2 s.
982 assert client.in_post_reconnect_settle is True
983 # Clean up
984 assert client._message_task is not None
985 client._message_task.cancel()
986 with suppress(asyncio.CancelledError):
987 await client._message_task
988
989 async def test_cold_start_does_not_arm_settle_window(self, client: YnisonClient) -> None:
990 """First-ever connect skips the settle window â nothing stale to swallow."""
991 mock_ws = AsyncMock()
992 mock_session = AsyncMock()
993 mock_session.ws_connect = AsyncMock(return_value=mock_ws)
994 client._session = mock_session
995
996 with patch.object(client, "send_full_state", new_callable=AsyncMock):
997 await client._connect_state("host.yandex.net", "ticket", 42)
998
999 assert client.in_post_reconnect_settle is False
1000 assert client._message_task is not None
1001 client._message_task.cancel()
1002 with suppress(asyncio.CancelledError):
1003 await client._message_task
1004
1005 async def test_auth_failure_401(self, client: YnisonClient) -> None:
1006 """401 during state connect raises LoginFailed."""
1007 err = aiohttp.WSServerHandshakeError(
1008 request_info=MagicMock(),
1009 history=(),
1010 status=401,
1011 message="Unauthorized",
1012 headers=MagicMock(),
1013 )
1014 mock_session = AsyncMock()
1015 mock_session.ws_connect = AsyncMock(side_effect=err)
1016 client._session = mock_session
1017
1018 with pytest.raises(LoginFailed):
1019 await client._connect_state("host", "ticket", 1)
1020
1021 async def test_no_session_raises_runtime_error(self, client: YnisonClient) -> None:
1022 """Raises RuntimeError when session is None."""
1023 client._session = None
1024 with pytest.raises(RuntimeError, match="session not initialized"):
1025 await client._connect_state("host", "ticket", 1)
1026
1027
1028# ------------------------------------------------------------------
1029# _message_loop
1030# ------------------------------------------------------------------
1031
1032
1033def _make_ws_msg(
1034 msg_type: aiohttp.WSMsgType,
1035 data: str | bytes | None = None,
1036 extra: Any = None,
1037) -> MagicMock:
1038 """Create a mock WS message."""
1039 msg = MagicMock()
1040 msg.type = msg_type
1041 msg.data = data
1042 msg.extra = extra
1043 return msg
1044
1045
1046class TestMessageLoop:
1047 """Tests for _message_loop."""
1048
1049 async def _run_loop_with_messages(
1050 self,
1051 client: YnisonClient,
1052 messages: list[MagicMock],
1053 ) -> None:
1054 """Set up mock ws and run _message_loop."""
1055
1056 async def _aiter(_self: Any) -> Any:
1057 for m in messages:
1058 yield m
1059
1060 mock_ws = MagicMock()
1061 mock_ws.__aiter__ = _aiter
1062 mock_ws.exception = MagicMock(return_value=None)
1063 mock_ws.close_code = None
1064 client._ws = mock_ws
1065 client._connected = True
1066
1067 with patch.object(client, "_reconnect", new_callable=AsyncMock):
1068 await client._message_loop()
1069
1070 async def test_text_message_parses_and_calls_callback(
1071 self,
1072 client: YnisonClient,
1073 mock_state_callback: AsyncMock,
1074 ) -> None:
1075 """TEXT message: parses JSON, updates state, invokes callback."""
1076 on_state_update = mock_state_callback
1077 payload = {
1078 "player_state": {
1079 "status": {"paused": False, "progress_ms": 1000, "duration_ms": 5000},
1080 "player_queue": {
1081 "current_playable_index": 0,
1082 "playable_list": [{"playable_id": "t1"}],
1083 },
1084 },
1085 "active_device_id_optional": "dev1",
1086 }
1087 msg = _make_ws_msg(aiohttp.WSMsgType.TEXT, json.dumps(payload))
1088 await self._run_loop_with_messages(client, [msg])
1089
1090 on_state_update.assert_awaited_once()
1091 assert client.state.current_track_id == "t1"
1092 assert client.state.is_paused is False
1093
1094 async def test_text_message_with_error_field(self, client: YnisonClient) -> None:
1095 """TEXT message with non-reconnect error logs warning, continues."""
1096 error_msg = _make_ws_msg(
1097 aiohttp.WSMsgType.TEXT,
1098 json.dumps({"error": {"code": 500, "message": "server error"}}),
1099 )
1100 # Second valid message to confirm the loop continues
1101 valid_msg = _make_ws_msg(
1102 aiohttp.WSMsgType.TEXT,
1103 json.dumps({"player_state": {"status": {"paused": True}}}),
1104 )
1105 await self._run_loop_with_messages(client, [error_msg, valid_msg])
1106
1107 client._logger.warning.assert_called()
1108
1109 async def test_rebalance_error_breaks_loop(
1110 self,
1111 client: YnisonClient,
1112 mock_state_callback: AsyncMock,
1113 ) -> None:
1114 """Ynison re-balance error (300100001) breaks the loop for immediate reconnect."""
1115 on_state_update = mock_state_callback
1116 rebalance_msg = _make_ws_msg(
1117 aiohttp.WSMsgType.TEXT,
1118 json.dumps(
1119 {
1120 "error": {
1121 "details": {
1122 "ynison-error-code": "300100001",
1123 "ynison-backoff-millis": "0:100:500:1000:1000:5000",
1124 },
1125 "grpc_code": 10,
1126 "http_code": 409,
1127 "message": "User re-balanced to another host",
1128 }
1129 }
1130 ),
1131 )
1132 # This message should NOT be reached because the loop breaks
1133 valid_msg = _make_ws_msg(
1134 aiohttp.WSMsgType.TEXT,
1135 json.dumps({"player_state": {"status": {"paused": True}}}),
1136 )
1137 await self._run_loop_with_messages(client, [rebalance_msg, valid_msg])
1138
1139 # The valid message was never processed (loop broke on re-balance error)
1140 on_state_update.assert_not_awaited()
1141
1142 async def test_not_served_error_breaks_loop(
1143 self,
1144 client: YnisonClient,
1145 mock_state_callback: AsyncMock,
1146 ) -> None:
1147 """Ynison 'not served' error (300100002) also breaks the loop."""
1148 on_state_update = mock_state_callback
1149 not_served_msg = _make_ws_msg(
1150 aiohttp.WSMsgType.TEXT,
1151 json.dumps(
1152 {
1153 "error": {
1154 "details": {"ynison-error-code": "300100002"},
1155 "grpc_code": 10,
1156 "http_code": 409,
1157 "message": "Current user's not served by this host",
1158 }
1159 }
1160 ),
1161 )
1162 valid_msg = _make_ws_msg(
1163 aiohttp.WSMsgType.TEXT,
1164 json.dumps({"player_state": {"status": {"paused": True}}}),
1165 )
1166 await self._run_loop_with_messages(client, [not_served_msg, valid_msg])
1167
1168 on_state_update.assert_not_awaited()
1169
1170 async def test_text_message_invalid_json(self, client: YnisonClient) -> None:
1171 """TEXT message with invalid JSON logs warning, continues."""
1172 bad_msg = _make_ws_msg(aiohttp.WSMsgType.TEXT, "not valid json{{{")
1173 valid_msg = _make_ws_msg(
1174 aiohttp.WSMsgType.TEXT,
1175 json.dumps({"player_state": {"status": {"paused": True}}}),
1176 )
1177 await self._run_loop_with_messages(client, [bad_msg, valid_msg])
1178
1179 client._logger.warning.assert_called()
1180
1181 async def test_callback_exception_continues(
1182 self,
1183 client: YnisonClient,
1184 mock_state_callback: AsyncMock,
1185 ) -> None:
1186 """Exception in state callback is caught, loop continues."""
1187 on_state_update = mock_state_callback
1188 on_state_update.side_effect = [ValueError("boom"), None]
1189
1190 msg1 = _make_ws_msg(
1191 aiohttp.WSMsgType.TEXT,
1192 json.dumps({"player_state": {"status": {"paused": True}}}),
1193 )
1194 msg2 = _make_ws_msg(
1195 aiohttp.WSMsgType.TEXT,
1196 json.dumps({"player_state": {"status": {"paused": False}}}),
1197 )
1198 await self._run_loop_with_messages(client, [msg1, msg2])
1199
1200 assert on_state_update.await_count == 2
1201
1202 async def test_binary_message_logged(self, client: YnisonClient) -> None:
1203 """BINARY message is logged, loop continues."""
1204 bin_msg = _make_ws_msg(aiohttp.WSMsgType.BINARY, b"\x00\x01\x02")
1205 valid_msg = _make_ws_msg(
1206 aiohttp.WSMsgType.TEXT,
1207 json.dumps({"player_state": {"status": {"paused": True}}}),
1208 )
1209 await self._run_loop_with_messages(client, [bin_msg, valid_msg])
1210
1211 client._logger.debug.assert_called()
1212
1213 async def test_error_message_breaks_and_reconnects(self, client: YnisonClient) -> None:
1214 """ERROR message breaks loop and schedules reconnect."""
1215
1216 async def _aiter(_self: Any) -> Any:
1217 yield _make_ws_msg(aiohttp.WSMsgType.ERROR)
1218
1219 mock_ws = MagicMock()
1220 mock_ws.__aiter__ = _aiter
1221 mock_ws.exception = MagicMock(return_value=Exception("ws error"))
1222 mock_ws.close_code = None
1223 client._ws = mock_ws
1224 client._connected = True
1225
1226 with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc:
1227 await client._message_loop()
1228 await asyncio.sleep(0) # let ensure_future task run
1229
1230 assert client._connected is False
1231 mock_rc.assert_awaited_once()
1232
1233 async def test_close_message_breaks_and_reconnects(self, client: YnisonClient) -> None:
1234 """CLOSE message breaks loop and schedules reconnect."""
1235
1236 async def _aiter(_self: Any) -> Any:
1237 yield _make_ws_msg(aiohttp.WSMsgType.CLOSE, extra="normal close")
1238
1239 mock_ws = MagicMock()
1240 mock_ws.__aiter__ = _aiter
1241 mock_ws.exception = MagicMock(return_value=None)
1242 mock_ws.close_code = 1000
1243 client._ws = mock_ws
1244 client._connected = True
1245
1246 with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc:
1247 await client._message_loop()
1248 await asyncio.sleep(0) # let ensure_future task run
1249
1250 assert client._connected is False
1251 mock_rc.assert_awaited_once()
1252
1253 async def test_closing_message_breaks_loop(self, client: YnisonClient) -> None:
1254 """CLOSING message breaks loop."""
1255
1256 async def _aiter(_self: Any) -> Any:
1257 yield _make_ws_msg(aiohttp.WSMsgType.CLOSING)
1258
1259 mock_ws = MagicMock()
1260 mock_ws.__aiter__ = _aiter
1261 mock_ws.exception = MagicMock(return_value=None)
1262 mock_ws.close_code = None
1263 client._ws = mock_ws
1264 client._connected = True
1265
1266 with patch.object(client, "_reconnect", new_callable=AsyncMock):
1267 await client._message_loop()
1268
1269 assert client._connected is False
1270
1271 async def test_stop_event_breaks_loop(self, client: YnisonClient) -> None:
1272 """stop_event set â breaks loop without reconnect."""
1273 client._stop_event.set()
1274
1275 async def _aiter(_self: Any) -> Any:
1276 yield _make_ws_msg(
1277 aiohttp.WSMsgType.TEXT,
1278 json.dumps({"player_state": {}}),
1279 )
1280
1281 mock_ws = MagicMock()
1282 mock_ws.__aiter__ = _aiter
1283 mock_ws.exception = MagicMock(return_value=None)
1284 mock_ws.close_code = None
1285 client._ws = mock_ws
1286 client._connected = True
1287
1288 with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc:
1289 await client._message_loop()
1290
1291 mock_rc.assert_not_awaited()
1292
1293 async def test_cancelled_error_exits_cleanly(self, client: YnisonClient) -> None:
1294 """CancelledError exits without reconnect."""
1295
1296 async def _aiter(_self: Any) -> Any:
1297 raise asyncio.CancelledError
1298 yield
1299
1300 mock_ws = MagicMock()
1301 mock_ws.__aiter__ = _aiter
1302 mock_ws.exception = MagicMock(return_value=None)
1303 mock_ws.close_code = None
1304 client._ws = mock_ws
1305 client._connected = True
1306
1307 # CancelledError should be handled cleanly (no reconnect)
1308 with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc:
1309 await client._message_loop()
1310
1311 mock_rc.assert_not_awaited()
1312
1313 async def test_empty_data_message(self, client: YnisonClient) -> None:
1314 """Message with empty data gets '<empty>' preview."""
1315 msg = _make_ws_msg(aiohttp.WSMsgType.TEXT, "")
1316 # Empty string â json.loads will fail â warning logged
1317 await self._run_loop_with_messages(client, [msg])
1318 client._logger.warning.assert_called()
1319
1320 async def test_no_ws_raises_runtime_error(self, client: YnisonClient) -> None:
1321 """_message_loop raises RuntimeError when ws is None."""
1322 client._ws = None
1323 with pytest.raises(RuntimeError, match="not connected"):
1324 await client._message_loop()
1325
1326
1327# ------------------------------------------------------------------
1328# _reconnect
1329# ------------------------------------------------------------------
1330
1331SLEEP_PATH = "music_assistant.providers.yandex_ynison.ynison_client.asyncio.sleep"
1332
1333
1334class TestReconnect:
1335 """Tests for _reconnect."""
1336
1337 async def test_success_on_first_attempt(self, client: YnisonClient) -> None:
1338 """Reconnect succeeds on first attempt."""
1339 client._session = MagicMock()
1340 client._session.closed = False
1341
1342 with (
1343 patch(SLEEP_PATH, new_callable=AsyncMock),
1344 patch.object(
1345 client,
1346 "_get_redirect_ticket",
1347 new_callable=AsyncMock,
1348 return_value=("host", "ticket", 1),
1349 ),
1350 patch.object(client, "_connect_state", new_callable=AsyncMock),
1351 ):
1352 await client._reconnect()
1353
1354 client._logger.info.assert_any_call("Ynison reconnected successfully")
1355
1356 async def test_retries_indefinitely_until_stopped(self, client: YnisonClient) -> None:
1357 """Reconnect keeps retrying past the old 5-attempt cap until stop_event."""
1358 client._session = MagicMock()
1359 client._session.closed = False
1360
1361 attempt_count = 0
1362 stop_after = 8 # well past the old MAX_RECONNECT_ATTEMPTS of 5
1363
1364 async def failing_redirect() -> tuple[str, str, int]:
1365 nonlocal attempt_count
1366 attempt_count += 1
1367 if attempt_count >= stop_after:
1368 client._stop_event.set()
1369 msg = "fail"
1370 raise ConnectionError(msg)
1371
1372 with (
1373 patch(SLEEP_PATH, new_callable=AsyncMock),
1374 patch.object(
1375 client,
1376 "_get_redirect_ticket",
1377 new_callable=AsyncMock,
1378 side_effect=failing_redirect,
1379 ),
1380 ):
1381 await client._reconnect()
1382
1383 assert attempt_count >= stop_after
1384
1385 async def test_stop_event_before_attempt(self, client: YnisonClient) -> None:
1386 """stop_event set before reconnect â exits immediately."""
1387 client._stop_event.set()
1388 await client._reconnect()
1389
1390 async def test_stop_event_after_sleep(self, client: YnisonClient) -> None:
1391 """stop_event set during sleep â exits on next check."""
1392
1393 async def set_stop(*_args: Any, **_kwargs: Any) -> None:
1394 client._stop_event.set()
1395
1396 client._session = MagicMock()
1397 client._session.closed = False
1398
1399 with patch(SLEEP_PATH, new_callable=AsyncMock, side_effect=set_stop):
1400 await client._reconnect()
1401
1402 # Should exit without calling _get_redirect_ticket
1403 assert client._stop_event.is_set()
1404
1405 async def test_cancelled_error_during_reconnect(self, client: YnisonClient) -> None:
1406 """CancelledError during reconnect exits cleanly."""
1407 client._session = MagicMock()
1408 client._session.closed = False
1409
1410 with (
1411 patch(SLEEP_PATH, new_callable=AsyncMock),
1412 patch.object(
1413 client,
1414 "_get_redirect_ticket",
1415 new_callable=AsyncMock,
1416 side_effect=asyncio.CancelledError,
1417 ),
1418 ):
1419 await client._reconnect()
1420
1421 async def test_creates_new_session_when_none(self, client: YnisonClient) -> None:
1422 """Creates new ClientSession when _session is None and no external."""
1423 client._session = None
1424 client._external_session = None
1425
1426 mock_new_session = MagicMock(spec=aiohttp.ClientSession)
1427 mock_new_session.closed = False
1428 mock_new_session.close = AsyncMock()
1429
1430 def stop_after_session(*_args: Any, **_kwargs: Any) -> None:
1431 client._stop_event.set()
1432 msg = "stop"
1433 raise RuntimeError(msg)
1434
1435 with (
1436 patch(SLEEP_PATH, new_callable=AsyncMock),
1437 patch(
1438 "music_assistant.providers.yandex_ynison.ynison_client.aiohttp.ClientSession",
1439 return_value=mock_new_session,
1440 ),
1441 patch.object(
1442 client,
1443 "_get_redirect_ticket",
1444 new_callable=AsyncMock,
1445 side_effect=stop_after_session,
1446 ),
1447 ):
1448 await client._reconnect()
1449
1450 assert client._session is mock_new_session
1451
1452 async def test_closes_stale_ws_on_reconnect(self, client: YnisonClient) -> None:
1453 """Stale ws is closed before reconnect attempt."""
1454 stale_ws = AsyncMock()
1455 stale_ws.closed = False
1456 client._ws = stale_ws
1457 client._session = MagicMock()
1458 client._session.closed = False
1459
1460 with (
1461 patch(SLEEP_PATH, new_callable=AsyncMock),
1462 patch.object(
1463 client,
1464 "_get_redirect_ticket",
1465 new_callable=AsyncMock,
1466 return_value=("host", "ticket", 1),
1467 ),
1468 patch.object(client, "_connect_state", new_callable=AsyncMock),
1469 ):
1470 await client._reconnect()
1471
1472 stale_ws.close.assert_awaited_once()
1473
1474
1475# ------------------------------------------------------------------
1476# _send() error handling
1477# ------------------------------------------------------------------
1478
1479
1480class TestSendErrorHandling:
1481 """Tests for _send() error handling and reconnect scheduling."""
1482
1483 async def test_connection_error_triggers_reconnect(self, client: YnisonClient) -> None:
1484 """ConnectionError during send sets _connected=False, schedules reconnect."""
1485 mock_ws = AsyncMock()
1486 mock_ws.closed = False
1487 mock_ws.send_str = AsyncMock(side_effect=ConnectionError("broken pipe"))
1488 client._ws = mock_ws
1489 client._connected = True
1490
1491 with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc:
1492 await client._send({"test": True})
1493 await asyncio.sleep(0)
1494
1495 assert client._connected is False
1496 mock_rc.assert_awaited_once()
1497
1498 async def test_client_error_triggers_reconnect(self, client: YnisonClient) -> None:
1499 """aiohttp.ClientError during send triggers reconnect."""
1500 mock_ws = AsyncMock()
1501 mock_ws.closed = False
1502 mock_ws.send_str = AsyncMock(side_effect=aiohttp.ClientError("connection lost"))
1503 client._ws = mock_ws
1504 client._connected = True
1505
1506 with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc:
1507 await client._send({"test": True})
1508 await asyncio.sleep(0)
1509
1510 assert client._connected is False
1511 mock_rc.assert_awaited_once()
1512
1513 async def test_runtime_error_triggers_reconnect(self, client: YnisonClient) -> None:
1514 """RuntimeError during send triggers reconnect."""
1515 mock_ws = AsyncMock()
1516 mock_ws.closed = False
1517 mock_ws.send_str = AsyncMock(side_effect=RuntimeError("ws closed"))
1518 client._ws = mock_ws
1519 client._connected = True
1520
1521 with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc:
1522 await client._send({"test": True})
1523 await asyncio.sleep(0)
1524
1525 assert client._connected is False
1526 mock_rc.assert_awaited_once()
1527
1528 async def test_os_error_triggers_reconnect(self, client: YnisonClient) -> None:
1529 """OSError during send triggers reconnect."""
1530 mock_ws = AsyncMock()
1531 mock_ws.closed = False
1532 mock_ws.send_str = AsyncMock(side_effect=OSError("network"))
1533 client._ws = mock_ws
1534 client._connected = True
1535
1536 with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc:
1537 await client._send({"test": True})
1538 await asyncio.sleep(0)
1539
1540 assert client._connected is False
1541 mock_rc.assert_awaited_once()
1542
1543 async def test_send_skips_when_ws_closed(self, client: YnisonClient) -> None:
1544 """_send skips when ws is present but closed."""
1545 mock_ws = AsyncMock()
1546 mock_ws.closed = True
1547 client._ws = mock_ws
1548 client._connected = True
1549
1550 await client._send({"test": True})
1551 mock_ws.send_str.assert_not_called()
1552
1553
1554# ------------------------------------------------------------------
1555# connect() creates session when none provided
1556# ------------------------------------------------------------------
1557
1558
1559class TestConnectSessionCreation:
1560 """Tests for connect() creating an aiohttp session."""
1561
1562 async def test_connect_creates_session(self) -> None:
1563 """connect() creates a new session when no external session given."""
1564 on_state = AsyncMock()
1565 client = YnisonClient(
1566 token=SecretStr("test-token"),
1567 device_info=YnisonDeviceInfo(device_id="d1", title="T"),
1568 on_state_update=on_state,
1569 logger=MagicMock(),
1570 )
1571 with (
1572 patch.object(
1573 client,
1574 "_get_redirect_ticket",
1575 new_callable=AsyncMock,
1576 return_value=("host", "ticket", 1),
1577 ),
1578 patch.object(client, "_connect_state", new_callable=AsyncMock),
1579 ):
1580 await client.connect()
1581
1582 assert client._session is not None
1583 # Clean up
1584 await client.disconnect()
1585
1586 async def test_disconnect_does_not_close_external_session(self) -> None:
1587 """disconnect() does not close an externally-provided session."""
1588 on_state = AsyncMock()
1589 ext_session = MagicMock(spec=aiohttp.ClientSession)
1590 ext_session.closed = False
1591 ext_session.close = AsyncMock()
1592
1593 client = YnisonClient(
1594 token=SecretStr("test-token"),
1595 device_info=YnisonDeviceInfo(device_id="d1", title="T"),
1596 on_state_update=on_state,
1597 logger=MagicMock(),
1598 http_session=ext_session,
1599 )
1600 client._session = ext_session
1601
1602 await client.disconnect()
1603
1604 ext_session.close.assert_not_called()
1605
1606
1607# ------------------------------------------------------------------
1608# Token refresh on auth failure during reconnect
1609# ------------------------------------------------------------------
1610
1611
1612class TestTokenRefreshOnReconnect:
1613 """Tests for on_auth_failure callback in _reconnect."""
1614
1615 async def test_auth_failure_triggers_token_refresh(self) -> None:
1616 """LoginFailed during reconnect invokes on_auth_failure callback."""
1617 on_state = AsyncMock()
1618 on_auth_failure = AsyncMock(return_value=SecretStr("new-token"))
1619
1620 client = YnisonClient(
1621 token=SecretStr("old-token"),
1622 device_info=YnisonDeviceInfo(device_id="d1", title="T"),
1623 on_state_update=on_state,
1624 logger=MagicMock(),
1625 on_auth_failure=on_auth_failure,
1626 )
1627 client._session = MagicMock()
1628 client._session.closed = False
1629
1630 # First attempt: LoginFailed â refresh â second attempt: success
1631 attempt_count = 0
1632
1633 async def redirect_side_effect() -> tuple[str, str, int]:
1634 nonlocal attempt_count
1635 attempt_count += 1
1636 if attempt_count == 1:
1637 raise LoginFailed("expired")
1638 return ("host", "ticket", 1)
1639
1640 with (
1641 patch(SLEEP_PATH, new_callable=AsyncMock),
1642 patch.object(
1643 client,
1644 "_get_redirect_ticket",
1645 new_callable=AsyncMock,
1646 side_effect=redirect_side_effect,
1647 ),
1648 patch.object(client, "_connect_state", new_callable=AsyncMock),
1649 ):
1650 await client._reconnect()
1651
1652 on_auth_failure.assert_awaited_once()
1653 assert client._token == SecretStr("new-token")
1654 client._logger.info.assert_any_call("Token refreshed, will retry with new token")
1655
1656 async def test_auth_failure_no_callback(self) -> None:
1657 """LoginFailed without on_auth_failure keeps retrying on the same token."""
1658 on_state = AsyncMock()
1659
1660 client = YnisonClient(
1661 token=SecretStr("old-token"),
1662 device_info=YnisonDeviceInfo(device_id="d1", title="T"),
1663 on_state_update=on_state,
1664 logger=MagicMock(),
1665 )
1666 client._session = MagicMock()
1667 client._session.closed = False
1668
1669 attempt_count = 0
1670
1671 async def failing_redirect() -> tuple[str, str, int]:
1672 nonlocal attempt_count
1673 attempt_count += 1
1674 if attempt_count >= 4:
1675 client._stop_event.set()
1676 raise LoginFailed("expired")
1677
1678 with (
1679 patch(SLEEP_PATH, new_callable=AsyncMock),
1680 patch.object(
1681 client,
1682 "_get_redirect_ticket",
1683 new_callable=AsyncMock,
1684 side_effect=failing_redirect,
1685 ),
1686 ):
1687 await client._reconnect()
1688
1689 assert attempt_count >= 4
1690 assert client._token == SecretStr("old-token")
1691
1692 async def test_auth_failure_callback_raises(self) -> None:
1693 """on_auth_failure raises â logs warning, keeps retrying until stopped."""
1694 on_state = AsyncMock()
1695 on_auth_failure = AsyncMock(side_effect=RuntimeError("refresh failed"))
1696
1697 client = YnisonClient(
1698 token=SecretStr("old-token"),
1699 device_info=YnisonDeviceInfo(device_id="d1", title="T"),
1700 on_state_update=on_state,
1701 logger=MagicMock(),
1702 on_auth_failure=on_auth_failure,
1703 )
1704 client._session = MagicMock()
1705 client._session.closed = False
1706
1707 attempt_count = 0
1708 stop_after = 6
1709
1710 async def failing_redirect() -> tuple[str, str, int]:
1711 nonlocal attempt_count
1712 attempt_count += 1
1713 if attempt_count >= stop_after:
1714 client._stop_event.set()
1715 raise LoginFailed("expired")
1716
1717 with (
1718 patch(SLEEP_PATH, new_callable=AsyncMock),
1719 patch.object(
1720 client,
1721 "_get_redirect_ticket",
1722 new_callable=AsyncMock,
1723 side_effect=failing_redirect,
1724 ),
1725 ):
1726 await client._reconnect()
1727
1728 # Callback was called on every attempt â no cap
1729 assert on_auth_failure.await_count == attempt_count
1730 # Token unchanged since callback always fails
1731 assert client._token == SecretStr("old-token")
1732
1733
1734class TestUpdateToken:
1735 """Tests for update_token method."""
1736
1737 def test_update_token_replaces_stored_token(self) -> None:
1738 """update_token swaps the internal _token."""
1739 on_state = AsyncMock()
1740 client = YnisonClient(
1741 token=SecretStr("old-token"),
1742 device_info=YnisonDeviceInfo(device_id="d1", title="T"),
1743 on_state_update=on_state,
1744 logger=MagicMock(),
1745 )
1746 assert client._token == SecretStr("old-token")
1747 client.update_token(SecretStr("new-token"))
1748 assert client._token == SecretStr("new-token")
1749
1750
1751# ------------------------------------------------------------------
1752# Strict-mode delivery signalling (spec 0003)
1753# ------------------------------------------------------------------
1754
1755
1756class TestSendStrictMode:
1757 """Tests for `_send`/`update_*` strict-mode raising on transport failure."""
1758
1759 async def test_send_strict_raises_ynison_send_error_when_disconnected(
1760 self, client: YnisonClient
1761 ) -> None:
1762 """`strict=True` on a disconnected client raises `YnisonSendError`."""
1763 client._ws = None
1764 with pytest.raises(YnisonSendError):
1765 await client._send({"test": True}, strict=True)
1766
1767 async def test_send_strict_raises_on_client_error_and_schedules_reconnect(
1768 self, client: YnisonClient
1769 ) -> None:
1770 """`strict=True` with a failing send_str raises AND schedules reconnect."""
1771 mock_ws = AsyncMock()
1772 mock_ws.closed = False
1773 mock_ws.send_str = AsyncMock(side_effect=aiohttp.ClientError("connection lost"))
1774 client._ws = mock_ws
1775 client._connected = True
1776
1777 with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc:
1778 with pytest.raises(YnisonSendError):
1779 await client._send({"test": True}, strict=True)
1780 await asyncio.sleep(0)
1781
1782 assert client._connected is False
1783 mock_rc.assert_awaited_once()
1784
1785 async def test_send_non_strict_swallows_and_schedules_reconnect(
1786 self, client: YnisonClient
1787 ) -> None:
1788 """Default (`strict=False`) keeps the existing swallow-and-reconnect behaviour."""
1789 mock_ws = AsyncMock()
1790 mock_ws.closed = False
1791 mock_ws.send_str = AsyncMock(side_effect=aiohttp.ClientError("connection lost"))
1792 client._ws = mock_ws
1793 client._connected = True
1794
1795 with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc:
1796 # Must NOT raise
1797 await client._send({"test": True})
1798 await asyncio.sleep(0)
1799
1800 assert client._connected is False
1801 mock_rc.assert_awaited_once()
1802
1803 async def test_update_playing_status_forwards_strict_kwarg(self, client: YnisonClient) -> None:
1804 """`update_playing_status(strict=True)` forwards to `_send`."""
1805 with patch.object(client, "_send", new_callable=AsyncMock) as mock_send:
1806 await client.update_playing_status(
1807 progress_ms=10, duration_ms=100, paused=False, strict=True
1808 )
1809 mock_send.assert_awaited_once()
1810 _args, kwargs = mock_send.call_args
1811 assert kwargs.get("strict") is True
1812
1813 async def test_update_playing_status_default_strict_false(self, client: YnisonClient) -> None:
1814 """Default call passes `strict=False` (or omits, equivalent)."""
1815 with patch.object(client, "_send", new_callable=AsyncMock) as mock_send:
1816 await client.update_playing_status(progress_ms=10, duration_ms=100, paused=False)
1817 _args, kwargs = mock_send.call_args
1818 assert kwargs.get("strict", False) is False
1819
1820 async def test_update_player_state_forwards_strict_kwarg(self, client: YnisonClient) -> None:
1821 """`update_player_state(strict=True)` forwards to `_send`."""
1822 with patch.object(client, "_send", new_callable=AsyncMock) as mock_send:
1823 await client.update_player_state(
1824 player_state={"player_queue": {}, "status": {}}, strict=True
1825 )
1826 mock_send.assert_awaited_once()
1827 _args, kwargs = mock_send.call_args
1828 assert kwargs.get("strict") is True
1829
1830
1831class TestScheduleReconnect:
1832 """Tests for the extracted `_schedule_reconnect` helper."""
1833
1834 async def test_schedule_reconnect_creates_task_when_none_alive(
1835 self, client: YnisonClient
1836 ) -> None:
1837 """First call creates a reconnect task."""
1838 assert client._reconnect_task is None
1839 with patch.object(client, "_reconnect", new_callable=AsyncMock):
1840 client._schedule_reconnect()
1841 task = client._reconnect_task
1842 assert task is not None
1843 await task # let it finish so we don't leak it
1844
1845 async def test_schedule_reconnect_idempotent_when_task_alive(
1846 self, client: YnisonClient
1847 ) -> None:
1848 """Second call while a task is alive does not create another."""
1849 # Use a real task that we can hold open
1850 started = asyncio.Event()
1851 finish = asyncio.Event()
1852
1853 async def slow_reconnect() -> None:
1854 started.set()
1855 await finish.wait()
1856
1857 with patch.object(client, "_reconnect", side_effect=slow_reconnect):
1858 client._schedule_reconnect()
1859 first = client._reconnect_task
1860 assert first is not None
1861 await started.wait()
1862
1863 # Try again while first is running
1864 client._schedule_reconnect()
1865 assert client._reconnect_task is first # same task, no replacement
1866
1867 finish.set()
1868 await first
1869
1870 async def test_schedule_reconnect_noop_when_stop_event_set(self, client: YnisonClient) -> None:
1871 """Once the client is being torn down, no new reconnect tasks are scheduled."""
1872 client._stop_event.set()
1873 with patch.object(client, "_reconnect", new_callable=AsyncMock) as mock_rc:
1874 client._schedule_reconnect()
1875 assert client._reconnect_task is None
1876 mock_rc.assert_not_called()
1877