active_storage_validations 4.0.0 → 4.1.1

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +71 -8
  3. data/config/locales/da.yml +2 -0
  4. data/config/locales/de.yml +2 -0
  5. data/config/locales/en-GB.yml +2 -0
  6. data/config/locales/en.yml +2 -0
  7. data/config/locales/es.yml +2 -0
  8. data/config/locales/fr.yml +2 -0
  9. data/config/locales/it.yml +2 -0
  10. data/config/locales/ja.yml +2 -0
  11. data/config/locales/nl.yml +2 -0
  12. data/config/locales/pl.yml +2 -0
  13. data/config/locales/pt-BR.yml +2 -0
  14. data/config/locales/ru.yml +2 -0
  15. data/config/locales/sv.yml +4 -2
  16. data/config/locales/tr.yml +2 -0
  17. data/config/locales/uk.yml +2 -0
  18. data/config/locales/vi.yml +2 -0
  19. data/config/locales/zh-CN.yml +2 -0
  20. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +7 -1
  21. data/lib/active_storage_validations/analyzer/content_type_analyzer/file.rb +0 -2
  22. data/lib/active_storage_validations/analyzer/content_type_analyzer/magika.rb +0 -2
  23. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +3 -0
  24. data/lib/active_storage_validations/analyzer/image_analyzer.rb +16 -6
  25. data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +11 -2
  26. data/lib/active_storage_validations/analyzer.rb +2 -35
  27. data/lib/active_storage_validations/aspect_ratio_validator.rb +21 -15
  28. data/lib/active_storage_validations/asv_attachable_adapter.rb +221 -0
  29. data/lib/active_storage_validations/content_type_validator.rb +40 -64
  30. data/lib/active_storage_validations/duration_validator.rb +3 -1
  31. data/lib/active_storage_validations/engine.rb +3 -0
  32. data/lib/active_storage_validations/extensors/asv_marcelable.rb +5 -5
  33. data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +11 -4
  34. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +2 -1
  35. data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +1 -0
  36. data/lib/active_storage_validations/matchers/shared/asv_allow_blankable.rb +1 -1
  37. data/lib/active_storage_validations/matchers/with_audio_validator_matcher.rb +114 -0
  38. data/lib/active_storage_validations/matchers.rb +22 -10
  39. data/lib/active_storage_validations/shared/asv_analyzable.rb +51 -8
  40. data/lib/active_storage_validations/shared/asv_attachable.rb +14 -97
  41. data/lib/active_storage_validations/shared/asv_errorable.rb +9 -8
  42. data/lib/active_storage_validations/version.rb +1 -1
  43. data/lib/active_storage_validations/with_audio_validator.rb +43 -0
  44. data/lib/active_storage_validations.rb +1 -0
  45. metadata +5 -2
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # Wraps a Rails attachable so callers do not repeat the type-dispatch case tree.
5
+ #
6
+ # Must not be named +Attachable+: ActiveStorageValidations is included into
7
+ # Active Record, so +include Attachable+ in an app model under this namespace
8
+ # would resolve to this class instead of the app's concern.
9
+ #
10
+ # Supported representations: ActiveStorage::Blob, ActionDispatch::Http::UploadedFile,
11
+ # Rack::Test::UploadedFile, Hash, File, Pathname, and a signed blob id (String).
12
+ class ASVAttachableAdapter
13
+ def self.wrap(attachable, file_supported:)
14
+ handler_class_for(attachable).new(attachable, file_supported: file_supported)
15
+ end
16
+
17
+ # Filename for error options. Unknown types return nil (never raise).
18
+ # File / Pathname are always accepted here so a validation error can name
19
+ # the file even on Rails versions that cannot attach them.
20
+ def self.filename_for(file)
21
+ case file
22
+ when ActiveStorage::Attached, ActiveStorage::Attachment
23
+ file.blob&.filename
24
+ else
25
+ return unless known?(file)
26
+
27
+ wrap(file, file_supported: true).filename
28
+ end
29
+ end
30
+
31
+ def self.known?(attachable)
32
+ handler_class_for(attachable) != Unsupported
33
+ end
34
+ private_class_method :known?
35
+
36
+ def self.handler_class_for(attachable)
37
+ case attachable
38
+ when ActiveStorage::Blob then Blob
39
+ when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile then Uploaded
40
+ when String then SignedId
41
+ when Hash then HashIo
42
+ when File then FileLike
43
+ when Pathname then Path
44
+ else Unsupported
45
+ end
46
+ end
47
+ private_class_method :handler_class_for
48
+
49
+ class Base
50
+ def initialize(attachable, file_supported:)
51
+ @attachable = attachable
52
+ @file_supported = file_supported
53
+ end
54
+
55
+ def content_type
56
+ raise NotImplementedError
57
+ end
58
+
59
+ def filename
60
+ raise NotImplementedError
61
+ end
62
+
63
+ def read(max_byte_size: nil)
64
+ raise NotImplementedError
65
+ end
66
+
67
+ def rewind
68
+ end
69
+
70
+ def with_media_path(_tempfile)
71
+ raise NotImplementedError
72
+ end
73
+
74
+ private
75
+
76
+ def raise_unsupported
77
+ raise ArgumentError,
78
+ "Could not find or build blob: expected attachable, " \
79
+ "got #{@attachable.inspect}"
80
+ end
81
+
82
+ def read_from(io, max_byte_size)
83
+ max_byte_size ? io.read(max_byte_size) : io.read
84
+ end
85
+
86
+ def copy_to_tempfile(tempfile, source)
87
+ if source.is_a?(ActiveStorage::Blob)
88
+ source.download { |chunk| tempfile.write(chunk) }
89
+ else
90
+ IO.copy_stream(source, tempfile)
91
+ source.rewind
92
+ end
93
+
94
+ tempfile.flush
95
+ tempfile.rewind
96
+ yield tempfile.path
97
+ end
98
+
99
+ def marcel_content_type
100
+ Marcel::MimeType.for(name: filename.to_s)
101
+ end
102
+ end
103
+
104
+ class Blob < Base
105
+ def content_type
106
+ @attachable.content_type
107
+ end
108
+
109
+ def filename
110
+ @attachable.filename
111
+ end
112
+
113
+ def read(max_byte_size: nil)
114
+ max_byte_size ? @attachable.download_chunk(0...max_byte_size) : @attachable.download
115
+ end
116
+
117
+ def with_media_path(tempfile, &block)
118
+ copy_to_tempfile(tempfile, @attachable, &block)
119
+ end
120
+ end
121
+
122
+ class SignedId < Blob
123
+ def initialize(attachable, file_supported:)
124
+ super(ActiveStorage::Blob.find_signed!(attachable), file_supported: file_supported)
125
+ end
126
+ end
127
+
128
+ class Uploaded < Base
129
+ def content_type
130
+ @attachable.content_type
131
+ end
132
+
133
+ def filename
134
+ @attachable.original_filename
135
+ end
136
+
137
+ def read(max_byte_size: nil)
138
+ read_from(@attachable, max_byte_size)
139
+ end
140
+
141
+ def rewind
142
+ @attachable.rewind
143
+ end
144
+
145
+ def with_media_path(_tempfile)
146
+ yield @attachable.path
147
+ end
148
+ end
149
+
150
+ class HashIo < Base
151
+ def content_type
152
+ @attachable[:content_type]
153
+ end
154
+
155
+ def filename
156
+ @attachable[:filename]
157
+ end
158
+
159
+ def read(max_byte_size: nil)
160
+ read_from(@attachable[:io], max_byte_size)
161
+ end
162
+
163
+ def rewind
164
+ @attachable[:io].rewind
165
+ end
166
+
167
+ def with_media_path(tempfile, &block)
168
+ io = @attachable[:io]
169
+ if io.is_a?(StringIO)
170
+ copy_to_tempfile(tempfile, io, &block)
171
+ else
172
+ File.open(io) { |file| yield file.path }
173
+ end
174
+ end
175
+ end
176
+
177
+ class FileLike < Base
178
+ def initialize(attachable, file_supported:)
179
+ super
180
+ raise_unsupported unless file_supported
181
+ end
182
+
183
+ def content_type
184
+ marcel_content_type
185
+ end
186
+
187
+ def filename
188
+ File.basename(@attachable)
189
+ end
190
+
191
+ def read(max_byte_size: nil)
192
+ read_from(@attachable, max_byte_size)
193
+ end
194
+
195
+ def rewind
196
+ @attachable.rewind
197
+ end
198
+
199
+ def with_media_path(_tempfile)
200
+ yield @attachable.path
201
+ end
202
+ end
203
+
204
+ class Path < FileLike
205
+ def rewind
206
+ File.open(@attachable) { |file| file.rewind }
207
+ end
208
+
209
+ def with_media_path(_tempfile)
210
+ yield @attachable.to_s
211
+ end
212
+ end
213
+
214
+ class Unsupported < Base
215
+ def initialize(attachable, file_supported:)
216
+ super
217
+ raise_unsupported
218
+ end
219
+ end
220
+ end
221
+ end
@@ -24,6 +24,11 @@ module ActiveStorageValidations
24
24
  ].freeze
