music-assistant-server

68.5 KBPY
test_ynison_client.py
68.5 KB1,877 lines • python
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