prop/test/microwaveprop/oban_error_reporter_test.exs
Graham McIntire 316fb2fbc7
Fix low-severity bugs and re-enable Credo checks
- Bug #12: Lower rate limit to 15/min
- Bug #13: Wrap model loading in Task.start
- Bug #14: Fix classify_time_period guard gap at -3.0
- Bug #15: Not applicable (Elixir has no ?? operator)
- Bug #16: Remove fallback Repo.get for station preload
- Bug #17: Extract cache_key() in ContactMapController
- Bug #18: Add has_many :contacts and :beacons to User schema
- A&D #4: Move serve_markdown_if_requested after secure headers
- Config #1: Move signing_salt to runtime.exs env var
- Config #2: Use --check-unused instead of --unused
- Config #3: Re-enable UnsafeToAtom Credo check
- Config #5: Re-enable LeakyEnvironment Credo check
- Add @spec annotations to fix re-enabled Specs violations
- Replace String.to_atom with to_existing_atom where guarded
2026-05-29 17:29:22 -05:00

271 lines
6.9 KiB
Elixir

defmodule Microwaveprop.ObanErrorReporterTest do
# async: false — test attaches/detaches a named :telemetry handler
# and toggles per-test callback modules in the process dictionary.
use ExUnit.Case, async: false
import ExUnit.CaptureLog
alias Microwaveprop.ObanErrorReporter
# Worker stubs used to assert callback dispatch. Each records a message
# back to the test process so we can `assert_receive`.
defmodule PermanentWorker do
@moduledoc false
@spec on_permanent_failure(Oban.Job.t()) :: :ok
def on_permanent_failure(job) do
send(:oban_error_reporter_test, {:permanent, job.id})
:ok
end
end
defmodule TransientWorker do
@moduledoc false
@spec on_transient_failure(Oban.Job.t()) :: :ok
def on_transient_failure(job) do
send(:oban_error_reporter_test, {:transient, job.id})
:ok
end
end
defmodule BothWorker do
@moduledoc false
@spec on_permanent_failure(Oban.Job.t()) :: :ok
def on_permanent_failure(job) do
send(:oban_error_reporter_test, {:permanent, job.id})
:ok
end
@spec on_transient_failure(Oban.Job.t()) :: :ok
def on_transient_failure(job) do
send(:oban_error_reporter_test, {:transient, job.id})
:ok
end
end
defmodule RaisingWorker do
@moduledoc false
@spec on_permanent_failure(Oban.Job.t()) :: any()
def on_permanent_failure(_job), do: raise("callback boom")
@spec on_transient_failure(Oban.Job.t()) :: any()
def on_transient_failure(_job), do: raise("callback boom")
end
defmodule SilentWorker do
@moduledoc false
# No callbacks — used to confirm the reporter still logs for workers
# that haven't opted in.
end
setup do
Process.register(self(), :oban_error_reporter_test)
:ok = ObanErrorReporter.attach()
on_exit(fn ->
ObanErrorReporter.detach()
end)
:ok
end
defp execute_exception(metadata, measurements \\ %{duration: 123}) do
:telemetry.execute([:oban, :job, :exception], measurements, metadata)
end
defp build_job(overrides) do
defaults = %{
id: 42,
args: %{"foo" => "bar"},
queue: "propagation",
worker: "Microwaveprop.Workers.SomeWorker",
attempt: 1,
max_attempts: 3,
state: "executing"
}
# Use a bare map (not Oban.Job struct) so tests don't depend on Oban
# compile-time internals. The reporter only reads fields.
Map.merge(defaults, overrides)
end
defp build_metadata(overrides) do
job = build_job(Map.get(overrides, :job, %{}))
Map.merge(
%{
job: job,
worker: job.worker,
queue: job.queue,
kind: :error,
reason: %RuntimeError{message: "kaboom"},
stacktrace: [{Mod, :fun, 1, [file: ~c"x.ex", line: 1]}]
},
Map.delete(overrides, :job)
)
end
describe "attach/0" do
test "is idempotent — calling twice does not raise" do
assert :ok = ObanErrorReporter.attach()
end
end
describe "handle_event/4 on [:oban, :job, :exception]" do
test "logs an error with structured metadata" do
meta =
build_metadata(%{
job: %{
id: 7,
worker: "Microwaveprop.Workers.SilentWorker",
queue: "weather",
attempt: 1,
max_attempts: 3
}
})
log = capture_log(fn -> execute_exception(meta) end)
assert log =~ "Oban job exception"
assert log =~ "worker="
assert log =~ "queue=weather"
assert log =~ "attempt=1"
assert log =~ "max_attempts=3"
assert log =~ "retry_exhausted?=false"
assert log =~ "kind=error"
assert log =~ "RuntimeError"
assert log =~ "job_id=7"
end
test "marks retry_exhausted? true when attempt >= max_attempts" do
meta =
build_metadata(%{
job: %{
id: 8,
worker: to_string(SilentWorker),
attempt: 3,
max_attempts: 3
}
})
log = capture_log(fn -> execute_exception(meta) end)
assert log =~ "retry_exhausted?=true"
end
test "invokes on_permanent_failure/1 when retry exhausted and callback defined" do
meta =
build_metadata(%{
job: %{
id: 101,
worker: to_string(PermanentWorker),
attempt: 5,
max_attempts: 5
}
})
capture_log(fn -> execute_exception(meta) end)
assert_receive {:permanent, 101}, 200
end
test "invokes on_transient_failure/1 on transient failure when callback defined" do
meta =
build_metadata(%{
job: %{
id: 202,
worker: to_string(TransientWorker),
attempt: 1,
max_attempts: 5
}
})
capture_log(fn -> execute_exception(meta) end)
assert_receive {:transient, 202}, 200
end
test "dispatches only the matching callback when both are defined" do
meta_transient =
build_metadata(%{
job: %{
id: 303,
worker: to_string(BothWorker),
attempt: 1,
max_attempts: 5
}
})
capture_log(fn -> execute_exception(meta_transient) end)
assert_receive {:transient, 303}, 200
refute_receive {:permanent, 303}, 50
meta_permanent =
build_metadata(%{
job: %{
id: 404,
worker: to_string(BothWorker),
attempt: 5,
max_attempts: 5
}
})
capture_log(fn -> execute_exception(meta_permanent) end)
assert_receive {:permanent, 404}, 200
refute_receive {:transient, 404}, 50
end
test "a raising callback does not propagate, and the log still lands" do
meta =
build_metadata(%{
job: %{
id: 505,
worker: to_string(RaisingWorker),
attempt: 5,
max_attempts: 5
}
})
log =
capture_log(fn ->
# Must not raise.
execute_exception(meta)
end)
assert log =~ "Oban job exception"
assert log =~ "job_id=505"
# Reporter surfaces the callback failure at warning level.
assert log =~ "on_permanent_failure"
end
test "does not raise when worker module is unknown / not loaded" do
meta =
build_metadata(%{
job: %{
id: 606,
worker: "Nonexistent.Worker.Module.ZZZ",
attempt: 1,
max_attempts: 3
}
})
log = capture_log(fn -> execute_exception(meta) end)
assert log =~ "job_id=606"
end
test "does not raise when reason is a plain term and stacktrace is missing" do
meta = %{
job: build_job(%{id: 707, worker: to_string(SilentWorker)}),
worker: to_string(SilentWorker),
queue: "propagation",
kind: :exit,
reason: :shutdown
}
log = capture_log(fn -> execute_exception(meta) end)
assert log =~ "job_id=707"
assert log =~ "kind=exit"
end
end
end