25
25
  METADATA_KEYS = %i[content_type].freeze
26
26
 
27
+ # State for a single attachable check. Active Model builds one validator per
28
+ # class and reuses it for every record and every thread, so this must travel
29
+ # through arguments rather than instance variables.
30
+ Context = Struct.new(:authorized_content_types, :content_type, :filename)
31
+
27
32
  def check_validity!
28
33
  ensure_exactly_one_validator_option
29
34
  ensure_content_types_validity
@@ -33,12 +38,12 @@ module ActiveStorageValidations
33
38
  def validate_each(record, attribute, _value)
34
39
  return if no_attachments?(record, attribute)
35
40
 
36
- @authorized_content_types = authorized_content_types_from_options(record)
37
- return if @authorized_content_types.empty?
41
+ authorized_content_types = authorized_content_types_from_options(record)
42
+ return if authorized_content_types.empty?
38
43
 
39
44
  attachables_and_blobs(record, attribute).each do |attachable, blob|
40
- set_attachable_cached_values(blob)
41
- is_valid?(record, attribute, attachable, blob)
45
+ context = Context.new(authorized_content_types, blob.content_type, blob.filename.to_s)
46
+ is_valid?(record, attribute, attachable, blob, context)
42
47
  end
43
48
  end
44
49
 
