Files
music_library/test/music_library_web/components/chat_test.exs
T
2026-05-16 22:27:46 +01:00

367 lines
12 KiB
Elixir

defmodule MusicLibraryWeb.Components.ChatTest do
use MusicLibraryWeb.ConnCase, async: false
import MusicLibrary.Fixtures.Records
import Phoenix.LiveViewTest,
only: [element: 2, form: 3, live: 2, render: 1, render_click: 1, render_submit: 1]
alias MusicBrainz.Fixtures.Release, as: ReleaseFixtures
alias MusicBrainz.Fixtures.ReleaseGroup
alias MusicLibrary.Chats
alias MusicLibraryWeb.Components.Chat
alias Req.Test
@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.
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)
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) ->
Test.json(conn, release_group)
match?([_, _, "release"], conn.path_info) ->
Test.json(conn, ReleaseGroup.release_group_releases(:marbles))
match?([_, _, "release", _], conn.path_info) ->
Test.json(conn, release)
true ->
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()
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