import dataclasses import os import socket import subprocess import sys import time import httpx import pytest def pytest_collection_modifyitems(config, items): """Skip tests marked 'live' unless ROOM_CAL_LIVE_TESTS=1. Live tests hit real service APIs and need credentials that dev systems only sometimes have (especially for 2FA-gated backends). """ if os.environ.get('ROOM_CAL_LIVE_TESTS'): return skip_live = pytest.mark.skip(reason='live service-api test; set ROOM_CAL_LIVE_TESTS=1 to run') for item in items: if 'live' in item.keywords: item.add_marker(skip_live) @pytest.fixture(autouse=True) def _reset_service_api_singleton(): """Drop the ServiceAPI singleton after each test so stubs and creds can't leak.""" yield import room_cal.service_api.client as _client _client._instance = None def _free_port() -> int: with socket.socket() as s: s.bind(('127.0.0.1', 0)) return s.getsockname()[1] @pytest.fixture(scope='session') def radicale_server(tmp_path_factory): """Start a local Radicale CalDAV server for the test session. auth.type = none accepts any credentials; the username in the HTTP auth still determines the path prefix (http://host/{username}/), so we always use username='test'. The 'House' calendar is created on first use; any test that writes to it cleans up in its own finally block. """ port = _free_port() data_dir = tmp_path_factory.mktemp('radicale') config_ini = data_dir / 'config.ini' config_ini.write_text('\n'.join([ '[server]', f'hosts = 127.0.0.1:{port}', '[auth]', 'type = none', '[storage]', f'filesystem_folder = {data_dir / "collections"}', ])) proc = subprocess.Popen( [sys.executable, '-m', 'radicale', '--config', str(config_ini)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) url = f'http://127.0.0.1:{port}/' for _ in range(50): try: httpx.get(url, timeout=1.0) break except Exception: time.sleep(0.1) else: proc.kill() pytest.fail('Radicale did not start within 5 seconds') import caldav client = caldav.DAVClient(url=url, username='test', password='test') principal = client.principal() principal.make_calendar(name='House', cal_id='house') yield {'url': url, 'username': 'test', 'password': 'test', 'calendar_name': 'House'} proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() @pytest.fixture def require_user_cal(radicale_server, monkeypatch): """Redirect user_cal to the local Radicale server instead of skipping. Patches get_config in the two modules that call it on the user_cal path: - room_cal.user_cal (UserCal.__init__) - room_cal.actions.fetch_user_cal (calendar_name lookup) """ from room_cal._utils import get_config cfg = get_config() test_user_cal_cfg = dataclasses.replace( cfg.user_cal, url=radicale_server['url'], username=radicale_server['username'], password=radicale_server['password'], calendar_name=radicale_server['calendar_name'], ) test_cfg = dataclasses.replace(cfg, user_cal=test_user_cal_cfg) mock = lambda: test_cfg monkeypatch.setattr('room_cal.user_cal.get_config', mock) monkeypatch.setattr('room_cal.actions.fetch_user_cal.get_config', mock) # reset the memoized user_cal Calendar so it's rebuilt from the patched # config (and restored after the test) monkeypatch.setattr('room_cal.user_cal._user_calendar', None)