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