335 lines
10 KiB
Elixir
335 lines
10 KiB
Elixir
defmodule MusicLibraryWeb.CollectionControllerTest do
|
|
use MusicLibraryWeb.ConnCase
|
|
|
|
import MusicLibrary.Fixtures.Records
|
|
|
|
alias MusicBrainz.Fixtures.Release, as: ReleaseFixtures
|
|
alias MusicLibrary.Secrets
|
|
alias Req.Test
|
|
|
|
defp create_record(_) do
|
|
%{record: record_with_artist("Steven Wilson", %{release_date: "2025-06-21"})}
|
|
end
|
|
|
|
defp api_token do
|
|
Application.get_env(:music_library, MusicLibraryWeb)
|
|
|> Keyword.fetch!(:api_token)
|
|
end
|
|
|
|
describe "authentication" do
|
|
test "all API endpoints require a bearer token", %{conn: conn} do
|
|
for {method, path} <- [
|
|
{:get, ~p"/api/v1/collection/latest"},
|
|
{:get, ~p"/api/v1/collection/random"},
|
|
{:get, ~p"/api/v1/collection"},
|
|
{:get, ~p"/api/v1/collection/on_this_day"},
|
|
{:post, ~p"/api/v1/collection/nonexistent/scrobble"}
|
|
] do
|
|
conn =
|
|
case method do
|
|
:get -> get(conn, path)
|
|
:post -> post(conn, path)
|
|
end
|
|
|
|
assert conn.status == 401,
|
|
"expected 401 for unauthenticated #{String.upcase(to_string(method))} #{path}"
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "GET /api/v1/collection/latest" do
|
|
setup [:create_record]
|
|
|
|
test "returns the latest record", %{conn: conn, record: record} do
|
|
conn =
|
|
conn
|
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|
|> get(~p"/api/v1/collection/latest")
|
|
|
|
assert json_response(conn, 200) == expected_record_json(record)
|
|
end
|
|
end
|
|
|
|
describe "GET /api/v1/collection/random" do
|
|
setup [:create_record]
|
|
|
|
# We're not testing random here - the query is solid enough
|
|
test "returns a random record", %{conn: conn, record: record} do
|
|
conn =
|
|
conn
|
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|
|> get(~p"/api/v1/collection/random")
|
|
|
|
assert json_response(conn, 200) == expected_record_json(record)
|
|
end
|
|
end
|
|
|
|
describe "GET /api/v1/collection" do
|
|
setup [:create_record]
|
|
|
|
test "returns a paginated list of records", %{conn: conn, record: record} do
|
|
conn =
|
|
conn
|
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|
|> get(~p"/api/v1/collection")
|
|
|
|
assert json_response(conn, 200) == %{
|
|
"total" => 1,
|
|
"limit" => 20,
|
|
"offset" => 0,
|
|
"records" => [expected_record_json(record)]
|
|
}
|
|
end
|
|
|
|
test "filters records when q param matches", %{conn: conn} do
|
|
beatles = record_with_artist("The Beatles", %{title: "Abbey Road"})
|
|
|
|
conn =
|
|
conn
|
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|
|> get(~p"/api/v1/collection?q=beatles")
|
|
|
|
assert json_response(conn, 200) == %{
|
|
"total" => 1,
|
|
"limit" => 20,
|
|
"offset" => 0,
|
|
"records" => [expected_record_json(beatles)]
|
|
}
|
|
end
|
|
|
|
test "returns all records when q is empty string", %{conn: conn, record: record} do
|
|
conn =
|
|
conn
|
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|
|> get(~p"/api/v1/collection?q=")
|
|
|
|
assert json_response(conn, 200) == %{
|
|
"total" => 1,
|
|
"limit" => 20,
|
|
"offset" => 0,
|
|
"records" => [expected_record_json(record)]
|
|
}
|
|
end
|
|
|
|
test "returns empty results when q matches nothing", %{conn: conn} do
|
|
conn =
|
|
conn
|
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|
|> get(~p"/api/v1/collection?q=nonexistentxyz")
|
|
|
|
assert json_response(conn, 200) == %{
|
|
"total" => 0,
|
|
"limit" => 20,
|
|
"offset" => 0,
|
|
"records" => []
|
|
}
|
|
end
|
|
|
|
test "respects pagination with search query", %{conn: conn} do
|
|
_a = record_with_artist("The Beatles", %{title: "Abbey Road"})
|
|
b = record_with_artist("The Beatles", %{title: "Revolver"})
|
|
_c = record_with_artist("The Beatles", %{title: "Sgt Pepper"})
|
|
|
|
conn =
|
|
conn
|
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|
|> get(~p"/api/v1/collection?q=beatles&limit=1&offset=1")
|
|
|
|
assert json_response(conn, 200) == %{
|
|
"total" => 3,
|
|
"limit" => 1,
|
|
"offset" => 1,
|
|
"records" => [expected_record_json(b)]
|
|
}
|
|
end
|
|
|
|
test "response shape is unchanged when searching", %{conn: conn} do
|
|
beatles = record_with_artist("The Beatles", %{title: "Abbey Road"})
|
|
|
|
conn =
|
|
conn
|
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|
|> get(~p"/api/v1/collection?q=beatles")
|
|
|
|
response = json_response(conn, 200)
|
|
assert %{"total" => 1, "limit" => 20, "offset" => 0, "records" => [record_json]} = response
|
|
assert record_json == expected_record_json(beatles)
|
|
end
|
|
end
|
|
|
|
describe "GET /api/v1/collection/on_this_day" do
|
|
setup [:create_record]
|
|
|
|
test "returns a list of records", %{conn: conn, record: record} do
|
|
conn =
|
|
conn
|
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|
|> get(~p"/api/v1/collection/on_this_day?date=2025-06-21")
|
|
|
|
assert json_response(conn, 200) == %{
|
|
"records" => [expected_record_json(record)]
|
|
}
|
|
end
|
|
end
|
|
|
|
describe "POST /api/v1/collection/:record_id/scrobble" do
|
|
defp stub_musicbrainz_release(_) do
|
|
Test.stub(MusicBrainz.API, fn conn ->
|
|
case conn.request_path do
|
|
"/ws/2/release/" <> _id ->
|
|
Test.json(conn, ReleaseFixtures.release_with_media(:marbles))
|
|
|
|
_ ->
|
|
Test.json(conn, %{})
|
|
end
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
defp stub_lastfm_success(_) do
|
|
Test.stub(LastFm.API, fn conn ->
|
|
Test.json(conn, %{"scrobbles" => %{"@attr" => %{"accepted" => 1}}})
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
defp stub_lastfm_error(_) do
|
|
Test.stub(LastFm.API, fn conn ->
|
|
Test.json(conn, %{
|
|
"error" => 16,
|
|
"message" => "The service is temporarily unavailable"
|
|
})
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
defp stub_musicbrainz_error(_) do
|
|
Test.stub(MusicBrainz.API, fn conn ->
|
|
Plug.Conn.send_resp(conn, 503, "service unavailable")
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
defp store_session_key(_) do
|
|
Secrets.store("last_fm_session_key", "test_session_key")
|
|
|
|
on_exit(fn ->
|
|
Secrets.store("last_fm_session_key", nil)
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
defp auth_header(conn) do
|
|
put_req_header(conn, "authorization", "Bearer #{api_token()}")
|
|
end
|
|
|
|
setup [:create_record]
|
|
|
|
test "returns 401 without bearer token", %{conn: conn, record: record} do
|
|
conn = post(conn, ~p"/api/v1/collection/#{record.id}/scrobble")
|
|
|
|
assert conn.status == 401
|
|
end
|
|
|
|
test "returns 404 for non-existent record", %{conn: conn} do
|
|
conn =
|
|
conn
|
|
|> auth_header()
|
|
|> post(~p"/api/v1/collection/00000000-0000-0000-0000-000000000000/scrobble")
|
|
|
|
assert json_response(conn, 404) == %{
|
|
"status" => "error",
|
|
"reason" => "not_found"
|
|
}
|
|
end
|
|
|
|
test "returns 422 when record has no selected_release_id", %{conn: conn} do
|
|
record = record_with_artist("No Release Artist", %{selected_release_id: nil})
|
|
|
|
conn =
|
|
conn
|
|
|> auth_header()
|
|
|> post(~p"/api/v1/collection/#{record.id}/scrobble")
|
|
|
|
assert json_response(conn, 422) == %{
|
|
"status" => "error",
|
|
"reason" => "no_selected_release"
|
|
}
|
|
end
|
|
|
|
@tag scrobble_success: true
|
|
test "returns 200 when scrobble succeeds", %{conn: conn, record: record} do
|
|
stub_musicbrainz_release(nil)
|
|
stub_lastfm_success(nil)
|
|
store_session_key(nil)
|
|
|
|
conn =
|
|
conn
|
|
|> auth_header()
|
|
|> post(~p"/api/v1/collection/#{record.id}/scrobble")
|
|
|
|
assert json_response(conn, 200) == %{"status" => "ok"}
|
|
end
|
|
|
|
@tag :capture_log
|
|
test "returns 502 when MusicBrainz API fails", %{conn: conn, record: record} do
|
|
stub_musicbrainz_error(nil)
|
|
|
|
conn =
|
|
conn
|
|
|> auth_header()
|
|
|> post(~p"/api/v1/collection/#{record.id}/scrobble")
|
|
|
|
assert json_response(conn, 502) == %{
|
|
"status" => "error",
|
|
"reason" => "musicbrainz_error"
|
|
}
|
|
end
|
|
|
|
@tag :capture_log
|
|
test "returns 502 when Last.fm API fails", %{conn: conn, record: record} do
|
|
stub_musicbrainz_release(nil)
|
|
stub_lastfm_error(nil)
|
|
store_session_key(nil)
|
|
|
|
conn =
|
|
conn
|
|
|> auth_header()
|
|
|> post(~p"/api/v1/collection/#{record.id}/scrobble")
|
|
|
|
assert json_response(conn, 502) == %{
|
|
"status" => "error",
|
|
"reason" => "lastfm_error"
|
|
}
|
|
end
|
|
end
|
|
|
|
defp expected_record_json(record) do
|
|
%{
|
|
"id" => record.id,
|
|
"type" => record.type |> to_string(),
|
|
"format" => record.format |> to_string(),
|
|
"musicbrainz_id" => record.musicbrainz_id,
|
|
"genres" => record.genres,
|
|
"release_date" => record.release_date,
|
|
"selected_release_id" => record.selected_release_id,
|
|
"purchased_at" => record.purchased_at |> DateTime.to_iso8601(),
|
|
"artists" => Enum.map(record.artists, & &1.name),
|
|
"title" => record.title,
|
|
"cover_url" =>
|
|
"http://localhost:4002/api/v1/assets/eyJoYXNoIjoiNTk5NDA3RERGNjk5MDdENEE2MEZFMTNDQ0FBODI0RDI1Q0YwOERDMTI0RkQ2QUEzRThFN0VDRDk4Qzg4NUZGRSIsIndpZHRoIjpudWxsfQ",
|
|
"thumb_url" =>
|
|
"http://localhost:4002/api/v1/assets/eyJoYXNoIjoiNTk5NDA3RERGNjk5MDdENEE2MEZFMTNDQ0FBODI0RDI1Q0YwOERDMTI0RkQ2QUEzRThFN0VDRDk4Qzg4NUZGRSIsIndpZHRoIjo0ODB9",
|
|
"mini_cover_url" =>
|
|
"http://localhost:4002/api/v1/assets/eyJoYXNoIjoiNTk5NDA3RERGNjk5MDdENEE2MEZFMTNDQ0FBODI0RDI1Q0YwOERDMTI0RkQ2QUEzRThFN0VDRDk4Qzg4NUZGRSIsIndpZHRoIjoxNTB9",
|
|
"micro_cover_url" =>
|
|
"http://localhost:4002/api/v1/assets/eyJoYXNoIjoiNTk5NDA3RERGNjk5MDdENEE2MEZFMTNDQ0FBODI0RDI1Q0YwOERDMTI0RkQ2QUEzRThFN0VDRDk4Qzg4NUZGRSIsIndpZHRoIjo0MH0"
|
|
}
|
|
end
|
|
end
|