@@ -55,76 +60,47 @@ module ActiveStorageValidations
55
60
  end
56
61
  end
57
62
 
58
- def set_attachable_cached_values(blob)
59
- @attachable_content_type = blob.content_type
60
- @attachable_filename = blob.filename.to_s
61
- end
62
-
63
63
  # Check if the provided content_type is authorized and not spoofed against
64
64
  # the file io.
65
- def is_valid?(record, attribute, attachable, blob)
66
- authorized_content_type?(record, attribute, attachable) &&
67
- not_spoofing_content_type?(record, attribute, attachable, blob)
65
+ def is_valid?(record, attribute, attachable, blob, context)
66
+ authorized_content_type?(record, attribute, attachable, context) &&
67
+ not_spoofing_content_type?(record, attribute, attachable, blob, context)
68
68
  end
69
69
 
70
- # Dead code that we keep here for some time, maybe we will find a solution
71
- # to this check later? (November 2024)
72
- #
73
- # We do not perform any validations against the extension because it is an
74
- # unreliable source of truth. For example, a `.csv` file could have its
75
- # `text/csv` content_type changed to `application/vnd.ms-excel` because
76
- # it had been opened by Excel at some point, making the file extension vs
77
- # file content_type check invalid.
78
- # def extension_matches_content_type?(record, attribute, attachable)
79
- # return true if !@attachable_filename || !@attachable_content_type
80
-
81
- # extension = @attachable_filename.split('.').last
82
- # possible_extensions = Marcel::TYPE_EXTS[@attachable_content_type]
83
- # return true if possible_extensions && extension.downcase.in?(possible_extensions)
84
-
85
- # errors_options = initialize_and_populate_error_options(options, attachable)
86
- # add_error(record, attribute, ERROR_TYPES.first, **errors_options)
87
- # false
88
- # end
89
-
90
- def authorized_content_type?(record, attribute, attachable)
91
- attachable_content_type_is_authorized = @authorized_content_types.any? do |authorized_content_type|
70
+ def authorized_content_type?(record, attribute, attachable, context)
71
+ attachable_content_type_is_authorized = context.authorized_content_types.any? do |authorized_content_type|
92
72
  case authorized_content_type
