First rough implementation of scrobble rules (Claude)

This plan outlines the implementation of functionality to manage
arbitrary rules for fixing data in the `scrobbled_tracks` table. The
system will support two types of transformations:

1. **Album Rule**: Update the album MusicBrainz ID of all tracks
belonging to a specific album (identified by album title)
2. **Artist Rule**: Update the artist MusicBrainz ID of all tracks
belonging to a specific artist (identified by artist name)

- `scrobbled_tracks` table exists with the following structure:
  - `scrobbled_at_uts` (integer, primary key)
  - `musicbrainz_id` (string) - track MusicBrainz ID
  - `title` (string) - track title
  - `cover_url` (string)
  - `scrobbled_at_label` (string)
  - `artist` (map) - embedded artist data with `musicbrainz_id` and
`name`
  - `album` (map) - embedded album data with `musicbrainz_id` and
`title`
  - `last_fm_data` (map) - raw Last.fm API response

- `LastFm.Track` - Schema for scrobbled tracks
- `LastFm.Artist` - Embedded artist schema
- `LastFm.Album` - Embedded album schema

- Main navigation has Stats, Collection, Wishlist sections
- "More" dropdown contains development tools and utilities
- Uses Phoenix LiveView for interactive components

**File**:
`priv/repo/migrations/YYYYMMDDHHMMSS_create_scrobble_rules.exs`

Create `scrobble_rules` table with:

- `id` (primary key)
- `type` (string) - "album" or "artist"
- `match_value` (string) - album title or artist name to match
- `target_musicbrainz_id` (string) - MusicBrainz ID to set
- `enabled` (boolean) - whether rule is active
- `description` (text) - optional description for the rule
- `inserted_at` (timestamp)
- `updated_at` (timestamp)

Indexes:

- `type, match_value` (compound index for efficient matching)
- `enabled` (for filtering active rules)

**File**: `lib/music_library/scrobble_rules/scrobble_rule.ex`

Create Ecto schema with:

- Field definitions matching migration
- Validations:
  - `type` must be "album" or "artist"
  - `match_value` required and non-empty
  - `target_musicbrainz_id` required and non-empty
  - `enabled` defaults to true
- Changeset functions for create/update operations

**File**: `lib/music_library/scrobble_rules.ex`

Implement context functions:

- `list_scrobble_rules(opts \\ [])` - list all rules with optional
filtering
- `get_scrobble_rule!(id)` - get specific rule by ID
- `create_scrobble_rule(attrs)` - create new rule
- `update_scrobble_rule(rule, attrs)` - update existing rule
- `delete_scrobble_rule(rule)` - delete rule
- `change_scrobble_rule(rule, attrs \\ %{})` - create changeset
- `list_enabled_rules()` - get only enabled rules for worker
- `apply_album_rule(rule)` - apply album transformation
- `apply_artist_rule(rule)` - apply artist transformation
- `apply_all_rules()` - apply all enabled rules

**File**: `lib/music_library_web/components/layouts/app.html.heex`

Add new navigation link in the "More" dropdown:

- Icon: `hero-adjustments-horizontal`
- Label: "Scrobble Rules"
- Route: `/scrobble-rules`
- Position: After "Oban" link, before separator

**File**: `lib/music_library_web/router.ex`

Add new LiveView routes in the authenticated scope:

- `/scrobble-rules` - index page
- `/scrobble-rules/new` - create new rule
- `/scrobble-rules/:id/edit` - edit existing rule

**File**: `lib/music_library_web/live/scrobble_rules_live/index.ex`

Implement LiveView with:

- List view showing all rules with status indicators
- Create/Edit forms with validation
- Delete confirmation
- Filter controls (by type, enabled/disabled)
- Manual rule application triggers
- Real-time updates via PubSub

**Template**:
`lib/music_library_web/live/scrobble_rules_live/index.html.heex`

