- 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
271 lines
6.9 KiB
Elixir
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
|