ML-162: enable /api/v1/errors endpoint

This commit is contained in:
Claudio Ortolina
2026-05-04 13:14:40 +01:00
parent 213ca31a42
commit 51bd24d5b4
8 changed files with 611 additions and 15 deletions
@@ -0,0 +1,230 @@
defmodule MusicLibraryWeb.ErrorControllerTest do
use MusicLibraryWeb.ConnCase
import MusicLibrary.ErrorsFixtures
defp api_token do
Application.get_env(:music_library, MusicLibraryWeb)
|> Keyword.fetch!(:api_token)
end
describe "authentication" do
test "GET /api/v1/errors requires a bearer token", %{conn: conn} do
assert get(conn, ~p"/api/v1/errors").status == 401
end
test "GET /api/v1/errors/:id requires a bearer token", %{conn: conn} do
assert get(conn, ~p"/api/v1/errors/1").status == 401
end
end
describe "GET /api/v1/errors" do
setup do
error1 =
error_fixture(%{
reason: "First error",
source_line: "lib/my_module.ex:10",
source_function: "MyModule.do_thing/0",
fingerprint:
error_fingerprint(:runtime_error, "lib/my_module.ex:10", "MyModule.do_thing/0")
})
error2 =
error_fixture(%{
reason: "Second error",
status: :resolved,
muted: true,
source_line: "lib/my_module.ex:20",
source_function: "MyModule.other_thing/1",
fingerprint:
error_fingerprint(:runtime_error, "lib/my_module.ex:20", "MyModule.other_thing/1")
})
error3 =
error_fixture(%{
kind: "ArgumentError",
reason: "Bad argument in MyOtherModule.call/2",
source_line: "lib/other_module.ex:10",
source_function: "MyOtherModule.call/2",
fingerprint:
error_fingerprint(:argument_error, "lib/other_module.ex:10", "MyOtherModule.call/2")
})
%{errors: [error1, error2, error3]}
end
test "returns a paginated list of errors", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors")
assert %{"errors" => returned, "total" => 3, "limit" => 50, "offset" => 0} =
json_response(conn, 200)
assert length(returned) == 3
# Verify each returned error has the expected fields
for error <- returned do
assert is_integer(error["id"])
assert is_binary(error["kind"])
assert is_binary(error["reason"])
assert is_binary(error["source_line"])
assert is_binary(error["source_function"])
assert error["status"] in ["resolved", "unresolved"]
assert is_binary(error["fingerprint"])
assert is_binary(error["last_occurrence_at"])
assert is_boolean(error["muted"])
assert is_binary(error["inserted_at"])
assert is_binary(error["updated_at"])
# List endpoint omits occurrence_count and first_occurrence_at
refute Map.has_key?(error, "occurrence_count")
refute Map.has_key?(error, "first_occurrence_at")
end
end
test "filters by status", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors?status=resolved")
assert %{"errors" => returned, "total" => 1} = json_response(conn, 200)
assert Enum.all?(returned, &(&1["status"] == "resolved"))
end
test "filters by muted", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors?muted=true")
assert %{"errors" => returned, "total" => 1} = json_response(conn, 200)
assert Enum.all?(returned, &(&1["muted"] == true))
end
test "filters by search on reason", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors?search=second")
assert %{"errors" => returned, "total" => 1} = json_response(conn, 200)
assert hd(returned)["reason"] == "Second error"
end
test "respects limit and offset", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors?limit=2&offset=1")
assert %{"errors" => returned, "total" => 3, "limit" => 2, "offset" => 1} =
json_response(conn, 200)
assert length(returned) == 2
end
test "returns empty list when no errors match", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors?search=NONEXISTENT")
assert %{"errors" => [], "total" => 0} = json_response(conn, 200)
end
end
describe "GET /api/v1/errors/:id" do
setup do
error = error_fixture(%{reason: "Something went wrong"})
occ1 = occurrence_fixture(error, %{breadcrumbs: ["first occurrence"]})
occ2 =
occurrence_fixture(error, %{
reason: "Updated reason",
context: %{user_id: 2},
breadcrumbs: ["second occurrence"],
stacktrace: %ErrorTracker.Stacktrace{
lines: [
%ErrorTracker.Stacktrace.Line{
application: "music_library",
module: "OtherModule",
function: "call",
arity: 2,
file: "lib/other_module.ex",
line: 10
},
%ErrorTracker.Stacktrace.Line{
application: "elixir",
module: "Enum",
function: "map",
arity: 2,
file: "lib/enum.ex",
line: 1500
}
]
}
})
%{error: error, occurrences: [occ1, occ2]}
end
test "returns a single error with occurrences", %{conn: conn, error: error} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors/#{error.id}")
assert %{"error" => returned} = json_response(conn, 200)
assert returned["id"] == error.id
assert returned["reason"] == "Something went wrong"
assert returned["status"] == "unresolved"
assert returned["muted"] == false
# Computed fields
assert returned["occurrence_count"] == 2
refute is_nil(returned["first_occurrence_at"])
# Occurrences
occurrences = returned["occurrences"]
assert length(occurrences) == 2
# Most recent first (desc inserted_at)
first_occ = hd(occurrences)
second_occ = List.last(occurrences)
assert first_occ["reason"] == "Updated reason"
assert first_occ["context"] == %{"user_id" => 2}
assert first_occ["breadcrumbs"] == ["second occurrence"]
assert second_occ["reason"] == "Something went wrong"
assert second_occ["context"] == %{"user_id" => 1}
assert second_occ["breadcrumbs"] == ["first occurrence"]
# Stacktrace lines
lines = first_occ["stacktrace"]["lines"]
assert length(lines) == 2
[line1, line2] = lines
assert line1["module"] == "OtherModule"
assert line1["function"] == "call"
assert line1["arity"] == 2
assert line1["file"] == "lib/other_module.ex"
assert line1["line"] == 10
assert line2["module"] == "Enum"
assert line2["application"] == "elixir"
end
test "returns 404 for non-existent error", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors/99999")
assert json_response(conn, 404) == %{"error" => "Not Found"}
end
end
end
+71
View File
@@ -0,0 +1,71 @@
defmodule MusicLibrary.ErrorsFixtures do
@moduledoc """
Fixtures for ErrorTracker error and occurrence data.
ErrorTracker is disabled in test (`enabled: false` in config), so we cannot use
`ErrorTracker.report/3` to seed data. Instead, we insert directly via `MusicLibrary.Repo`
using ErrorTracker's own schemas.
"""
alias ErrorTracker.{Error, Occurrence, Stacktrace}
alias MusicLibrary.Repo
@doc """
Inserts an error record with the given attributes merged over defaults.
"""
def error_fixture(attrs \\ []) do
defaults = %{
kind: "RuntimeError",
reason: "Something went wrong",
source_line: "lib/my_module.ex:42",
source_function: "MyModule.do_thing/0",
status: :unresolved,
fingerprint:
error_fingerprint(:runtime_error, "lib/my_module.ex:42", "MyModule.do_thing/0"),
last_occurrence_at: DateTime.utc_now(),
muted: false
}
defaults
|> Map.merge(Map.new(attrs))
|> then(&Repo.insert!(struct!(Error, &1)))
end
@doc """
Inserts an occurrence record associated with the given error.
"""
def occurrence_fixture(error, attrs \\ []) do
defaults = %{
reason: error.reason,
context: %{user_id: 1},
breadcrumbs: ["step 1"],
stacktrace: %Stacktrace{
lines: [
%Stacktrace.Line{
application: "music_library",
module: "MyModule",
function: "do_thing",
arity: 0,
file: "lib/my_module.ex",
line: 42
}
]
},
error_id: error.id
}
defaults
|> Map.merge(Map.new(attrs))
|> then(&Repo.insert!(struct!(Occurrence, &1)))
end
@doc """
Generates a fingerprint matching ErrorTracker's algorithm: hex(SHA256(joined_params)).
"""
def error_fingerprint(kind, source_line, source_function) do
[kind, source_line, source_function]
|> Enum.join()
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16()
end
end