ML-169.10: async delete actions with initial implementation
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: ML-169.10
|
id: ML-169.10
|
||||||
title: "Fix: Async optimistic UI for index/list delete streams"
|
title: "Fix: Async optimistic UI for index/list delete streams"
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: "2026-05-19 11:48"
|
created_date: "2026-05-19 11:48"
|
||||||
updated_date: "2026-05-22 14:33"
|
updated_date: "2026-05-22 15:00"
|
||||||
labels:
|
labels:
|
||||||
- perf
|
- perf
|
||||||
- fix
|
- fix
|
||||||
@@ -83,15 +83,15 @@ Per project conventions, `handle_async` always handles three cases:
|
|||||||
|
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
- [ ] #1 Index/list delete operations remove the item from the relevant stream before waiting for the database delete
|
- [x] #1 Index/list delete operations remove the item from the relevant stream before waiting for the database delete
|
||||||
- [ ] #2 Database delete still executes through LiveView-owned async handling, not bare `Task.start/1`
|
- [x] #2 Database delete still executes through LiveView-owned async handling, not bare `Task.start/1`
|
||||||
- [ ] #3 Async delete failure or exit reinserts the original item into the stream and shows an error toast
|
- [x] #3 Async delete failure or exit reinserts the original item into the stream and shows an error toast
|
||||||
- [ ] #4 Existing delete confirmation prompts still work correctly
|
- [x] #4 Existing delete confirmation prompts still work correctly
|
||||||
- [ ] #5 Show-page delete handlers are not changed as part of this task
|
- [x] #5 Show-page delete handlers are not changed as part of this task
|
||||||
- [ ] #6 Collection and wishlist index delete behavior remains shared through `IndexActions` where practical
|
- [x] #6 Collection and wishlist index delete behavior remains shared through `IndexActions` where practical
|
||||||
- [ ] #7 Existing `data-confirm` browser prompts are not bypassed or altered
|
- [x] #7 Existing `data-confirm` browser prompts are not bypassed or altered
|
||||||
- [ ] #8 handle_async {:exit, reason} uses a generic gettext string, never leaks stacktrace reason to user
|
- [x] #8 handle_async {:exit, reason} uses a generic gettext string, never leaks stacktrace reason to user
|
||||||
- [ ] #9 All three LiveViews have delete tests covering success, error, and exit paths
|
- [x] #9 All three LiveViews have delete tests covering success, error, and exit paths
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -220,3 +220,47 @@ Unit tests (tests 1-4): import the LiveView module, call `handle_async/3` direct
|
|||||||
Test 4 specifically asserts that `{:exit, :killed}` produces a generic gettext string, not the raw exit reason — this prevents accidental stacktrace leakage to users.
|
Test 4 specifically asserts that `{:exit, :killed}` produces a generic gettext string, not the raw exit reason — this prevents accidental stacktrace leakage to users.
|
||||||
|
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
|
||||||
|
Finished implementation and tests. All 1147 tests pass (0 failures). Changes:
|
||||||
|
|
||||||
|
1. IndexActions.handle_delete/2: optimistic stream_delete before start_async
|
||||||
|
2. CollectionLive.Index: 3 handle_async clauses + ErrorMessages alias
|
||||||
|
3. WishlistLive.Index: same 3 handle_async clauses + ErrorMessages alias
|
||||||
|
4. ScrobbledTracksLive.Index: reworked delete handler + 3 handle_async clauses
|
||||||
|
5. Tests: updated 3 existing delete integration tests (added render_async), added 12 new unit tests (4 per LiveView) covering success/error/exit paths
|
||||||
|
|
||||||
|
Show page deletes untouched (verified via show_test.exs).
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Replaced synchronous delete handlers in three index/list LiveViews with optimistic `stream_delete` + LiveView-owned `start_async` / `handle_async` pattern:
|
||||||
|
|
||||||
|
- **`IndexActions.handle_delete/2`** (shared by Collection + Wishlist): `stream_delete` immediately, then `start_async({:delete_record, id})` to run `Records.delete_record/1`
|
||||||
|
- **`CollectionLive.Index`**: 3 new `handle_async({:delete_record, ...})` clauses — success no-op, error reinsert + toast via `ErrorMessages.friendly_message/1`, exit reinsert + generic gettext
|
||||||
|
- **`WishlistLive.Index`**: identical 3 clauses (duplication intentional per project conventions — extract only at 3+ callers)
|
||||||
|
- **`ScrobbledTracksLive.Index`**: reworked `handle_event("delete")` to pass `stream_element` through async tuple for zero-query rollback; 3 `handle_async({:delete_track, ...})` clauses (exit path can't recover element — toast only)
|
||||||
|
|
||||||
|
Previously both callers used `{:ok, _} =` pattern match which would crash the LiveView process on `{:error, changeset}`. Now errors are handled gracefully.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Updated 3 existing integration delete tests with `render_async()` to wait for async completion
|
||||||
|
- Added 12 unit tests (4 per LiveView) for success/error/exit `handle_async` paths, including verification that `{:exit, reason}` never leaks stacktrace to user
|
||||||
|
- Show-page delete handlers unchanged (verified via `show_test.exs`)
|
||||||
|
- Full suite: 1147 tests pass, 0 failures
|
||||||
|
|
||||||
|
## Risks / follow-ups
|
||||||
|
|
||||||
|
- `{:exit, _}` in record deletes calls `Records.get_record!(id)` for reinsertion — if the DB delete succeeded before the task crashed, this raises `Ecto.NoResultsError` crashing the LiveView. In practice `Records.delete_record/1` is simple (`Repo.delete` + `prune_artist_info_async`) so this is unlikely, but could be hardened with a rescue.
|
||||||
|
- `{:exit, _}` in scrobbled tracks can't recover the stream element (toast-only) — acceptable tradeoff to avoid re-running expensive correlated subqueries.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ defmodule MusicLibraryWeb.CollectionLive.Index do
|
|||||||
alias MusicLibrary.Collection
|
alias MusicLibrary.Collection
|
||||||
alias MusicLibrary.Records
|
alias MusicLibrary.Records
|
||||||
alias MusicLibraryWeb.Components.AddRecord
|
alias MusicLibraryWeb.Components.AddRecord
|
||||||
|
alias MusicLibraryWeb.ErrorMessages
|
||||||
alias MusicLibraryWeb.LiveHelpers.IndexActions
|
alias MusicLibraryWeb.LiveHelpers.IndexActions
|
||||||
|
|
||||||
defp index_config do
|
defp index_config do
|
||||||
@@ -323,6 +324,28 @@ defmodule MusicLibraryWeb.CollectionLive.Index do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_async({:delete_record, _id}, {:ok, {:ok, _record}}, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async({:delete_record, id}, {:ok, {:error, reason}}, socket) do
|
||||||
|
record = Records.get_record!(id)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> stream_insert(:records, record)
|
||||||
|
|> put_toast(:error, gettext("Failed to delete: ") <> ErrorMessages.friendly_message(reason))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async({:delete_record, id}, {:exit, _reason}, socket) do
|
||||||
|
record = Records.get_record!(id)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> stream_insert(:records, record)
|
||||||
|
|> put_toast(:error, gettext("Delete failed, please try again"))}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
IndexActions.handle_delete(socket, id)
|
IndexActions.handle_delete(socket, id)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
|
|||||||
alias LastFm.Track
|
alias LastFm.Track
|
||||||
alias MusicLibrary.Assets.Transform
|
alias MusicLibrary.Assets.Transform
|
||||||
alias MusicLibrary.ListeningStats
|
alias MusicLibrary.ListeningStats
|
||||||
|
alias MusicLibraryWeb.ErrorMessages
|
||||||
|
|
||||||
@default_tracks_list_params %{
|
@default_tracks_list_params %{
|
||||||
query: "",
|
query: "",
|
||||||
@@ -298,9 +299,13 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
|
|||||||
|
|
||||||
def handle_event("delete", %{"scrobbled-at-uts" => scrobbled_at_uts}, socket) do
|
def handle_event("delete", %{"scrobbled-at-uts" => scrobbled_at_uts}, socket) do
|
||||||
track = ListeningStats.get_track!(scrobbled_at_uts)
|
track = ListeningStats.get_track!(scrobbled_at_uts)
|
||||||
{:ok, _} = ListeningStats.delete_track(track)
|
stream_element = %{track: track}
|
||||||
|
socket = stream_delete(socket, :tracks, stream_element)
|
||||||
|
|
||||||
{:noreply, stream_delete(socket, :tracks, %{track: track})}
|
{:noreply,
|
||||||
|
start_async(socket, {:delete_track, scrobbled_at_uts}, fn ->
|
||||||
|
{ListeningStats.delete_track(track), stream_element}
|
||||||
|
end)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("search", %{"query" => query}, socket) do
|
def handle_event("search", %{"query" => query}, socket) do
|
||||||
@@ -317,6 +322,22 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_async({:delete_track, _uts}, {:ok, {{:ok, _track}, _element}}, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async({:delete_track, _uts}, {:ok, {{:error, reason}, element}}, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> stream_insert(:tracks, element)
|
||||||
|
|> put_toast(:error, gettext("Failed to delete: ") <> ErrorMessages.friendly_message(reason))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async({:delete_track, _uts}, {:exit, _reason}, socket) do
|
||||||
|
{:noreply, put_toast(socket, :error, gettext("Delete failed, please try again"))}
|
||||||
|
end
|
||||||
|
|
||||||
defp parse_order("scrobbled_at"), do: :scrobbled_at
|
defp parse_order("scrobbled_at"), do: :scrobbled_at
|
||||||
defp parse_order("title"), do: :title
|
defp parse_order("title"), do: :title
|
||||||
defp parse_order("artist"), do: :artist
|
defp parse_order("artist"), do: :artist
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ defmodule MusicLibraryWeb.WishlistLive.Index do
|
|||||||
alias MusicLibrary.Records
|
alias MusicLibrary.Records
|
||||||
alias MusicLibrary.Wishlist
|
alias MusicLibrary.Wishlist
|
||||||
alias MusicLibraryWeb.Components.AddRecord
|
alias MusicLibraryWeb.Components.AddRecord
|
||||||
|
alias MusicLibraryWeb.ErrorMessages
|
||||||
alias MusicLibraryWeb.LiveHelpers.IndexActions
|
alias MusicLibraryWeb.LiveHelpers.IndexActions
|
||||||
|
|
||||||
defp index_config do
|
defp index_config do
|
||||||
@@ -230,6 +231,29 @@ defmodule MusicLibraryWeb.WishlistLive.Index do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_async({:delete_record, _id}, {:ok, {:ok, _record}}, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async({:delete_record, id}, {:ok, {:error, reason}}, socket) do
|
||||||
|
record = Records.get_record!(id)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> stream_insert(:records, record)
|
||||||
|
|> put_toast(:error, gettext("Failed to delete: ") <> ErrorMessages.friendly_message(reason))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async({:delete_record, id}, {:exit, _reason}, socket) do
|
||||||
|
record = Records.get_record!(id)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> stream_insert(:records, record)
|
||||||
|
|> put_toast(:error, gettext("Delete failed, please try again"))}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
IndexActions.handle_delete(socket, id)
|
IndexActions.handle_delete(socket, id)
|
||||||
|
|||||||
@@ -3,7 +3,16 @@ defmodule MusicLibraryWeb.LiveHelpers.IndexActions do
|
|||||||
|
|
||||||
import LiveToast, only: [put_toast: 3]
|
import LiveToast, only: [put_toast: 3]
|
||||||
import Phoenix.Component, only: [assign: 3]
|
import Phoenix.Component, only: [assign: 3]
|
||||||
import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, stream: 4, stream_delete: 3]
|
|
||||||
|
import Phoenix.LiveView,
|
||||||
|
only: [
|
||||||
|
push_patch: 2,
|
||||||
|
push_navigate: 2,
|
||||||
|
start_async: 3,
|
||||||
|
stream: 4,
|
||||||
|
stream_delete: 3
|
||||||
|
]
|
||||||
|
|
||||||
import MusicLibraryWeb.Components.Pagination, only: [page_to_offset: 2]
|
import MusicLibraryWeb.Components.Pagination, only: [page_to_offset: 2]
|
||||||
import MusicLibraryWeb.LiveHelpers.Params
|
import MusicLibraryWeb.LiveHelpers.Params
|
||||||
|
|
||||||
@@ -91,9 +100,12 @@ defmodule MusicLibraryWeb.LiveHelpers.IndexActions do
|
|||||||
|
|
||||||
def handle_delete(socket, id) do
|
def handle_delete(socket, id) do
|
||||||
record = Records.get_record!(id)
|
record = Records.get_record!(id)
|
||||||
{:ok, _} = Records.delete_record(record)
|
socket = stream_delete(socket, :records, record)
|
||||||
|
|
||||||
{:noreply, stream_delete(socket, :records, record)}
|
{:noreply,
|
||||||
|
start_async(socket, {:delete_record, id}, fn ->
|
||||||
|
Records.delete_record(record)
|
||||||
|
end)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_search(socket, query) do
|
def handle_search(socket, query) do
|
||||||
|
|||||||
@@ -2562,3 +2562,17 @@ msgstr ""
|
|||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Record was updated in the background. Your edits may be stale — save and re-open to see the latest data."
|
msgid "Record was updated in the background. Your edits may be stale — save and re-open to see the latest data."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/live/collection_live/index.ex
|
||||||
|
#: lib/music_library_web/live/scrobbled_tracks_live/index.ex
|
||||||
|
#: lib/music_library_web/live/wishlist_live/index.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete failed, please try again"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/live/collection_live/index.ex
|
||||||
|
#: lib/music_library_web/live/scrobbled_tracks_live/index.ex
|
||||||
|
#: lib/music_library_web/live/wishlist_live/index.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Failed to delete: "
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -2562,3 +2562,17 @@ msgstr ""
|
|||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Record was updated in the background. Your edits may be stale — save and re-open to see the latest data."
|
msgid "Record was updated in the background. Your edits may be stale — save and re-open to see the latest data."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/live/collection_live/index.ex
|
||||||
|
#: lib/music_library_web/live/scrobbled_tracks_live/index.ex
|
||||||
|
#: lib/music_library_web/live/wishlist_live/index.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete failed, please try again"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/live/collection_live/index.ex
|
||||||
|
#: lib/music_library_web/live/scrobbled_tracks_live/index.ex
|
||||||
|
#: lib/music_library_web/live/wishlist_live/index.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Failed to delete: "
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ defmodule MusicLibraryWeb.CollectionLive.IndexTest do
|
|||||||
alias MusicLibrary.Records.Record
|
alias MusicLibrary.Records.Record
|
||||||
alias MusicLibrary.Worker.ImportFromMusicbrainzRelease
|
alias MusicLibrary.Worker.ImportFromMusicbrainzRelease
|
||||||
alias MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup
|
alias MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup
|
||||||
|
alias MusicLibraryWeb.CollectionLive.Index, as: CollectionIndex
|
||||||
alias Req.Test
|
alias Req.Test
|
||||||
|
|
||||||
# make it a multiple of 4 for easier calculations
|
# make it a multiple of 4 for easier calculations
|
||||||
@@ -137,6 +138,7 @@ defmodule MusicLibraryWeb.CollectionLive.IndexTest do
|
|||||||
|> assert_has("#records-#{record.id}")
|
|> assert_has("#records-#{record.id}")
|
||||||
|> click_link("#records-#{record.id} a[data-confirm='Are you sure?']", "Delete")
|
|> click_link("#records-#{record.id} a[data-confirm='Are you sure?']", "Delete")
|
||||||
|> refute_has("#records-#{record.id}")
|
|> refute_has("#records-#{record.id}")
|
||||||
|
|> render_async()
|
||||||
|
|
||||||
assert_raise Ecto.NoResultsError, fn ->
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
Records.get_record!(record.id)
|
Records.get_record!(record.id)
|
||||||
@@ -775,4 +777,72 @@ defmodule MusicLibraryWeb.CollectionLive.IndexTest do
|
|||||||
payload = Transform.encode!(transform)
|
payload = Transform.encode!(transform)
|
||||||
~p"/assets/#{payload}"
|
~p"/assets/#{payload}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "handle_async delete_record" do
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
defp build_socket(conn) do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/collection")
|
||||||
|
state = :sys.get_state(view.pid)
|
||||||
|
state.socket
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:ok, {:ok, _}} is a no-op", %{conn: conn} do
|
||||||
|
record = record(%{title: "HandleAsync Success"})
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :records, record)
|
||||||
|
|
||||||
|
assert {:noreply, _socket} =
|
||||||
|
CollectionIndex.handle_async(
|
||||||
|
{:delete_record, record.id},
|
||||||
|
{:ok, {:ok, record}},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:ok, {:error, _}} does not crash", %{conn: conn} do
|
||||||
|
record = record(%{title: "HandleAsync Error"})
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :records, record)
|
||||||
|
|
||||||
|
assert {:noreply, _socket} =
|
||||||
|
CollectionIndex.handle_async(
|
||||||
|
{:delete_record, record.id},
|
||||||
|
{:ok, {:error, :not_found}},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:exit, _} does not crash", %{conn: conn} do
|
||||||
|
record = record(%{title: "HandleAsync Exit"})
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :records, record)
|
||||||
|
|
||||||
|
assert {:noreply, _socket} =
|
||||||
|
CollectionIndex.handle_async(
|
||||||
|
{:delete_record, record.id},
|
||||||
|
{:exit, :killed},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:exit, _} uses generic message, never leaks reason", %{conn: conn} do
|
||||||
|
record = record(%{title: "HandleAsync Exit No Leak"})
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :records, record)
|
||||||
|
|
||||||
|
{:noreply, socket} =
|
||||||
|
CollectionIndex.handle_async(
|
||||||
|
{:delete_record, record.id},
|
||||||
|
{:exit, {:killed, "some stacktrace that should not leak"}},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the toast contains the generic message, NOT the raw exit reason
|
||||||
|
assert socket.assigns[:toasts_sync]
|
||||||
|
[toast | _] = socket.assigns.toasts_sync
|
||||||
|
refute inspect(toast) =~ "stacktrace"
|
||||||
|
refute inspect(toast) =~ "killed"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLiveTest do
|
|||||||
import MusicLibrary.ScrobbledTracksFixtures
|
import MusicLibrary.ScrobbledTracksFixtures
|
||||||
|
|
||||||
alias MusicLibrary.ListeningStats
|
alias MusicLibrary.ListeningStats
|
||||||
|
alias MusicLibraryWeb.ScrobbledTracksLive.Index, as: ScrobbledTracksIndex
|
||||||
|
|
||||||
@valid_track_attrs %{
|
@valid_track_attrs %{
|
||||||
title: "Updated Track Title",
|
title: "Updated Track Title",
|
||||||
@@ -95,6 +96,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLiveTest do
|
|||||||
|> assert_has("p", track.title)
|
|> assert_has("p", track.title)
|
||||||
|> click_button("button[data-confirm='Are you sure?']", "Delete")
|
|> click_button("button[data-confirm='Are you sure?']", "Delete")
|
||||||
|> refute_has("p", track.title)
|
|> refute_has("p", track.title)
|
||||||
|
|> render_async()
|
||||||
|
|
||||||
assert_raise Ecto.NoResultsError, fn ->
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
ListeningStats.get_track!(track.scrobbled_at_uts)
|
ListeningStats.get_track!(track.scrobbled_at_uts)
|
||||||
@@ -154,4 +156,75 @@ defmodule MusicLibraryWeb.ScrobbledTracksLiveTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "handle_async delete_track" do
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
defp build_socket(conn) do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/scrobbled-tracks")
|
||||||
|
state = :sys.get_state(view.pid)
|
||||||
|
state.socket
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:ok, {{:ok, _}, _}} is a no-op", %{conn: conn} do
|
||||||
|
track = track_fixture()
|
||||||
|
element = %{track: track}
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :tracks, element)
|
||||||
|
|
||||||
|
assert {:noreply, _socket} =
|
||||||
|
ScrobbledTracksIndex.handle_async(
|
||||||
|
{:delete_track, track.scrobbled_at_uts},
|
||||||
|
{:ok, {{:ok, track}, element}},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:ok, {{:error, _}, _}} does not crash", %{conn: conn} do
|
||||||
|
track = track_fixture()
|
||||||
|
element = %{track: track}
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :tracks, element)
|
||||||
|
|
||||||
|
assert {:noreply, _socket} =
|
||||||
|
ScrobbledTracksIndex.handle_async(
|
||||||
|
{:delete_track, track.scrobbled_at_uts},
|
||||||
|
{:ok, {{:error, :not_found}, element}},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:exit, _} does not crash", %{conn: conn} do
|
||||||
|
track = track_fixture()
|
||||||
|
element = %{track: track}
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :tracks, element)
|
||||||
|
|
||||||
|
assert {:noreply, _socket} =
|
||||||
|
ScrobbledTracksIndex.handle_async(
|
||||||
|
{:delete_track, track.scrobbled_at_uts},
|
||||||
|
{:exit, :killed},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:exit, _} uses generic message, never leaks reason", %{conn: conn} do
|
||||||
|
track = track_fixture()
|
||||||
|
element = %{track: track}
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :tracks, element)
|
||||||
|
|
||||||
|
{:noreply, socket} =
|
||||||
|
ScrobbledTracksIndex.handle_async(
|
||||||
|
{:delete_track, track.scrobbled_at_uts},
|
||||||
|
{:exit, {:killed, "some stacktrace that should not leak"}},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
|
||||||
|
assert socket.assigns[:toasts_sync]
|
||||||
|
[toast | _] = socket.assigns.toasts_sync
|
||||||
|
refute inspect(toast) =~ "stacktrace"
|
||||||
|
refute inspect(toast) =~ "killed"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ defmodule MusicLibraryWeb.WishlistLive.IndexTest do
|
|||||||
alias MusicLibrary.Records
|
alias MusicLibrary.Records
|
||||||
alias MusicLibrary.Records.Record
|
alias MusicLibrary.Records.Record
|
||||||
alias MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup
|
alias MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup
|
||||||
|
alias MusicLibraryWeb.WishlistLive.Index, as: WishlistIndex
|
||||||
alias Req.Test
|
alias Req.Test
|
||||||
|
|
||||||
defp fill_wishlist(_) do
|
defp fill_wishlist(_) do
|
||||||
@@ -53,6 +54,7 @@ defmodule MusicLibraryWeb.WishlistLive.IndexTest do
|
|||||||
|> assert_has("#records-#{record.id}")
|
|> assert_has("#records-#{record.id}")
|
||||||
|> click_link("#records-#{record.id} a[data-confirm='Are you sure?']", "Delete")
|
|> click_link("#records-#{record.id} a[data-confirm='Are you sure?']", "Delete")
|
||||||
|> refute_has("#records-#{record.id}")
|
|> refute_has("#records-#{record.id}")
|
||||||
|
|> render_async()
|
||||||
|
|
||||||
assert_raise Ecto.NoResultsError, fn ->
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
Records.get_record!(record.id)
|
Records.get_record!(record.id)
|
||||||
@@ -174,4 +176,71 @@ defmodule MusicLibraryWeb.WishlistLive.IndexTest do
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "handle_async delete_record" do
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
defp build_socket(conn) do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/wishlist")
|
||||||
|
state = :sys.get_state(view.pid)
|
||||||
|
state.socket
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:ok, {:ok, _}} is a no-op", %{conn: conn} do
|
||||||
|
record = record(%{title: "HandleAsync Success", purchased_at: nil})
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :records, record)
|
||||||
|
|
||||||
|
assert {:noreply, _socket} =
|
||||||
|
WishlistIndex.handle_async(
|
||||||
|
{:delete_record, record.id},
|
||||||
|
{:ok, {:ok, record}},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:ok, {:error, _}} does not crash", %{conn: conn} do
|
||||||
|
record = record(%{title: "HandleAsync Error", purchased_at: nil})
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :records, record)
|
||||||
|
|
||||||
|
assert {:noreply, _socket} =
|
||||||
|
WishlistIndex.handle_async(
|
||||||
|
{:delete_record, record.id},
|
||||||
|
{:ok, {:error, :not_found}},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:exit, _} does not crash", %{conn: conn} do
|
||||||
|
record = record(%{title: "HandleAsync Exit", purchased_at: nil})
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :records, record)
|
||||||
|
|
||||||
|
assert {:noreply, _socket} =
|
||||||
|
WishlistIndex.handle_async(
|
||||||
|
{:delete_record, record.id},
|
||||||
|
{:exit, :killed},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "{:exit, _} uses generic message, never leaks reason", %{conn: conn} do
|
||||||
|
record = record(%{title: "HandleAsync Exit No Leak", purchased_at: nil})
|
||||||
|
socket = build_socket(conn)
|
||||||
|
socket = Phoenix.LiveView.stream_delete(socket, :records, record)
|
||||||
|
|
||||||
|
{:noreply, socket} =
|
||||||
|
WishlistIndex.handle_async(
|
||||||
|
{:delete_record, record.id},
|
||||||
|
{:exit, {:killed, "some stacktrace that should not leak"}},
|
||||||
|
socket
|
||||||
|
)
|
||||||
|
|
||||||
|
assert socket.assigns[:toasts_sync]
|
||||||
|
[toast | _] = socket.assigns.toasts_sync
|
||||||
|
refute inspect(toast) =~ "stacktrace"
|
||||||
|
refute inspect(toast) =~ "killed"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user