ML-192: add high value missing tests

- Record editing + cover search
- Barcode scan errors + cart editing
- Notes
- Scrobble rules application over set of tracks
- Image conversion
This commit is contained in:
Claudio Ortolina
2026-05-21 06:38:02 +01:00
parent 07f43e59bb
commit 2983313426
5 changed files with 571 additions and 9 deletions
+19
View File
@@ -11,4 +11,23 @@ defmodule MusicLibrary.Assets.ImageTest do
assert cover_data !== resized_cover
end
end
describe "convert/3" do
test "same-format passthrough returns original binary unchanged" do
data = Image.fallback_data()
assert {:ok, result} = Image.convert(data, "image/jpeg", "image/jpeg")
assert result == data
end
test "converts JPEG to WebP successfully" do
data = Image.fallback_data()
assert {:ok, webp_data} = Image.convert(data, "image/jpeg", "image/webp")
assert webp_data != data
assert byte_size(webp_data) > 0
end
test "returns error tuple for invalid image data" do
assert {:error, _reason} = Image.convert("not an image", "image/jpeg", "image/webp")
end
end
end
@@ -421,6 +421,43 @@ defmodule MusicLibrary.ScrobbleRulesTest do
assert {:ok, 0} = ScrobbleRules.apply_all_artist_rules([])
end
test "apply_all_rules/1 only updates supplied track subset and leaves other matching tracks unchanged" do
rule =
scrobble_rule_fixture(%{
match_value: "Test Subset Album",
target_musicbrainz_id: "99999999-9999-9999-9999-999999999999"
})
# Use explicit scrobbled_at_uts values (SQLite second precision requires this for ordering)
now = System.system_time(:second)
supplied_track =
scrobbled_track_fixture(%{
scrobbled_at_uts: now,
album: %{title: "Test Subset Album"},
artist: %{name: "Test Artist"}
})
non_supplied_matching_track =
scrobbled_track_fixture(%{
scrobbled_at_uts: now + 1,
album: %{title: "Test Subset Album"},
artist: %{name: "Test Artist"}
})
results = ScrobbleRules.apply_all_rules([supplied_track])
assert results != []
# The supplied track should have been updated
updated_supplied = Repo.get(Track, supplied_track.scrobbled_at_uts)
assert updated_supplied.album.musicbrainz_id == rule.target_musicbrainz_id
# The non-supplied matching track should NOT have been updated
# (musicbrainz_id remains as stored, which is nil for unset embedded fields)
updated_non_supplied = Repo.get(Track, non_supplied_matching_track.scrobbled_at_uts)
assert updated_non_supplied.album.musicbrainz_id == nil
end
test "apply_all_rules/0 batches rules by type" do
# Create multiple rules of each type
_album_rule1 = scrobble_rule_fixture(@valid_album_attrs)
@@ -11,6 +11,7 @@ defmodule MusicLibraryWeb.CollectionLive.IndexTest do
alias MusicLibrary.Assets.{Image, Transform}
alias MusicLibrary.Records
alias MusicLibrary.Records.Record
alias MusicLibrary.Worker.ImportFromMusicbrainzRelease
alias MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup
alias Req.Test
@@ -561,6 +562,132 @@ defmodule MusicLibraryWeb.CollectionLive.IndexTest do
|> assert_has("video#camera-preview")
end
@tag :capture_log
test "shows error toast on scan failure", %{conn: conn} do
barcode = "123"
Test.stub(MusicBrainz.API, fn conn ->
Req.Test.transport_error(conn, :timeout)
end)
# With a transport_error stub, the scan should fail gracefully.
# The cart should remain empty (no results added).
conn
|> visit(~p"/collection/scan")
|> trigger_hook("#barcode-scanner", "barcode_scanned", %{"number" => barcode})
|> assert_has("#cart-empty")
end
test "removes one scanned result from the cart", %{conn: conn} do
barcode1 = "5037300650128"
barcode2 = "1234567890123"
releases = releases(:marbles)
# Stub MusicBrainz for both barcode scans
Test.stub(MusicBrainz.API, fn conn ->
case conn.params["query"] do
^barcode1 -> Test.json(conn, releases)
^barcode2 -> Test.json(conn, releases)
_ -> Test.json(conn, %{"releases" => []})
end
end)
session =
conn
|> visit(~p"/collection/scan")
|> trigger_hook("#barcode-scanner", "barcode_scanned", %{"number" => barcode1})
|> trigger_hook("#barcode-scanner", "barcode_scanned", %{"number" => barcode2})
assert_has(session, "#cart-items li", count: 2)
# Remove the first result
session =
unwrap(session, fn view ->
view
|> element("#barcode-scanner")
|> render_hook("remove_result", %{"number" => barcode1})
end)
assert_has(session, "#cart-items li", count: 1)
refute_has(session, "#cart-item-#{barcode1}")
assert_has(session, "#cart-item-#{barcode2}")
end
test "clears all scanned results", %{conn: conn} do
barcode1 = "5037300650128"
barcode2 = "1234567890123"
releases = releases(:marbles)
# Stub MusicBrainz for both barcode scans
Test.stub(MusicBrainz.API, fn conn ->
case conn.params["query"] do
^barcode1 -> Test.json(conn, releases)
^barcode2 -> Test.json(conn, releases)
_ -> Test.json(conn, %{"releases" => []})
end
end)
session =
conn
|> visit(~p"/collection/scan")
|> trigger_hook("#barcode-scanner", "barcode_scanned", %{"number" => barcode1})
|> trigger_hook("#barcode-scanner", "barcode_scanned", %{"number" => barcode2})
assert_has(session, "#cart-items li", count: 2)
# Clear all results
session =
unwrap(session, fn view ->
view
|> element("#barcode-scanner")
|> render_hook("clear_results")
end)
assert_has(session, "#cart-empty")
end
test "enqueues import jobs for 2+ new releases via context function" do
barcode1 = "5037300650128"
barcode2 = "1234567890123"
releases = releases(:marbles)
# Ensure clean MusicBrainz stub state
# The barcode search query includes "barcode:NUMBER AND NOT format:digitalmedia"
Test.stub(MusicBrainz.API, fn conn ->
query = conn.params["query"] || ""
cond do
String.contains?(query, barcode1) -> Test.json(conn, releases)
String.contains?(query, barcode2) -> Test.json(conn, releases)
true -> Test.json(conn, %{"releases" => []})
end
end)
# Scan both barcodes to get scan results
{:ok, result1} = MusicLibrary.BarcodeScan.scan(barcode1)
{:ok, result2} = MusicLibrary.BarcodeScan.scan(barcode2)
results = [result1, result2]
# Both should be new results (no existing records)
assert MusicLibrary.BarcodeScan.should_import_async?(results)
# Import async should succeed and enqueue jobs
current_time = DateTime.utc_now()
{:ok, [], async_count} =
MusicLibrary.BarcodeScan.import_results_async(results, current_time)
assert async_count == 2
# Verify jobs were enqueued
assert_enqueued(worker: ImportFromMusicbrainzRelease)
assert_enqueued(worker: ImportFromMusicbrainzRelease)
# No records should have been inserted synchronously
assert MusicLibrary.Repo.all(Record) == []
end
test "adds a record after scanning", %{conn: conn} do
barcode = "5037300650128"
releases = releases(:marbles)
@@ -1,11 +1,20 @@
defmodule MusicLibraryWeb.CollectionLive.ShowTest do
use MusicLibraryWeb.ConnCase
import Phoenix.LiveViewTest,
only: [
render_click: 1,
render_hook: 3,
element: 2
]
import MusicLibrary.Fixtures.Records
import MusicLibraryWeb.RecordComponents, only: [format_label: 1, type_label: 1]
alias BraveSearch.API, as: BraveSearchAPI
alias MusicBrainz.Fixtures
alias MusicLibrary.Assets.Transform
alias MusicLibrary.Assets.{Image, Transform}
alias MusicLibrary.Notes
alias MusicLibrary.Records
alias MusicLibrary.Records.Record
alias Phoenix.PubSub
@@ -177,6 +186,326 @@ defmodule MusicLibraryWeb.CollectionLive.ShowTest do
end
end
describe "RecordForm genre editing" do
setup do
artist_name = "Steven Wilson"
record = record_with_artist(artist_name)
release_response = Fixtures.Release.release(:marbles)
# Stub MusicBrainz API for the show page release data
Req.Test.stub(MusicBrainz.API, fn conn ->
Req.Test.json(conn, release_response)
end)
%{record: record}
end
test "shows genre search suggestions when typing", %{conn: conn, record: record} do
session =
conn
|> visit(~p"/collection/#{record.id}")
|> render_async()
|> click_link("Edit")
# Trigger the genre search via the hook
session =
unwrap(session, fn view ->
view
|> element("#genre-input-container")
|> render_hook("search_genres", %{"value" => "prog"})
end)
assert_has(session, "#genre-suggestions")
end
test "adding a new normalized genre shows a badge", %{conn: conn, record: record} do
existing_genres = record.genres
new_genre = "test-genre"
refute new_genre in existing_genres
session =
conn
|> visit(~p"/collection/#{record.id}")
|> render_async()
|> click_link("Edit")
|> unwrap(fn view ->
view
|> element("#genre-input-container")
|> render_hook("add_genre", %{"genre" => new_genre})
end)
# The new genre should appear in a badge element, normalized to lowercase
assert_has(session, "[phx-click='remove_genre']", String.downcase(new_genre))
end
test "adding a duplicate genre does not create a second badge", %{conn: conn, record: record} do
[existing_genre | _] = record.genres
session =
conn
|> visit(~p"/collection/#{record.id}")
|> render_async()
|> click_link("Edit")
|> unwrap(fn view ->
view
|> element("#genre-input-container")
|> render_hook("add_genre", %{"genre" => existing_genre})
end)
# The genre should appear exactly once (no duplicate badge)
assert_has(session, "[phx-click='remove_genre']", existing_genre, count: 1)
end
test "removing a genre hides its badge", %{conn: conn, record: record} do
[genre_to_remove | _] = record.genres
session =
conn
|> visit(~p"/collection/#{record.id}")
|> render_async()
|> click_link("Edit")
# Verify the genre badge exists before removal
assert_has(session, "[phx-click='remove_genre']", genre_to_remove)
# Click the remove icon on the genre badge
session =
unwrap(session, fn view ->
view
|> element("[phx-click='remove_genre'][phx-value-genre='#{genre_to_remove}']")
|> render_click()
end)
# The badge should now be gone
refute_has(session, "[phx-click='remove_genre']", genre_to_remove)
end
test "persists genre changes when saving the form", %{conn: conn, record: record} do
new_genre = "test-persisted-genre"
refute new_genre in record.genres
session =
conn
|> visit(~p"/collection/#{record.id}")
|> render_async()
|> click_link("Edit")
|> unwrap(fn view ->
view
|> element("#genre-input-container")
|> render_hook("add_genre", %{"genre" => new_genre})
end)
assert_has(session, "[phx-click='remove_genre']", String.downcase(new_genre))
session
|> click_button("#record-form button", "Save")
|> assert_has("p", "Record updated successfully")
# Verify the genre was actually persisted
updated_record = MusicLibrary.Records.get_record!(record.id)
assert String.downcase(new_genre) in updated_record.genres
end
end
describe "RecordForm cover search" do
setup do
Req.Test.set_req_test_to_shared()
record = record()
release_response = Fixtures.Release.release(:marbles)
Req.Test.stub(MusicBrainz.API, fn conn ->
Req.Test.json(conn, release_response)
end)
on_exit(fn ->
Req.Test.stub(BraveSearchAPI, nil)
end)
%{record: record}
end
test "shows cover search results on success", %{conn: conn, record: record} do
Req.Test.stub(BraveSearchAPI, fn conn ->
Req.Test.json(conn, %{
"results" => [
%{
"thumbnail" => %{"src" => "https://example.com/thumb1.jpg"},
"properties" => %{
"url" => "https://example.com/full1.jpg",
"width" => 800,
"height" => 600
},
"title" => "Test Cover 1",
"source" => "example.com"
},
%{
"thumbnail" => %{"src" => "https://example.com/thumb2.jpg"},
"properties" => %{
"url" => "https://example.com/full2.jpg",
"width" => 1024,
"height" => 1024
},
"title" => "Test Cover 2",
"source" => "example.com"
}
]
})
end)
conn
|> visit(~p"/collection/#{record.id}")
|> render_async()
|> click_link("Edit")
|> click_button("#cover-search-button", "Search")
|> render_async()
|> assert_has("#cover-search-results")
|> assert_has("img[alt='Test Cover 1']")
|> assert_has("img[alt='Test Cover 2']")
end
@tag :capture_log
test "shows friendly error message on search failure", %{conn: conn, record: record} do
Req.Test.stub(BraveSearchAPI, fn conn ->
conn
|> Plug.Conn.put_status(429)
|> Req.Test.json(%{"error" => "Rate limit exceeded"})
end)
conn
|> visit(~p"/collection/#{record.id}")
|> render_async()
|> click_link("Edit")
|> click_button("#cover-search-button", "Search")
|> render_async()
|> assert_has("p", "Search failed")
end
test "selecting a cover result downloads and persists the cover", %{
conn: conn,
record: record
} do
original_hash = record.cover_hash
Req.Test.stub(BraveSearchAPI, fn
conn when conn.request_path == "/res/v1/images/search" ->
Req.Test.json(conn, %{
"results" => [
%{
"thumbnail" => %{"src" => "https://example.com/thumb1.jpg"},
"properties" => %{
"url" => "https://example.com/full1.jpg",
"width" => 800,
"height" => 600
},
"title" => "New Cover",
"source" => "example.com"
}
]
})
conn ->
Plug.Conn.send_resp(conn, 200, Image.fallback_data())
end)
session =
conn
|> visit(~p"/collection/#{record.id}")
|> render_async()
|> click_link("Edit")
|> click_button("#cover-search-button", "Search")
|> render_async()
|> assert_has("#cover-search-results")
# Click the first cover search result
session =
unwrap(session, fn view ->
view
|> element("#cover-search-results button:first-child")
|> render_click()
end)
|> render_async()
assert_has(session, "p", "Cover art updated successfully")
# Verify the cover hash actually changed
updated_record = MusicLibrary.Records.get_record!(record.id)
assert updated_record.cover_hash != original_hash
# Verify an asset exists for the new hash
asset = MusicLibrary.Assets.get(updated_record.cover_hash)
assert asset != nil
end
end
describe "Notes component" do
setup do
record = record()
release_response = Fixtures.Release.release(:marbles)
Req.Test.stub(MusicBrainz.API, fn conn ->
Req.Test.json(conn, release_response)
end)
%{record: record}
end
test "creates a new note through the Notes context", %{record: record} do
# No note exists yet
assert Notes.get_note(:record, record.musicbrainz_id) == nil
{:ok, note} =
Notes.create_note(
%MusicLibrary.Notes.Note{entity: :record, musicbrainz_id: record.musicbrainz_id},
%{"content" => "My test note content"}
)
assert note.content == "My test note content"
assert note.entity == :record
assert note.musicbrainz_id == record.musicbrainz_id
# Verify persistence
fetched = Notes.get_note(:record, record.musicbrainz_id)
assert fetched.content == "My test note content"
end
test "renders an existing note in read mode", %{conn: conn, record: record} do
# Pre-create a note
{:ok, _note} =
Notes.create_note(
%MusicLibrary.Notes.Note{entity: :record, musicbrainz_id: record.musicbrainz_id},
%{"content" => "Existing note content"}
)
session =
conn
|> visit(~p"/collection/#{record.id}")
|> render_async()
# The Read tab panel contains the rendered note content
assert_has(session, "#read-panel article", text: "Existing note content")
end
test "updates existing note content through the Notes context", %{record: record} do
# Pre-create a note
{:ok, note} =
Notes.create_note(
%MusicLibrary.Notes.Note{entity: :record, musicbrainz_id: record.musicbrainz_id},
%{"content" => "Original content"}
)
{:ok, updated} = Notes.update_note(note, %{"content" => "Updated content"})
assert updated.content == "Updated content"
# Verify persistence
fetched = Notes.get_note(:record, record.musicbrainz_id)
assert fetched.content == "Updated content"
end
end
describe "Side panel" do
test "shows a record's tracks", %{conn: conn} do
record = record()