0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 15:55:38 +08:00
discourse/plugins/automation/spec/models/automation_spec.rb
Sam 43cd7ecee7
DEV: Make automation recursion depth configurable (#40778)
- Replace the hardcoded automation recursion limit with a hidden site
setting so deployments can tune nested trigger behavior.

discourse_automation_max_recursion_depth

- Stop raising exceptions on recursion guard (to match old behavior)
2026-06-11 09:41:00 +10:00

478 lines
14 KiB
Ruby
Vendored
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
describe DiscourseAutomation::Automation do
describe "#trigger!" do
context "when not enabled" do
fab!(:automation) { Fabricate(:automation, enabled: false) }
it "doesnt do anything" do
list = capture_contexts { automation.trigger!("Howdy!") }
expect(list).to eq([])
end
end
context "when enabled" do
fab!(:automation) { Fabricate(:automation, enabled: true) }
it "runs the script" do
list = capture_contexts { automation.trigger!("Howdy!") }
expect(list).to eq(["Howdy!"])
end
end
context "when triggers are suppressed" do
it "doesnt run the script" do
automation = Fabricate(:automation, enabled: true)
list =
capture_contexts do
DiscourseAutomation.suppress_triggers { automation.trigger!("Howdy!") }
end
expect(list).to eq([])
end
end
context "when recursively triggered" do
before do
DiscourseAutomation::Scriptable.add("recursive_test_scriptable") do
script do |context, _, automation|
depth = context[:depth]
DiscourseAutomation::CapturedContext.add(depth)
if depth < context[:target_depth]
automation.trigger!(depth: depth + 1, target_depth: context[:target_depth])
end
end
end
end
after { DiscourseAutomation::Scriptable.remove("recursive_test_scriptable") }
it "runs nested triggers up to the configured maximum depth" do
SiteSetting.discourse_automation_max_recursion_depth = 5
automation = Fabricate(:automation, enabled: true, script: "recursive_test_scriptable")
list = capture_contexts { automation.trigger!(depth: 1, target_depth: 5) }
expect(list).to eq([1, 2, 3, 4, 5])
end
it "does not allow recursive triggers by default" do
automation = Fabricate(:automation, enabled: true, script: "recursive_test_scriptable")
list = capture_contexts { automation.trigger!(depth: 1, target_depth: 2) }
expect(list).to eq([1])
end
it "silently stops when the configured maximum depth is exceeded" do
SiteSetting.discourse_automation_max_recursion_depth = 5
automation = Fabricate(:automation, enabled: true, script: "recursive_test_scriptable")
list = capture_contexts { automation.trigger!(depth: 1, target_depth: 6) }
expect(list).to eq([1, 2, 3, 4, 5])
end
it "clears recursion depth after the limit is reached" do
SiteSetting.discourse_automation_max_recursion_depth = 5
automation = Fabricate(:automation, enabled: true, script: "recursive_test_scriptable")
capture_contexts { automation.trigger!(depth: 1, target_depth: 6) }
list = capture_contexts { automation.trigger!(depth: 1, target_depth: 1) }
expect(list).to eq([1])
end
end
end
describe "when a script is meant to be triggered in the background" do
fab!(:automation) do
Fabricate(:automation, enabled: true, script: "test-background-scriptable")
end
before do
DiscourseAutomation::Scriptable.add("test_background_scriptable") do
run_in_background
script do |context|
DiscourseAutomation::CapturedContext.add(context)
nil
end
end
end
it "runs a sidekiq job to trigger it" do
expect { automation.trigger!({ val: "Howdy!" }) }.to change {
Jobs::DiscourseAutomation::Trigger.jobs.size
}.by(1)
end
end
describe "#remove_id_from_custom_field" do
fab!(:automation)
it "expects a User/Topic/Post instance" do
expect {
automation.remove_id_from_custom_field(
Invite.new,
DiscourseAutomation::AUTOMATION_IDS_CUSTOM_FIELD,
)
}.to raise_error(RuntimeError)
end
end
describe "#add_id_to_custom_field" do
fab!(:automation)
it "expects a User/Topic/Post instance" do
expect {
automation.add_id_to_custom_field(
Invite.new,
DiscourseAutomation::AUTOMATION_IDS_CUSTOM_FIELD,
)
}.to raise_error(RuntimeError)
end
end
context "when automations script has a field with validator" do
before do
DiscourseAutomation::Scriptable.add("required_dogs") do
field :dog, component: :text, validator: ->(input) { "must have dog" if input != "dog" }
end
end
context "when validating automation" do
fab!(:automation) { Fabricate(:automation, enabled: false, script: "required_dogs") }
it "raises an error if invalid" do
expect {
automation.fields.create!(
name: "dog",
component: "text",
metadata: {
value: nil,
},
target: "script",
)
}.to raise_error(ActiveRecord::RecordInvalid, /must have dog/)
end
it "does nothing if valid" do
expect {
automation.fields.create!(
name: "dog",
component: "text",
metadata: {
value: "dog",
},
target: "script",
)
}.not_to raise_error
end
end
end
context "when automations script has a required field" do
before do
DiscourseAutomation::Scriptable.add("required_dogs") do
field :dog, component: :text, required: true
end
end
context "when field is not filled" do
fab!(:automation) { Fabricate(:automation, enabled: false, script: "required_dogs") }
context "when validating automation" do
it "raises an error" do
expect {
automation.fields.create!(
name: "dog",
component: "text",
metadata: {
value: nil,
},
target: "script",
)
}.to raise_error(ActiveRecord::RecordInvalid, /dog/)
end
end
end
end
context "when automations trigger has a required field" do
before do
DiscourseAutomation::Triggerable.add("required_dogs") do
field :dog, component: :text, required: true
end
end
context "when field is not filled" do
fab!(:automation) { Fabricate(:automation, enabled: false, trigger: "required_dogs") }
context "when validating automation" do
it "raises an error" do
expect {
automation.fields.create!(
name: "dog",
component: "text",
metadata: {
value: nil,
},
target: "trigger",
)
}.to raise_error(ActiveRecord::RecordInvalid, /dog/)
end
end
end
end
describe ".serialize_context" do
it "serializes date and time as iso8601" do
expect(
described_class.serialize_context(
{
"time" => Time.utc(2026, 4, 15, 12, 0, 0),
"time_with_zone" => Time.utc(2026, 4, 15, 12, 0, 0).in_time_zone("Europe/Warsaw"),
"date_time" => DateTime.new(2026, 4, 15, 12, 0, 0),
"date" => Date.new(2026, 4, 15),
},
),
).to eq(
{
"time" => "2026-04-15T12:00:00Z",
"time_with_zone" => "2026-04-15T14:00:00+02:00",
"date_time" => "2026-04-15T12:00:00+00:00",
"date" => "2026-04-15",
},
)
end
end
describe "after_destroy" do
fab!(:automation) { Fabricate(:automation, enabled: false) }
fab!(:automation2) { Fabricate(:automation, enabled: false) }
it "deletes user custom fields that indicate new users" do
user = Fabricate(:user)
user.custom_fields[automation.new_user_custom_field_name] = "1"
user.custom_fields[automation2.new_user_custom_field_name] = "1"
user.save_custom_fields
automation.destroy!
user.reload
expect(user.custom_fields).to eq({ automation2.new_user_custom_field_name => "1" })
end
end
context "when creating a new automation" do
it "validates the name length" do
automation = Fabricate.build(:automation, name: "a" * 101)
expect(automation).not_to be_valid
expect(automation.errors[:name]).to eq(["is too long (maximum is 100 characters)"])
automation = Fabricate.build(:automation, name: "c" * 50)
expect(automation).to be_valid
end
end
describe "#required_fields_complete" do
fab!(:automation) do
Fabricate(
:automation,
enabled: false,
script: "required_cats",
trigger: "required_cats_trigger",
)
end
before do
DiscourseAutomation::Scriptable.add("required_cats") do
field :cat, component: :text, required: true
field :dog, component: :text, required: false
end
DiscourseAutomation::Triggerable.add("required_cats_trigger") do
field :litterbox, component: :text, required: true
end
end
context "when the perform_required_fields_validation flag is true" do
before { automation.perform_required_fields_validation = true }
context "when a script required field is missing" do
before do
automation.fields.create!(
name: "litterbox",
component: "text",
metadata: {
value: "kittytoilet",
},
target: "trigger",
)
end
it "blocks enabling the automation and indicates the missing fields" do
automation.enabled = true
expect(automation.save).to eq(false)
expect(automation.errors.full_messages).to contain_exactly(
I18n.t(
"discourse_automation.models.automations.validations.script_missing_required_fields",
fields: "cat",
),
)
cat_field =
automation.fields.create!(
name: "cat",
component: "text",
metadata: {
value: "kitty",
},
target: "script",
)
automation.reload
automation.enabled = true
expect(automation.save).to eq(true)
automation.update!(enabled: false)
cat_field.update_attribute(:metadata, { value: "" })
automation.enabled = true
expect(automation.save).to eq(false)
expect(automation.errors.full_messages).to contain_exactly(
I18n.t(
"discourse_automation.models.automations.validations.script_missing_required_fields",
fields: "cat",
),
)
end
it "doesn't block disabling the automation" do
automation.fields.destroy_all
automation.update_attribute(:enabled, true)
automation.reload
expect(automation.enabled).to eq(true)
automation.enabled = false
expect(automation.save).to eq(true)
expect(automation.errors.full_messages).to be_empty
end
end
context "when a trigger required field is missing" do
before do
automation.fields.create!(
name: "cat",
component: "text",
metadata: {
value: "hellokitty",
},
target: "script",
)
end
it "blocks enabling the automation and indicates the missing fields" do
automation.enabled = true
expect(automation.save).to eq(false)
expect(automation.errors.full_messages).to contain_exactly(
I18n.t(
"discourse_automation.models.automations.validations.trigger_missing_required_fields",
fields: "litterbox",
),
)
litterbox_field =
automation.fields.create!(
name: "litterbox",
component: "text",
metadata: {
value: "kitty",
},
target: "trigger",
)
automation.reload
automation.enabled = true
expect(automation.save).to eq(true)
automation.update!(enabled: false)
litterbox_field.update_attribute(:metadata, { value: "" })
automation.enabled = true
expect(automation.save).to eq(false)
expect(automation.errors.full_messages).to contain_exactly(
I18n.t(
"discourse_automation.models.automations.validations.trigger_missing_required_fields",
fields: "litterbox",
),
)
end
it "doesn't block disabling the automation" do
automation.fields.destroy_all
automation.update_attribute(:enabled, true)
automation.reload
expect(automation.enabled).to eq(true)
automation.enabled = false
expect(automation.save).to eq(true)
expect(automation.errors.full_messages).to be_empty
end
end
context "when all required fields are filled" do
it "allows enabling the automation" do
automation.fields.create!(
name: "cat",
component: "text",
metadata: {
value: "kitty",
},
target: "script",
)
automation.fields.create!(
name: "litterbox",
component: "text",
metadata: {
value: "kittytoilet",
},
target: "trigger",
)
automation.enabled = true
expect(automation.save).to eq(true)
expect(automation.errors.full_messages).to be_empty
end
end
context "when trigger is blank" do
before { automation.update!(trigger: nil) }
it "blocks enabling the automation and indicates the trigger is blank" do
automation.enabled = true
expect(automation.save).to eq(false)
expect(automation.errors.full_messages).to contain_exactly(
I18n.t("discourse_automation.models.automations.validations.trigger_blank"),
)
end
end
end
context "when the perform_required_fields_validation flag is false" do
before { automation.perform_required_fields_validation = false }
it "doesn't block enabling the automation even if there are missing required fields" do
automation.enabled = true
expect(automation.save).to eq(true)
end
end
end
end