prop/test/microwaveprop/oban_error_reporter_test.exs
Graham McIntire fd976b0cd5
fix: resolve 391 Credo issues across codebase
- Add jump_credo_checks ~> 0.4 with all 20 checks enabled
- Fix all standard Credo issues: 139 @spec (113 done, 26 remain),
  4 refactoring, 3 alias usage, 9 System.cmd env, 5 unsafe_to_atom,
  2 max line length, 9 assert_receive timeout
- Fix 170+ jump_credo_checks warnings:
  - 117 TopLevelAliasImportRequire: move nested alias/import to module top
  - 32 UseObanProWorker: switch to Oban.Pro.Worker
  - 4 DoctestIExExamples: add doctests / create test file
  - ~20 WeakAssertion: strengthen type-check assertions
  - Various ConditionalAssertion, AssertReceiveTimeout fixes
- Exclude vendor/ from Credo analysis
- Remaining: 175 warnings (mostly opinionated WeakAssertion,
  AvoidSocketAssignsInTest), 26 @spec annotations
2026-06-12 13:51:32 -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}
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}
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}
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}
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