diff --git a/test/last_fm/import_test.exs b/test/last_fm/import_test.exs new file mode 100644 index 00000000..8e0b1ce2 --- /dev/null +++ b/test/last_fm/import_test.exs @@ -0,0 +1,50 @@ +defmodule LastFm.ImportTest do + use MusicLibrary.DataCase + + alias MusicLibrary.ListeningStats + + @recent_tracks_fixture Path.expand( + "../support/fixtures/last_fm/user.getrecenttracks.json", + __DIR__ + ) + + describe "batch/1" do + test "fetches recent tracks and persists them via ListeningStats" do + response = @recent_tracks_fixture |> File.read!() |> JSON.decode!() + + Req.Test.stub(LastFm.API, fn conn -> + assert URI.decode_query(conn.query_string)["method"] == "user.getrecenttracks" + + Req.Test.json(conn, response) + end) + + assert {:ok, count} = LastFm.Import.batch([]) + assert count > 0 + assert ListeningStats.scrobble_count() == count + end + + test "forwards limit and to_uts options to the Last.fm API" do + response = @recent_tracks_fixture |> File.read!() |> JSON.decode!() + + Req.Test.stub(LastFm.API, fn conn -> + params = URI.decode_query(conn.query_string) + assert params["limit"] == "50" + assert params["to"] == "1730600000" + + Req.Test.json(conn, response) + end) + + assert {:ok, _count} = LastFm.Import.batch(limit: 50, to_uts: 1_730_600_000) + end + + @tag :capture_log + test "returns {:error, reason} when the Last.fm API fails" do + Req.Test.stub(LastFm.API, fn conn -> + Req.Test.json(conn, %{"error" => 10, "message" => "Invalid API key"}) + end) + + assert {:error, :invalid_api_key} = LastFm.Import.batch([]) + assert ListeningStats.scrobble_count() == 0 + end + end +end diff --git a/test/music_library/error_ignorer_test.exs b/test/music_library/error_ignorer_test.exs new file mode 100644 index 00000000..1f875ac9 --- /dev/null +++ b/test/music_library/error_ignorer_test.exs @@ -0,0 +1,22 @@ +defmodule MusicLibrary.ErrorIgnorerTest do + use ExUnit.Case, async: true + + alias MusicLibrary.ErrorIgnorer + + describe "ignore?/2" do + test "ignores Phoenix.Router.NoRouteError" do + error = %ErrorTracker.Error{kind: "Elixir.Phoenix.Router.NoRouteError"} + assert ErrorIgnorer.ignore?(error, %{}) == true + end + + test "does not ignore other error kinds" do + error = %ErrorTracker.Error{kind: "Elixir.RuntimeError"} + assert ErrorIgnorer.ignore?(error, %{}) == false + end + + test "does not ignore Ecto.NoResultsError" do + error = %ErrorTracker.Error{kind: "Elixir.Ecto.NoResultsError"} + assert ErrorIgnorer.ignore?(error, %{}) == false + end + end +end diff --git a/test/music_library/worker/generate_record_embedding_test.exs b/test/music_library/worker/generate_record_embedding_test.exs new file mode 100644 index 00000000..14437221 --- /dev/null +++ b/test/music_library/worker/generate_record_embedding_test.exs @@ -0,0 +1,63 @@ +defmodule MusicLibrary.Worker.GenerateRecordEmbeddingTest do + use MusicLibrary.DataCase + + import MusicLibrary.Fixtures.Records + + alias MusicLibrary.Records + alias MusicLibrary.Records.Similarity + alias MusicLibrary.Worker.GenerateRecordEmbedding + + describe "perform/1" do + test "returns :ok and broadcasts update when embedding is generated" do + record = record() + + Req.Test.stub(OpenAI.API, fn conn -> + Req.Test.json(conn, %{"data" => [%{"embedding" => [0.1, 0.2, 0.3]}]}) + end) + + :ok = Records.subscribe(record.id) + + assert :ok = perform_job(GenerateRecordEmbedding, %{"record_id" => record.id}) + + assert_receive {:update, %Records.Record{id: id}} + assert id == record.id + + assert {:ok, _text} = Similarity.get_embedding_text(record.id) + end + + test "returns :ok without broadcasting when text representation is unchanged" do + record = record() + + {:ok, _} = + Similarity.store_embedding( + record.id, + SqliteVec.Float32.new([0.1, 0.2, 0.3]), + Similarity.text_representation(record) + ) + + :ok = Records.subscribe(record.id) + + assert :ok = perform_job(GenerateRecordEmbedding, %{"record_id" => record.id}) + + refute_receive {:update, _} + end + + @tag :capture_log + test "returns {:error, reason} when OpenAI API fails" do + record = record() + + Req.Test.stub(OpenAI.API, fn conn -> + Plug.Conn.send_resp(conn, 500, JSON.encode!(%{"error" => "internal server error"})) + end) + + assert {:error, _reason} = + perform_job(GenerateRecordEmbedding, %{"record_id" => record.id}) + end + + test "raises when record does not exist" do + assert_raise Ecto.NoResultsError, fn -> + perform_job(GenerateRecordEmbedding, %{"record_id" => Ecto.UUID.generate()}) + end + end + end +end diff --git a/test/music_library_web/live/maintenance_live/index_test.exs b/test/music_library_web/live/maintenance_live/index_test.exs new file mode 100644 index 00000000..75391c12 --- /dev/null +++ b/test/music_library_web/live/maintenance_live/index_test.exs @@ -0,0 +1,162 @@ +defmodule MusicLibraryWeb.MaintenanceLive.IndexTest do + use MusicLibraryWeb.ConnCase + use Oban.Testing, repo: MusicLibrary.BackgroundRepo + + import MusicLibrary.ArtistInfoFixtures + import MusicLibrary.Fixtures.Records + import Phoenix.LiveViewTest + + alias MusicLibrary.Secrets + + describe "Maintenance page" do + test "renders all sections and Last.fm status", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/maintenance") + + assert html =~ "Records" + assert html =~ "Artists" + assert html =~ "Database" + assert html =~ "Assets" + assert html =~ "Emails" + assert html =~ "Last.fm" + end + + test "async status resolves to :not_connected when no session key is stored", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/maintenance") + + assert render_async(view) =~ "Not connected" + end + + test "async status resolves to connected when session key is valid", %{conn: conn} do + {:ok, _} = Secrets.store("last_fm_session_key", "sk-xyz") + + Req.Test.stub(LastFm.API, fn conn -> + Req.Test.json(conn, %{"user" => %{"name" => "alice"}}) + end) + + {:ok, view, _html} = live(conn, ~p"/maintenance") + + assert render_async(view) =~ "Connected as alice" + end + end + + describe "records section" do + test "'Refresh MusicBrainz data' enqueues a RecordRefreshMusicBrainzData job per record", + %{conn: conn} do + r1 = record() + r2 = record() + + {:ok, view, _html} = live(conn, ~p"/maintenance") + + render_click(view, "refresh_records_musicbrainz_data") + + assert_enqueued( + worker: MusicLibrary.Worker.RecordRefreshMusicBrainzData, + args: %{"id" => r1.id} + ) + + assert_enqueued( + worker: MusicLibrary.Worker.RecordRefreshMusicBrainzData, + args: %{"id" => r2.id} + ) + + assert render(view) =~ "Operation started in the background." + end + + test "'Regenerate record embeddings' enqueues a GenerateRecordEmbedding job per record", + %{conn: conn} do + r1 = record() + + {:ok, view, _html} = live(conn, ~p"/maintenance") + + render_click(view, "generate_record_embeddings") + + assert_enqueued( + worker: MusicLibrary.Worker.GenerateRecordEmbedding, + args: %{"record_id" => r1.id} + ) + + assert render(view) =~ "Operation started in the background." + end + end + + describe "artists section" do + setup do + artist_info = artist_info_fixture() + %{artist_info: artist_info} + end + + for {event, worker} <- [ + {"refresh_artists_musicbrainz_data", MusicLibrary.Worker.ArtistRefreshMusicBrainzData}, + {"refresh_artists_discogs_data", MusicLibrary.Worker.ArtistRefreshDiscogsData}, + {"refresh_artists_wikipedia_data", MusicLibrary.Worker.ArtistRefreshWikipediaData}, + {"refresh_artists_lastfm_data", MusicLibrary.Worker.FetchArtistLastFmData} + ] do + test "'#{event}' enqueues a #{inspect(worker)} job per artist", %{ + conn: conn, + artist_info: artist_info + } do + {:ok, view, _html} = live(conn, ~p"/maintenance") + + render_click(view, unquote(event)) + + assert_enqueued(worker: unquote(worker), args: %{"id" => artist_info.id}) + + assert render(view) =~ "Operation started in the background." + end + end + end + + describe "database section" do + test "'Optimize' runs PRAGMA optimize and toasts success", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/maintenance") + + render_click(view, "db_optimize") + + assert render(view) =~ "Database optimized successfully." + end + end + + describe "assets section" do + test "'Prune asset cache' runs synchronously and reports the pruned count", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/maintenance") + + render_click(view, "prune_asset_cache") + + assert render(view) =~ "Pruned 0 cached assets." + end + + test "'Prune unreferenced assets' enqueues a PruneAssets job", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/maintenance") + + render_click(view, "prune_assets") + + assert_enqueued(worker: MusicLibrary.Worker.PruneAssets) + assert render(view) =~ "Asset pruning started in the background." + end + end + + describe "emails section" do + test "'Send records on this day' shows the :no_records toast when the collection is empty", + %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/maintenance") + + render_click(view, "send_records_on_this_day_email") + + assert render(view) =~ "No records on this day." + end + end + + describe "Last.fm section" do + @tag :capture_log + test "'Re-connect to Last.fm' deletes the stored session key and redirects externally", + %{conn: conn} do + {:ok, _} = Secrets.store("last_fm_session_key", "sk-xyz") + {:ok, view, _html} = live(conn, ~p"/maintenance") + + assert {:error, {:redirect, %{to: url}}} = render_click(view, "reconnect_lastfm") + + assert url == LastFm.auth_url() + assert Secrets.get("last_fm_session_key") == nil + end + end +end