Watcher: - Accept both NC 22+ (type="file_created") and older NC (subject="created_self") so the upload filter works across all Nextcloud versions - Add .opus to audio_extensions - Fix tests: set nc.username on mocks, use realistic activity dicts with type field - Add tests for old NC style, non-band path filter, normalize_nc_path, cursor advance API: - Fix internal.py title extraction: always use filename stem (was using parts[-2] for >3-part paths, which gave folder name instead of song title) - nc-scan now returns NcScanResult with folder, files_found, imported, skipped counts instead of bare song list — gives the UI actionable feedback Web: - Show rich scan result message: folder scanned, count imported, count already registered Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
210 lines
6.6 KiB
Python
210 lines
6.6 KiB
Python
"""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
|