- Table layout with rule details
- Action buttons (Edit, Delete, Apply)
- Form components for create/edit
- Status indicators for enabled/disabled rules

**File**:
`lib/music_library_web/live/scrobble_rules_live/form_component.ex`

Create reusable form component:

- Type selection (album/artist)
- Match value input with validation
- Target MusicBrainz ID input
- Description textarea
- Enable/disable toggle
- Save/Cancel actions

Where possible, use the components available at
<https://docs.fluxonui.com/1.1.4/overview.html>.

**File**: `lib/music_library/scrobble_rules/worker.ex`

Implement Oban worker:

- Scheduled to run periodically (e.g., every 30 minutes)
- Fetches all enabled rules
- Applies each rule in sequence
- Logs application results
- Handles errors gracefully
- Tracks last application time

**File**: `test/music_library/scrobble_rules_test.exs`

- Test all context functions
- Test rule application logic
- Test edge cases and error handling

**File**: `test/music_library/scrobble_rules/scrobble_rule_test.exs`

- Test schema validations
- Test changeset functions
- Test constraint validations

**File**: `test/music_library_web/live/scrobble_rules_live_test.exs`

- Test LiveView interactions
- Test form submissions
- Test real-time updates
- Test navigation and routing

**File**: `test/music_library/scrobble_rules/worker_test.exs`

- Test job execution
- Test rule application
- Test error handling
- Test scheduling

- Ensure atomic operations when applying rules
- Validate MusicBrainz IDs before applying
- Log all transformations for audit trail

- Index optimization for rule matching
- Batch processing for large datasets
- Rate limiting for external API calls

- Clear feedback on rule application
- Preview mode to show what would change
- Bulk operations for managing multiple rules
- Export/import functionality for rule sets

- Input validation and sanitization
- Rate limiting on rule creation
- Audit logging for all changes

```sql
-- scrobble_rules table
CREATE TABLE scrobble_rules (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  type TEXT NOT NULL CHECK (type IN ('album', 'artist')),
  match_value TEXT NOT NULL,
  target_musicbrainz_id TEXT NOT NULL,
  enabled BOOLEAN NOT NULL DEFAULT TRUE,
  description TEXT,
  inserted_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

CREATE INDEX idx_scrobble_rules_type_match ON scrobble_rules(type,
match_value);
CREATE INDEX idx_scrobble_rules_enabled ON scrobble_rules(enabled);
```

```
lib/music_library/
├── scrobble_rules/
│   ├── scrobble_rule.ex          # Schema
│   └── worker.ex                 # Oban worker
└── scrobble_rules.ex             # Context

lib/music_library_web/
└── live/
    └── scrobble_rules_live/
        ├── index.ex              # LiveView
        ├── index.html.heex       # Template
        └── form_component.ex     # Form component

priv/repo/migrations/
└── YYYYMMDDHHMMSS_create_scrobble_rules.exs

test/
├── music_library/
│   ├── scrobble_rules_test.exs
│   └── scrobble_rules/
│       ├── scrobble_rule_test.exs
│       └── worker_test.exs
└── music_library_web/
    └── live/
        └── scrobble_rules_live_test.exs
```

1. Database migration and schema
2. Context module with basic CRUD operations
3. LiveView interface for rule management
4. Navigation and routing updates
5. Periodic worker implementation
6. Comprehensive testing
7. Documentation and deployment

