"""Round 64: Cassette's footing — the Syncthing REST client, the host machine, the Cassette state file and the Sync Settings shell. * LinTunes only ever talks to *local* Syncthing. The key and address come from Syncthing's config.xml, overridable in config.json, and every failure names the step and what Syncthing said, sorted into "not running" / "bad key" / "it said no" so the UI can offer the right next step. * One machine is the Cassette host: config.json flags it, preferences.json (synced) names it so the other machine can say where friend sharing lives. * Sync Settings edits a copy; Cancel leaves everything as it was. """ import json import pytest import requests from PyQt6.QtGui import QColor, QImage from lintunes.cassette import host, probe as probe_mod, syncthing_api as st from lintunes.cassette.state import ( CassetteState, Friend, Invite, Selection, load_state, save_state, ) from lintunes.preferences import Preferences # ---- a fake Syncthing ---- class _Response: def __init__(self, status=200, body=None): self.status_code = status self._body = body if body is None: self.content = b"" self.text = "" elif isinstance(body, str): self.content = body.encode() self.text = body else: self.text = json.dumps(body) self.content = self.text.encode() def json(self): if isinstance(self._body, str): raise ValueError("not json") return self._body class FakeSession: """Routes (method, path) to canned responses and records every call.""" def __init__(self, routes=None, error=None): self.headers = {} self.routes = routes or {} self.error = error self.calls = [] def request(self, method, url, params=None, json=None, timeout=None, verify=None): path = url.split("8384", 1)[-1] self.calls.append((method, path, params, json)) if self.error is not None: raise self.error route = self.routes.get((method, path)) if callable(route): return route(params, json) return route if route is not None else _Response(404, "404 page not found") def _client(routes=None, error=None): session = FakeSession(routes, error) return st.SyncthingClient(st.Endpoint("127.0.0.1:8384", "KEY"), session), session # ---- endpoint discovery ---- CONFIG_XML = """
{address}
{key}
""" def _write_config(tmp_path, address="127.0.0.1:8384", key="abc", tls="false"): (tmp_path / "config.xml").write_text( CONFIG_XML.format(address=address, key=key, tls=tls)) return tmp_path class TestEndpoint: def test_reads_config_xml(self, tmp_path): endpoint = st.read_gui_config(_write_config(tmp_path)) assert endpoint == st.Endpoint("127.0.0.1:8384", "abc", False) assert endpoint.base_url == "http://127.0.0.1:8384" def test_any_address_means_loopback(self, tmp_path): endpoint = st.read_gui_config( _write_config(tmp_path, address="0.0.0.0:9999", tls="true")) assert endpoint.address == "127.0.0.1:9999" assert endpoint.base_url.startswith("https://") def test_no_key_is_none(self, tmp_path): assert st.read_gui_config(_write_config(tmp_path, key="")) is None assert st.read_gui_config(tmp_path / "nowhere") is None def test_config_json_overrides(self, tmp_path, monkeypatch): monkeypatch.setattr(st, "config_dir", lambda: _write_config(tmp_path)) assert st.resolve_endpoint({}).api_key == "abc" endpoint = st.resolve_endpoint({st.API_KEY_KEY: "mine", st.ADDRESS_KEY: "127.0.0.1:1"}) assert (endpoint.api_key, endpoint.address) == ("mine", "127.0.0.1:1") def test_no_key_anywhere(self, monkeypatch): monkeypatch.setattr(st, "config_dir", lambda: None) assert st.resolve_endpoint({}) is None # ---- the client ---- class TestClient: def test_sends_the_key(self): client, session = _client({("GET", "/rest/system/version"): _Response(body={"version": "v1.30.0"})}) assert client.version() == "v1.30.0" assert session.headers["X-API-Key"] == "KEY" def test_not_running(self): client, _ = _client(error=requests.ConnectionError("refused")) with pytest.raises(st.SyncthingError) as info: client.version() assert info.value.kind == st.NOT_RUNNING assert info.value.step == "Checking Syncthing's version" def test_bad_key(self): client, _ = _client({("GET", "/rest/system/version"): _Response(403, "CSRF")}) with pytest.raises(st.SyncthingError) as info: client.version() assert info.value.kind == st.BAD_KEY def test_syncthing_said_no_is_quoted(self): client, _ = _client({("PUT", "/rest/config/devices/DEV"): _Response(400, "invalid device ID")}) with pytest.raises(st.SyncthingError) as info: client.add_device("DEV", "Sam") assert info.value.kind == st.OTHER assert "Sam" in info.value.step assert info.value.detail == "invalid device ID" def test_missing_folder_is_none(self): client, _ = _client() assert client.folder("nope") is None def test_share_folder_is_idempotent(self): folder = {"id": "f", "devices": [{"deviceID": "ME"}]} patched = [] routes = { ("GET", "/rest/config/folders/f"): lambda p, j: _Response(body=folder), ("PATCH", "/rest/config/folders/f"): lambda p, j: (patched.append(j), folder.update(j), _Response())[-1], } client, _ = _client(routes) client.share_folder("f", "SAM") client.share_folder("f", "SAM") assert len(patched) == 1 assert [d["deviceID"] for d in folder["devices"]] == ["ME", "SAM"] def test_add_folder_body(self): client, session = _client({("PUT", "/rest/config/folders/x"): _Response()}) client.add_folder("x", "Cassette", "/tmp/x", "sendonly", ["SAM"]) _, _, _, body = session.calls[-1] assert body["type"] == "sendonly" assert body["devices"] == [{"deviceID": "SAM"}] assert body["path"] == "/tmp/x" def test_min_disk_free_falls_back_to_defaults(self): client, _ = _client({("GET", "/rest/config/defaults/folder"): _Response(body={"minDiskFree": {"value": 1, "unit": "%"}})}) assert client.min_disk_free("missing") == (1.0, "%") def test_disk_floor_bytes(self): assert st.disk_floor_bytes(1, "%", 1000) == 10 assert st.disk_floor_bytes(2, "GB", 0) == 2 * 10**9 assert st.disk_floor_bytes(1, "GiB", 0) == 2**30 # ---- probe ---- class TestProbe: def test_ok(self, monkeypatch): monkeypatch.setattr(st, "config_dir", lambda: None) session = FakeSession({ ("GET", "/rest/system/version"): _Response(body={"version": "v1.30.0"}), ("GET", "/rest/system/status"): _Response(body={"myID": "AAA"}), }) result = probe_mod.probe({st.API_KEY_KEY: "k"}, session=session) assert result.ok and result.version == "v1.30.0" and result.my_id == "AAA" def test_no_key(self, monkeypatch): monkeypatch.setattr(st, "config_dir", lambda: None) assert probe_mod.probe({}).state == probe_mod.NO_API def test_not_running(self, monkeypatch): monkeypatch.setattr(st, "config_dir", lambda: None) session = FakeSession(error=requests.ConnectionError("refused")) result = probe_mod.probe({st.API_KEY_KEY: "k"}, session=session) assert result.state == probe_mod.NOT_RUNNING # ---- state ---- class TestState: def test_round_trip(self, tmp_path): state = CassetteState(display_name="trav", share_same_for_all=True) state.general = Selection(all_library=True, playlist_ids={"P1"}) state.invites["t1"] = Invite(token="t1", created="2026-09-28") state.friends["t2"] = Friend(token="t2", device_id="DEV", name="Sam", selection=Selection(track_ids={3, 1}), cassetted=[5, 6]) save_state(state, tmp_path) loaded = load_state(tmp_path) assert loaded.to_dict() == state.to_dict() assert loaded.friends["t2"].selection.track_ids == {1, 3} assert loaded.selection_for(loaded.friends["t2"]) is loaded.general def test_missing_or_broken_is_fresh(self, tmp_path): assert load_state(tmp_path).friends == {} (tmp_path / "state.json").write_text("{nope") assert load_state(tmp_path).invites == {} def test_copy_is_deep(self): state = CassetteState() state.general.track_ids.add(1) clone = state.copy() clone.general.track_ids.add(2) assert state.general.track_ids == {1} # ---- host ---- @pytest.fixture def isolated_config(tmp_path, monkeypatch): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) return tmp_path class TestHost: def test_unclaimed_then_claimed(self, qapp, isolated_config): prefs = Preferences(isolated_config / "data") assert host.host_status(prefs)[0] == host.UNCLAIMED host.claim(prefs) assert host.host_status(prefs) == (host.HERE, host.hostname()) assert prefs.get("cassette_host")["hostname"] == host.hostname() def test_elsewhere_names_the_host(self, qapp, isolated_config): prefs = Preferences(isolated_config / "data") prefs.set("cassette_host", {"machine_id": "other", "hostname": "tummult"}) assert host.host_status(prefs) == (host.ELSEWHERE, "tummult") assert not host.is_host(prefs) # ---- the dialog ---- class _NoProbe: """Stands in for SyncthingProbe: never touches a socket.""" def __init__(self): from PyQt6.QtCore import QObject, pyqtSignal class _Sig(QObject): finished = pyqtSignal(object) self._sig = _Sig() self.finished = self._sig.finished self.starts = [] def start(self, config=None): self.starts.append(config) class TestSyncSettings: def _dialog(self, tmp_path, state=None, config=None): from lintunes.gui.sync_settings_dialog import SyncSettingsDialog return SyncSettingsDialog(state or CassetteState(display_name="trav"), tmp_path, config or {}, probe=_NoProbe()) def test_general_first_then_friends(self, qapp, tmp_path): state = CassetteState() state.friends["b"] = Friend(token="b", device_id="B", name="zed") state.friends["a"] = Friend(token="a", device_id="A", name="Amy") dialog = self._dialog(tmp_path, state) labels = [dialog.pages_list.item(i).text() for i in range(dialog.pages_list.count())] assert labels == ["General", "Amy", "zed"] def test_cancel_changes_nothing(self, qapp, tmp_path): original = CassetteState(display_name="trav") dialog = self._dialog(tmp_path, original) dialog.general.display_name.setText("someone else") dialog.reject() assert original.display_name == "trav" def test_ok_takes_the_name(self, qapp, tmp_path): dialog = self._dialog(tmp_path) dialog.general.display_name.setText(" T ") dialog._accept() assert dialog.state.display_name == "T" def test_status_text_always_has_a_next_step(self): from lintunes.gui.sync_settings_dialog import status_text for state in (probe_mod.NOT_RUNNING, probe_mod.NO_API): headline, hint = status_text(probe_mod.ProbeResult(state)) assert headline and hint assert "Retry" in status_text(probe_mod.ProbeResult(probe_mod.NOT_RUNNING))[1] assert status_text(probe_mod.ProbeResult(probe_mod.OK, version="v1"))[0] \ == "Syncthing v1 is running." def test_key_row_only_when_the_api_is_the_problem(self, qapp, tmp_path): dialog = self._dialog(tmp_path) dialog.general.show_probe(probe_mod.ProbeResult(probe_mod.NO_API)) assert not dialog.general._key_row.isHidden() dialog.general.show_probe(probe_mod.ProbeResult(probe_mod.OK, version="v")) assert dialog.general._key_row.isHidden() assert dialog.general._retry.isHidden() def test_api_key_change_goes_to_config(self, qapp, tmp_path): dialog = self._dialog(tmp_path, config={st.API_KEY_KEY: "old"}) assert dialog.config_changes() == {} dialog.general.api_key.setText("new") assert dialog.config_changes() == {st.API_KEY_KEY: "new"} def test_avatar_is_cropped_square_and_written_on_apply(self, qapp, tmp_path): from lintunes.gui.sync_settings_dialog import AVATAR_PX, load_avatar_image wide = QImage(400, 200, QImage.Format.Format_RGB32) wide.fill(QColor("red")) source = tmp_path / "me.png" wide.save(str(source)) image = load_avatar_image(source) assert (image.width(), image.height()) == (AVATAR_PX, AVATAR_PX) assert load_avatar_image(tmp_path / "missing.png") is None root = tmp_path / "root" dialog = self._dialog(root) dialog.general.new_avatar = image dialog._accept() dialog.apply() assert dialog.state.avatar == "avatar.png" assert not QImage(str(root / "avatar.png")).isNull() class TestMenu: def test_other_machine_greys_the_group_and_says_where(self, qapp, isolated_config): from PyQt6.QtWidgets import QMainWindow, QMenu from lintunes.gui.cassette_ui import CassetteUi prefs = Preferences(isolated_config / "data") window = QMainWindow() ui = CassetteUi(window, prefs) menu = QMenu() ui.build_menu(menu) ui.refresh_menu() assert all(a.isEnabled() for a in ui._actions) assert not ui._elsewhere_action.isVisible() prefs.set("cassette_host", {"machine_id": "other", "hostname": "tummult"}) ui.refresh_menu() assert not any(a.isEnabled() for a in ui._actions) assert ui._elsewhere_action.isVisible() assert "tummult" in ui._elsewhere_action.text()