93
- when String then authorized_content_type == marcel_attachable_content_type(attachable)
94
- when Regexp then authorized_content_type.match?(marcel_attachable_content_type(attachable).to_s)
73
+ when String then authorized_content_type == marcel_attachable_content_type(context)
74
+ when Regexp then authorized_content_type.match?(marcel_attachable_content_type(context).to_s)
95
75
  end
96
76
  end
97
77
 
98
78
  return true if attachable_content_type_is_authorized
99
79
 
100
- add_content_type_invalid_error(record, attribute, attachable)
80
+ add_content_type_invalid_error(record, attribute, attachable, context)
101
81
  end
102
82
 
103
- def marcel_attachable_content_type(attachable)
104
- Marcel::MimeType.for(declared_type: @attachable_content_type, name: @attachable_filename)
83
+ def marcel_attachable_content_type(context)
84
+ Marcel::MimeType.for(declared_type: context.content_type, name: context.filename)
105
85
  end
106
86
 
107
- def not_spoofing_content_type?(record, attribute, attachable, blob)
87
+ def not_spoofing_content_type?(record, attribute, attachable, blob, context)
108
88
  return true unless enable_spoofing_protection?
109
89
 
110
- @detected_content_type = begin
90
+ detected_content_type = begin
111
91
  metadata_for(blob, attachable, METADATA_KEYS)&.fetch(:content_type, nil)
112
92
  rescue ActiveStorage::FileNotFoundError
113
93
  add_attachment_missing_error(record, attribute, attachable)
114
94
  return false
115
95
  end
116
96
 
117
- if attachable_content_type_vs_detected_content_type_mismatch?
118
- add_content_type_spoofed_error(record, attribute, attachable, @detected_content_type)
97
+ if content_type_mismatch?(context.content_type, detected_content_type)
98
+ add_content_type_spoofed_error(record, attribute, attachable, context, detected_content_type)
119
99
  else
120
100
  true
121
101
  end
122
102
  end
123
103
 
124
- def disable_spoofing_protection?
125
- !enable_spoofing_protection?
126
- end
127
-
128
104
  def enable_spoofing_protection?
129
105
  spoofing_protection_backend.present?
130
106
  end
@@ -147,14 +123,14 @@ module ActiveStorageValidations
147
123
  ERROR_MESSAGE
148
124
  end
149
125
 
150
- def attachable_content_type_vs_detected_content_type_mismatch?
151
- @attachable_content_type.present? &&
152
- !attachable_content_type_intersects_detected_content_type?
126
+ def content_type_mismatch?(attachable_content_type, detected_content_type)
127
+ attachable_content_type.present? &&
128
+ !content_types_intersect?(attachable_content_type, detected_content_type)
153
129
  end
154
130
 
