diff --git a/.simplecov b/.simplecov index 59c36f4..5ab499c 100644 --- a/.simplecov +++ b/.simplecov @@ -7,12 +7,30 @@ SimpleCov.configure do enable_coverage :branch primary_coverage :branch + # Enable nocov comments to exclude lines from coverage + nocov_token "nocov" + + # Use database name in command_name for proper merging across database runs + command_name "#{ENV.fetch('TARGET_DB', 'sqlite')}-tests" + # Focus on the gem's code, not the test dummy app or other non-gem files add_filter "/test/" add_filter "/gemfiles/" add_filter "/.github/" add_filter "/bin/" + # Ignore gem scaffolding base classes (inherited by users, contain no logic) + add_filter %r{app/mailers/acidic_job/application_mailer\.rb} + add_filter %r{app/jobs/acidic_job/application_job\.rb} + add_filter %r{app/controllers/acidic_job/application_controller\.rb} + + # Ignore trivial files + add_filter "version.rb" + add_filter "Rakefile" + + # Ignore test utility module (testing test utilities is meta) + add_filter "testing.rb" + # Group the gem's code add_group "Library", "lib/" add_group "App", "app/" @@ -31,8 +49,15 @@ SimpleCov.configure do # Minimum coverage thresholds - fail CI if coverage drops below these # Only enforce when running the full test suite (not during db:prepare, etc.) + # + # Rationale for thresholds: + # - 95% line / 80% branch overall: High bar to catch regressions while allowing + # some leeway for legitimately untestable code (marked with :nocov:) + # - 80% line per-file: Ensures no single file is significantly under-tested + # - 0% branch per-file: Branch coverage varies widely by file complexity; + # enforcing at the global level is sufficient if ENV["COVERAGE_CHECK"] - minimum_coverage line: 70, branch: 40 - minimum_coverage_by_file line: 0, branch: 0 + minimum_coverage line: 95, branch: 80 + minimum_coverage_by_file line: 80, branch: 0 end end diff --git a/lib/acidic_job/context.rb b/lib/acidic_job/context.rb index e871caa..ed8a241 100644 --- a/lib/acidic_job/context.rb +++ b/lib/acidic_job/context.rb @@ -21,6 +21,7 @@ def set(hash) AcidicJob::Value.upsert_all(records, unique_by: [ :execution_id, :key ]) when :mysql2, :mysql, :trilogy AcidicJob::Value.upsert_all(records) + # :nocov: else # Fallback for other adapters - try with unique_by first, fall back without begin @@ -32,6 +33,7 @@ def set(hash) raise end end + # :nocov: end end end diff --git a/lib/acidic_job/engine.rb b/lib/acidic_job/engine.rb index 3c2ad3f..7281102 100644 --- a/lib/acidic_job/engine.rb +++ b/lib/acidic_job/engine.rb @@ -6,11 +6,13 @@ class Engine < ::Rails::Engine config.acidic_job = ActiveSupport::OrderedOptions.new + # :nocov: initializer "acidic_job.config" do config.acidic_job.each do |name, value| AcidicJob.public_send("#{name}=", value) end end + # :nocov: initializer "acidic_job.logger" do ActiveSupport.on_load :acidic_job do @@ -33,11 +35,13 @@ class Engine < ::Rails::Engine Serializers::JobSerializer.instance ] + # :nocov: # Rails 7.1+ includes a RangeSerializer, so only add ours for older versions unless defined?(ActiveJob::Serializers::RangeSerializer) require_relative "serializers/range_serializer" serializers << Serializers::RangeSerializer.instance end + # :nocov: ActiveJob::Serializers.add_serializers(*serializers) end diff --git a/test/acidic_job/arguments_test.rb b/test/acidic_job/arguments_test.rb new file mode 100644 index 0000000..72017fe --- /dev/null +++ b/test/acidic_job/arguments_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "test_helper" + +class AcidicJob::ArgumentsTest < ActiveSupport::TestCase + # The GlobalID key used by ActiveJob for serialization + GLOBALID_KEY = "_aj_globalid" + + # ============================================ + # deserialize_global_id + # ============================================ + + test "deserialize_global_id locates existing record" do + thing = Thing.create! + gid_hash = { GLOBALID_KEY => thing.to_global_id.to_s } + + result = AcidicJob::Arguments.deserialize_global_id(gid_hash) + + assert_equal thing, result + end + + test "deserialize_global_id returns nil for deleted record" do + thing = Thing.create! + gid_hash = { GLOBALID_KEY => thing.to_global_id.to_s } + thing.destroy! + + result = AcidicJob::Arguments.deserialize_global_id(gid_hash) + + assert_nil result + end + + test "deserialize_global_id returns nil for non-existent record ID" do + # Create a GlobalID for a record that doesn't exist + gid_hash = { GLOBALID_KEY => "gid://dummy/Thing/999999" } + + result = AcidicJob::Arguments.deserialize_global_id(gid_hash) + + assert_nil result + end + + # ============================================ + # convert_to_global_id_hash + # ============================================ + + test "convert_to_global_id_hash returns GlobalID hash for persisted record" do + thing = Thing.create! + + result = AcidicJob::Arguments.convert_to_global_id_hash(thing) + + assert_kind_of Hash, result + assert result.key?(GLOBALID_KEY) + assert_match(/gid:\/\/.*\/Thing\/#{thing.id}/, result[GLOBALID_KEY]) + end + + test "convert_to_global_id_hash falls back to ActiveJob serializer for new record" do + new_thing = Thing.new # not persisted, no ID + + result = AcidicJob::Arguments.convert_to_global_id_hash(new_thing) + + # Should use ActiveJob::Serializers.serialize which uses our NewRecordSerializer + assert_kind_of Hash, result + # The result should have some serialization key (exact key depends on serializer) + assert result.key?("_aj_serialized") || result.key?(GLOBALID_KEY) + end +end diff --git a/test/acidic_job/context_test.rb b/test/acidic_job/context_test.rb new file mode 100644 index 0000000..ced00ca --- /dev/null +++ b/test/acidic_job/context_test.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "test_helper" + +class AcidicJob::ContextTest < ActiveSupport::TestCase + def create_execution + serialized_job = { + "job_class" => "TestJob", + "job_id" => SecureRandom.uuid, + "arguments" => [] + } + definition = { + "meta" => { "version" => AcidicJob::VERSION }, + "steps" => { + "step_1" => { "does" => "step_1", "then" => AcidicJob::FINISHED_RECOVERY_POINT } + } + } + AcidicJob::Execution.create!( + idempotency_key: SecureRandom.hex(32), + serialized_job: serialized_job, + definition: definition, + recover_to: "step_1" + ) + end + + # ============================================ + # set + # ============================================ + + test "set stores a single key-value pair" do + execution = create_execution + context = AcidicJob::Context.new(execution) + + context.set(foo: "bar") + + assert_equal 1, execution.values.count + assert_equal "bar", execution.values.find_by(key: "foo").value + end + + test "set stores multiple key-value pairs" do + execution = create_execution + context = AcidicJob::Context.new(execution) + + context.set(foo: "bar", baz: 123, qux: [ 1, 2, 3 ]) + + assert_equal 3, execution.values.count + assert_equal "bar", execution.values.find_by(key: "foo").value + assert_equal 123, execution.values.find_by(key: "baz").value + assert_equal [ 1, 2, 3 ], execution.values.find_by(key: "qux").value + end + + test "set upserts existing keys" do + execution = create_execution + context = AcidicJob::Context.new(execution) + + context.set(foo: "original") + context.set(foo: "updated") + + assert_equal 1, execution.values.count + assert_equal "updated", execution.values.find_by(key: "foo").value + end + + # ============================================ + # get + # ============================================ + + test "get retrieves a single value" do + execution = create_execution + context = AcidicJob::Context.new(execution) + context.set(foo: "bar") + + result = context.get(:foo) + + assert_equal [ "bar" ], result + end + + test "get retrieves multiple values" do + execution = create_execution + context = AcidicJob::Context.new(execution) + context.set(foo: "bar", baz: 123) + + result = context.get(:foo, :baz) + + # Order is not guaranteed, so check both values are present + assert_equal 2, result.size + assert_includes result, "bar" + assert_includes result, 123 + end + + test "get returns empty array for non-existent key" do + execution = create_execution + context = AcidicJob::Context.new(execution) + + result = context.get(:nonexistent) + + assert_equal [], result + end + + # ============================================ + # fetch + # ============================================ + + test "fetch returns existing value" do + execution = create_execution + context = AcidicJob::Context.new(execution) + context.set(foo: "existing") + + result = context.fetch(:foo, "default") + + assert_equal "existing", result + end + + test "fetch uses default when key does not exist" do + execution = create_execution + context = AcidicJob::Context.new(execution) + + result = context.fetch(:foo, "default") + + assert_equal "default", result + # Should also store the default + assert_equal "default", execution.values.find_by(key: "foo").value + end + + test "fetch uses block when key does not exist and no default" do + execution = create_execution + context = AcidicJob::Context.new(execution) + + result = context.fetch(:foo) { |key| "computed_#{key}" } + + assert_equal "computed_foo", result + assert_equal "computed_foo", execution.values.find_by(key: "foo").value + end + + # ============================================ + # []= and [] + # ============================================ + + test "[]= sets a value" do + execution = create_execution + context = AcidicJob::Context.new(execution) + + context[:foo] = "bar" + + assert_equal "bar", execution.values.find_by(key: "foo").value + end + + test "[] gets a value" do + execution = create_execution + context = AcidicJob::Context.new(execution) + context.set(foo: "bar") + + result = context[:foo] + + assert_equal "bar", result + end + + test "[] returns nil for non-existent key" do + execution = create_execution + context = AcidicJob::Context.new(execution) + + result = context[:nonexistent] + + assert_nil result + end + + # ============================================ + # Integration with workflow + # ============================================ + + # This test verifies that workflow context values persist across job retries. + # + # How it works: + # 1. First execution (executions=1): set_context stores attempt=1, then raises DefaultsError + # 2. retry_on triggers a retry, incrementing the job's `executions` counter to 2 + # 3. Second execution (executions=2): set_context stores attempt=2 (overwriting), completes successfully + # 4. read_context runs and logs the final context values + # + # The assertion checks that attempt=2 because set_context ran twice (once per execution), + # each time storing the current `executions` value. The nested data persists unchanged + # since it was set identically in both executions. + test "context persists across job retries" do + class ContextRetryJob < ActiveJob::Base + include AcidicJob::Workflow + + retry_on DefaultsError + + def perform + execute_workflow(unique_by: job_id) do |w| + w.step :set_context + w.step :read_context + end + end + + def set_context + ctx[:attempt] = executions + ctx[:data] = { nested: "value" } + raise DefaultsError if executions == 1 + end + + def read_context + ChaoticJob.log_to_journal!({ + "attempt" => ctx[:attempt], + "data" => ctx[:data] + }) + end + end + + ContextRetryJob.perform_later + perform_all_jobs + + entry = ChaoticJob.top_journal_entry + # After retry, attempt=2 because set_context ran twice, storing executions each time + assert_equal 2, entry["attempt"] + assert_equal "value", entry["data"][:nested] + end +end diff --git a/test/acidic_job/engine_test.rb b/test/acidic_job/engine_test.rb new file mode 100644 index 0000000..8af3105 --- /dev/null +++ b/test/acidic_job/engine_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "test_helper" + +class AcidicJob::EngineTest < ActiveSupport::TestCase + test "engine is a Rails::Engine" do + assert_kind_of ::Rails::Engine, AcidicJob::Engine.instance + end + + test "engine isolates AcidicJob namespace" do + assert_equal "acidic_job", AcidicJob::Engine.engine_name + end + + test "config.acidic_job is available" do + assert_respond_to Rails.application.config, :acidic_job + end + + test "logger can be configured" do + original_logger = AcidicJob.logger + + custom_logger = Logger.new(IO::NULL) + AcidicJob.logger = custom_logger + + assert_equal custom_logger, AcidicJob.logger + ensure + AcidicJob.logger = original_logger + end + + test "LogSubscriber responds to acidic_job events" do + subscriber = AcidicJob::LogSubscriber.new + + # These are the events the LogSubscriber should handle + assert_respond_to subscriber, :define_workflow + assert_respond_to subscriber, :initialize_workflow + assert_respond_to subscriber, :process_workflow + assert_respond_to subscriber, :process_step + assert_respond_to subscriber, :perform_step + end + + test "custom serializers are registered" do + serializers = ActiveJob::Serializers.serializers + + # Check our custom serializers are registered + serializer_classes = serializers.map(&:class) + + assert_includes serializer_classes, AcidicJob::Serializers::ExceptionSerializer + assert_includes serializer_classes, AcidicJob::Serializers::NewRecordSerializer + assert_includes serializer_classes, AcidicJob::Serializers::JobSerializer + end + + test "ExceptionSerializer can serialize and deserialize exceptions" do + original = StandardError.new("test message") + + serialized = ActiveJob::Serializers.serialize(original) + deserialized = ActiveJob::Serializers.deserialize(serialized) + + assert_kind_of StandardError, deserialized + assert_equal "test message", deserialized.message + end + + test "JobSerializer can serialize and deserialize jobs" do + job = DoingJob.new + + serialized = ActiveJob::Serializers.serialize(job) + deserialized = ActiveJob::Serializers.deserialize(serialized) + + assert_kind_of DoingJob, deserialized + assert_equal job.job_id, deserialized.job_id + end + + test "NewRecordSerializer can serialize and deserialize unpersisted records" do + original = Thing.new + + serialized = ActiveJob::Serializers.serialize(original) + deserialized = ActiveJob::Serializers.deserialize(serialized) + + assert_kind_of Thing, deserialized + assert deserialized.new_record? + end +end diff --git a/test/acidic_job/errors_test.rb b/test/acidic_job/errors_test.rb index fc85850..26e4cd5 100644 --- a/test/acidic_job/errors_test.rb +++ b/test/acidic_job/errors_test.rb @@ -154,4 +154,80 @@ class AcidicJob::ErrorsTest < ActiveJob::TestCase AcidicJob::InvalidMethodError.new("test", "test") end end + + # ============================================ + # Error message tests (to exercise #message methods) + # ============================================ + + # Helper to create an anonymous module plugin for testing error messages. + # Plugins must respond to `keyword` to be valid. + def create_test_plugin(keyword: :test) + Module.new do + extend self + define_method(:keyword) { keyword } + end + end + + # Helper to create a named class instance for testing error messages. + # Used to verify error messages include the class name. + def create_named_plugin_instance(name) + plugin_class = Class.new do + define_singleton_method(:name) { name } + end + plugin_class.new + end + + test "SucceededStepError message includes step name" do + error = AcidicJob::SucceededStepError.new("my_step") + assert_match(/my_step/, error.message) + assert_match(/already recorded.*succeeded/i, error.message) + end + + test "InvalidMethodError message includes step name" do + error = AcidicJob::InvalidMethodError.new("bad_step") + assert_match(/bad_step/, error.message) + assert_match(/cannot expect arguments/i, error.message) + end + + test "DoublePluginCallError takes plugin and step arguments" do + error = AcidicJob::DoublePluginCallError.new(AcidicJob::Plugins::TransactionalStep, "my_step") + assert_match(/TransactionalStep/, error.message) + assert_match(/my_step/, error.message) + assert_match(/multiple times/i, error.message) + end + + test "DoublePluginCallError works with module plugin" do + plugin = create_test_plugin(keyword: :test) + + error = AcidicJob::DoublePluginCallError.new(plugin, "step_name") + assert_match(/step_name/, error.message) + end + + test "DoublePluginCallError works with class instance plugin" do + plugin = create_named_plugin_instance("MyPluginClass") + + error = AcidicJob::DoublePluginCallError.new(plugin, "step_name") + assert_match(/MyPluginClass/, error.message) + end + + test "MissingPluginCallError takes plugin and step arguments" do + error = AcidicJob::MissingPluginCallError.new(AcidicJob::Plugins::TransactionalStep, "my_step") + assert_match(/TransactionalStep/, error.message) + assert_match(/my_step/, error.message) + assert_match(/failed to call/i, error.message) + end + + test "MissingPluginCallError works with module plugin" do + plugin = create_test_plugin(keyword: :another) + + error = AcidicJob::MissingPluginCallError.new(plugin, "some_step") + assert_match(/some_step/, error.message) + end + + test "MissingPluginCallError works with class instance plugin" do + plugin = create_named_plugin_instance("InstancePlugin") + + error = AcidicJob::MissingPluginCallError.new(plugin, "step") + assert_match(/InstancePlugin/, error.message) + end end diff --git a/test/acidic_job/plugin_context_test.rb b/test/acidic_job/plugin_context_test.rb new file mode 100644 index 0000000..5576fd0 --- /dev/null +++ b/test/acidic_job/plugin_context_test.rb @@ -0,0 +1,376 @@ +# frozen_string_literal: true + +require "test_helper" + +class AcidicJob::PluginContextTest < ActiveJob::TestCase + # Thread-local storage for capturing values from plugins during tests. + # This approach is cleaner than cattr_accessor with manual resets and + # is safe for parallel test execution. + module TestCapture + class << self + def store + Thread.current[:plugin_context_test_capture] ||= {} + end + + def [](key) + store[key] + end + + def []=(key, value) + store[key] = value + end + + def clear! + Thread.current[:plugin_context_test_capture] = {} + end + end + end + + setup do + TestCapture.clear! + end + + teardown do + # Ensure cleanup even if a test fails mid-execution + TestCapture.clear! + end + + test "PluginContext#set delegates to context" do + class PluginSetJob < ActiveJob::Base + include AcidicJob::Workflow + + module SetPlugin + extend self + def keyword; :setter; end + def validate(input); input; end + def around_step(context, &block) + context.set(plugin_called: true) + yield + end + end + + def perform + execute_workflow(unique_by: job_id, with: [ SetPlugin ]) do |w| + w.step :do_work, setter: true + end + end + + def do_work + ChaoticJob.log_to_journal!({ "plugin_called" => ctx[:plugin_called] }) + end + end + + PluginSetJob.perform_later + perform_all_jobs + + entry = ChaoticJob.top_journal_entry + assert_equal true, entry["plugin_called"] + end + + test "PluginContext#current_step returns the step name" do + class CurrentStepJob < ActiveJob::Base + include AcidicJob::Workflow + + module CapturePlugin + extend self + def keyword; :capture; end + def validate(input); input; end + def around_step(context, &block) + AcidicJob::PluginContextTest::TestCapture[:captured_step] = context.current_step + yield + end + end + + def perform + execute_workflow(unique_by: job_id, with: [ CapturePlugin ]) do |w| + w.step :my_step, capture: true + end + end + + def my_step; end + end + + CurrentStepJob.perform_now + assert_equal "my_step", TestCapture[:captured_step] + end + + test "PluginContext#entries_for_action queries entries with plugin prefix" do + class EntriesJob < ActiveJob::Base + include AcidicJob::Workflow + + module EntriesPlugin + extend self + def keyword; :entries_test; end + def validate(input); input; end + def around_step(context, &block) + # First call records, second call should find entries + context.record!(step: "test", action: "recorded", timestamp: Time.current) + AcidicJob::PluginContextTest::TestCapture[:found_entries] = context.entries_for_action("recorded").count + yield + end + end + + def perform + execute_workflow(unique_by: job_id, with: [ EntriesPlugin ]) do |w| + w.step :check_entries, entries_test: true + end + end + + def check_entries; end + end + + EntriesJob.perform_now + assert_equal 1, TestCapture[:found_entries] + end + + test "PluginContext#record! creates entry with plugin-prefixed action" do + class RecordJob < ActiveJob::Base + include AcidicJob::Workflow + + module RecordPlugin + extend self + def keyword; :recorder; end + def validate(input); input; end + def around_step(context, &block) + context.record!(step: "test_step", action: "custom_action", timestamp: Time.current) + yield + end + end + + def perform + execute_workflow(unique_by: job_id, with: [ RecordPlugin ]) do |w| + w.step :do_record, recorder: true + end + end + + def do_record; end + end + + RecordJob.perform_now + + execution = AcidicJob::Execution.first + entry = execution.entries.find_by(action: "recorder/custom_action") + assert_not_nil entry + assert_equal "test_step", entry.step + end + + test "PluginContext#plugin_action prefixes action with keyword" do + class PluginActionJob < ActiveJob::Base + include AcidicJob::Workflow + + module ActionPlugin + extend self + def keyword; :my_plugin; end + def validate(input); input; end + def around_step(context, &block) + AcidicJob::PluginContextTest::TestCapture[:action_result] = context.plugin_action("something") + yield + end + end + + def perform + execute_workflow(unique_by: job_id, with: [ ActionPlugin ]) do |w| + w.step :test_action, my_plugin: true + end + end + + def test_action; end + end + + PluginActionJob.perform_now + assert_equal "my_plugin/something", TestCapture[:action_result] + end + + test "PluginContext#enqueue_job enqueues the job" do + class EnqueuePluginJob < ActiveJob::Base + include AcidicJob::Workflow + + module EnqueuePlugin + extend self + def keyword; :enqueuer; end + def validate(input); input; end + def around_step(context, &block) + # Enqueue a delayed retry and record that we called it + AcidicJob::PluginContextTest::TestCapture[:enqueue_called] = true + context.enqueue_job(wait: 1.hour) + context.halt_workflow! + end + end + + def perform + execute_workflow(unique_by: job_id, with: [ EnqueuePlugin ]) do |w| + w.step :will_enqueue, enqueuer: true + end + end + + def will_enqueue; end + end + + # Use perform_now to avoid the infinite loop from perform_all_jobs + EnqueuePluginJob.perform_now + + assert TestCapture[:enqueue_called] + # Should have enqueued a delayed job + assert_equal 1, enqueued_jobs.size + end + + test "PluginContext#halt_workflow! halts the workflow" do + class HaltPluginJob < ActiveJob::Base + include AcidicJob::Workflow + + module HaltPlugin + extend self + def keyword; :halter; end + def validate(input); input; end + def around_step(context, &block) + context.halt_workflow! + end + end + + def perform + execute_workflow(unique_by: job_id, with: [ HaltPlugin ]) do |w| + w.step :will_halt, halter: true + w.step :never_reached + end + end + + def will_halt + raise "Should not reach here" + end + + def never_reached + raise "Should not reach here" + end + end + + HaltPluginJob.perform_now + + execution = AcidicJob::Execution.first + # Should be halted, not finished + assert_equal "will_halt", execution.recover_to + assert execution.entries.exists?(action: "halted") + end + + test "PluginContext#repeat_step! repeats the current step" do + class RepeatPluginJob < ActiveJob::Base + include AcidicJob::Workflow + + module RepeatPlugin + extend self + def keyword; :repeater; end + def validate(input); input; end + def around_step(context, &block) + AcidicJob::PluginContextTest::TestCapture[:call_count] ||= 0 + AcidicJob::PluginContextTest::TestCapture[:call_count] += 1 + if AcidicJob::PluginContextTest::TestCapture[:call_count] < 3 + context.repeat_step! + else + yield + end + end + end + + def perform + execute_workflow(unique_by: job_id, with: [ RepeatPlugin ]) do |w| + w.step :will_repeat, repeater: true + end + end + + def will_repeat; end + end + + RepeatPluginJob.perform_now + + assert_equal 3, TestCapture[:call_count] + assert AcidicJob::Execution.first.finished? + end + + test "PluginContext#resolve_method returns method object" do + class ResolveMethodJob < ActiveJob::Base + include AcidicJob::Workflow + + module ResolverPlugin + extend self + def keyword; :resolver; end + def validate(input); input; end + def around_step(context, &block) + AcidicJob::PluginContextTest::TestCapture[:resolved_method] = context.resolve_method(:my_method) + yield + end + end + + def perform + execute_workflow(unique_by: job_id, with: [ ResolverPlugin ]) do |w| + w.step :test_resolve, resolver: true + end + end + + def test_resolve; end + def my_method; "found"; end + end + + ResolveMethodJob.perform_now + assert_kind_of Method, TestCapture[:resolved_method] + assert_equal "found", TestCapture[:resolved_method].call + end + + test "PluginContext#resolve_method raises UndefinedMethodError for missing method" do + class PluginContextMissingMethodJob < ActiveJob::Base + include AcidicJob::Workflow + + module MissingPlugin + extend self + def keyword; :missing; end + def validate(input); input; end + def around_step(context, &block) + begin + context.resolve_method(:nonexistent_method) + rescue AcidicJob::UndefinedMethodError => e + AcidicJob::PluginContextTest::TestCapture[:raised_error] = e + end + yield + end + end + + def perform + execute_workflow(unique_by: job_id, with: [ MissingPlugin ]) do |w| + w.step :test_missing, missing: true + end + end + + def test_missing; end + end + + PluginContextMissingMethodJob.perform_now + assert_kind_of AcidicJob::UndefinedMethodError, TestCapture[:raised_error] + assert_match(/nonexistent_method/, TestCapture[:raised_error].message) + end + + test "PluginContext#get delegates to context" do + class PluginGetJob < ActiveJob::Base + include AcidicJob::Workflow + + module GetPlugin + extend self + def keyword; :getter; end + def validate(input); input; end + def around_step(context, &block) + context.set(test_key: "test_value") + AcidicJob::PluginContextTest::TestCapture[:got_value] = context.get(:test_key) + yield + end + end + + def perform + execute_workflow(unique_by: job_id, with: [ GetPlugin ]) do |w| + w.step :do_get, getter: true + end + end + + def do_get; end + end + + PluginGetJob.perform_now + assert_equal [ "test_value" ], TestCapture[:got_value] + end +end diff --git a/test/acidic_job/plugins/transactional_step_test.rb b/test/acidic_job/plugins/transactional_step_test.rb new file mode 100644 index 0000000..05fe17e --- /dev/null +++ b/test/acidic_job/plugins/transactional_step_test.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require "test_helper" + +class AcidicJob::Plugins::TransactionalStepTest < ActiveSupport::TestCase + # ============================================ + # Validation tests + # ============================================ + + test "validate accepts true" do + result = AcidicJob::Plugins::TransactionalStep.validate(true) + assert_equal true, result + end + + test "validate accepts false" do + result = AcidicJob::Plugins::TransactionalStep.validate(false) + assert_equal false, result + end + + test "validate accepts hash with on: Model" do + result = AcidicJob::Plugins::TransactionalStep.validate(on: Thing) + assert_equal({ on: Thing }, result) + end + + test "validate accepts hash with on: AcidicJob::Execution" do + result = AcidicJob::Plugins::TransactionalStep.validate(on: AcidicJob::Execution) + assert_equal({ on: AcidicJob::Execution }, result) + end + + test "validate rejects string" do + error = assert_raises(ArgumentError) do + AcidicJob::Plugins::TransactionalStep.validate("invalid") + end + assert_match(/must be boolean or hash/, error.message) + end + + test "validate rejects integer" do + error = assert_raises(ArgumentError) do + AcidicJob::Plugins::TransactionalStep.validate(123) + end + assert_match(/must be boolean or hash/, error.message) + end + + test "validate rejects nil" do + error = assert_raises(ArgumentError) do + AcidicJob::Plugins::TransactionalStep.validate(nil) + end + assert_match(/must be boolean or hash/, error.message) + end + + test "validate rejects array" do + error = assert_raises(ArgumentError) do + AcidicJob::Plugins::TransactionalStep.validate([ Thing ]) + end + assert_match(/must be boolean or hash/, error.message) + end + + test "validate rejects hash without on key" do + error = assert_raises(ArgumentError) do + AcidicJob::Plugins::TransactionalStep.validate(model: Thing) + end + assert_match(/must have `on` key/, error.message) + end + + test "validate rejects hash with empty keys" do + error = assert_raises(ArgumentError) do + AcidicJob::Plugins::TransactionalStep.validate({}) + end + assert_match(/must have `on` key/, error.message) + end + + test "validate rejects hash with on: string" do + error = assert_raises(ArgumentError) do + AcidicJob::Plugins::TransactionalStep.validate(on: "Thing") + end + assert_match(/must have module value/, error.message) + end + + test "validate rejects hash with on: symbol" do + error = assert_raises(ArgumentError) do + AcidicJob::Plugins::TransactionalStep.validate(on: :Thing) + end + assert_match(/must have module value/, error.message) + end + + test "validate rejects hash with on: nil" do + error = assert_raises(ArgumentError) do + AcidicJob::Plugins::TransactionalStep.validate(on: nil) + end + assert_match(/must have module value/, error.message) + end + + # ============================================ + # Integration tests - transactional behavior + # ============================================ + + test "transactional: true wraps step in AcidicJob::Execution transaction" do + class TransactionalTrueJob < ActiveJob::Base + include AcidicJob::Workflow + + def perform + execute_workflow(unique_by: job_id) do |w| + w.step :create_thing, transactional: true + end + end + + def create_thing + Thing.create! + raise StandardError, "rollback" + end + end + + assert_raises(StandardError) do + TransactionalTrueJob.perform_now + end + + # Thing creation should be rolled back + assert_equal 0, Thing.count + end + + test "transactional: false does not wrap step in transaction" do + class TransactionalFalseJob < ActiveJob::Base + include AcidicJob::Workflow + + def perform + execute_workflow(unique_by: job_id) do |w| + w.step :create_thing, transactional: false + end + end + + def create_thing + Thing.create! + raise StandardError, "no rollback" + end + end + + assert_raises(StandardError) do + TransactionalFalseJob.perform_now + end + + # Thing creation should NOT be rolled back + assert_equal 1, Thing.count + end + + test "step without transactional option does not wrap in transaction" do + class NonTransactionalJob < ActiveJob::Base + include AcidicJob::Workflow + + def perform + execute_workflow(unique_by: job_id) do |w| + w.step :create_thing + end + end + + def create_thing + Thing.create! + raise StandardError, "no rollback" + end + end + + assert_raises(StandardError) do + NonTransactionalJob.perform_now + end + + # Thing creation should NOT be rolled back (no transaction wrapper) + assert_equal 1, Thing.count + end + + test "transactional step rolls back on retry error but persists on success" do + class TransactionalRetryJob < ActiveJob::Base + include AcidicJob::Workflow + + retry_on DefaultsError + + def perform + execute_workflow(unique_by: job_id) do |w| + w.step :create_thing, transactional: true + end + end + + def create_thing + Thing.create! + raise DefaultsError if executions == 1 + end + end + + TransactionalRetryJob.perform_later + perform_all_jobs + + # First attempt rolls back, second attempt succeeds + # So only 1 Thing should exist (from successful second attempt) + assert_equal 1, Thing.count + end +end diff --git a/test/acidic_job/workflow_errors_test.rb b/test/acidic_job/workflow_errors_test.rb new file mode 100644 index 0000000..91be226 --- /dev/null +++ b/test/acidic_job/workflow_errors_test.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "test_helper" + +class AcidicJob::WorkflowErrorsTest < ActiveJob::TestCase + test "raises RedefiningWorkflowError when execute_workflow called twice" do + class DoubleExecuteJob < ActiveJob::Base + include AcidicJob::Workflow + + def perform + execute_workflow(unique_by: job_id) do |w| + w.step :step_1 + end + execute_workflow(unique_by: job_id) do |w| + w.step :step_1 + end + end + + def step_1; end + end + + error = assert_raises(AcidicJob::RedefiningWorkflowError) do + DoubleExecuteJob.perform_now + end + assert_match(/can only call.*once/i, error.message) + end + + test "raises UndefinedWorkflowBlockError when no block given" do + class NoBlockJob < ActiveJob::Base + include AcidicJob::Workflow + + def perform + execute_workflow(unique_by: job_id) + end + end + + error = assert_raises(AcidicJob::UndefinedWorkflowBlockError) do + NoBlockJob.perform_now + end + assert_match(/block must be passed/i, error.message) + end + + test "raises InvalidWorkflowBlockError when block takes no arguments" do + class ZeroArityBlockJob < ActiveJob::Base + include AcidicJob::Workflow + + def perform + execute_workflow(unique_by: job_id) { } + end + end + + error = assert_raises(AcidicJob::InvalidWorkflowBlockError) do + ZeroArityBlockJob.perform_now + end + assert_match(/workflow builder must be yielded/i, error.message) + end + + test "raises MissingStepsError when no steps defined" do + class EmptyWorkflowJob < ActiveJob::Base + include AcidicJob::Workflow + + def perform + execute_workflow(unique_by: job_id) { |w| } + end + end + + error = assert_raises(AcidicJob::MissingStepsError) do + EmptyWorkflowJob.perform_now + end + assert_match(/must define at least one step/i, error.message) + end + + test "raises UndefinedMethodError when step method doesn't exist" do + class MissingMethodJob < ActiveJob::Base + include AcidicJob::Workflow + + def perform + execute_workflow(unique_by: job_id) do |w| + w.step :nonexistent_step + end + end + end + + error = assert_raises(AcidicJob::UndefinedMethodError) do + MissingMethodJob.perform_now + end + assert_match(/undefined step method/i, error.message) + assert_match(/nonexistent_step/, error.message) + end + + test "raises ArgumentMismatchError when re-running with different arguments" do + class ArgMismatchJob < ActiveJob::Base + include AcidicJob::Workflow + + def perform(arg) + execute_workflow(unique_by: "fixed-key") do |w| + w.step :step_1 + end + end + + def step_1 + raise DefaultsError + end + end + + # First run with arg=1, will fail on step_1 and leave execution record + assert_raises(DefaultsError) do + ArgMismatchJob.perform_now(1) + end + + assert_equal 1, AcidicJob::Execution.count + + # Second run with arg=2 but same idempotency key (fixed-key) + error = assert_raises(AcidicJob::ArgumentMismatchError) do + ArgMismatchJob.perform_now(2) + end + assert_match(/arguments do not match/i, error.message) + assert_match(/existing/, error.message) + assert_match(/expected/, error.message) + end + + test "raises DefinitionMismatchError when re-running with different workflow definition" do + # Create a job class that can change its workflow definition + class DynamicDefinitionJob < ActiveJob::Base + include AcidicJob::Workflow + + cattr_accessor :workflow_variant, default: :original + + def perform + execute_workflow(unique_by: "definition-test-key") do |w| + if self.class.workflow_variant == :original + w.step :step_1 + w.step :step_2 + else + w.step :step_a + w.step :step_b + w.step :step_c + end + end + end + + def step_1 + raise DefaultsError + end + + def step_2; end + def step_a; end + def step_b; end + def step_c; end + end + + # Run with original definition, fail on step_1, creating an execution record + DynamicDefinitionJob.workflow_variant = :original + assert_raises(DefaultsError) do + DynamicDefinitionJob.perform_now + end + + assert_equal 1, AcidicJob::Execution.count + execution = AcidicJob::Execution.first + assert_equal "step_1", execution.recover_to + + # Now change the workflow definition and try to resume + DynamicDefinitionJob.workflow_variant = :changed + + error = assert_raises(AcidicJob::DefinitionMismatchError) do + DynamicDefinitionJob.perform_now + end + assert_match(/definition does not match/i, error.message) + assert_match(/existing/, error.message) + assert_match(/expected/, error.message) + end + + test "raises UndefinedStepError when execution recover_to points to undefined step" do + class ValidStepJob < ActiveJob::Base + include AcidicJob::Workflow + + def perform + execute_workflow(unique_by: "recover-test-key") do |w| + w.step :step_1 + end + end + + def step_1; end + end + + # Create a valid execution first + ValidStepJob.perform_now + + execution = AcidicJob::Execution.first + assert execution.finished? + + # Manually corrupt the recover_to to point to a non-existent step + execution.update_column(:recover_to, "nonexistent_step") + + # Now try to resume the workflow - should raise UndefinedStepError + error = assert_raises(AcidicJob::UndefinedStepError) do + ValidStepJob.perform_now + end + assert_match(/does not reference this step/i, error.message) + assert_match(/nonexistent_step/, error.message) + end +end diff --git a/test/models/entry_test.rb b/test/models/entry_test.rb new file mode 100644 index 0000000..d74dbb5 --- /dev/null +++ b/test/models/entry_test.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "test_helper" + +class AcidicJob::EntryTest < ActiveSupport::TestCase + def create_execution + serialized_job = { + "job_class" => "TestJob", + "job_id" => SecureRandom.uuid, + "arguments" => [] + } + definition = { + "meta" => { "version" => AcidicJob::VERSION }, + "steps" => { + "step_1" => { "does" => "step_1", "then" => AcidicJob::FINISHED_RECOVERY_POINT } + } + } + AcidicJob::Execution.create!( + idempotency_key: SecureRandom.hex(32), + serialized_job: serialized_job, + definition: definition, + recover_to: "step_1" + ) + end + + def create_entry(execution, step:, action:, timestamp: Time.current) + AcidicJob::Entry.create!( + execution: execution, + step: step, + action: action, + timestamp: timestamp, + data: {} + ) + end + + # ============================================ + # Scope: for_step + # ============================================ + + test "for_step scope filters by step name" do + execution = create_execution + step1_entry = create_entry(execution, step: "step_1", action: "started") + step2_entry = create_entry(execution, step: "step_2", action: "started") + + results = AcidicJob::Entry.for_step("step_1") + + assert_includes results, step1_entry + assert_not_includes results, step2_entry + end + + # ============================================ + # Scope: for_action + # ============================================ + + test "for_action scope filters by action" do + execution = create_execution + started_entry = create_entry(execution, step: "step_1", action: "started") + succeeded_entry = create_entry(execution, step: "step_1", action: "succeeded") + + results = AcidicJob::Entry.for_action("started") + + assert_includes results, started_entry + assert_not_includes results, succeeded_entry + end + + # ============================================ + # Scope: ordered + # ============================================ + + test "ordered scope sorts by timestamp ascending" do + execution = create_execution + early = create_entry(execution, step: "step_1", action: "started", timestamp: 2.minutes.ago) + late = create_entry(execution, step: "step_1", action: "succeeded", timestamp: 1.minute.ago) + + results = AcidicJob::Entry.ordered + + assert_equal [ early, late ], results.to_a + end + + # ============================================ + # Class method: most_recent + # ============================================ + + test "most_recent returns the most recently created entry" do + execution = create_execution + + # Explicitly set created_at timestamps to avoid relying on database clock ordering + first = create_entry(execution, step: "step_1", action: "started") + first.update_column(:created_at, 2.minutes.ago) + + second = create_entry(execution, step: "step_1", action: "succeeded") + second.update_column(:created_at, 1.minute.ago) + + result = AcidicJob::Entry.most_recent + + assert_equal second, result + end + + test "most_recent returns nil when no entries exist" do + result = AcidicJob::Entry.most_recent + + assert_nil result + end + + # ============================================ + # Instance method: action? + # ============================================ + + test "action? returns true when action matches" do + execution = create_execution + entry = create_entry(execution, step: "step_1", action: "started") + + assert entry.action?("started") + end + + test "action? returns false when action does not match" do + execution = create_execution + entry = create_entry(execution, step: "step_1", action: "started") + + assert_not entry.action?("succeeded") + end +end diff --git a/test/models/execution_test.rb b/test/models/execution_test.rb new file mode 100644 index 0000000..9d0f85d --- /dev/null +++ b/test/models/execution_test.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require "test_helper" + +class AcidicJob::ExecutionTest < ActiveSupport::TestCase + # Helper to create execution records for testing scopes + def create_execution(recover_to:, last_run_at: Time.current, job_class: "TestJob") + # Build a minimal valid serialized_job + serialized_job = { + "job_class" => job_class, + "job_id" => SecureRandom.uuid, + "arguments" => [] + } + + # Build a minimal valid definition + definition = { + "meta" => { "version" => AcidicJob::VERSION }, + "steps" => { + "step_1" => { "does" => "step_1", "then" => AcidicJob::FINISHED_RECOVERY_POINT } + } + } + + AcidicJob::Execution.create!( + idempotency_key: SecureRandom.hex(32), + serialized_job: serialized_job, + definition: definition, + recover_to: recover_to, + last_run_at: last_run_at + ) + end + + # ============================================ + # Scope: finished + # ============================================ + + test "finished scope returns executions with FINISHED_RECOVERY_POINT" do + finished_execution = create_execution(recover_to: AcidicJob::FINISHED_RECOVERY_POINT) + unfinished_execution = create_execution(recover_to: "step_1") + + results = AcidicJob::Execution.finished + + assert_includes results, finished_execution + assert_not_includes results, unfinished_execution + end + + test "finished scope does not include nil or empty recover_to" do + finished_execution = create_execution(recover_to: AcidicJob::FINISHED_RECOVERY_POINT) + nil_execution = create_execution(recover_to: nil) + empty_execution = create_execution(recover_to: "") + + results = AcidicJob::Execution.finished + + assert_includes results, finished_execution + assert_not_includes results, nil_execution + assert_not_includes results, empty_execution + end + + # ============================================ + # Scope: outstanding + # ============================================ + + test "outstanding scope returns executions not finished" do + finished_execution = create_execution(recover_to: AcidicJob::FINISHED_RECOVERY_POINT) + unfinished_execution = create_execution(recover_to: "step_1") + + results = AcidicJob::Execution.outstanding + + assert_includes results, unfinished_execution + assert_not_includes results, finished_execution + end + + test "outstanding scope includes nil and empty recover_to" do + nil_execution = create_execution(recover_to: nil) + empty_execution = create_execution(recover_to: "") + step_execution = create_execution(recover_to: "step_2") + + results = AcidicJob::Execution.outstanding + + assert_includes results, nil_execution + assert_includes results, empty_execution + assert_includes results, step_execution + end + + # ============================================ + # Scope: clearable + # ============================================ + + test "clearable scope returns finished executions older than default threshold" do + old_finished = create_execution( + recover_to: AcidicJob::FINISHED_RECOVERY_POINT, + last_run_at: 2.weeks.ago + ) + recent_finished = create_execution( + recover_to: AcidicJob::FINISHED_RECOVERY_POINT, + last_run_at: 1.day.ago + ) + old_unfinished = create_execution( + recover_to: "step_1", + last_run_at: 2.weeks.ago + ) + + results = AcidicJob::Execution.clearable + + assert_includes results, old_finished + assert_not_includes results, recent_finished + assert_not_includes results, old_unfinished + end + + test "clearable scope accepts custom finished_before parameter" do + three_days_old = create_execution( + recover_to: AcidicJob::FINISHED_RECOVERY_POINT, + last_run_at: 3.days.ago + ) + one_day_old = create_execution( + recover_to: AcidicJob::FINISHED_RECOVERY_POINT, + last_run_at: 1.day.ago + ) + + # With 2 day threshold, only the 3-day-old record should be clearable + results = AcidicJob::Execution.clearable(finished_before: 2.days.ago) + + assert_includes results, three_days_old + assert_not_includes results, one_day_old + end + + # ============================================ + # Method: clear_finished_in_batches + # ============================================ + + test "clear_finished_in_batches removes old finished executions" do + # Create 3 old finished executions + 3.times do + create_execution( + recover_to: AcidicJob::FINISHED_RECOVERY_POINT, + last_run_at: 2.weeks.ago + ) + end + + # Create 2 outstanding executions (should not be deleted) + 2.times do + create_execution( + recover_to: "step_1", + last_run_at: 2.weeks.ago + ) + end + + # Create 1 recent finished execution (should not be deleted) + create_execution( + recover_to: AcidicJob::FINISHED_RECOVERY_POINT, + last_run_at: 1.day.ago + ) + + assert_equal 6, AcidicJob::Execution.count + + AcidicJob::Execution.clear_finished_in_batches + + # Only outstanding (2) + recent finished (1) should remain + assert_equal 3, AcidicJob::Execution.count + assert_equal 2, AcidicJob::Execution.outstanding.count + assert_equal 1, AcidicJob::Execution.finished.count + end + + test "clear_finished_in_batches respects batch_size parameter" do + # Create 5 old finished executions + 5.times do + create_execution( + recover_to: AcidicJob::FINISHED_RECOVERY_POINT, + last_run_at: 2.weeks.ago + ) + end + + assert_equal 5, AcidicJob::Execution.count + + # Verify that batch_size limits the records deleted per iteration. + # With batch_size=2, all 5 records should still be deleted (just in smaller chunks). + # This is a functional test - we verify the end result is correct regardless of + # how many internal iterations occurred. + AcidicJob::Execution.clear_finished_in_batches(batch_size: 2) + + assert_equal 0, AcidicJob::Execution.count + end + + test "clear_finished_in_batches accepts custom finished_before parameter" do + create_execution( + recover_to: AcidicJob::FINISHED_RECOVERY_POINT, + last_run_at: 10.days.ago + ) + newer_execution = create_execution( + recover_to: AcidicJob::FINISHED_RECOVERY_POINT, + last_run_at: 3.days.ago + ) + + assert_equal 2, AcidicJob::Execution.count + + # Only delete executions older than 5 days + AcidicJob::Execution.clear_finished_in_batches(finished_before: 5.days.ago) + + assert_equal 1, AcidicJob::Execution.count + assert_equal newer_execution, AcidicJob::Execution.first + end + + test "clear_finished_in_batches handles empty result gracefully" do + # No executions exist + assert_equal 0, AcidicJob::Execution.count + + # Should not raise, just return immediately + AcidicJob::Execution.clear_finished_in_batches + + assert_equal 0, AcidicJob::Execution.count + end +end