siftly-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5a64b92b6f28032c4a3284e1d718c1c432bc9d5537c35ec5b2762797658de24c
4
+ data.tar.gz: c86c1d29b33eac1d9c24a51a8b0ff29a7bf4bf2509cd9017248391f0321c80a2
5
+ SHA512:
6
+ metadata.gz: 27192b24f4b2e69b02a1dc937bcac2cbaf4f0aee6508add3e7cd06643afb440b6c786b8d982fda8baffe5b80508caac587ed7b81f935e0cf28119315c12caa66
7
+ data.tar.gz: 03ea60160b8b602fd5d8038ec7a951e847c0abe5bdd9b6bc53a312ab5249ae92b2109c9a4490a89d9fdb2b2e0957af822125948e0fa64aa88d09f8f4208fed34
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release.
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tomos Rees
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # Siftly::Rails
2
+
3
+ `siftly-rails` wires `Siftly.check` into Active Model and Active Record validations. Use it when you want attributes checked automatically during validation and want the results attached to the model instance.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem "siftly-rails"
9
+ ```
10
+
11
+ ```ruby
12
+ require "siftly/rails"
13
+ ```
14
+
15
+ Requiring `siftly/rails` auto-includes `Siftly::Rails::Model` into all Active Record models via `ActiveSupport.on_load(:active_record)`. You still need to require whichever plugin gems provide the filters you want — `siftly-rails` only adds the model integration layer.
16
+
17
+ For plain Active Model objects that don't inherit from `ActiveRecord::Base`, include the concern manually with `include Siftly::Rails::Model`.
18
+
19
+ ## Quick Start
20
+
21
+ First configure the normal Siftly pipeline:
22
+
23
+ ```ruby
24
+ Siftly.configure do |config|
25
+ config.aggregator = :score
26
+ config.threshold = 1.0
27
+ config.use :keyword_pack
28
+
29
+ config.filter :keyword_pack do |filter|
30
+ filter.keywords = ["spam", "buy now"]
31
+ filter.weight = 1.0
32
+ end
33
+ end
34
+ ```
35
+
36
+ Then declare the attributes to check:
37
+
38
+ ```ruby
39
+ class ContactRequest < ApplicationRecord
40
+ siftly_check :email, :message, message: "contains spam"
41
+ end
42
+ ```
43
+
44
+ At validation time, each declared attribute is checked with:
45
+
46
+ - `value:` the attribute value
47
+ - `attribute:` the attribute name
48
+ - `record:` the model instance
49
+ - `context:` resolved from the check options
50
+ - `filter_overrides:` resolved from the check options
51
+
52
+ Example:
53
+
54
+ ```ruby
55
+ request = ContactRequest.new(email: "spam@example.com", message: "buy now")
56
+
57
+ request.valid? # => false
58
+ request.siftly_spam? # => true
59
+ request.siftly_spam_attributes # => [:email, :message]
60
+ request.siftly_results.keys # => [:email, :message]
61
+ request.siftly_spam_result_for(:message) # => #<Siftly::Result ...>
62
+ ```
63
+
64
+ ## What `siftly_check` Does
65
+
66
+ Each `siftly_check` call:
67
+
68
+ - registers one validation callback for the declared attributes
69
+ - runs `Siftly.check` once per attribute
70
+ - stores the latest result per attribute in `@siftly_results`
71
+ - adds an Active Model error when a result is spam
72
+
73
+ The spam-tracking state is reset in `before_validation`, so old results do not leak across revalidation.
74
+
75
+ ## Supported Options
76
+
77
+ Siftly options passed through to `Siftly.check`:
78
+
79
+ - `filters`
80
+ - `filter_overrides`
81
+ - `aggregator`
82
+ - `threshold`
83
+ - `failure_mode`
84
+ - `instrumenter`
85
+ - `context`
86
+
87
+ Model-side validation behavior:
88
+
89
+ - `allow_nil`
90
+ - `allow_blank`
91
+ - `message`
92
+ - `strict`
93
+
94
+ Active Model callback options:
95
+
96
+ - `if`
97
+ - `unless`
98
+ - `on`
99
+
100
+ ## Option Resolution
101
+
102
+ Every non-callback option can be a literal value or a callable. This is broader than just `context` and `filter_overrides`.
103
+
104
+ Callables are executed via `instance_exec` on the model instance, so `self` inside the block is the record. Arity determines which explicit arguments are passed:
105
+
106
+ - arity `0`: no arguments (access the record through `self`)
107
+ - arity `1`: `attribute`
108
+ - arity `2+`: `attribute` and `record`
109
+
110
+ Example:
111
+
112
+ ```ruby
113
+ class Lead < ApplicationRecord
114
+ siftly_check :message,
115
+ filters: -> { [:keyword_pack] },
116
+ filter_overrides: ->(attribute) { { keyword_pack: { keywords: [trigger_phrase], weight: 1.2 } } },
117
+ context: ->(attribute, record) { { source: "#{attribute}:#{record.source_name}" } },
118
+ allow_blank: true
119
+ end
120
+ ```
121
+
122
+ ## Skip Behavior
123
+
124
+ A check is skipped entirely when:
125
+
126
+ - `allow_nil` is truthy and the value is `nil`
127
+ - `allow_blank` is truthy and the value is blank
128
+
129
+ Skipped checks:
130
+
131
+ - do not call `Siftly.check`
132
+ - do not store a result for that attribute
133
+ - do not add errors
134
+
135
+ ## Exposed Instance Helpers
136
+
137
+ Instances get:
138
+
139
+ - `siftly_spam?`
140
+ - `siftly_results`
141
+ - `siftly_result_for(attribute)`
142
+ - `siftly_spam_attributes`
143
+ - `siftly_spam_results`
144
+ - `siftly_spam_result_for(attribute)`
145
+
146
+ ## Practical Advice
147
+
148
+ - Scope `filters:` per model when different models need different spam rules. Relying only on global configuration is how you end up checking everything with everything.
149
+ - Remember that `siftly-rails` does not decide what counts as spam. It just runs the configured pipeline during validation.
150
+ - If a filter is flaky or network-backed, set `failure_mode` deliberately. Pretending the default is fine without thinking about it is lazy.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new do |test|
6
+ test.libs << "lib" << "test"
7
+ test.pattern = "test/**/*_test.rb"
8
+ end
9
+
10
+ task default: :test
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Siftly
4
+ module Rails
5
+ class AttributeCheck
6
+ CALLBACK_OPTIONS = %i[if unless on].freeze
7
+
8
+ attr_reader :attribute, :options
9
+
10
+ def initialize(attribute, options = {})
11
+ @attribute = attribute.to_sym
12
+ @options = options.transform_keys(&:to_sym).freeze
13
+ end
14
+
15
+ def callback_options
16
+ select_options(CALLBACK_OPTIONS)
17
+ end
18
+
19
+ def siftly_options
20
+ options.reject { |key, _value| CALLBACK_OPTIONS.include?(key) }
21
+ end
22
+
23
+ private
24
+
25
+ def select_options(keys)
26
+ options.each_with_object({}) do |(key, value), result|
27
+ result[key] = value if keys.include?(key)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+
6
+ module Siftly
7
+ module Rails
8
+ module Model
9
+ extend ActiveSupport::Concern
10
+
11
+ DEFAULT_ERROR_MESSAGE = "was flagged as spam"
12
+ CHECK_OPTION_KEYS = %i[
13
+ filters
14
+ filter_overrides
15
+ aggregator
16
+ threshold
17
+ failure_mode
18
+ instrumenter
19
+ context
20
+ ].freeze
21
+
22
+ included do
23
+ class_attribute :siftly_attribute_checks, instance_accessor: false, default: []
24
+ before_validation :reset_siftly_spam_tracking
25
+ end
26
+
27
+ class_methods do
28
+ def siftly_check(*attributes, **options)
29
+ raise ArgumentError, "siftly_check requires at least one attribute" if attributes.empty?
30
+
31
+ checks = attributes.map { |attribute| AttributeCheck.new(attribute, options) }
32
+ self.siftly_attribute_checks = siftly_attribute_checks + checks
33
+
34
+ validate(**checks.first.callback_options) do
35
+ checks.each { |check| run_siftly_check(check) }
36
+ end
37
+ end
38
+ end
39
+
40
+ def siftly_spam?
41
+ siftly_spam_attributes.any?
42
+ end
43
+
44
+ def siftly_results
45
+ current_siftly_results.dup
46
+ end
47
+
48
+ def siftly_result_for(attribute)
49
+ current_siftly_results[attribute.to_sym]
50
+ end
51
+
52
+ def siftly_spam_attributes
53
+ siftly_spam_results.keys
54
+ end
55
+
56
+ def siftly_spam_results
57
+ current_siftly_results.each_with_object({}) do |(attribute, result), spam_results|
58
+ spam_results[attribute] = result if result.spam?
59
+ end
60
+ end
61
+
62
+ def siftly_spam_result_for(attribute)
63
+ siftly_spam_results[attribute.to_sym]
64
+ end
65
+
66
+ private
67
+
68
+ def current_siftly_results
69
+ @siftly_results ||= {}
70
+ end
71
+
72
+ def reset_siftly_spam_tracking
73
+ @siftly_results = {}
74
+ end
75
+
76
+ def run_siftly_check(check)
77
+ return if skip_siftly_check?(check)
78
+
79
+ result = Siftly.check(**build_siftly_arguments(check))
80
+ current_siftly_results[check.attribute] = result
81
+
82
+ add_siftly_error(check) if result.spam?
83
+ end
84
+
85
+ def skip_siftly_check?(check)
86
+ value = public_send(check.attribute)
87
+
88
+ return true if truthy_option?(check, :allow_nil) && value.nil?
89
+ return true if truthy_option?(check, :allow_blank) && blank_value?(value)
90
+
91
+ false
92
+ end
93
+
94
+ def build_siftly_arguments(check)
95
+ arguments = {
96
+ value: public_send(check.attribute),
97
+ attribute: check.attribute,
98
+ record: self,
99
+ context: resolve_check_option(check, :context) || {},
100
+ filter_overrides: resolve_check_option(check, :filter_overrides) || {}
101
+ }
102
+
103
+ CHECK_OPTION_KEYS.each do |key|
104
+ next if %i[context filter_overrides].include?(key)
105
+
106
+ value = resolve_check_option(check, key)
107
+ arguments[key] = value unless value.nil?
108
+ end
109
+
110
+ arguments
111
+ end
112
+
113
+ def add_siftly_error(check)
114
+ options = {}
115
+ strict = resolve_check_option(check, :strict)
116
+ options[:strict] = strict unless strict.nil?
117
+
118
+ errors.add(check.attribute, resolve_check_option(check, :message) || DEFAULT_ERROR_MESSAGE, **options)
119
+ end
120
+
121
+ def truthy_option?(check, key)
122
+ !!resolve_check_option(check, key)
123
+ end
124
+
125
+ def resolve_check_option(check, key)
126
+ value = check.siftly_options[key]
127
+ return value unless value.respond_to?(:call)
128
+
129
+ case value.arity
130
+ when 0
131
+ instance_exec(&value)
132
+ when 1
133
+ instance_exec(check.attribute, &value)
134
+ else
135
+ instance_exec(check.attribute, self, &value)
136
+ end
137
+ end
138
+
139
+ def blank_value?(value)
140
+ return true if value.nil?
141
+ return value.blank? if value.respond_to?(:blank?)
142
+ return value.empty? if value.respond_to?(:empty?)
143
+
144
+ false
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Siftly
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "siftly"
4
+ require "active_support/lazy_load_hooks"
5
+ require_relative "rails/version"
6
+ require_relative "rails/attribute_check"
7
+ require_relative "rails/model"
8
+
9
+ ActiveSupport.on_load(:active_record) do
10
+ include Siftly::Rails::Model
11
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/siftly/rails/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "siftly-rails"
7
+ spec.version = Siftly::Rails::VERSION
8
+ spec.authors = ["Tomos Rees"]
9
+
10
+ spec.summary = "Active Record integration for the Siftly spam filtering core."
11
+ spec.description = "Siftly::Rails lets Rails applications validate model attributes with Siftly and inspect flagged attributes through the model instance."
12
+ spec.homepage = "https://github.com/tomosjohnrees/siftly-rails"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.2"
15
+
16
+ spec.metadata["source_code_uri"] = spec.homepage
17
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
18
+
19
+ spec.files = Dir.glob("{lib,test}/**/*") + %w[CHANGELOG.md Gemfile LICENSE README.md Rakefile siftly-rails.gemspec]
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.add_dependency "activerecord", ">= 7.1"
23
+ spec.add_dependency "activesupport", ">= 7.1"
24
+ spec.add_dependency "siftly", "~> 0.1.0"
25
+
26
+ spec.add_development_dependency "minitest", "= 6.0.2"
27
+ spec.add_development_dependency "rake", "~> 13.0"
28
+ spec.add_development_dependency "sqlite3", "~> 2.0"
29
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ModelTest < Minitest::Test
6
+ def test_flagged_attributes_are_exposed_on_the_model
7
+ Siftly.configure do |config|
8
+ config.use :test_keyword
9
+
10
+ config.filter :test_keyword do |filter|
11
+ filter.keywords = ["spam"]
12
+ filter.weight = 1.0
13
+ end
14
+ end
15
+
16
+ model_class = Class.new(SiftlyRailsRecord) do
17
+ siftly_check :email, :message, message: "contains spam"
18
+ end
19
+
20
+ record = model_class.new(email: "spam@example.com", message: "this message is spam", title: "hello")
21
+
22
+ refute record.valid?
23
+ assert_equal [:email, :message], record.siftly_spam_attributes.sort
24
+ assert record.siftly_spam?
25
+ assert_equal ["contains spam"], record.errors[:email]
26
+ assert_equal ["contains spam"], record.errors[:message]
27
+ assert_equal [:email, :message], record.siftly_results.keys.sort
28
+ assert_equal [:test_keyword], record.siftly_spam_result_for(:email).matches.map(&:filter)
29
+ end
30
+
31
+ def test_revalidation_clears_previous_spam_state
32
+ Siftly.configure do |config|
33
+ config.use :test_keyword
34
+
35
+ config.filter :test_keyword do |filter|
36
+ filter.keywords = ["spam"]
37
+ end
38
+ end
39
+
40
+ model_class = Class.new(SiftlyRailsRecord) do
41
+ siftly_check :message
42
+ end
43
+
44
+ record = model_class.new(message: "spam offer")
45
+
46
+ refute record.valid?
47
+ assert_equal [:message], record.siftly_spam_attributes
48
+
49
+ record.message = "ordinary text"
50
+
51
+ assert record.valid?
52
+ assert_empty record.siftly_spam_attributes
53
+ assert_nil record.siftly_spam_result_for(:message)
54
+ refute record.siftly_result_for(:message).spam?
55
+ assert_empty record.errors[:message]
56
+ end
57
+
58
+ def test_callable_options_can_build_context_and_filter_overrides
59
+ model_class = Class.new(SiftlyRailsRecord) do
60
+ siftly_check :message,
61
+ filters: [:test_keyword],
62
+ filter_overrides: ->(attribute) { { test_keyword: { keywords: [trigger_phrase], weight: 1.2 } } },
63
+ context: ->(attribute) { { source: "#{attribute}:#{source_name}" } }
64
+ end
65
+
66
+ record = model_class.new(message: "limited offer today", trigger_phrase: "offer", source_name: "landing-page")
67
+
68
+ refute record.valid?
69
+
70
+ result = record.siftly_spam_result_for(:message)
71
+
72
+ assert result.spam?
73
+ assert_equal "message matched offer", result.reasons.first
74
+ assert_equal "message:landing-page", result.matches.first.metadata[:source]
75
+ end
76
+
77
+ def test_allow_blank_skips_checking
78
+ model_class = Class.new(SiftlyRailsRecord) do
79
+ siftly_check :message, filters: [:test_keyword], allow_blank: true
80
+ end
81
+
82
+ record = model_class.new(message: "")
83
+
84
+ assert record.valid?
85
+ refute record.siftly_spam?
86
+ assert_nil record.siftly_spam_result_for(:message)
87
+ end
88
+
89
+ def test_allow_nil_skips_checking
90
+ model_class = Class.new(SiftlyRailsRecord) do
91
+ siftly_check :message, filters: [:test_keyword], allow_nil: true
92
+ end
93
+
94
+ record = model_class.new(message: nil)
95
+
96
+ assert record.valid?
97
+ refute record.siftly_spam?
98
+ assert_nil record.siftly_result_for(:message)
99
+ end
100
+
101
+ def test_allow_nil_does_not_skip_blank_strings
102
+ Siftly.configure do |config|
103
+ config.use :test_keyword
104
+ config.filter(:test_keyword) { |f| f.keywords = [""] }
105
+ end
106
+
107
+ model_class = Class.new(SiftlyRailsRecord) do
108
+ siftly_check :message, filters: [:test_keyword], allow_nil: true
109
+ end
110
+
111
+ record = model_class.new(message: "")
112
+
113
+ # allow_nil: true should NOT skip blank strings — only nil
114
+ assert_kind_of Siftly::Result, record.siftly_result_for(:message) unless record.valid?
115
+ end
116
+
117
+ def test_clean_submission_passes_validation
118
+ Siftly.configure do |config|
119
+ config.use :test_keyword
120
+ config.filter(:test_keyword) { |f| f.keywords = ["spam"] }
121
+ end
122
+
123
+ model_class = Class.new(SiftlyRailsRecord) do
124
+ siftly_check :message
125
+ end
126
+
127
+ record = model_class.new(message: "perfectly normal text")
128
+
129
+ assert record.valid?
130
+ refute record.siftly_spam?
131
+ assert_empty record.siftly_spam_attributes
132
+ refute record.siftly_result_for(:message).spam?
133
+ end
134
+
135
+ def test_strict_option_raises_on_spam
136
+ Siftly.configure do |config|
137
+ config.use :test_keyword
138
+ config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
139
+ end
140
+
141
+ model_class = Class.new(SiftlyRailsRecord) do
142
+ def self.name = "StrictSubmission"
143
+ siftly_check :message, strict: true
144
+ end
145
+
146
+ record = model_class.new(message: "this is spam")
147
+
148
+ assert_raises(ActiveModel::StrictValidationFailed) { record.valid? }
149
+ end
150
+
151
+ def test_if_condition_prevents_check_when_falsy
152
+ Siftly.configure do |config|
153
+ config.use :test_keyword
154
+ config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
155
+ end
156
+
157
+ model_class = Class.new(SiftlyRailsRecord) do
158
+ siftly_check :message, if: -> { source_name == "trusted" }
159
+ end
160
+
161
+ record = model_class.new(message: "spam offer", source_name: "untrusted")
162
+ assert record.valid?
163
+
164
+ record.source_name = "trusted"
165
+ refute record.valid?
166
+ assert record.siftly_spam?
167
+ end
168
+
169
+ def test_unless_condition_prevents_check_when_truthy
170
+ Siftly.configure do |config|
171
+ config.use :test_keyword
172
+ config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
173
+ end
174
+
175
+ model_class = Class.new(SiftlyRailsRecord) do
176
+ siftly_check :message, unless: -> { source_name == "trusted" }
177
+ end
178
+
179
+ record = model_class.new(message: "spam offer", source_name: "trusted")
180
+ assert record.valid?
181
+
182
+ record.source_name = "untrusted"
183
+ refute record.valid?
184
+ end
185
+
186
+ def test_siftly_check_requires_at_least_one_attribute
187
+ assert_raises(ArgumentError) do
188
+ Class.new(SiftlyRailsRecord) do
189
+ siftly_check
190
+ end
191
+ end
192
+ end
193
+
194
+ def test_multiple_siftly_check_declarations
195
+ Siftly.configure do |config|
196
+ config.use :test_keyword
197
+ config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
198
+ end
199
+
200
+ model_class = Class.new(SiftlyRailsRecord) do
201
+ siftly_check :email
202
+ siftly_check :message, message: "message spam"
203
+ end
204
+
205
+ record = model_class.new(email: "spam@example.com", message: "spam offer")
206
+
207
+ refute record.valid?
208
+ assert_equal [:email, :message], record.siftly_spam_attributes.sort
209
+ assert_equal ["was flagged as spam"], record.errors[:email]
210
+ assert_equal ["message spam"], record.errors[:message]
211
+ end
212
+
213
+ def test_callable_context_with_zero_arity
214
+ Siftly.configure do |config|
215
+ config.use :test_keyword
216
+ config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
217
+ end
218
+
219
+ model_class = Class.new(SiftlyRailsRecord) do
220
+ siftly_check :message,
221
+ context: -> { { source: "zero-arity" } }
222
+ end
223
+
224
+ record = model_class.new(message: "spam")
225
+
226
+ refute record.valid?
227
+ assert_equal "zero-arity", record.siftly_spam_result_for(:message).matches.first.metadata[:source]
228
+ end
229
+
230
+ def test_callable_filter_overrides_with_two_arity
231
+ model_class = Class.new(SiftlyRailsRecord) do
232
+ siftly_check :message,
233
+ filters: [:test_keyword],
234
+ filter_overrides: ->(attribute, record) { { test_keyword: { keywords: [record.trigger_phrase], weight: 1.0 } } }
235
+ end
236
+
237
+ record = model_class.new(message: "limited offer", trigger_phrase: "offer")
238
+
239
+ refute record.valid?
240
+ assert record.siftly_spam?
241
+ end
242
+
243
+ def test_default_error_message
244
+ Siftly.configure do |config|
245
+ config.use :test_keyword
246
+ config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
247
+ end
248
+
249
+ model_class = Class.new(SiftlyRailsRecord) do
250
+ siftly_check :message
251
+ end
252
+
253
+ record = model_class.new(message: "spam")
254
+
255
+ refute record.valid?
256
+ assert_equal ["was flagged as spam"], record.errors[:message]
257
+ end
258
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
6
+
7
+ require "minitest/autorun"
8
+ require "active_record"
9
+ require "sqlite3"
10
+ require "siftly"
11
+ require "siftly/rails"
12
+
13
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
14
+ ActiveRecord::Schema.verbose = false
15
+
16
+ ActiveRecord::Schema.define do
17
+ create_table :submissions, force: true do |table|
18
+ table.string :email
19
+ table.string :title
20
+ table.text :message
21
+ table.string :trigger_phrase
22
+ table.string :source_name
23
+ end
24
+ end
25
+
26
+ class SiftlyRailsTestFilter < Siftly::Filter
27
+ register_as :test_keyword
28
+
29
+ def call(value:, attribute: nil, record: nil, context: {})
30
+ keywords = Array(config.fetch(:keywords, []))
31
+ matched_keyword = keywords.find { |keyword| value.to_s.downcase.include?(keyword.downcase) }
32
+
33
+ result(
34
+ matched: !matched_keyword.nil?,
35
+ score: matched_keyword ? config.fetch(:weight, 1.0) : 0.0,
36
+ reason: matched_keyword ? "#{attribute} matched #{matched_keyword}" : nil,
37
+ metadata: {
38
+ matched_keyword: matched_keyword,
39
+ source: context[:source],
40
+ record_class: record.class.name
41
+ }
42
+ )
43
+ end
44
+ end
45
+
46
+ class SiftlyRailsRecord < ActiveRecord::Base
47
+ self.abstract_class = true
48
+ self.table_name = "submissions"
49
+ end
50
+
51
+ class Minitest::Test
52
+ def setup
53
+ super
54
+ Siftly.reset_configuration!
55
+ Siftly::Registry.clear
56
+ Siftly::Registry.register(SiftlyRailsTestFilter)
57
+ end
58
+ end
metadata ADDED
@@ -0,0 +1,137 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: siftly-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tomos Rees
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: siftly
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.1.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.1.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: minitest
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - '='
59
+ - !ruby/object:Gem::Version
60
+ version: 6.0.2
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - '='
66
+ - !ruby/object:Gem::Version
67
+ version: 6.0.2
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: sqlite3
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '2.0'
96
+ description: Siftly::Rails lets Rails applications validate model attributes with
97
+ Siftly and inspect flagged attributes through the model instance.
98
+ executables: []
99
+ extensions: []
100
+ extra_rdoc_files: []
101
+ files:
102
+ - CHANGELOG.md
103
+ - Gemfile
104
+ - LICENSE
105
+ - README.md
106
+ - Rakefile
107
+ - lib/siftly/rails.rb
108
+ - lib/siftly/rails/attribute_check.rb
109
+ - lib/siftly/rails/model.rb
110
+ - lib/siftly/rails/version.rb
111
+ - siftly-rails.gemspec
112
+ - test/model_test.rb
113
+ - test/test_helper.rb
114
+ homepage: https://github.com/tomosjohnrees/siftly-rails
115
+ licenses:
116
+ - MIT
117
+ metadata:
118
+ source_code_uri: https://github.com/tomosjohnrees/siftly-rails
119
+ changelog_uri: https://github.com/tomosjohnrees/siftly-rails/blob/main/CHANGELOG.md
120
+ rdoc_options: []
121
+ require_paths:
122
+ - lib
123
+ required_ruby_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '3.2'
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ requirements: []
134
+ rubygems_version: 3.6.9
135
+ specification_version: 4
136
+ summary: Active Record integration for the Siftly spam filtering core.
137
+ test_files: []