"""Tests for watcher event loop logic.""" from unittest.mock import AsyncMock, patch import pytest from watcher.event_loop import ( extract_nc_file_path, is_audio_file, is_band_audio_path, normalize_nc_path, poll_once, ) def test_is_audio_file_matches_extensions(): extensions = [".wav", ".mp3", ".flac"] assert is_audio_file("/bands/foo/songs/bar/take1.wav", extensions) assert is_audio_file("/bands/foo/songs/bar/take1.MP3", extensions) assert not is_audio_file("/bands/foo/songs/bar/cover.jpg", extensions) assert not is_audio_file("/bands/foo/songs/bar/notes.txt", extensions) def test_is_band_audio_path(): assert is_band_audio_path("/bands/myband/songs/mysong/take.wav") assert is_band_audio_path("bands/slug/songs/") assert not is_band_audio_path("/nextcloud/files/random.wav") assert not is_band_audio_path("/") def test_normalize_nc_path_strips_dav_prefix(): assert normalize_nc_path("remote.php/dav/files/ncadmin/bands/myband/take.wav", "ncadmin") == "bands/myband/take.wav" def test_normalize_nc_path_strips_user_files_prefix(): assert normalize_nc_path("ncadmin/files/bands/myband/take.wav", "ncadmin") == "bands/myband/take.wav" def test_normalize_nc_path_strips_files_prefix(): assert normalize_nc_path("files/bands/myband/take.wav", "ncadmin") == "bands/myband/take.wav" def test_normalize_nc_path_already_relative(): assert normalize_nc_path("bands/myband/take.wav", "ncadmin") == "bands/myband/take.wav" def test_extract_nc_file_path_from_objects(): activity = {"objects": {"42": "/bands/foo/songs/bar/take.wav"}} path = extract_nc_file_path(activity) assert path == "/bands/foo/songs/bar/take.wav" def test_extract_nc_file_path_from_object_name(): activity = {"objects": {}, "object_name": "/bands/foo/songs/bar/take.wav"} path = extract_nc_file_path(activity) assert path == "/bands/foo/songs/bar/take.wav" def test_extract_nc_file_path_returns_none_when_missing(): activity = {"objects": {}} path = extract_nc_file_path(activity) assert path is None def _make_nc_mock(username: str = "admin") -> AsyncMock: """Return a properly configured NextcloudWatcherClient mock.""" from watcher.nc_client import NextcloudWatcherClient nc = AsyncMock(spec=NextcloudWatcherClient) nc.username = username return nc # ── poll_once: NC 22+ style (type field) ────────────────────────────────────── @pytest.mark.asyncio async def test_poll_once_ignores_non_audio_files(settings): nc = _make_nc_mock() nc.get_activities.return_value = [ { "activity_id": 1, "type": "file_created", "subject": "created_self", "objects": {"1": "bands/foo/songs/bar/image.jpg"}, } ] with patch("watcher.event_loop.register_version_with_api") as mock_register: await poll_once(nc, settings) mock_register.assert_not_called() @pytest.mark.asyncio async def test_poll_once_registers_audio_upload_new_nc_style(settings): nc = _make_nc_mock() nc.get_activities.return_value = [ { "activity_id": 5, "type": "file_created", "subject": "created_self", "objects": {"10": "bands/myband/songs/mysong/take1.wav"}, } ] nc.get_file_etag.return_value = "abc123" with patch( "watcher.event_loop.register_version_with_api", new_callable=AsyncMock, return_value=True ) as mock_register: await poll_once(nc, settings) mock_register.assert_called_once_with( "bands/myband/songs/mysong/take1.wav", "abc123", settings.api_url, ) @pytest.mark.asyncio async def test_poll_once_registers_audio_upload_old_nc_style(settings): """Old NC: type='files', subject='created_self' (machine key).""" nc = _make_nc_mock() nc.get_activities.return_value = [ { "activity_id": 7, "type": "files", "subject": "created_self", "objects": {"11": "bands/myband/231015/take1.wav"}, } ] nc.get_file_etag.return_value = "etag999" with patch( "watcher.event_loop.register_version_with_api", new_callable=AsyncMock, return_value=True ) as mock_register: await poll_once(nc, settings) mock_register.assert_called_once_with( "bands/myband/231015/take1.wav", "etag999", settings.api_url, ) @pytest.mark.asyncio async def test_poll_once_ignores_non_file_events(settings): nc = _make_nc_mock() nc.get_activities.return_value = [ { "activity_id": 2, "type": "shared_with_user", "subject": "shared_with_you", "objects": {"5": "bands/foo/songs/bar/take.wav"}, } ] with patch("watcher.event_loop.register_version_with_api") as mock_register: await poll_once(nc, settings) mock_register.assert_not_called() @pytest.mark.asyncio async def test_poll_once_ignores_non_band_audio_files(settings): nc = _make_nc_mock() nc.get_activities.return_value = [ { "activity_id": 3, "type": "file_created", "subject": "created_self", "objects": {"6": "Music/personal/track.mp3"}, } ] with patch("watcher.event_loop.register_version_with_api") as mock_register: await poll_once(nc, settings) mock_register.assert_not_called() @pytest.mark.asyncio async def test_poll_once_empty_activities_does_nothing(settings): nc = _make_nc_mock() nc.get_activities.return_value = [] with patch("watcher.event_loop.register_version_with_api") as mock_register: await poll_once(nc, settings) mock_register.assert_not_called() @pytest.mark.asyncio async def test_poll_once_advances_cursor(settings): nc = _make_nc_mock() nc.get_activities.return_value = [ { "activity_id": 10, "type": "file_created", "subject": "created_self", "objects": {"1": "bands/band/cover.jpg"}, # non-audio → skipped }, { "activity_id": 20, "type": "file_created", "subject": "created_self", "objects": {"2": "bands/band/doc.pdf"}, # non-audio → skipped }, ] import watcher.event_loop as el el._last_activity_id = 0 with patch("watcher.event_loop.register_version_with_api"): await poll_once(nc, settings) # Cursor should advance to the highest seen activity_id assert el._last_activity_id == 20