defmodule Oban.Pro.Testing do @moduledoc """ Advanced helpers for testing supervised Oban instances, workers, and making assertions about enqueued jobs. The `Oban.Pro.Testing` module is a drop-in replacement for `Oban.Testing`, with additional functions tailored toward integration testing and Pro modules. ## Usage in Tests The most convenient way to use `Oban.Pro.Testing` is to `use` the module: use Oban.Pro.Testing, repo: MyApp.Repo Other repo-specific configuration options can also be used: use Oban.Pro.Testing, repo: MyApp.Repo, prefix: "private", log: :debug If you already have `use Oban.Testing` in your tests or test cases, simply replace it with `use Oban.Pro.Testing`. ## Naming Convention The testing helpers in this module adhere to the following naming convention: * `perform_*` — executes jobs locally, without touching the database, for unit testing. * `drain_*` — execute jobs inline, for integration testing. * `run_*` — insert jobs into the database and execute them inline, for integration testing. ## Shared Repo Options The `use` macro accepts all of these repo-specific configuration options, and they may be passed to all database functions (`run_`, `drain_`, etc.) * `:log` — a usable log level or `false` to disable logging. See `t:Logger.level/0` for valid options. * `:prefix` — an optional database prefix. Defaults to `public`. * `:repo` — the name of an Ecto repo, which should be running in sandbox mode. """ import Ecto.Query import ExUnit.Assertions, only: [assert: 2, flunk: 1] import ExUnit.Callbacks, only: [on_exit: 2, start_supervised!: 1] alias Ecto.Adapters.SQL.Sandbox alias Ecto.Changeset alias Oban.{Config, Job, Repo, Worker} alias Oban.Pro.{Batch, Utils, Workflow} alias Oban.Pro.Chunk alias Oban.Pro.Engines.Smart alias Oban.Queue.Executor @type repo_option :: {:log, false | Logger.level()} | {:prefix, String.t()} | {:repo, module()} @type drain_option :: repo_option() | {:queue, atom()} | {:with_limit, pos_integer()} | {:with_recursion, boolean()} | {:with_safety, boolean()} | {:with_scheduled, boolean()} | {:with_summary, boolean()} @type drain_summary :: %{ cancelled: non_neg_integer(), completed: non_neg_integer(), discarded: non_neg_integer(), exhausted: non_neg_integer(), retryable: non_neg_integer(), scheduled: non_neg_integer() } @type drain_result :: drain_summary() | [Job.t()] @type perform_option :: Job.option() | repo_option() @typedoc """ Batch callback identifiers, correlating to a `handle_` callback function. """ @type callback :: :attempted | :completed | :discarded | :exhausted @doc false defmacro __using__(repo_opts) do if not Keyword.has_key?(repo_opts, :repo) do raise ArgumentError, "testing requires a :repo option to be set" end quote do def all_enqueued(opts \\ []) do unquote(repo_opts) |> Keyword.merge(opts) |> Oban.Pro.Testing.all_enqueued() end def assert_enqueue(opts \\ [], fun) do unquote(repo_opts) |> Keyword.merge(opts) |> Oban.Pro.Testing.assert_enqueue(fun) end def assert_enqueued(opts \\ [], timeout \\ :none) do unquote(repo_opts) |> Keyword.merge(opts) |> Oban.Pro.Testing.assert_enqueued(timeout) end def refute_enqueue(opts \\ [], fun) do unquote(repo_opts) |> Keyword.merge(opts) |> Oban.Pro.Testing.refute_enqueue(fun) end def refute_enqueued(opts \\ [], timeout \\ :none) do unquote(repo_opts) |> Keyword.merge(opts) |> Oban.Pro.Testing.refute_enqueued(timeout) end def build_job(worker, args, opts \\ []) do Oban.Pro.Testing.build_job(worker, args, Keyword.merge(unquote(repo_opts), opts)) end def drain_jobs(opts \\ []) do unquote(repo_opts) |> Keyword.merge(opts) |> Oban.Pro.Testing.drain_jobs() end def perform_job(job) when is_struct(job) do Oban.Pro.Testing.perform_job(job, unquote(repo_opts)) end def perform_job(job, opts) when is_struct(job) and is_list(opts) do Oban.Pro.Testing.perform_job(job, Keyword.merge(unquote(repo_opts), opts)) end def perform_job(worker, args, opts \\ []) do Oban.Pro.Testing.perform_job(worker, args, Keyword.merge(unquote(repo_opts), opts)) end def perform_callback(worker, callback, args, opts \\ []) do opts = Keyword.merge(unquote(repo_opts), opts) Oban.Pro.Testing.perform_callback(worker, callback, args, opts) end def perform_chunk(worker, args, opts \\ []) do Oban.Pro.Testing.perform_chunk(worker, args, Keyword.merge(unquote(repo_opts), opts)) end def run_batch(batch, opts \\ []) do Oban.Pro.Testing.run_batch(batch, Keyword.merge(unquote(repo_opts), opts)) end def run_chain([_ | _] = chain, opts \\ []) do Oban.Pro.Testing.run_chain(chain, Keyword.merge(unquote(repo_opts), opts)) end def run_chunk([_ | _] = chunk, opts \\ []) do Oban.Pro.Testing.run_chunk(chunk, Keyword.merge(unquote(repo_opts), opts)) end def run_jobs([_ | _] = changesets, opts \\ []) do Oban.Pro.Testing.run_jobs(changesets, Keyword.merge(unquote(repo_opts), opts)) end def run_workflow(%_{} = workflow, opts \\ []) do Oban.Pro.Testing.run_workflow(workflow, Keyword.merge(unquote(repo_opts), opts)) end def start_supervised_oban!(opts \\ []) do unquote(repo_opts) |> Keyword.merge(opts) |> Oban.Pro.Testing.start_supervised_oban!() end end end @conf_keys [] |> Config.new() |> Map.from_struct() |> Map.keys() @callbacks ~w(attempted cancelled completed discarded exhausted retryable)a @default_supervised_opts [ engine: Oban.Pro.Engines.Smart, notifier: Oban.Notifiers.Isolated, peer: Oban.Peers.Isolated, stage_interval: :infinity, shutdown_grace_period: 250 ] @empty_summary %{ cancelled: 0, completed: 0, discarded: 0, exhausted: 0, retryable: 0, scheduled: 0 } @doc """ Construct a job from a worker, args, and options. This is a wrapper around `Oban.Testing.build_job/3` that ensures compatibility with Pro worker features by using the Smart engine. The helper makes the following assertions: * That the worker implements the `Oban.Worker` behaviour * That the options provided build a valid job This helper is used to build jobs for execution by `perform_job/2`. ## Options See [shared options](#module-shared-repo-options) for additional repo-specific options. ## Examples Build a job without args: job = build_job(MyWorker, %{}) Build a job with stringified args: assert %{args: %{"id" => 1}} = build_job(MyWorker, %{id: 1}) Build a job with custom options: assert %{attempt: 5, priority: 9} = build_job(MyWorker, %{}, attempt: 5, priority: 9) """ @doc since: "1.6.8" @spec build_job(Worker.t(), term(), [Job.option() | repo_option()]) :: Job.t() def build_job(worker, args, opts) when is_atom(worker) and is_list(opts) do {_conf_opts, job_opts} = Keyword.split(opts, @conf_keys) Oban.Testing.build_job(worker, args, job_opts) end @doc """ Retrieve all currently enqueued jobs matching a set of criteria. This is a wrapper around `Oban.Testing.all_enqueued/1`, see `Oban.Testing` for more details. ## Options See [shared options](#module-shared-repo-options) for additional repo-specific options. """ @doc since: "0.11.0" @spec all_enqueued(keyword()) :: [Job.t()] def all_enqueued(opts) do {repo, opts} = Keyword.pop!(opts, :repo) Oban.Testing.all_enqueued(repo, opts) end @doc """ Assert that one or more jobs were enqueued during a function call. Any pre-existing jobs are ignored for the assertion. If the assertion passes then the function's return value is passed back. ## Options See `assert_enqueued/2` for standard options, and [shared options](#module-shared-repo-options) for additional repo-specific options. ## Examples Assert that a `MyApp.Worker` job was added to the `default` queue: assert_enqueue([queue: :default, worker: MyApp.Worker], fn -> MyApp.do_some_business() end) Make an assertion about the return value: result = assert_enqueue([worker: MyApp.Worker], &MyApp.more_business/0) assert {:ok, _} = result """ @doc since: "1.1.0" @spec assert_enqueue(keyword(), (-> return)) :: return when return: any() def assert_enqueue(opts, fun) when is_function(fun, 0) do opts = expand_decorated(opts) {enqueued, returned} = diff_enqueued(opts, fun) if Enum.any?(enqueued) do returned else flunk(""" Expected a job matching: #{inspect_opts(opts)} to be enqueued. """) end end @doc """ Assert that a job with particular criteria is enqueued. This is a wrapper around `Oban.Testing.assert_enqueued/2` with additions to support jobs built with `Oban.Pro.Decorator` decorated functions. Only values for the provided fields are checked. For example, an assertion made on `worker: "MyWorker"` will match _any_ jobs for that worker, regardless of every other field. ## Options The following options are supported in addition to all `t:Oban.Job` fields: * `:decorated` — a function capture for the decorated function, e.g. `&MyApp.Foo.bar/2`. By default, only the module and function name are matched. To match on `args`, a _full list_ of arguments must be provided rather than the standard map. See [shared options](#module-shared-repo-options) for additional repo-specific options. ## Examples Assert that a job is enqueued for a certain worker and args: assert_enqueued worker: MyWorker, args: %{id: 1} Assert that a job is enqueued for a particular queue and priority: assert_enqueued queue: :business, priority: 3 Assert that a job's args deeply match: assert_enqueued args: %{config: %{enabled: true}} Use the `:_` wildcard to assert that a job's meta has a key with any value: assert_enqueued meta: %{batch_id: :_} Assert on a decorated function with any args: assert_enqueued decorated: &MyApp.notify/2 Assert on exact args used to enqueue a decorated function: assert_enqueued args: ["example", 123], decorated: &MyApp.notify/2 """ @doc since: "0.11.0" @spec assert_enqueued(keyword(), timeout() | :none) :: true def assert_enqueued(opts, timeout \\ :none) do opts = expand_decorated(opts) if timeout == :none do Oban.Testing.assert_enqueued(opts) else Oban.Testing.assert_enqueued(opts, timeout) end end @doc """ Refute that any jobs were enqueued during a function call. Any pre-existing jobs are ignored for the refutation. If the refutation passes then the function's return value is passed back. ## Options See `refute_enqueued/2` for standard options, and [shared options](#module-shared-repo-options) for additional repo-specific options. ## Examples Refute that a `MyApp.Worker` job was added to the `default` queue: refute_enqueue([queue: :default, worker: MyApp.Worker], fn -> MyApp.do_some_business() end) Make a refutation about the return value: result = refute_enqueue([worker: MyApp.Worker], &MyApp.more_business/0) assert {:ok, _} = result """ @doc since: "1.1.0" @spec refute_enqueue(keyword(), (-> return)) :: return when return: any() def refute_enqueue(opts, fun) when is_function(fun, 0) do opts = expand_decorated(opts) {enqueued, returned} = diff_enqueued(opts, fun) if Enum.empty?(enqueued) do returned else flunk(""" Expected no jobs matching: #{inspect_opts(opts)} to be enqueued. """) end end @doc """ Refute that a job with particular criteria is enqueued. This is a wrapper around `Oban.Testing.refute_enqueued/2` with additions to support jobs built with `Oban.Pro.Decorator` decorated functions. ## Options The following options are supported in addition to all `t:Oban.Job` fields: * `:decorated` — a function capture for the decorated function, e.g. `&MyApp.Foo.bar/2`. By default, only the module and function name are matched. To match on `args`, a _full list_ of arguments must be provided rather than the standard map. See [shared options](#module-shared-repo-options) for additional repo-specific options. ## Examples Refute that a job is enqueued for a certain worker: refute_enqueued worker: MyWorker Refute that a job is enqueued for a certain worker and args: refute_enqueued worker: MyWorker, args: %{id: 1} Refute that a job's nested args match: refute_enqueued args: %{config: %{enabled: false}} Use the `:_` wildcard to refute that a job's meta has a key with any value: refute_enqueued meta: %{batch_id: :_} Refute a decorated function was enqueued with any args: refute_enqueued decorated: &MyApp.notify/2 Refute that exact args were used to enqueue a decorated function: refute_enqueued args: ["example", 123], decorated: &MyApp.notify/2 """ @doc since: "0.11.0" @spec refute_enqueued(keyword(), timeout() | :none) :: false def refute_enqueued(opts, timeout \\ :none) do opts = expand_decorated(opts) if timeout == :none do Oban.Testing.refute_enqueued(opts) else Oban.Testing.refute_enqueued(opts, timeout) end end @doc """ Synchronously execute jobs in one or all queues, from within the current process. Jobs that are enqueued by a process when `Ecto` is in sandbox mode are only visible to that process. Calling `drain_jobs/1` allows you to control when the jobs are executed and to wait synchronously for all jobs to complete. This function provides several distinct advantages over the standard `Oban.drain_queue/2`: * It can drain jobs across one or all queues simultaneously * It can return the drained jobs rather than a count summary * It optimizes the defaults for testing batches, workflows, etc. * It always uses the `Smart` engine to guarantee that Pro worker features work as expected ## Options * `:queue` - an atom specifying the queue to drain, or `:all` to drain jobs across all queues at once. Defaults to `:all` when no queue is provided. * `:with_limit` — the maximum number of jobs to fetch for draining at once. The limit only impacts how many jobs are fetched at once, _not_ concurrency. When recursion is enabled this is how many jobs are processed per-iteration, and it defaults to `1`. Otherwise, there isn't a limit and all available jobs are fetched. * `:with_recursion` — whether to draining jobs recursively, or all in a single pass. Either way, jobs are processed sequentially, one at a time. Recursion is required when jobs insert other jobs (e.g. batches), or depend on the execution of other jobs (e.g. workflows). Defaults to `true`. * `:with_safety` — whether to silently catch errors when draining. When `false`, raised exceptions or unhandled exits are reraised (unhandled exits are wrapped in `Oban.CrashError`). Defaults to `false`. * `:with_scheduled` — whether to include scheduled or retryable jobs when draining. In recursive mode, which is the default, this will include snoozed jobs, and may lead to an infinite loop if the job snoozes repeatedly. Defaults to `true`. * `:with_summary` — whether to summarize execution results with a map of counts by state, or return a list of each job that was drained. Defaults to `true`, which returns a summary map. See [shared options](#module-shared-repo-options) for additional repo-specific options. ## Examples Drain all available jobs across all queues: assert %{completed: 3} = drain_jobs(queue: :all) Drain and return all executed jobs without a count summary: assert [%Oban.Job{}, %Oban.Job{}] = drain_jobs(with_summary: false) Drain including a job that you expect to raise: assert_raise RuntimeError, fn -> drain_jobs(queue: :risky) end Drain without recursion to identify snoozed jobs: assert %{scheduled: 3} = drain_jobs(with_recursion: false) Drain without staging scheduled jobs: assert %{completed: 1, scheduled: 0} = drain_jobs(with_scheduled: false) Drain within a custom prefix: assert %{completed: 3} = drain_jobs(queue: :default, prefix: "private") Drain using a specific repo (necessary when calling this function directly): assert %{completed: 3} = drain_jobs(queue: :default, repo: MyApp.Repo) """ @doc since: "0.11.0" @spec drain_jobs([drain_option()]) :: drain_result() def drain_jobs(opts) when is_list(opts) do conf = init_conf(opts) opts = Keyword.drop(opts, @conf_keys) with_limit = if Keyword.get(opts, :with_recursion, true), do: 1, else: 999_999 opts = opts |> Map.new() |> Map.put_new(:queue, :all) |> Map.update!(:queue, &if(&1 == :all, do: :__all__, else: &1)) |> Map.put_new(:with_limit, with_limit) |> Map.put_new(:with_recursion, true) |> Map.put_new(:with_safety, false) |> Map.put_new(:with_scheduled, true) |> Map.put_new(:with_summary, true) Process.put(:oban_draining, true) drain(conf, [], opts) after Process.delete(:oban_draining) end @doc """ Execute a job using the given config options. This is a wrapper around `Oban.Testing.perform_job/2` that ensures the Smart engine is used for compatibility with Pro worker features. See `perform_job/3` for more details and examples. ## Options See [shared options](#module-shared-repo-options) for additional repo-specific options. ## Examples Execute a job without any options: job = build_job(MyWorker, %{id: 1}) assert :ok = perform_job(job) Execute a job with a custom prefix and repo: assert :ok = perform_job(job, prefix: "private", repo: MyApp.Repo) """ @doc since: "1.6.8" @spec perform_job(Job.t(), [repo_option()]) :: Worker.result() def perform_job(%Job{} = job, opts) when is_list(opts) do opts = Keyword.put_new(opts, :engine, Smart) Oban.Testing.perform_job(job, opts) end @doc """ Construct a job and execute it with a worker module. This is a wrapper around `Oban.Testing.perform_job/3` that ensures the Smart engine is used for compatibility with Pro worker features. ## Options See [shared options](#module-shared-repo-options) for additional repo-specific options. ## Examples Successfully execute a job with some string arguments: assert :ok = perform_job(MyWorker, %{"id" => 1}) Successfully execute a job and assert that it returns an error tuple: assert {:error, _} = perform_job(MyWorker, %{"bad" => "arg"}) Execute a job with the args keys automatically stringified: assert :ok = perform_job(MyWorker, %{id: 1}) """ @doc since: "0.11.0" @spec perform_job(Worker.t(), term(), [perform_option()]) :: Worker.result() def perform_job(worker, args, opts) do opts = Keyword.put_new(opts, :engine, Smart) Oban.Testing.perform_job(worker, args, opts) end @doc """ Change the testing mode within the context of a function. This is a direct delegation to `Oban.Testing.with_testing_mode/2`. Only `:manual` and `:inline` mode are supported, as `:disabled` implies that supervised queues and plugins are running and this function won't start any processes. ## Examples Switch to `:manual` mode when an Oban instance is configured for `:inline` testing: with_testing_mode(:manual, fn -> Oban.insert(MyWorker.new(%{id: 123})) assert_enqueued worker: MyWorker, args: %{id: 123} end) Visa-versa, switch to `:inline` mode: with_testing_mode(:inline, fn -> {:ok, %Job{state: "completed"}} = Oban.insert(MyWorker.new(%{id: 123})) end) """ @doc since: "1.6.8" @spec with_testing_mode(:inline | :manual, (-> any())) :: any() defdelegate with_testing_mode(mode, fun), to: Oban.Testing @doc """ Construct and execute a job with a batch `handle_*` callback. This helper verifies that the batch worker exports the requested callback handler, along with the standard assertions made by `perform_job/3`. ## Examples Execute the `handle_attempted` callback without any args: assert :ok = perform_callback(MyBatch, :attempted, %{}) Execute the `handle_exhausted` callback with args: assert :ok = perform_callback(MyBatch, :exhausted, %{for_account_id: 123}) """ @doc since: "0.11.0" @spec perform_callback(Worker.t(), callback(), term(), [perform_option()]) :: Worker.result() def perform_callback(worker, callback, args, opts) when is_list(opts) do assert_valid_callback(worker, callback) opts = opts |> Keyword.put_new(:meta, %{}) |> Keyword.update!(:meta, &Map.put_new(&1, "callback", to_string(callback))) |> Keyword.update!(:meta, &Map.put_new(&1, "batch_id", Oban.Pro.UUIDv7.generate())) perform_job(worker, args, opts) end @doc """ Construct a list of jobs and process them with a Chunk worker. Like `perform_job/3`, this helper reduces boilerplate when constructing jobs and checks for common pitfalls. Unlike `perform_job/3`, this helper calls the chunk's `process/1` function directly and it **won't trigger telemetry events**. ## Examples Successfully process a chunk of jobs: assert :ok = perform_chunk(MyChunk, [%{id: 1}, %{id: 2}]) Process a chunk of jobs with job options: assert :ok = perform_chunk(MyChunk, [%{id: 1}, %{id: 2}], attempt: 5, priority: 3) """ @doc since: "0.11.0" @spec perform_chunk(Worker.t(), [term()], [perform_option()]) :: Chunk.result() def perform_chunk(worker, args_list, opts) when is_list(args_list) and is_list(opts) do {conf_opts, opts} = Keyword.split(opts, @conf_keys) conf = Config.new(conf_opts) opts = opts |> Keyword.merge(worker.__opts__()) |> Keyword.put_new(:attempt, 1) chunk = Enum.reduce_while(args_list, [], fn args, acc -> now = DateTime.utc_now() job = args |> worker.new(opts) |> Changeset.update_change(:args, &json_encode_decode/1) |> Changeset.put_change(:attempted_at, now) |> Changeset.put_change(:scheduled_at, now) |> Changeset.apply_action!(:insert) |> Map.replace!(:conf, conf) case Oban.Pro.Worker.before_process(worker, job, opts) do {:ok, job} -> {:cont, [job | acc]} {:error, error} -> {:halt, {:error, error}} end end) if is_list(chunk) do chunk |> Enum.reverse() |> worker.process() |> tap(&assert_valid_chunk_result/1) else chunk end end @doc """ Insert and execute a complete batch of jobs, along with callbacks, within the test process. ## Options Accepts all options for `drain_jobs/1`, including the repo-specific options listed in [shared-options](#module-shared-repo-options). ## Examples Run a batch: ids |> Enum.map(&MyBatch.new(%{id: &1})) |> MyBatch.new_batch() |> run_batch() Run a batch with a specific repo (necessary when calling this function directly): run_batch(my_batch, repo: MyApp.Repo, prefix: "private") """ @doc since: "0.11.0" @spec run_batch(Batch.t(), [drain_option()]) :: drain_result() def run_batch(batch, opts) when is_list(opts) do opts = Keyword.put(opts, :batch_ids, [batch.opts.batch_id]) batch.changesets |> Enum.to_list() |> run_jobs(opts) end @doc """ Insert and execute chained jobs within the test process. ## Options Accepts all options for `drain_jobs/1`, including the repo-specific options listed in [shared-options](#module-shared-repo-options). ## Examples Run all jobs in a chain: 1..10 |> Enum.map(&MyChain.new(%{id: &1})) |> run_chain() """ @doc since: "1.1.0" @doc deprecated: "Use run_jobs/2 instead" def run_chain(chain, opts) when is_list(opts) do chain_keys = ~w(hold_snooze on_cancelled on_discarded wait_retry wait_sleep wait_snooze)a chain_opts = opts |> Keyword.take(chain_keys) |> Map.new() chain |> Enum.map(fn changeset -> Changeset.update_change(changeset, :meta, &Map.merge(&1, chain_opts)) end) |> run_jobs(opts) end @doc """ Insert and execute chunked jobs within the test process. This helper overrides the chunk's `timeout` to force immediate processing of jobs up to the chunk size. ## Options Accepts all options for `drain_jobs/1`, including the repo-specific options listed in [shared-options](#module-shared-repo-options). ## Examples Run jobs in chunks: 1..50 |> Enum.map(&MyChunk.new(%{id: &1})) |> run_chunk() Run a chunk with an overriden size: 1..10 |> Enum.map(&MyChunk.new(%{id: &1})) |> run_chunk(size: 10) Run chunks with an explicit repo (necessary when calling this function directly): run_chunk(changesets, repo: MyApp.Repo, prefix: "private") Run chunks only for a specific queue: run_chunk(changesets, queue: "default") """ @doc since: "0.11.0" @spec run_chunk([Job.changeset()], [drain_option()]) :: drain_result() def run_chunk([_ | _] = chunk, opts) when is_list(opts) do chunk_opts = opts |> Keyword.take(~w(by leading size)a) |> Map.new(fn {:by, by} -> {:chunk_by, Utils.normalize_by(by)} {:leading, leading} -> {:chunk_leading, leading} {:size, size} -> {:chunk_size, size} end) merge = fn meta -> Map.merge(meta, chunk_opts) end chunk |> Enum.map(fn change -> Changeset.update_change(change, :meta, merge) end) |> run_jobs(Keyword.put_new(opts, :with_limit, 1)) end @doc """ Insert and execute jobs synchronously, within the test process. This is the basis of all other `run_*` helpers. ## Options Accepts all options for `drain_jobs/1`, including the repo-specific options listed in [shared-options](#module-shared-repo-options). ## Examples Run a list of jobs: ids |> Enum.map(&MyWorker.new(%{id: &1})) |> run_jobs() Run jobs with an explicit repo (necessary when calling this function directly): run_jobs(changesets, repo: MyApp.Repo) """ @doc since: "0.11.0" @spec run_jobs([Job.changeset()], [drain_option()]) :: drain_result() def run_jobs([_ | _] = changesets, opts) when is_list(changesets) and is_list(opts) do conf = init_conf(opts) Oban.Engine.insert_all_jobs(conf, changesets, []) opts |> Keyword.put_new(:queue, :all) |> drain_jobs() end @doc """ Insert and execute a workflow synchronously, within the test process. This helper augments the workflow with options optimized for testing, but it will still respect all standard workflow options. ## Options Accepts all options for `drain_jobs/1`, including the repo-specific options listed in [shared-options](#module-shared-repo-options). ## Examples Run a basic workflow: MyFlow.new_workflow() |> MyFlow.add(:a, MyFlow.new(%{id: 1})) |> MyFlow.add(:b, MyFlow.new(%{id: 2}), deps: [:a]) |> MyFlow.add(:c, MyFlow.new(%{id: 3}), deps: [:b]) |> run_workflow() Run a workflow and match on returned jobs, but be careful that the execution order may differ from the insertion order: workflow = MyFlow.new_workflow() |> MyFlow.add(:a, MyFlow.new(%{id: 1})) |> MyFlow.add(:b, MyFlow.new(%{id: 2}), deps: [:a]) |> MyFlow.add(:c, MyFlow.new(%{id: 3}), deps: [:b]) [_job_a, _job_b, _job_c] = run_workflow(workflow, with_summary: false) Run a workflow with increased parallelism to prevent slow or deadlocked workflows: run_workflow(my_expansive_workflow, with_limit: 5) Run a workflow with an explicit repo (necessary when calling this function directly): run_workflow(workflow, repo: MyApp.Repo) """ @doc since: "0.11.0" @spec run_workflow(Workflow.t(), Workflow.new_opts() | drain_option()) :: drain_result() def run_workflow(%Workflow{} = workflow, opts \\ []) when is_list(opts) do conf = init_conf(opts) Oban.Engine.insert_all_jobs(conf, workflow.changesets, []) workflow_ids = workflow.changesets |> Enum.map(& &1.changes.meta.workflow_id) |> Enum.uniq() opts |> Keyword.put(:workflow_ids, workflow_ids) |> drain_jobs() # The workflow facilitator won't run "held" jobs, so we have to reload everything that was # inserted to get all jobs and their real fields. Additionally, the workflow ids may be added # while the workflow is executing so they must be refreshed afterwards. workflow_ids = all_workflow_ids(conf, workflow_ids) jobs = Job |> where([j], j.meta["workflow_id"] in ^workflow_ids) |> order_by([j], asc_nulls_last: j.completed_at, asc: j.id) |> then(&Repo.all(conf, &1)) if Keyword.get(opts, :with_summary, true) do Enum.reduce(jobs, @empty_summary, fn job, acc -> state = if job.state == "discarded" and job.attempt >= job.max_attempts do :exhausted else String.to_existing_atom(job.state) end Map.update(acc, state, 1, &(&1 + 1)) end) else jobs end end @doc """ Start a supervised Oban instance under the test supervisor. All valid Oban options are accepted. The supervised instance is registered with a unique reference, rather than the default `Oban`. That prevents any conflict with Oban instances started by your Application, or with other tests running asynchronously. Furthermore, this helper automatically adds sandbox allowances for any plugins or queue producers, allowing tests to run async. After the test finishes the test process will wait for the Oban instance to shut down cleanly. ## Running Jobs By default, the supervised instance won't process any jobs because the `stage_interval` is set to `:infinity`. Set the `stage_interval` to a low value to process jobs normally, without manual draining. ## Options Any option accepted by `Oban.start_link/1` is acceptable, including the repo-specific options listed in [shared options](#module-shared-repo-options). ## Examples Start a basic supervised instance without any queues or plugins: name = start_supervised_oban!(repo: MyApp.Repo) Oban.insert(name, MyWorker.new()) Start the supervisor with a single queue and polling every 10ms: start_supervised_oban!(repo: MyApp.Repo, stage_interval: 10, queues: [alpha: 10]) """ @doc since: "0.11.0" @spec start_supervised_oban!([Oban.option()]) :: Registry.key() def start_supervised_oban!(opts) when is_list(opts) do opts = conf_opts(opts) name = Keyword.fetch!(opts, :name) repo = Keyword.fetch!(opts, :repo) attach_auto_allow(repo, name) start_supervised!({Oban, opts}) name |> Oban.config() |> ensure_queues_started() name end defp json_encode_decode(map) do map |> Oban.JSON.encode!() |> Oban.JSON.decode!() end # Enqueued Helpers defp diff_enqueued(opts, fun) do existing = all_enqueued(opts) returned = fun.() inserted = all_enqueued(opts) existing_ids = Enum.map(existing, & &1.id) difference = Enum.reject(inserted, &(&1.id in existing_ids)) {difference, returned} end defp inspect_opts(opts) do opts |> Keyword.drop([:log, :prefix, :repo]) |> inspect(charlists: :as_lists, pretty: true) end defp expand_decorated(opts) do case Keyword.fetch(opts, :decorated) do {:ok, fun} when is_function(fun) -> info = Function.info(fun) args = %{mod: inspect(info[:module]), fun: info[:name], arg: args_to_term(info, opts)} opts |> Keyword.delete(:decorated) |> Keyword.merge(args: args, worker: Oban.Pro.Decorator) {:ok, term} -> raise ArgumentError, "expected :decorated option to be a function capture, got: #{inspect(term)}" :error -> opts end end defp args_to_term(info, opts) do arity = info[:arity] case Keyword.fetch(opts, :args) do {:ok, list} when is_list(list) and length(list) == arity -> "term-" <> Utils.encode64(list) {:ok, list} when is_list(list) -> raise ArgumentError, "expected :args list to have arity #{arity}, got: #{length(list)}" {:ok, term} -> raise ArgumentError, "expected :args option to be a list, got: #{inspect(term)}" :error -> :_ end end # Callback Helpers defp assert_valid_callback(worker, callback) do assert is_atom(callback) and callback in @callbacks, """ Expected callback to be included in #{inspect(@callbacks)}, got: #{inspect(callback)} """ assert Code.ensure_loaded?(worker), """ Expected worker to be an existing module, got: #{inspect(worker)} """ new_fun = Map.fetch!(Batch.callbacks_to_functions(), to_string(callback)) old_fun = Map.fetch!(Batch.callbacks_to_deprecated(), to_string(callback)) assert function_exported?(worker, new_fun, 1) or function_exported?(worker, old_fun, 1), """ Expected #{inspect(new_fun)} callback to be implemented """ end # Startup Helpers defp attach_auto_allow(repo, name) do telemetry_name = "oban-auto-allow-#{inspect(name)}" :telemetry.attach_many( telemetry_name, [[:oban, :engine, :init, :start], [:oban, :plugin, :init]], &__MODULE__.auto_allow/4, {name, repo, self()} ) on_exit(name, fn -> :telemetry.detach(telemetry_name) end) end @doc false def auto_allow(_event, _measure, %{conf: conf}, {name, repo, test_pid}) do if conf.name == name, do: Sandbox.allow(repo, test_pid, self()) end defp ensure_queues_started(%{name: name, queues: queues}) do with [_ | _] <- queues do queues |> Keyword.keys() |> Enum.each(&Oban.check_queue(name, queue: &1)) end end # Draining Helpers defp conf_opts(opts) do conf_opts = Keyword.take(opts, @conf_keys) @default_supervised_opts |> Keyword.merge(conf_opts) |> Keyword.put_new(:name, make_ref()) end defp init_conf(opts) do opts |> conf_opts() |> Config.new() |> tap(&Registry.register(Oban.Registry, &1.name, &1)) end defp drain(conf, acc, opts) do if opts.with_scheduled, do: stage_scheduled(conf) case Smart.fetch_for_drain(conf, opts) do [_ | _] = jobs -> executed = jobs |> streamline_pro_workers() |> Enum.map(fn job -> conf |> Executor.new(job, safe: opts.with_safety) |> Executor.call() end) if opts.with_recursion do drain(conf, acc ++ executed, with_workflow_ids(conf, opts)) else complete_drain(conf, executed, opts) end [] -> case opts do %{workflow_ids: [_ | _], flushed: true} -> complete_drain(conf, acc, opts) %{workflow_ids: [_ | _] = workflow_ids} -> # Final flush to release held jobs whose deps became satisfied during processing. Enum.each(workflow_ids, &Workflow.on_flush(&1, conf)) drain(conf, acc, Map.put(opts, :flushed, true)) _ -> complete_drain(conf, acc, opts) end end end defp stage_scheduled(conf) do query = where(Job, [j], j.state in ["scheduled", "retryable"]) Repo.update_all(conf, query, set: [state: "available"]) end defp streamline_pro_workers(jobs) do Enum.map(jobs, fn %{meta: meta} = job -> case meta do %{"chain_key" => _} -> meta = meta |> Map.put_new("wait_retry", 1) |> Map.put_new("wait_sleep", 1) |> Map.put_new("wait_snooze", 1) %{job | meta: meta} %{"chunk_size" => _} -> meta = meta |> Map.put("chunk_sleep", 1) |> Map.put("chunk_timeout", 0) %{job | meta: meta} _ -> job end end) end defp with_workflow_ids(conf, opts) do case opts do %{workflow_ids: [_ | _]} -> Map.put(opts, :workflow_ids, all_workflow_ids(conf, opts.workflow_ids)) _ -> opts end end defp all_workflow_ids(conf, [_ | _] = workflow_ids) do all_workflow_ids(conf, workflow_ids, MapSet.new(workflow_ids)) end defp all_workflow_ids(_conf, ids), do: ids defp all_workflow_ids(conf, search_ids, seen) do graft_query = Job |> select([j], j.meta["graft_id"]) |> where([j], j.meta["workflow_id"] in ^search_ids) |> where([j], not is_nil(j.meta["graft_id"])) |> distinct(true) graft_ids = Repo.all(conf, graft_query) all_search_ids = search_ids ++ graft_ids query = Job |> select([j], j.meta["workflow_id"]) |> where([j], j.meta["sup_workflow_id"] in ^all_search_ids) |> distinct(true) new_ids = conf |> Repo.all(query) |> Enum.reject(&MapSet.member?(seen, &1)) case new_ids do [] -> MapSet.to_list(seen) ids -> all_workflow_ids(conf, ids, MapSet.union(seen, MapSet.new(ids))) end end defp complete_drain(_conf, executed, %{with_summary: true}) do Enum.reduce(executed, @empty_summary, fn exec, acc -> state = case exec.state do :cancelled -> :cancelled :discard -> :discarded :exhausted -> :exhausted :failure -> :retryable :snoozed -> :scheduled :success -> :completed end Map.update(acc, state, 1, &(&1 + 1)) end) end defp complete_drain(conf, executed, _opts) do ids = executed |> Enum.map(& &1.job.id) |> Enum.uniq() jobs = conf |> Repo.all(where(Job, [j], j.id in ^ids)) |> Map.new(fn job -> {job.id, job} end) Enum.map(ids, &Map.fetch!(jobs, &1)) end # Chunk Helpers @ops ~w(cancel discard error)a defp assert_valid_chunk_result(result) do valid? = case result do :ok -> true {:ok, _value} -> true {ops, _reason, [_ | _]} when ops in @ops -> true [{_op, {_reason, _jobs}} | _] = list -> Keyword.keyword?(list) and Enum.all?(list, &match?({_key, {_reason, [_ | _]}}, &1)) _ -> false end assert valid?, """ Expected result to be one of - `:ok` - `{:ok, value}` - `{:cancel, reason, jobs}` - `{:discard, reason, jobs}` - `{:error, reason, jobs}` - `[cancel: {reason, jobs}, discard: {reason, jobs}, error: {reason, jobs}]` Instead received: #{inspect(result, pretty: true)} """ end end