From 90838d1a3fede9371389aa3efe45177a487b5afd Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Fri, 17 Apr 2026 07:01:07 +0100 Subject: [PATCH] Add tests for RecordActions and Chat component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all action handlers on LiveHelpers.RecordActions and all update/event clauses on Components.Chat — including streaming chunk/done/error transitions and the retry re-send path. Closes #179 --- .../components/chat_test.exs | 363 ++++++++++++++++++ .../live_helpers/record_actions_test.exs | 229 +++++++++++ 2 files changed, 592 insertions(+) create mode 100644 test/music_library_web/components/chat_test.exs create mode 100644 test/music_library_web/live_helpers/record_actions_test.exs diff --git a/test/music_library_web/components/chat_test.exs b/test/music_library_web/components/chat_test.exs new file mode 100644 index 00000000..f6c8ace7 --- /dev/null +++ b/test/music_library_web/components/chat_test.exs @@ -0,0 +1,363 @@ +defmodule MusicLibraryWeb.Components.ChatTest do + use MusicLibraryWeb.ConnCase, async: false + + import MusicLibrary.Fixtures.Records + import Phoenix.LiveViewTest + + alias MusicBrainz.Fixtures.Release, as: ReleaseFixtures + alias MusicBrainz.Fixtures.ReleaseGroup + alias MusicLibrary.Chats + alias MusicLibraryWeb.Components.Chat + + @component_id "record-chat" + + setup do + # Some tests intentionally trigger the streaming pipeline which calls + # `OpenAI.API`. A default stub that returns an empty SSE stream prevents + # Req.Test from raising inside the supervised Task process and keeps + # test output noise-free. Individual tests can override this as needed. + Req.Test.stub(OpenAI.API, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("text/event-stream") + |> Plug.Conn.send_resp( + 200, + ~s(data: {"type":"response.completed"}\n\n) + ) + end) + + :ok + end + + defp stub_musicbrainz_happy_path(release_group_id) do + release_group = ReleaseGroup.release_group(:marbles) + release = ReleaseFixtures.release(:marbles) + + Req.Test.stub(MusicBrainz.API, fn conn -> + cond do + conn.host == "coverartarchive.org" -> + Plug.Conn.send_resp(conn, 200, marbles_cover_data()) + + match?([_, _, "release-group", ^release_group_id], conn.path_info) -> + Req.Test.json(conn, release_group) + + match?([_, _, "release"], conn.path_info) -> + Req.Test.json(conn, ReleaseGroup.release_group_releases(:marbles)) + + match?([_, _, "release", _], conn.path_info) -> + Req.Test.json(conn, release) + + true -> + Req.Test.json(conn, %{}) + end + end) + end + + defp setup_record do + record = record() + stub_musicbrainz_happy_path(record.musicbrainz_id) + record + end + + defp mount_view(conn, record) do + {:ok, view, _html} = live(conn, ~p"/collection/#{record.id}") + view + end + + defp seed_chat(record, content \\ "Tell me about this album") do + {:ok, chat} = + Chats.create_chat_with_message( + %{entity: :record, musicbrainz_id: record.musicbrainz_id}, + %{role: "user", content: content} + ) + + chat + end + + defp send_component_update(view, updates) do + Phoenix.LiveView.send_update(view.pid, Chat, [id: @component_id] ++ updates) + end + + describe "update/2 streaming state transitions" do + test "chunk clause appends to the streaming doc", %{conn: conn} do + record = setup_record() + view = mount_view(conn, record) + + send_component_update(view, chunk: "Hello ") + send_component_update(view, chunk: "world") + html = render(view) + + assert html =~ "Hello world" + end + + test "done clause finalizes the assistant message and clears loading", %{conn: conn} do + record = setup_record() + # Seed the chat before mount so the component picks up `has_history` + # and we have a persisted `Chat` to write the assistant message to. + chat = seed_chat(record, "User prompt") + view = mount_view(conn, record) + + # Enter active view by selecting the seeded chat, so the active-view + # render path (where the streaming doc and messages render) is active. + view + |> element("button[phx-click='select_chat'][phx-value-id='#{chat.id}']") + |> render_click() + + # Drive the component directly into a mid-stream state, bypassing the + # Task.Supervisor streaming pipeline (which is exercised in the + # send_message test). + send_component_update(view, loading: true) + send_component_update(view, chunk: "Great album.") + send_component_update(view, done: true) + + html = render(view) + + refute html =~ "Thinking..." + assert html =~ "Great album." + + reloaded = Chats.get_chat!(chat.id) + assistant_messages = Enum.filter(reloaded.messages, &(&1.role == "assistant")) + assert [%{content: "Great album."}] = assistant_messages + end + + test "error clause surfaces the error and a retry button", %{conn: conn} do + record = setup_record() + view = mount_view(conn, record) + + send_component_update(view, error: "Something went wrong. Please try again.") + html = render(view) + + assert html =~ "Something went wrong. Please try again." + assert html =~ "phx-click=\"retry\"" + end + end + + describe "send_message event" do + @tag :capture_log + test "appends the user message and persists a chat row", %{conn: conn} do + record = setup_record() + view = mount_view(conn, record) + + view + |> form("##{@component_id}-form", %{"message" => "What is this album about?"}) + |> render_submit() + + html = render(view) + assert html =~ "What is this album about?" + + # A chat is persisted with the user message and derived topic. + assert [chat] = Chats.list_chats(:record, record.musicbrainz_id) + assert chat.topic == "What is this album about?" + end + + test "empty message submission is a no-op", %{conn: conn} do + record = setup_record() + view = mount_view(conn, record) + + view + |> form("##{@component_id}-form", %{"message" => ""}) + |> render_submit() + + assert Chats.list_chats(:record, record.musicbrainz_id) == [] + end + + @tag :capture_log + test "streaming error propagates to the component as user-facing text", %{conn: conn} do + record = setup_record() + + Req.Test.stub(OpenAI.API, fn conn -> + Plug.Conn.send_resp(conn, 500, JSON.encode!(%{"error" => "internal server error"})) + end) + + view = mount_view(conn, record) + + view + |> form("##{@component_id}-form", %{"message" => "Trigger an error"}) + |> render_submit() + + # Req.Test stubs respond synchronously, so the Task completes before + # we render again — the component has already processed `send_update` + # with `error:`. + assert render(view) =~ "Something went wrong. Please try again." + end + end + + describe "new_chat event" do + test "clears messages and switches to active view", %{conn: conn} do + record = setup_record() + chat = seed_chat(record) + view = mount_view(conn, record) + + # Initial view is :list because the component detected existing chats. + view + |> element("button[phx-click='select_chat'][phx-value-id='#{chat.id}']") + |> render_click() + + assert render(view) =~ "Tell me about this album" + + view + |> element("button[phx-click='new_chat']") + |> render_click() + + html = render(view) + # The empty-prompt copy from CollectionLive.Show is shown again. + assert html =~ "Ask anything about this album" + end + end + + describe "show_chat_list event" do + test "renders the persisted chats in the list view", %{conn: conn} do + record = setup_record() + seed_chat(record, "First prompt") + seed_chat(record, "Second prompt") + view = mount_view(conn, record) + + # Already on list view at mount, but exercise the event explicitly by + # selecting a chat then clicking the history button. + chat = List.first(Chats.list_chats(:record, record.musicbrainz_id)) + + view + |> element("button[phx-click='select_chat'][phx-value-id='#{chat.id}']") + |> render_click() + + view + |> element("button[phx-click='show_chat_list']") + |> render_click() + + html = render(view) + assert html =~ "First prompt" + assert html =~ "Second prompt" + assert html =~ "Chat history" + end + end + + describe "select_chat event" do + test "loads the selected chat's messages", %{conn: conn} do + record = setup_record() + chat = seed_chat(record, "Original question") + + {:ok, _assistant} = + Chats.add_message(chat, %{role: "assistant", content: "Original answer"}) + + view = mount_view(conn, record) + + view + |> element("button[phx-click='select_chat'][phx-value-id='#{chat.id}']") + |> render_click() + + html = render(view) + assert html =~ "Original question" + assert html =~ "Original answer" + end + end + + describe "delete_chat event" do + test "removes the active chat and clears its messages", %{conn: conn} do + record = setup_record() + chat = seed_chat(record, "Goodbye chat") + {:ok, _assistant} = Chats.add_message(chat, %{role: "assistant", content: "Some reply"}) + view = mount_view(conn, record) + + # Select the chat (transitions into active view), then re-open the list + # to reach the delete button (only rendered in list view). + view + |> element("button[phx-click='select_chat'][phx-value-id='#{chat.id}']") + |> render_click() + + view + |> element("button[phx-click='show_chat_list']") + |> render_click() + + view + |> element("button[phx-click='delete_chat'][phx-value-id='#{chat.id}']") + |> render_click() + + assert_raise Ecto.NoResultsError, fn -> Chats.get_chat!(chat.id) end + assert Chats.list_chats(:record, record.musicbrainz_id) == [] + + # The active-chat branch of `delete_chat` clears `chat` and `messages` + # — verify by starting a new chat and confirming the empty state, not + # the stale assistant reply. + view + |> element("button[phx-click='new_chat']") + |> render_click() + + html = render(view) + assert html =~ "Ask anything about this album" + refute html =~ "Some reply" + end + + test "deleting a non-active chat leaves the remaining chats in the list", %{conn: conn} do + record = setup_record() + _chat_a = seed_chat(record, "Keep me") + chat_b = seed_chat(record, "Delete me") + view = mount_view(conn, record) + + # Already on list view; delete chat B directly from the list. + view + |> element("button[phx-click='delete_chat'][phx-value-id='#{chat_b.id}']") + |> render_click() + + assert_raise Ecto.NoResultsError, fn -> Chats.get_chat!(chat_b.id) end + html = render(view) + assert html =~ "Keep me" + refute html =~ "Delete me" + end + end + + describe "retry event" do + test "clears the error when the last message is not from the user", %{conn: conn} do + record = setup_record() + view = mount_view(conn, record) + + send_component_update(view, + error: "Something went wrong. Please try again.", + messages: [] + ) + + assert render(view) =~ "Something went wrong. Please try again." + + view + |> element("button[phx-click='retry']") + |> render_click() + + refute render(view) =~ "Something went wrong. Please try again." + end + + @tag :capture_log + test "re-sends the last user message when retry is clicked", %{conn: conn} do + # Mount without pre-seeded chats so the component starts in :active + # view — the error UI and retry button only render in that view. + record = setup_record() + view = mount_view(conn, record) + + chat = seed_chat(record, "Resend me") + + # The regular-assigns clause of `update/2` and the error-clause pattern + # match on different keys — splitting the calls avoids the error clause + # consuming the whole map and dropping `chat`/`messages`. + send_component_update(view, + chat: chat, + messages: [%{role: "user", content: "Resend me"}] + ) + + send_component_update(view, error: "Something went wrong. Please try again.") + + assert render(view) =~ "Something went wrong. Please try again." + + view + |> element("button[phx-click='retry']") + |> render_click() + + html = render(view) + refute html =~ "Something went wrong. Please try again." + assert html =~ "Resend me" + + # `retry` drops the last user message, then `do_send_message/2` + # re-persists it via `Chats.add_message/2` — so the seeded chat + # contains two user rows with the same content. + reloaded = Chats.get_chat!(chat.id) + user_messages = Enum.filter(reloaded.messages, &(&1.role == "user")) + assert length(user_messages) == 2 + end + end +end diff --git a/test/music_library_web/live_helpers/record_actions_test.exs b/test/music_library_web/live_helpers/record_actions_test.exs new file mode 100644 index 00000000..d7465604 --- /dev/null +++ b/test/music_library_web/live_helpers/record_actions_test.exs @@ -0,0 +1,229 @@ +defmodule MusicLibraryWeb.LiveHelpers.RecordActionsTest do + use MusicLibraryWeb.ConnCase, async: true + use Oban.Testing, repo: MusicLibrary.BackgroundRepo + + import MusicLibrary.Fixtures.Records + import Phoenix.LiveViewTest + + alias MusicBrainz.Fixtures.Release, as: ReleaseFixtures + alias MusicBrainz.Fixtures.ReleaseGroup + alias MusicLibrary.Chats + alias MusicLibrary.Records + alias MusicLibrary.Records.Similarity + + # A MusicBrainz stub that returns valid fixture responses for every route + # the Collection Show page touches during `handle_params` and via async + # components (Release tracklist). Tests override specific behaviour by + # re-stubbing before triggering events. + defp stub_musicbrainz_happy_path(release_group_id, opts \\ []) do + release_group = ReleaseGroup.release_group(:marbles) + release = ReleaseFixtures.release(:marbles) + cover_data = Keyword.get(opts, :cover_data, marbles_cover_data()) + + Req.Test.stub(MusicBrainz.API, fn conn -> + cond do + # Cover art archive returns raw image bytes + conn.host == "coverartarchive.org" -> + Plug.Conn.send_resp(conn, 200, cover_data) + + match?([_, _, "release-group", ^release_group_id], conn.path_info) -> + Req.Test.json(conn, release_group) + + match?([_, _, "release"], conn.path_info) -> + Req.Test.json(conn, ReleaseGroup.release_group_releases(:marbles)) + + match?([_, _, "release", _], conn.path_info) -> + Req.Test.json(conn, release) + + true -> + Req.Test.json(conn, %{}) + end + end) + end + + describe "refresh_musicbrainz_data event" do + test "success path shows confirmation toast", %{conn: conn} do + record = record() + stub_musicbrainz_happy_path(record.musicbrainz_id) + + {:ok, view, _html} = live(conn, ~p"/collection/#{record.id}") + render_click(view, "refresh_musicbrainz_data", %{}) + + assert render(view) =~ "MusicBrainz data refreshed successfully" + end + + @tag :capture_log + test "error path shows error toast with friendly message", %{conn: conn} do + record = record() + release_group_id = record.musicbrainz_id + + # Initial page load needs a valid response, then the refresh request fails. + Req.Test.stub(MusicBrainz.API, fn conn -> + case conn.path_info do + [_, _, "release-group", ^release_group_id] -> + Req.Test.transport_error(conn, :timeout) + + _ -> + Req.Test.json(conn, %{}) + end + end) + + {:ok, view, _html} = live(conn, ~p"/collection/#{record.id}") + render_click(view, "refresh_musicbrainz_data", %{}) + + html = render(view) + assert html =~ "Error refreshing MusicBrainz data" + end + end + + describe "refresh_cover event" do + test "success path stores new cover and shows toast", %{conn: conn} do + record = record() + stub_musicbrainz_happy_path(record.musicbrainz_id, cover_data: raven_cover_data()) + + {:ok, view, _html} = live(conn, ~p"/collection/#{record.id}") + render_click(view, "refresh_cover", %{}) + + assert render(view) =~ "Cover refreshed successfully" + refute Records.get_record!(record.id).cover_hash == record.cover_hash + end + + @tag :capture_log + test "error path shows error toast", %{conn: conn} do + record = record() + release_group_id = record.musicbrainz_id + release_group = ReleaseGroup.release_group(:marbles) + + Req.Test.stub(MusicBrainz.API, fn conn -> + cond do + conn.host == "coverartarchive.org" -> + Req.Test.transport_error(conn, :timeout) + + match?([_, _, "release-group", ^release_group_id], conn.path_info) -> + Req.Test.json(conn, release_group) + + true -> + Req.Test.json(conn, %{}) + end + end) + + {:ok, view, _html} = live(conn, ~p"/collection/#{record.id}") + render_click(view, "refresh_cover", %{}) + + assert render(view) =~ "Error refreshing cover" + end + end + + describe "populate_genres event" do + test "enqueues a PopulateGenres Oban job and shows toast", %{conn: conn} do + record = record() + stub_musicbrainz_happy_path(record.musicbrainz_id) + + {:ok, view, _html} = live(conn, ~p"/collection/#{record.id}") + render_click(view, "populate_genres", %{}) + + assert render(view) =~ "In progress - record will update automatically" + + assert_enqueued( + worker: MusicLibrary.Worker.PopulateGenres, + args: %{"id" => record.id} + ) + end + end + + describe "extract_colors event" do + test "success path extracts colors from cover and shows toast", %{conn: conn} do + record = record(%{dominant_colors: []}) + stub_musicbrainz_happy_path(record.musicbrainz_id) + + {:ok, view, _html} = live(conn, ~p"/collection/#{record.id}") + render_click(view, "extract_colors", %{}) + + assert render(view) =~ "Colors extracted" + + updated = Records.get_record!(record.id) + # FakeColorExtractor returns a fixed 5-color palette + assert length(updated.dominant_colors) == 5 + end + + @tag :capture_log + test "error path shows error toast when cover asset is missing", %{conn: conn} do + # Fixture stores an asset then points the record at it; we swap in a + # bogus hash after creation so `Assets.get/1` returns nil and + # `extract_colors/1` falls through the `{:error, :asset_not_found}` branch. + record = record() + {:ok, record} = Records.update_record(record, %{cover_hash: String.duplicate("00", 32)}) + stub_musicbrainz_happy_path(record.musicbrainz_id) + + {:ok, view, _html} = live(conn, ~p"/collection/#{record.id}") + render_click(view, "extract_colors", %{}) + + assert render(view) =~ "Error extracting colors" + end + end + + describe "handle_chats_changed" do + test "re-counts chats when the Chat component broadcasts a change", %{conn: conn} do + record = record() + stub_musicbrainz_happy_path(record.musicbrainz_id) + + {:ok, view, _html} = live(conn, ~p"/collection/#{record.id}") + + {:ok, _chat} = + Chats.create_chat_with_message( + %{entity: :record, musicbrainz_id: record.musicbrainz_id}, + %{role: "user", content: "hello"} + ) + + # The Chat component would `send(self(), {Chat, :chats_changed})` after + # seeding a chat. We simulate that by sending the same message directly. + send(view.pid, {MusicLibraryWeb.Components.Chat, :chats_changed}) + + # Forcing a re-render flushes the handle_info that re-computes chat_count; + # we verify via the context function which is the authoritative count. + _ = render(view) + assert Chats.count_chats(:record, record.musicbrainz_id) == 1 + end + end + + describe "handle_record_updated" do + test "updates record assign and shows toast on PubSub broadcast", %{conn: conn} do + record = record() + stub_musicbrainz_happy_path(record.musicbrainz_id) + + {:ok, view, _html} = live(conn, ~p"/collection/#{record.id}") + + updated_record = %{record | title: "Brand New Title"} + + Phoenix.PubSub.broadcast( + MusicLibrary.PubSub, + "records:#{record.id}", + {:update, updated_record} + ) + + html = render(view) + assert html =~ "Brand New Title" + assert html =~ "Record updated in the background" + end + end + + describe "assign_embedding_text" do + test "renders stored embedding text in the debug sheet when one exists", %{conn: conn} do + record = record() + stub_musicbrainz_happy_path(record.musicbrainz_id) + + embedding_text = "Title: #{record.title}\nGenres: progressive rock" + embedding = Enum.map(1..1536, fn _ -> 0.0 end) + + {:ok, _} = Similarity.store_embedding(record.id, embedding, embedding_text) + + {:ok, view, _html} = live(conn, ~p"/collection/#{record.id}") + + assert render(view) =~ "Genres: progressive rock" + end + + # The `{:error, :not_found}` branch is exercised implicitly by every + # other test in this file — fixture records have no stored embedding, + # so page mount falls through to `assign(:embedding_text, "Not available")`. + end +end