155
- def attachable_content_type_intersects_detected_content_type?
156
- enlarged_content_type(content_type_without_parameters(@attachable_content_type)).intersect?(
157
- enlarged_content_type(content_type_without_parameters(@detected_content_type))
131
+ def content_types_intersect?(attachable_content_type, detected_content_type)
132
+ enlarged_content_type(content_type_without_parameters(attachable_content_type)).intersect?(
133
+ enlarged_content_type(content_type_without_parameters(detected_content_type))
158
134
  )
159
135
  end
160
136
 
@@ -166,26 +142,26 @@ module ActiveStorageValidations
166
142
  Marcel::TYPE_PARENTS[content_type] || []
167
143
  end
168
144
 
169
- def add_content_type_invalid_error(record, attribute, attachable)
170
- errors_options = initialize_and_populate_error_options(options, attachable)
145
+ def add_content_type_invalid_error(record, attribute, attachable, context)
146
+ errors_options = initialize_and_populate_error_options(options, attachable, context)
171
147
  add_error(record, attribute, ERROR_TYPES.first, **errors_options)
172
148
  false
173
149
  end
174
150
 
175
- def add_content_type_spoofed_error(record, attribute, attachable, detected_content_type)
176
- errors_options = initialize_and_populate_error_options(options, attachable)
177
- errors_options[:detected_content_type] = @detected_content_type
178
- errors_options[:detected_human_content_type] = content_type_to_human_format(@detected_content_type)
151
+ def add_content_type_spoofed_error(record, attribute, attachable, context, detected_content_type)
152
+ errors_options = initialize_and_populate_error_options(options, attachable, context)
153
+ errors_options[:detected_content_type] = detected_content_type
154
+ errors_options[:detected_human_content_type] = content_type_to_human_format(detected_content_type)
179
155
  add_error(record, attribute, ERROR_TYPES.second, **errors_options)
180
156
  false
181
157
  end
182
158
 
183
- def initialize_and_populate_error_options(options, attachable)
159
+ def initialize_and_populate_error_options(options, attachable, context)
184
160
  errors_options = initialize_error_options(options, attachable)
185
- errors_options[:content_type] = @attachable_content_type
186
- errors_options[:human_content_type] = content_type_to_human_format(@attachable_content_type)
187
- errors_options[:authorized_human_content_types] = content_type_to_human_format(@authorized_content_types)
188
- errors_options[:count] = @authorized_content_types.size
161
+ errors_options[:content_type] = context.content_type
162
+ errors_options[:human_content_type] = content_type_to_human_format(context.content_type)
163
+ errors_options[:authorized_human_content_types] = content_type_to_human_format(context.authorized_content_types)
164
+ errors_options[:count] = context.authorized_content_types.size
189
165
  errors_options
190
166
  end
191
167
 
@@ -30,7 +30,9 @@ module ActiveStorageValidations
30
30
  next
31
31
  end
32
32
 
33
- if duration.to_i <= 0
33
+ # to_f, not to_i: durations are floats, and truncating would treat any
34
+ # file shorter than a second as unanalyzable.
35
+ if duration.to_f <= 0
34
36
  add_media_metadata_missing_error(record, attribute, attachable)
35
37
  next
36
38
  end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageValidations
4
+ # Registers the gem as a Rails engine so `config/locales/*.yml` is added to
5
+ # the I18n load path. Initializers live on {Railtie} (Active Record include,
6
+ # FormBuilder prepend, Blob metadata).
4
7
  class Engine < ::Rails::Engine
5
8
  end
6
9
  end
@@ -2,11 +2,11 @@
2
2
 
3
3
  require "marcel"
4
4
 
5
- Marcel::MimeType.extend "application/x-rar-compressed", parents: %(application/x-rar)
6
- Marcel::MimeType.extend "audio/x-hx-aac-adts", parents: %(audio/x-aac)
7
- Marcel::MimeType.extend "audio/x-m4a", parents: %(audio/mp4)
8
- Marcel::MimeType.extend "text/xml", parents: %(application/xml) # alias
9
- Marcel::MimeType.extend "video/theora", parents: %(video/ogg)
5
+ Marcel::MimeType.extend "application/x-rar-compressed", parents: %w[application/x-rar]
6
+ Marcel::MimeType.extend "audio/x-hx-aac-adts", parents: %w[audio/x-aac]
7
+ Marcel::MimeType.extend "audio/x-m4a", parents: %w[audio/mp4]
8
+ Marcel::MimeType.extend "text/xml", parents: %w[application/xml] # alias
9
+ Marcel::MimeType.extend "video/theora", parents: %w[video/ogg]
10
10
 
11
11
  # Add empty content type
12
12
  Marcel::MimeType.extend "inode/x-empty", extensions: %w[empty]
@@ -33,7 +33,7 @@ module ActiveStorageValidations
33
33
  initialize_messageable
34
34
  initialize_rspecable
35
35
  @attribute_name = attribute_name
36
- @min = @max = nil
36
+ @min = @max = @exact = nil
37
37
  end
38
38
 
39
39
  def less_than(value)
@@ -107,12 +107,15 @@ module ActiveStorageValidations
107
107
  @min.nil? || !passes_validation_with_value(@min - 1)
108
108
  end
109
109
 
110
+ # Probe the inclusive bound itself. Checking only min+1 / max-1 lets
111
+ # less_than(n) satisfy less_than_or_equal_to(n) (and greater_than
112
+ # satisfy greater_than_or_equal_to).
110
113
  def higher_than_min?
111
- @min.nil? || passes_validation_with_value(@min + 1)
114
+ @min.nil? || passes_validation_with_value(@min)
112
115
  end
113
116
 
114
117
  def lower_than_max?
115
- @max.nil? || @max == Float::INFINITY || passes_validation_with_value(@max - 1)
118
+ @max.nil? || @max == Float::INFINITY || passes_validation_with_value(@max)
116
119
  end
117
120
 
118
121
  def not_higher_than_max?
@@ -120,7 +123,11 @@ module ActiveStorageValidations
120
123
  end
121
124
 
122
125
  def equal_to_exact?
123
- @exact.nil? || passes_validation_with_value(@exact)
126
+ return true if @exact.nil?
127
+
128
+ passes_validation_with_value(@exact) &&
129
+ !passes_validation_with_value(@exact - smallest_measurement) &&
130
+ !passes_validation_with_value(@exact + smallest_measurement)
124
131
  end
125
132
 
126
133
  def smallest_measurement
@@ -61,6 +61,7 @@ module ActiveStorageValidations
61
61
  is_a_valid_active_storage_attribute? &&
62
62
  is_context_valid? &&
63
63
  is_except_on_valid? &&
64
+ is_allowing_blank? &&
64
65
  is_custom_message_valid? &&
65
66
  file_count_not_smaller_than_min? &&
66
67
  file_count_equal_min? &&
@@ -83,7 +84,7 @@ module ActiveStorageValidations
83
84
  end
84
85
 
85
86
  def file_count_not_smaller_than_min?
86
- @min.nil? || @min.zero? || !passes_validation_with_limits(@min - 1)
87
+ @min.nil? || @min.zero? || (@allow_blank && @min == 1) || !passes_validation_with_limits(@min - 1)
87
88
  end
88
89
 
89
90
  def file_count_equal_min?
@@ -51,6 +51,7 @@ module ActiveStorageValidations
51
51
  is_a_valid_active_storage_attribute? &&
52
52
  is_context_valid? &&
53
53
  is_except_on_valid? &&
54
+ is_allowing_blank? &&
54
55
  is_timeout_valid? &&
55
56
  is_custom_message_valid? &&
56
57
  is_valid_when_image_processable? &&
@@ -21,7 +21,7 @@ module ActiveStorageValidations
21
21
  def is_allowing_blank?
22
22
  return true unless @allow_blank
23
23
 
24
- validate
24
+ attribute_validators.any? { |validator| validator.options[:allow_blank] }
25
25
  end
26
26
  end
27
27
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shared/asv_active_storageable"
4
+ require_relative "shared/asv_allow_blankable"
5
+ require_relative "shared/asv_attachable"
6
+ require_relative "shared/asv_contextable"
7
+ require_relative "shared/asv_except_onable"
8
+ require_relative "shared/asv_messageable"
9
+ require_relative "shared/asv_rspecable"
10
+ require_relative "shared/asv_timeoutable"
11
+ require_relative "shared/asv_validatable"
12
+
13
+ module ActiveStorageValidations
14
+ module Matchers
15
+ def validate_with_audio_of(attribute_name)
16
+ WithAudioValidatorMatcher.new(attribute_name)
17
+ end
18
+
19
+ class WithAudioValidatorMatcher
20
+ include ASVActiveStorageable
21
+ include ASVAllowBlankable
22
+ include ASVAttachable
23
+ include ASVContextable
24
+ include ASVExceptOnable
25
+ include ASVMessageable
26
+ include ASVRspecable
27
+ include ASVTimeoutable
28
+ include ASVValidatable
29
+
30
+ def initialize(attribute_name)
31
+ initialize_allow_blankable
32
+ initialize_contextable
33
+ initialize_except_onable
34
+ initialize_messageable
35
+ initialize_rspecable
36
+ initialize_timeoutable
37
+ @attribute_name = attribute_name
38
+ @expected_audio = true
39
+ end
40
+
41
+ def description
42
+ "validate that :#{@attribute_name} #{audio_expectation}"
43
+ end
44
+
45
+ def failure_message
46
+ "is expected to validate that :#{@attribute_name} #{audio_expectation}"
47
+ end
48
+
49
+ def without_audio
50
+ @expected_audio = false
51
+ self
52
+ end
53
+
54
+ def matches?(subject)
55
+ @subject = subject.is_a?(Class) ? subject.new : subject
56
+
57
+ is_a_valid_active_storage_attribute? &&
58
+ is_context_valid? &&
59
+ is_except_on_valid? &&
60
+ is_allowing_blank? &&
61
+ is_timeout_valid? &&
62
+ is_custom_message_valid? &&
63
+ is_valid_with_expected_audio? &&
64
+ is_invalid_with_unexpected_audio?
65
+ end
66
+
67
+ private
68
+
69
+ def audio_expectation
70
+ @expected_audio ? "has an audio track" : "has no audio track"
71
+ end
72
+
73
+ def is_valid_with_expected_audio?
74
+ validation_passes_with_audio?(@expected_audio)
75
+ end
76
+
77
+ def is_invalid_with_unexpected_audio?
78
+ !validation_passes_with_audio?(!@expected_audio)
79
+ end
80
+
81
+ def is_custom_message_valid?
82
+ return true unless @custom_message
83
+
84
+ with_audio_metadata(false) do
85
+ attach_file(video_file)
86
+ validate
87
+ detach_file
88
+ has_an_error_message_which_is_custom_message?
89
+ end
90
+ end
91
+
92
+ def validation_passes_with_audio?(audio)
93
+ with_audio_metadata(audio) do
94
+ attach_file(video_file)
95
+ validate
96
+ detach_file
97
+ is_valid?
98
+ end
99
+ end
100
+
101
+ def with_audio_metadata(audio, &block)
102
+ Matchers.mock_metadata(io, { audio: audio }, &block)
103
+ end
104
+
105
+ def video_file
106
+ {
107
+ io: io,
108
+ filename: "test.mp4",
109
+ content_type: "video/mp4"
110
+ }
111
+ end
112
+ end
113
+ end
114
+ end
@@ -7,25 +7,37 @@ require "active_storage_validations/matchers/limit_validator_matcher"
7
7
  require "active_storage_validations/matchers/content_type_validator_matcher"
8
8
  require "active_storage_validations/matchers/dimension_validator_matcher"
9
9
  require "active_storage_validations/matchers/duration_validator_matcher"
10
+ require "active_storage_validations/matchers/with_audio_validator_matcher"
10
11
  require "active_storage_validations/matchers/size_validator_matcher"
11
12
  require "active_storage_validations/matchers/total_size_validator_matcher"
12
13
  require "active_storage_validations/matchers/pages_validator_matcher"
13
14
 
14
15
  module ActiveStorageValidations
15
16
  module Matchers
16
- # Helper to stub a method with either RSpec or Minitest (whatever is available)
17
+ # Temporary singleton-method wrap. Independent of RSpec::Mocks and of
18
+ # Minitest::Mock / Object#stub (extracted to minitest-mock in Minitest 6),
19
+ # so size / metadata matchers work on stock Minitest 5 and 6.
17
20
  def self.stub_method(object, method, result)
18
- if defined?(Minitest::Mock)
19
- object.stub(method, result) do
20
- yield
21
- end
22
- elsif defined?(RSpec::Mocks)
23
- RSpec::Mocks.allow_message(object, method) { result }
24
- yield
25
- else
26
- raise "Need either Minitest::Mock or RSpec::Mocks to run this validator matcher"
21
+ singleton = object.singleton_class
22
+ owned = singleton.instance_methods(false).include?(method)
23
+ original = owned ? singleton.instance_method(method) : nil
24
+
25
+ singleton.define_method(method) { |*_args, **_kwargs| result }
26
+ yield
27
+ ensure
28
+ restore_stubbed_method(singleton, method, owned, original)
29
+ end
30
+
31
+ def self.restore_stubbed_method(singleton, method, owned, original)
32
+ return unless singleton
33
+
34
+ if owned
35
+ singleton.define_method(method, original)
36
+ elsif singleton.instance_methods(false).include?(method)
37
+ singleton.remove_method(method)
27
38
  end
28
39
  end
40
+ private_class_method :restore_stubbed_method
29
41
 
30
42
  def self.mock_metadata(attachment, metadata = {})
31
43
  asv_metadata_available_keys = { width: nil, height: nil, duration: nil, content_type: nil }