This plan ensures a robust, maintainable solution that integrates
seamlessly with the existing Music Library application architecture.
This commit is contained in:
Claudio Ortolina
2025-07-03 16:46:21 +01:00
parent f70cb317ac
commit d91685b646
23 changed files with 2371 additions and 2 deletions
@@ -0,0 +1,136 @@
defmodule MusicLibrary.ScrobbleRules.ScrobbleRuleTest do
use MusicLibrary.DataCase
alias MusicLibrary.ScrobbleRules.ScrobbleRule
describe "changeset/2" do
test "valid changeset with all required fields" do
attrs = %{
type: "album",
match_value: "Dark Side of the Moon",
target_musicbrainz_id: "12345678-1234-1234-1234-123456789012",
enabled: true,
description: "Fix Pink Floyd album"
}
changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs)
assert changeset.valid?
end
test "valid changeset with minimal required fields" do
attrs = %{
type: "artist",
match_value: "Pink Floyd",
target_musicbrainz_id: "12345678-1234-1234-1234-123456789012"
}
changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs)
assert changeset.valid?
# Check that enabled defaults to true if not set
end
test "invalid changeset when type is missing" do
attrs = %{
match_value: "Pink Floyd",
target_musicbrainz_id: "12345678-1234-1234-1234-123456789012"
}
changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).type
end
test "invalid changeset when match_value is missing" do
attrs = %{
type: "artist",
target_musicbrainz_id: "12345678-1234-1234-1234-123456789012"
}
changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).match_value
end
test "invalid changeset when target_musicbrainz_id is missing" do
attrs = %{
type: "artist",
match_value: "Pink Floyd"
}
changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).target_musicbrainz_id
end
test "invalid changeset when type is not album or artist" do
attrs = %{
type: "invalid_type",
match_value: "Pink Floyd",
target_musicbrainz_id: "12345678-1234-1234-1234-123456789012"
}
changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).type
end
test "invalid changeset when match_value is empty" do
attrs = %{
type: "artist",
match_value: "",
target_musicbrainz_id: "12345678-1234-1234-1234-123456789012"
}
changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).match_value
end
test "invalid changeset when target_musicbrainz_id is empty" do
attrs = %{
type: "artist",
match_value: "Pink Floyd",
target_musicbrainz_id: ""
}
changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).target_musicbrainz_id
end
test "invalid changeset when target_musicbrainz_id is not a valid UUID" do
attrs = %{
type: "artist",
match_value: "Pink Floyd",
target_musicbrainz_id: "invalid-uuid"
}
changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs)
refute changeset.valid?
assert "must be a valid MusicBrainz ID (UUID format)" in errors_on(changeset).target_musicbrainz_id
end
test "valid changeset with uppercase UUID" do
attrs = %{
type: "artist",
match_value: "Pink Floyd",
target_musicbrainz_id: "12345678-1234-1234-1234-123456789012"
}
changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs)
assert changeset.valid?
end
test "valid changeset with lowercase UUID" do
attrs = %{
type: "artist",
match_value: "Pink Floyd",
target_musicbrainz_id: "abcdefab-abcd-abcd-abcd-abcdefabcdef"
}
changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs)
assert changeset.valid?
end
end
end
@@ -0,0 +1,95 @@
defmodule MusicLibrary.ScrobbleRules.WorkerTest do
use MusicLibrary.DataCase
alias LastFm.Track
alias MusicLibrary.ScrobbleRules
alias MusicLibrary.ScrobbleRules.Worker
describe "perform/1" do
test "successfully applies all enabled rules" do
# Create enabled rules
{:ok, album_rule} =
ScrobbleRules.create_scrobble_rule(%{
type: "album",
match_value: "Dark Side of the Moon",
target_musicbrainz_id: "12345678-1234-1234-1234-123456789012",
enabled: true
})
{:ok, artist_rule} =
ScrobbleRules.create_scrobble_rule(%{
type: "artist",
match_value: "Pink Floyd",
target_musicbrainz_id: "87654321-4321-4321-4321-210987654321",
enabled: true
})
# Create disabled rule
{:ok, _disabled_rule} =
ScrobbleRules.create_scrobble_rule(%{
type: "album",
match_value: "Disabled Album",
target_musicbrainz_id: "11111111-1111-1111-1111-111111111111",
enabled: false
})
# Create test tracks
%Track{}
|> Track.changeset(%{
scrobbled_at_uts: System.system_time(:second),
musicbrainz_id: "track-mbid-1",
title: "Breathe",
cover_url: "http://example.com/cover.jpg",
scrobbled_at_label: "01 Jan 2023, 12:00",
artist: %{musicbrainz_id: "", name: "Pink Floyd"},
album: %{musicbrainz_id: "", title: "Dark Side of the Moon"},
last_fm_data: %{}
})
|> Repo.insert!()
%Track{}
|> Track.changeset(%{
scrobbled_at_uts: System.system_time(:second) + 1,
musicbrainz_id: "track-mbid-2",
title: "Money",
cover_url: "http://example.com/cover.jpg",
scrobbled_at_label: "01 Jan 2023, 12:05",
artist: %{musicbrainz_id: "", name: "Pink Floyd"},
album: %{musicbrainz_id: "", title: "Wish You Were Here"},
last_fm_data: %{}
})
|> Repo.insert!()
# Execute the worker
assert :ok = Worker.perform(%Oban.Job{args: %{}})
# Verify rules were applied
updated_track1 = Repo.get_by(Track, musicbrainz_id: "track-mbid-1")
assert updated_track1.album.musicbrainz_id == album_rule.target_musicbrainz_id
assert updated_track1.artist.musicbrainz_id == artist_rule.target_musicbrainz_id
updated_track2 = Repo.get_by(Track, musicbrainz_id: "track-mbid-2")
# Only artist should be updated for track2 since album doesn't match
assert updated_track2.artist.musicbrainz_id == artist_rule.target_musicbrainz_id
# Should remain unchanged (nil or empty string)
assert updated_track2.album.musicbrainz_id in [nil, ""]
end
test "handles errors gracefully" do
# Since we can't easily mock here, let's test with no rules
# which should still return :ok
assert :ok = Worker.perform(%Oban.Job{args: %{}})
end
test "handles empty rules list" do
# No rules exist
assert :ok = Worker.perform(%Oban.Job{args: %{}})
end
end
describe "run_now/0" do
test "enqueues job for immediate execution" do
assert {:ok, %Oban.Job{}} = Worker.run_now()
end
end
end
+301
View File
@@ -0,0 +1,301 @@
defmodule MusicLibrary.ScrobbleRulesTest do
use MusicLibrary.DataCase
alias LastFm.Track
alias MusicLibrary.ScrobbleRules
alias MusicLibrary.ScrobbleRules.ScrobbleRule
describe "scrobble_rules" do
@valid_album_attrs %{
type: "album",
match_value: "Dark Side of the Moon",
target_musicbrainz_id: "12345678-1234-1234-1234-123456789012",
enabled: true,
description: "Fix Pink Floyd album"
}
@valid_artist_attrs %{
type: "artist",
match_value: "Pink Floyd",
target_musicbrainz_id: "87654321-4321-4321-4321-210987654321",
enabled: true,
description: "Fix Pink Floyd artist"
}
@invalid_attrs %{type: nil, match_value: nil, target_musicbrainz_id: nil}
def scrobble_rule_fixture(attrs \\ %{}) do
{:ok, scrobble_rule} =
attrs
|> Enum.into(@valid_album_attrs)
|> ScrobbleRules.create_scrobble_rule()
scrobble_rule
end
def scrobbled_track_fixture(attrs \\ %{}) do
default_attrs = %{
scrobbled_at_uts: System.system_time(:second),
musicbrainz_id: "track-mbid-12345",
title: "Breathe",
cover_url: "http://example.com/cover.jpg",
scrobbled_at_label: "01 Jan 2023, 12:00",
artist: %{
musicbrainz_id: "",
name: "Pink Floyd"
},
album: %{
musicbrainz_id: "",
title: "Dark Side of the Moon"
},
last_fm_data: %{}
}
attrs = Enum.into(attrs, default_attrs)
%Track{}
|> Track.changeset(attrs)
|> Repo.insert!()
end
test "list_scrobble_rules/0 returns all scrobble_rules" do
scrobble_rule = scrobble_rule_fixture()
assert ScrobbleRules.list_scrobble_rules() == [scrobble_rule]
end
test "list_scrobble_rules/1 filters by type" do
album_rule = scrobble_rule_fixture(@valid_album_attrs)
_artist_rule = scrobble_rule_fixture(@valid_artist_attrs)
assert ScrobbleRules.list_scrobble_rules(type: "album") == [album_rule]
end
test "list_scrobble_rules/1 filters by enabled status" do
enabled_rule = scrobble_rule_fixture(%{enabled: true})
_disabled_rule = scrobble_rule_fixture(%{enabled: false, match_value: "Different Album"})
assert ScrobbleRules.list_scrobble_rules(enabled: true) == [enabled_rule]
end
test "get_scrobble_rule!/1 returns the scrobble_rule with given id" do
scrobble_rule = scrobble_rule_fixture()
assert ScrobbleRules.get_scrobble_rule!(scrobble_rule.id) == scrobble_rule
end
test "create_scrobble_rule/1 with valid data creates a scrobble_rule" do
valid_attrs = @valid_album_attrs
assert {:ok, %ScrobbleRule{} = scrobble_rule} =
ScrobbleRules.create_scrobble_rule(valid_attrs)
assert scrobble_rule.type == "album"
assert scrobble_rule.match_value == "Dark Side of the Moon"
assert scrobble_rule.target_musicbrainz_id == "12345678-1234-1234-1234-123456789012"
assert scrobble_rule.enabled == true
assert scrobble_rule.description == "Fix Pink Floyd album"
end
test "create_scrobble_rule/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = ScrobbleRules.create_scrobble_rule(@invalid_attrs)
end
test "update_scrobble_rule/2 with valid data updates the scrobble_rule" do
scrobble_rule = scrobble_rule_fixture()
update_attrs = %{enabled: false, description: "Updated description"}
assert {:ok, %ScrobbleRule{} = scrobble_rule} =
ScrobbleRules.update_scrobble_rule(scrobble_rule, update_attrs)
assert scrobble_rule.enabled == false
assert scrobble_rule.description == "Updated description"
end
test "update_scrobble_rule/2 with invalid data returns error changeset" do
scrobble_rule = scrobble_rule_fixture()
assert {:error, %Ecto.Changeset{}} =
ScrobbleRules.update_scrobble_rule(scrobble_rule, @invalid_attrs)
assert scrobble_rule == ScrobbleRules.get_scrobble_rule!(scrobble_rule.id)
end
test "delete_scrobble_rule/1 deletes the scrobble_rule" do
scrobble_rule = scrobble_rule_fixture()
assert {:ok, %ScrobbleRule{}} = ScrobbleRules.delete_scrobble_rule(scrobble_rule)
assert_raise Ecto.NoResultsError, fn ->
ScrobbleRules.get_scrobble_rule!(scrobble_rule.id)
end
end
test "change_scrobble_rule/1 returns a scrobble_rule changeset" do
scrobble_rule = scrobble_rule_fixture()
assert %Ecto.Changeset{} = ScrobbleRules.change_scrobble_rule(scrobble_rule)
end
test "list_enabled_rules/0 returns only enabled rules" do
enabled_rule = scrobble_rule_fixture(%{enabled: true})
_disabled_rule = scrobble_rule_fixture(%{enabled: false, match_value: "Different Album"})
assert ScrobbleRules.list_enabled_rules() == [enabled_rule]
end
end
describe "rule application" do
test "apply_album_rule/1 updates matching tracks" do
rule = scrobble_rule_fixture(@valid_album_attrs)
# Create matching track
track1 =
scrobbled_track_fixture(%{
album: %{musicbrainz_id: "", title: "Dark Side of the Moon"}
})
# Create non-matching track
_track2 =
scrobbled_track_fixture(%{
scrobbled_at_uts: System.system_time(:second) + 1,
album: %{musicbrainz_id: "", title: "Wish You Were Here"}
})
assert {:ok, 1} = ScrobbleRules.apply_album_rule(rule)
# Verify the matching track was updated
updated_track = Repo.get_by(Track, scrobbled_at_uts: track1.scrobbled_at_uts)
assert updated_track.album.musicbrainz_id == rule.target_musicbrainz_id
end
test "apply_artist_rule/1 updates matching tracks" do
rule = scrobble_rule_fixture(@valid_artist_attrs)
# Create matching track
track1 =
scrobbled_track_fixture(%{
artist: %{musicbrainz_id: "", name: "Pink Floyd"}
})
# Create non-matching track
_track2 =
scrobbled_track_fixture(%{
scrobbled_at_uts: System.system_time(:second) + 1,
artist: %{musicbrainz_id: "", name: "Led Zeppelin"}
})
assert {:ok, 1} = ScrobbleRules.apply_artist_rule(rule)
# Verify the matching track was updated
updated_track = Repo.get_by(Track, scrobbled_at_uts: track1.scrobbled_at_uts)
assert updated_track.artist.musicbrainz_id == rule.target_musicbrainz_id
end
test "apply_album_rule/1 with wrong type returns error" do
rule = scrobble_rule_fixture(@valid_artist_attrs)
assert {:error, _reason} = ScrobbleRules.apply_album_rule(rule)
end
test "apply_artist_rule/1 with wrong type returns error" do
rule = scrobble_rule_fixture(@valid_album_attrs)
assert {:error, _reason} = ScrobbleRules.apply_artist_rule(rule)
end
test "apply_rule/1 delegates to correct function based on type" do
album_rule = scrobble_rule_fixture(@valid_album_attrs)
artist_rule = scrobble_rule_fixture(@valid_artist_attrs)
# Create test tracks that only match their respective rules
_album_track =
scrobbled_track_fixture(%{
album: %{musicbrainz_id: "", title: "Dark Side of the Moon"},
artist: %{musicbrainz_id: "", name: "Different Artist"}
})
_artist_track =
scrobbled_track_fixture(%{
scrobbled_at_uts: System.system_time(:second) + 1,
artist: %{musicbrainz_id: "", name: "Pink Floyd"},
album: %{musicbrainz_id: "", title: "Different Album"}
})
assert {:ok, 1} = ScrobbleRules.apply_rule(album_rule)
assert {:ok, 1} = ScrobbleRules.apply_rule(artist_rule)
end
test "apply_all_rules/0 applies all enabled rules" do
_album_rule = scrobble_rule_fixture(@valid_album_attrs)
_artist_rule = scrobble_rule_fixture(@valid_artist_attrs)
_disabled_rule = scrobble_rule_fixture(%{enabled: false, match_value: "Disabled Album"})
# Create test tracks that only match their respective rules
_album_track =
scrobbled_track_fixture(%{
album: %{musicbrainz_id: "", title: "Dark Side of the Moon"},
artist: %{musicbrainz_id: "", name: "Different Artist"}
})
_artist_track =
scrobbled_track_fixture(%{
scrobbled_at_uts: System.system_time(:second) + 1,
artist: %{musicbrainz_id: "", name: "Pink Floyd"},
album: %{musicbrainz_id: "", title: "Different Album"}
})
assert {:ok, results} = ScrobbleRules.apply_all_rules()
assert length(results) == 2
# Verify all results are successful
Enum.each(results, fn result ->
assert {:ok, {_type, _match_value, _count}} = result
end)
end
test "count_album_matches/1 returns correct count" do
rule = scrobble_rule_fixture(@valid_album_attrs)
# Create matching tracks
_track1 =
scrobbled_track_fixture(%{
album: %{musicbrainz_id: "", title: "Dark Side of the Moon"}
})
_track2 =
scrobbled_track_fixture(%{
scrobbled_at_uts: System.system_time(:second) + 1,
album: %{musicbrainz_id: "", title: "Dark Side of the Moon"}
})
# Create non-matching track
_track3 =
scrobbled_track_fixture(%{
scrobbled_at_uts: System.system_time(:second) + 2,
album: %{musicbrainz_id: "", title: "Wish You Were Here"}
})
assert ScrobbleRules.count_album_matches(rule) == 2
end
test "count_artist_matches/1 returns correct count" do
rule = scrobble_rule_fixture(@valid_artist_attrs)
# Create matching tracks
_track1 =
scrobbled_track_fixture(%{
artist: %{musicbrainz_id: "", name: "Pink Floyd"}
})
_track2 =
scrobbled_track_fixture(%{
scrobbled_at_uts: System.system_time(:second) + 1,
artist: %{musicbrainz_id: "", name: "Pink Floyd"}
})
# Create non-matching track
_track3 =
scrobbled_track_fixture(%{
scrobbled_at_uts: System.system_time(:second) + 2,
artist: %{musicbrainz_id: "", name: "Led Zeppelin"}
})
assert ScrobbleRules.count_artist_matches(rule) == 2
end
end
end
@@ -0,0 +1,190 @@
defmodule MusicLibraryWeb.ScrobbleRulesLiveTest do
use MusicLibraryWeb.ConnCase
import MusicLibrary.ScrobbleRulesFixtures
import Phoenix.LiveViewTest
alias MusicLibrary.ScrobbleRules
# Test data
@invalid_attrs %{type: "", match_value: "", target_musicbrainz_id: ""}
@valid_attrs %{
type: "album",
match_value: "some match_value",
target_musicbrainz_id: "12345678-1234-1234-1234-123456789012",
description: "some description",
enabled: "true"
}
@update_attrs %{
type: "artist",
match_value: "some updated match_value",
target_musicbrainz_id: "87654321-4321-4321-4321-210987654321",
description: "some updated description"
}
defp create_scrobble_rule(_) do
scrobble_rule = scrobble_rule_fixture()
%{scrobble_rule: scrobble_rule}
end
describe "Index" do
setup [:create_scrobble_rule]
test "lists all scrobble_rules", %{conn: conn, scrobble_rule: scrobble_rule} do
{:ok, _index_live, html} = live(conn, ~p"/scrobble-rules")
assert html =~ "Scrobble Rules"
assert html =~ scrobble_rule.match_value
end
test "saves new scrobble_rule", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/scrobble-rules")
assert index_live |> element("a", "New Rule") |> render_click() =~
"New Scrobble Rule"
assert_patch(index_live, ~p"/scrobble-rules/new")
assert index_live
|> form("#scrobble_rule-form", scrobble_rule: @invalid_attrs)
|> render_change() =~ "can&#39;t be blank"
index_live
|> form("#scrobble_rule-form", scrobble_rule: @valid_attrs)
|> render_submit()
# The rule should be created and visible in the list
html = render(index_live)
assert html =~ "some match_value"
end
test "updates scrobble_rule in listing", %{conn: conn, scrobble_rule: scrobble_rule} do
{:ok, index_live, _html} = live(conn, ~p"/scrobble-rules")
assert index_live
|> element("#scrobble_rules-#{scrobble_rule.id} a[href*='edit']")
|> render_click() =~
"Edit Scrobble Rule"
assert_patch(index_live, ~p"/scrobble-rules/#{scrobble_rule}/edit")
assert index_live
|> form("#scrobble_rule-form", scrobble_rule: @invalid_attrs)
|> render_change() =~ "can&#39;t be blank"
html =
index_live
|> form("#scrobble_rule-form", scrobble_rule: @update_attrs)
|> render_submit()
assert html =~ "Scrobble rule updated successfully"
assert html =~ "some updated match_value"
end
test "deletes scrobble_rule in listing", %{conn: conn, scrobble_rule: scrobble_rule} do
{:ok, index_live, _html} = live(conn, ~p"/scrobble-rules")
assert index_live
|> element("#scrobble_rules-#{scrobble_rule.id} button[phx-click='delete']")
|> render_click()
refute has_element?(index_live, "#scrobble_rule-#{scrobble_rule.id}")
end
test "toggles rule enabled status", %{conn: conn, scrobble_rule: scrobble_rule} do
{:ok, index_live, _html} = live(conn, ~p"/scrobble-rules")
# Toggle to disabled
assert index_live
|> element("#scrobble_rules-#{scrobble_rule.id} button[phx-click='toggle_enabled']")
|> render_click()
# Check that the rule was disabled
updated_rule = ScrobbleRules.get_scrobble_rule!(scrobble_rule.id)
refute updated_rule.enabled
# Toggle back to enabled
assert index_live
|> element("#scrobble_rules-#{scrobble_rule.id} button[phx-click='toggle_enabled']")
|> render_click()
# Check that the rule was enabled again
updated_rule = ScrobbleRules.get_scrobble_rule!(scrobble_rule.id)
assert updated_rule.enabled
end
test "applies individual rule", %{conn: conn, scrobble_rule: scrobble_rule} do
{:ok, index_live, _html} = live(conn, ~p"/scrobble-rules")
assert index_live
|> element("#scrobble_rules-#{scrobble_rule.id} button[phx-click='apply_rule']")
|> render_click()
# Should show success message (even if no tracks were updated)
assert render(index_live) =~ "Rule applied successfully"
end
test "applies all rules", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/scrobble-rules")
assert index_live
|> element("button[phx-click='apply_all_rules']")
|> render_click()
# Should show success message
assert render(index_live) =~ "All rules applied successfully"
end
end
describe "Form validation" do
test "shows validation errors for missing required fields", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/scrobble-rules")
assert index_live |> element("a", "New Rule") |> render_click()
assert index_live
|> form("#scrobble_rule-form",
scrobble_rule: %{type: "", match_value: "", target_musicbrainz_id: ""}
)
|> render_change() =~ "can&#39;t be blank"
end
test "shows validation errors for invalid MusicBrainz ID", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/scrobble-rules")
assert index_live |> element("a", "New Rule") |> render_click()
assert index_live
|> form("#scrobble_rule-form",
scrobble_rule: %{
type: "album",
match_value: "Test Album",
target_musicbrainz_id: "invalid-uuid"
}
)
|> render_change() =~ "must be a valid MusicBrainz ID"
end
test "updates form labels based on rule type", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/scrobble-rules")
assert index_live |> element("a", "New Rule") |> render_click()
# Select album type
html =
index_live
|> form("#scrobble_rule-form", scrobble_rule: %{type: "album"})
|> render_change()
assert html =~ "Album Title"
# Select artist type
html =
index_live
|> form("#scrobble_rule-form", scrobble_rule: %{type: "artist"})
|> render_change()
assert html =~ "Artist Name"
end
end
end
@@ -0,0 +1,28 @@
defmodule MusicLibrary.ScrobbleRulesFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `MusicLibrary.ScrobbleRules` context.
"""
alias MusicLibrary.ScrobbleRules
@doc """
Generate a scrobble_rule.
"""
def scrobble_rule_fixture(attrs \\ %{}) do
default_attrs = %{
type: "album",
match_value: "Dark Side of the Moon",
target_musicbrainz_id: "12345678-1234-1234-1234-123456789012",
enabled: true,
description: "Fix Pink Floyd album"
}
{:ok, scrobble_rule} =
attrs
|> Enum.into(default_attrs)
|> ScrobbleRules.create_scrobble_rule()
scrobble_rule
end
end