diff --git a/tests/scrobbles_tests/conftest.py b/tests/scrobbles_tests/conftest.py index 97e4744..c84e355 100644 --- a/tests/scrobbles_tests/conftest.py +++ b/tests/scrobbles_tests/conftest.py @@ -23,7 +23,7 @@ def boardgame_scrobble(): log={ "players": [ {"person_id": first.id, "win": True, "score": 30, "color": "Blue"}, - {"person_id": second.id, "win": False, "score": 28, "color": "Red"} + {"person_id": second.id, "win": False, "score": 28, "color": "Red"}, ], }, ) @@ -58,9 +58,7 @@ class MopidyRequest: "artist": kwargs.get("artist", self.artist), "album": kwargs.get("album", self.album), "track_number": int(kwargs.get("track_number", self.track_number)), - "run_time_ticks": int( - kwargs.get("run_time_ticks", self.run_time_ticks) - ), + "run_time_ticks": int(kwargs.get("run_time_ticks", self.run_time_ticks)), "run_time": int(kwargs.get("run_time", self.run_time)), "playback_time_ticks": int( kwargs.get("playback_time_ticks", self.playback_time_ticks) @@ -103,9 +101,7 @@ def mopidy_track(): @pytest.fixture def mopidy_track_diff_album_request_data(**kwargs): mb_album_id = "0c56c457-afe1-4679-baab-759ba8dd2a58" - return MopidyRequest( - album="Gold", musicbrainz_album_id=mb_album_id - ).request_json + return MopidyRequest(album="Gold", musicbrainz_album_id=mb_album_id).request_json @pytest.fixture @@ -115,6 +111,7 @@ def mopidy_podcast_request_data(): mopidy_uri=mopidy_uri, artist="NPR", album="Up First" ).request_json + @pytest.fixture def mopidy_podcast_https_request_data(): mopidy_uri = "podcast+https://feeds.npr.org/510318/podcast.xml#85b9c4c4-ae09-43d9-8853-31ccf43f68e6" @@ -122,6 +119,7 @@ def mopidy_podcast_https_request_data(): mopidy_uri=mopidy_uri, artist="NPR", album="Up First" ).request_json + class JellyfinTrackRequest: name = "Emotion" artist = "Carly Rae Jepsen" @@ -136,6 +134,7 @@ class JellyfinTrackRequest: musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d" musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0" status = "resumed" + client_name = "Jellyfin" def __init__(self, **kwargs): self.request_data = { @@ -159,6 +158,7 @@ class JellyfinTrackRequest: "musicbrainz_artist_id", self.musicbrainz_artist_id ), "Status": kwargs.get("status", self.status), + "ClientName": kwargs.get("client_name", self.client_name), } def __eq__(self, other): diff --git a/tests/scrobbles_tests/test_views.py b/tests/scrobbles_tests/test_views.py index d95143c..b15bd7d 100644 --- a/tests/scrobbles_tests/test_views.py +++ b/tests/scrobbles_tests/test_views.py @@ -1,11 +1,11 @@ from datetime import datetime, timedelta -from unittest.mock import patch +from unittest.mock import patch, MagicMock from django.utils import timezone import pytest import time_machine from django.urls import reverse -from music.models import Track +from music.models import Track, Artist, Album from podcasts.models import PodcastEpisode from scrobbles.models import Scrobble @@ -18,6 +18,14 @@ def test_get_not_allowed_from_mopidy(client, valid_auth_token): assert response.status_code == 405 +@pytest.mark.django_db +def test_get_not_allowed_from_jellyfin(client, valid_auth_token): + url = reverse("scrobbles:jellyfin-webhook") + headers = {"Authorization": f"Token {valid_auth_token}"} + response = client.get(url, headers=headers) + assert response.status_code == 405 + + @pytest.mark.django_db def test_bad_mopidy_request_data(client, valid_auth_token): url = reverse("scrobbles:mopidy-webhook") @@ -30,6 +38,172 @@ def test_bad_mopidy_request_data(client, valid_auth_token): ) +@pytest.mark.django_db +def test_bad_jellyfin_request_data(client, valid_auth_token): + url = reverse("scrobbles:jellyfin-webhook") + headers = {"Authorization": f"Token {valid_auth_token}"} + response = client.post(url, headers) + assert response.status_code == 400 + assert ( + response.data["detail"] + == "JSON parse error - Expecting value: line 1 column 1 (char 0)" + ) + + +@pytest.mark.django_db +@patch("music.models.Artist.find_or_create") +@patch("music.models.Track.find_or_create") +def test_create_scrobble_from_mopidy_track_webhook( + mock_track_fc, + mock_artist_fc, + client, + valid_auth_token, + mopidy_track, +): + mock_artist = MagicMock(spec=Artist) + mock_artist.id = 1 + mock_artist_fc.return_value = mock_artist + + mock_track = MagicMock(spec=Track) + mock_track.id = 1 + mock_track.scrobble_for_user.return_value = Scrobble( + id=1, track_id=1, user_id=1, in_progress=True + ) + mock_track_fc.return_value = mock_track + + url = reverse("scrobbles:mopidy-webhook") + headers = {"Authorization": f"Token {valid_auth_token}"} + response = client.post( + url, + mopidy_track.request_data, + content_type="application/json", + headers=headers, + ) + assert response.status_code == 200 + assert response.data == {"scrobble_id": 1} + mock_track.scrobble_for_user.assert_called_once() + + +@pytest.mark.django_db +@patch("music.models.Artist.find_or_create") +@patch("music.models.Track.find_or_create") +def test_create_scrobble_from_jellyfin_track_webhook( + mock_track_fc, + mock_artist_fc, + client, + valid_auth_token, + jellyfin_track, +): + mock_artist = MagicMock(spec=Artist) + mock_artist.id = 1 + mock_artist_fc.return_value = mock_artist + + mock_track = MagicMock(spec=Track) + mock_track.id = 1 + mock_track.scrobble_for_user.return_value = Scrobble( + id=1, track_id=1, user_id=1, in_progress=True + ) + mock_track_fc.return_value = mock_track + + url = reverse("scrobbles:jellyfin-webhook") + headers = {"Authorization": f"Token {valid_auth_token}"} + + with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)): + jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime( + "%Y-%m-%d %H:%M:%S" + ) + response = client.post( + url, + jellyfin_track.request_json, + content_type="application/json", + headers=headers, + ) + assert response.status_code == 200 + assert response.data == {"scrobble_id": 1} + mock_track.scrobble_for_user.assert_called_once() + + +@pytest.mark.django_db +@patch("music.models.Artist.find_or_create") +@patch("music.models.Track.find_or_create") +def test_mopidy_track_webhook_creates_track_and_scrobble( + mock_track_fc, + mock_artist_fc, + client, + valid_auth_token, + mopidy_track, +): + artist = Artist.objects.create(name="Sublime") + album = Album.objects.create(name="Sublime", album_artist=artist) + track = Track.objects.create( + title="Same in the End", + artist=artist, + base_run_time_seconds=60, + ) + track.albums.add(album) + + mock_artist_fc.return_value = artist + mock_track_fc.return_value = track + + url = reverse("scrobbles:mopidy-webhook") + headers = {"Authorization": f"Token {valid_auth_token}"} + response = client.post( + url, + mopidy_track.request_data, + content_type="application/json", + headers=headers, + ) + assert response.status_code == 200 + assert response.data == {"scrobble_id": 1} + + scrobble = Scrobble.objects.get(id=1) + assert scrobble.track == track + assert scrobble.source == "Mopidy" + + +@pytest.mark.django_db +@patch("music.models.Artist.find_or_create") +@patch("music.models.Track.find_or_create") +def test_jellyfin_track_webhook_creates_track_and_scrobble( + mock_track_fc, + mock_artist_fc, + client, + valid_auth_token, + jellyfin_track, +): + artist = Artist.objects.create(name="Carly Rae Jepsen") + album = Album.objects.create(name="Emotion", album_artist=artist) + track = Track.objects.create( + title="Emotion", + artist=artist, + base_run_time_seconds=60, + ) + track.albums.add(album) + + mock_artist_fc.return_value = artist + mock_track_fc.return_value = track + + url = reverse("scrobbles:jellyfin-webhook") + headers = {"Authorization": f"Token {valid_auth_token}"} + + with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)): + jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime( + "%Y-%m-%d %H:%M:%S" + ) + response = client.post( + url, + jellyfin_track.request_json, + content_type="application/json", + headers=headers, + ) + assert response.status_code == 200 + assert response.data == {"scrobble_id": 1} + + scrobble = Scrobble.objects.get(id=1) + assert scrobble.track == track + assert scrobble.source == "Jellyfin" + + @pytest.mark.skip("Need to refactor") @pytest.mark.django_db @patch("music.utils.lookup_artist_from_mb", return_value={}) diff --git a/vrobbler/apps/books/tests/test_koreader.py b/vrobbler/apps/books/tests/test_koreader.py index f55b5a6..76988ba 100644 --- a/vrobbler/apps/books/tests/test_koreader.py +++ b/vrobbler/apps/books/tests/test_koreader.py @@ -1,11 +1,16 @@ import pytest from unittest import mock +from datetime import datetime from books.koreader import ( KoReaderBookColumn, build_book_map, build_page_data, build_scrobbles_from_book_map, + get_author_str_from_row, + lookup_or_create_authors_from_author_str, + create_book_from_row, + process_koreader_sqlite_file, ) @@ -52,3 +57,157 @@ def test_build_scrobbles_from_pages( assert len(scrobbles[3].logdata.page_data.keys()) == 20 assert len(scrobbles[4].logdata.page_data.keys()) == 20 assert len(scrobbles[5].logdata.page_data.keys()) == 18 + + +def test_get_author_str_from_row(): + row = [ + 1, + "Test Book", + "John Smith\nJane Doe", + 0, + 0, + 0, + 300, + "", + "", + "abc123", + 0, + 120, + ] + result = get_author_str_from_row(row) + assert result == "John Smith, Jane Doe" + + +def test_get_author_str_from_row_strips_middle_initials(): + row = [ + 1, + "Test Book", + "John A. Smith", + 0, + 0, + 0, + 300, + "", + "", + "abc123", + 0, + 120, + ] + result = get_author_str_from_row(row) + assert result == "John Smith" + + +@pytest.mark.django_db +def test_lookup_or_create_authors_from_author_str_creates_new_author(): + author = lookup_or_create_authors_from_author_str("New Author") + assert len(author) == 1 + assert author[0].name == "New Author" + + +@pytest.mark.django_db +def test_lookup_or_create_authors_from_author_str_finds_existing(): + from books.models import Author + + Author.objects.create(name="Existing Author") + author = lookup_or_create_authors_from_author_str("Existing Author") + assert len(author) == 1 + assert author[0].name == "Existing Author" + + +@pytest.mark.django_db +def test_lookup_or_create_authors_from_author_str_ignores_na(): + author = lookup_or_create_authors_from_author_str("N/A") + assert len(author) == 0 + + +@pytest.mark.django_db +def test_lookup_or_create_authors_from_author_str_handles_multiple(): + author = lookup_or_create_authors_from_author_str("Author One, Author Two") + assert len(author) == 2 + + +@pytest.mark.django_db +def test_create_book_from_row(): + row = [ + 1, + "Test Book - Author Name", + "Test Author", + 0, + 0, + 0, + 300, + "", + "", + "abc123def456", + 3600, + 120, + ] + book = create_book_from_row(row) + assert book.title == "Test Book" + assert book.pages == 300 + assert "abc123def456" in book.koreader_data_by_hash + assert book.authors.count() == 1 + + +@pytest.mark.django_db +def test_create_book_from_row_handles_title_with_author_suffix(): + row = [ + 1, + "Test Book - Author_Name", + "N/A", + 0, + 0, + 0, + 300, + "", + "", + "abc123def456", + 3600, + 120, + ] + book = create_book_from_row(row) + assert book.title == "Test Book" + assert book.authors.count() == 1 + assert book.authors.first().name == "Author" + + +@pytest.mark.django_db +def test_create_book_from_row_handles_null_bytes(): + row = [ + 1, + "Test\x00Book", + "Test\x00Author", + 0, + 0, + 0, + 300, + "", + "", + "abc123def456", + 3600, + 120, + ] + book = create_book_from_row(row) + assert "TestBook" in book.title + assert "TestAuthor" in book.authors.first().name + + +@pytest.mark.django_db +def test_build_book_map_ignores_garbage_titles(koreader_rows): + garbage_row = [ + 999, + "KOReader Quickstart Guide", + "N/A", + 0, + 0, + 0, + 0, + "", + "", + "garbage123", + 0, + 0, + ] + book_map = build_book_map(koreader_rows.BOOK_ROWS + [garbage_row]) + assert len(book_map) == 1 + assert 999 not in book_map