active_storage_validations 0.9.7 → 2.0.2

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +737 -229
  3. data/config/locales/da.yml +53 -0
  4. data/config/locales/de.yml +50 -19
  5. data/config/locales/en.yml +50 -19
  6. data/config/locales/es.yml +50 -19
  7. data/config/locales/fr.yml +50 -19
  8. data/config/locales/it.yml +50 -19
  9. data/config/locales/ja.yml +50 -19
  10. data/config/locales/nl.yml +50 -19
  11. data/config/locales/pl.yml +50 -19
  12. data/config/locales/pt-BR.yml +50 -19
  13. data/config/locales/ru.yml +50 -19
  14. data/config/locales/sv.yml +53 -0
  15. data/config/locales/tr.yml +50 -19
  16. data/config/locales/uk.yml +50 -19
  17. data/config/locales/vi.yml +50 -19
  18. data/config/locales/zh-CN.yml +53 -0
  19. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +58 -0
  20. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +60 -0
  21. data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +47 -0
  22. data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +57 -0
  23. data/lib/active_storage_validations/analyzer/image_analyzer.rb +49 -0
  24. data/lib/active_storage_validations/analyzer/null_analyzer.rb +18 -0
  25. data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +61 -0
  26. data/lib/active_storage_validations/analyzer/video_analyzer.rb +130 -0
  27. data/lib/active_storage_validations/analyzer.rb +87 -0
  28. data/lib/active_storage_validations/aspect_ratio_validator.rb +154 -99
  29. data/lib/active_storage_validations/attached_validator.rb +22 -5
  30. data/lib/active_storage_validations/base_comparison_validator.rb +71 -0
  31. data/lib/active_storage_validations/content_type_validator.rb +206 -25
  32. data/lib/active_storage_validations/dimension_validator.rb +105 -82
  33. data/lib/active_storage_validations/duration_validator.rb +55 -0
  34. data/lib/active_storage_validations/extensors/asv_blob_metadatable.rb +49 -0
  35. data/lib/active_storage_validations/extensors/asv_marcelable.rb +12 -0
  36. data/lib/active_storage_validations/limit_validator.rb +75 -16
  37. data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +119 -0
  38. data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +48 -25
  39. data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +140 -0
  40. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +94 -59
  41. data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +97 -55
  42. data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +39 -0
  43. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +127 -0
  44. data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +78 -0
  45. data/lib/active_storage_validations/matchers/shared/asv_active_storageable.rb +19 -0
  46. data/lib/active_storage_validations/matchers/shared/asv_allow_blankable.rb +28 -0
  47. data/lib/active_storage_validations/matchers/shared/asv_attachable.rb +72 -0
  48. data/lib/active_storage_validations/matchers/shared/asv_contextable.rb +49 -0
  49. data/lib/active_storage_validations/matchers/shared/asv_messageable.rb +28 -0
  50. data/lib/active_storage_validations/matchers/shared/asv_rspecable.rb +27 -0
  51. data/lib/active_storage_validations/matchers/shared/asv_validatable.rb +56 -0
  52. data/lib/active_storage_validations/matchers/size_validator_matcher.rb +17 -71
  53. data/lib/active_storage_validations/matchers/total_size_validator_matcher.rb +47 -0
  54. data/lib/active_storage_validations/matchers.rb +11 -16
  55. data/lib/active_storage_validations/processable_file_validator.rb +37 -0
  56. data/lib/active_storage_validations/railtie.rb +11 -0
  57. data/lib/active_storage_validations/shared/asv_active_storageable.rb +30 -0
  58. data/lib/active_storage_validations/shared/asv_analyzable.rb +80 -0
  59. data/lib/active_storage_validations/shared/asv_attachable.rb +204 -0
  60. data/lib/active_storage_validations/shared/asv_errorable.rb +40 -0
  61. data/lib/active_storage_validations/shared/asv_loggable.rb +11 -0
  62. data/lib/active_storage_validations/shared/asv_optionable.rb +29 -0
  63. data/lib/active_storage_validations/shared/asv_symbolizable.rb +14 -0
  64. data/lib/active_storage_validations/size_validator.rb +24 -40
  65. data/lib/active_storage_validations/total_size_validator.rb +51 -0
  66. data/lib/active_storage_validations/version.rb +1 -1
  67. data/lib/active_storage_validations.rb +20 -6
  68. metadata +127 -21
  69. data/lib/active_storage_validations/metadata.rb +0 -123
@@ -1,33 +1,92 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'shared/asv_active_storageable'
4
+ require_relative 'shared/asv_errorable'
5
+ require_relative 'shared/asv_optionable'
6
+ require_relative 'shared/asv_symbolizable'
7
+
3
8
  module ActiveStorageValidations
4
9
  class LimitValidator < ActiveModel::EachValidator # :nodoc:
10
+ include ASVActiveStorageable
11
+ include ASVErrorable
12
+ include ASVOptionable
13
+ include ASVSymbolizable
14
+
5
15
  AVAILABLE_CHECKS = %i[max min].freeze
16
+ ERROR_TYPES = %i[
17
+ limit_out_of_range
18
+ limit_min_not_reached
19
+ limit_max_exceeded
20
+ ].freeze
6
21
 
7
22
  def check_validity!
8
- return true if AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
9
-
10
- raise ArgumentError, 'You must pass either :max or :min to the validator'
23
+ ensure_at_least_one_validator_option
24
+ ensure_arguments_validity
11
25
  end
12
26
 
13
- def validate_each(record, attribute, _)
14
- return true unless record.send(attribute).attached?
27
+ def validate_each(record, attribute, _value)
28
+ files = attached_files(record, attribute).reject(&:blank?)
29
+ flat_options = set_flat_options(record)
30
+ count = files.count
31
+
32
+ return if files_count_valid?(count, flat_options)
33
+
34
+ errors_options = initialize_error_options(options)
35
+ errors_options[:min] = flat_options[:min]
36
+ errors_options[:max] = flat_options[:max]
37
+ errors_options[:count] = count
38
+ error_type = if flat_options[:min] && flat_options[:max]
39
+ :limit_out_of_range
40
+ elsif flat_options[:min] && count < flat_options[:min]
41
+ :limit_min_not_reached
42
+ else
43
+ :limit_max_exceeded
44
+ end
45
+
46
+ add_error(record, attribute, error_type, **errors_options)
47
+ end
15
48
 
16
- files = Array.wrap(record.send(attribute)).compact.uniq
17
- errors_options = { min: options[:min], max: options[:max] }
49
+ private
18
50
 
19
- return true if files_count_valid?(files.count)
20
- record.errors.add(attribute, options[:message].presence || :limit_out_of_range, **errors_options)
51
+ def files_count_valid?(count, flat_options)
52
+ if flat_options[:max].present? && flat_options[:min].present?
53
+ count >= flat_options[:min] && count <= flat_options[:max]
54
+ elsif flat_options[:max].present?
55
+ count <= flat_options[:max]
56
+ elsif flat_options[:min].present?
57
+ count >= flat_options[:min]
58
+ end
21
59
  end
22
60
 
23
- def files_count_valid?(count)
24
- if options[:max].present? && options[:min].present?
25
- count >= options[:min] && count <= options[:max]
26
- elsif options[:max].present?
27
- count <= options[:max]
28
- elsif options[:min].present?
29
- count >= options[:min]
61
+ def ensure_at_least_one_validator_option
62
+ unless AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
63
+ raise ArgumentError, 'You must pass either :max or :min to the validator'
30
64
  end
31
65
  end
66
+
67
+ def ensure_arguments_validity
68
+ return true if min_max_are_proc? || min_or_max_is_proc_and_other_not_present?
69
+
70
+ raise ArgumentError, 'You must pass integers to :min and :max' if min_or_max_defined_and_not_integer?
71
+ raise ArgumentError, 'You must pass a higher value to :max than to :min' if min_higher_than_max?
72
+ end
73
+
74
+ def min_max_are_proc?
75
+ options[:min]&.is_a?(Proc) && options[:max]&.is_a?(Proc)
76
+ end
77
+
78
+ def min_or_max_is_proc_and_other_not_present?
79
+ (options[:min]&.is_a?(Proc) && options[:max].nil?) ||
80
+ (options[:min].nil? && options[:max]&.is_a?(Proc))
81
+ end
82
+
83
+ def min_or_max_defined_and_not_integer?
84
+ (options.key?(:min) && !options[:min].is_a?(Integer)) ||
85
+ (options.key?(:max) && !options[:max].is_a?(Integer))
86
+ end
87
+
88
+ def min_higher_than_max?
89
+ options[:min] > options[:max] if options[:min].is_a?(Integer) && options[:max].is_a?(Integer)
90
+ end
32
91
  end
33
92
  end
@@ -0,0 +1,119 @@
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_messageable'
8
+ require_relative 'shared/asv_rspecable'
9
+ require_relative 'shared/asv_validatable'
10
+
11
+ module ActiveStorageValidations
12
+ module Matchers
13
+ def validate_aspect_ratio_of(attribute_name)
14
+ AspectRatioValidatorMatcher.new(attribute_name)
15
+ end
16
+
17
+ class AspectRatioValidatorMatcher
18
+ include ASVActiveStorageable
19
+ include ASVAllowBlankable
20
+ include ASVAttachable
21
+ include ASVContextable
22
+ include ASVMessageable
23
+ include ASVRspecable
24
+ include ASVValidatable
25
+
26
+ def initialize(attribute_name)
27
+ initialize_allow_blankable
28
+ initialize_contextable
29
+ initialize_messageable
30
+ initialize_rspecable
31
+ @attribute_name = attribute_name
32
+ @allowed_aspect_ratios = @rejected_aspect_ratios = []
33
+ end
34
+
35
+ def description
36
+ "validate the aspect ratios allowed on :#{@attribute_name}."
37
+ end
38
+
39
+ def failure_message
40
+ "is expected to validate aspect ratio of :#{@attribute_name}"
41
+ end
42
+
43
+ def allowing(*aspect_ratios)
44
+ @allowed_aspect_ratios = aspect_ratios.flatten
45
+ self
46
+ end
47
+
48
+ def rejecting(*aspect_ratios)
49
+ @rejected_aspect_ratios = aspect_ratios.flatten
50
+ self
51
+ end
52
+
53
+ def matches?(subject)
54
+ @subject = subject.is_a?(Class) ? subject.new : subject
55
+
56
+ is_a_valid_active_storage_attribute? &&
57
+ is_context_valid? &&
58
+ is_allowing_blank? &&
59
+ is_custom_message_valid? &&
60
+ all_allowed_aspect_ratios_allowed? &&
61
+ all_rejected_aspect_ratios_rejected?
62
+ end
63
+
64
+ protected
65
+
66
+ def all_allowed_aspect_ratios_allowed?
67
+ @allowed_aspect_ratios_not_allowed ||= @allowed_aspect_ratios.reject { |aspect_ratio| aspect_ratio_allowed?(aspect_ratio) }
68
+ @allowed_aspect_ratios_not_allowed.empty?
69
+ end
70
+
71
+ def all_rejected_aspect_ratios_rejected?
72
+ @rejected_aspect_ratios_not_rejected ||= @rejected_aspect_ratios.select { |aspect_ratio| aspect_ratio_allowed?(aspect_ratio) }
73
+ @rejected_aspect_ratios_not_rejected.empty?
74
+ end
75
+
76
+ def aspect_ratio_allowed?(aspect_ratio)
77
+ width, height = valid_width_and_height_for(aspect_ratio)
78
+
79
+ mock_dimensions_for(attach_file, width, height) do
80
+ validate
81
+ detach_file
82
+ is_valid?
83
+ end
84
+ end
85
+
86
+ def is_custom_message_valid?
87
+ return true unless @custom_message
88
+
89
+ mock_dimensions_for(attach_file, -1, -1) do
90
+ validate
91
+ detach_file
92
+ has_an_error_message_which_is_custom_message?
93
+ end
94
+ end
95
+
96
+ def mock_dimensions_for(attachment, width, height)
97
+ Matchers.mock_metadata(attachment, { width: width, height: height }) do
98
+ yield
99
+ end
100
+ end
101
+
102
+ def valid_width_and_height_for(aspect_ratio)
103
+ case aspect_ratio
104
+ when :square then [100, 100]
105
+ when :portrait then [100, 200]
106
+ when :landscape then [200, 100]
107
+ when validator_class::ASPECT_RATIO_REGEX
108
+ aspect_ratio =~ validator_class::ASPECT_RATIO_REGEX
109
+ x = Regexp.last_match(1).to_i
110
+ y = Regexp.last_match(2).to_i
111
+
112
+ [100 * x, 100 * y]
113
+ else
114
+ [-1, -1]
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -1,58 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'shared/asv_active_storageable'
4
+ require_relative 'shared/asv_attachable'
5
+ require_relative 'shared/asv_contextable'
6
+ require_relative 'shared/asv_messageable'
7
+ require_relative 'shared/asv_rspecable'
8
+ require_relative 'shared/asv_validatable'
9
+
3
10
  module ActiveStorageValidations
4
11
  module Matchers
5
- def validate_attached_of(name)
6
- AttachedValidatorMatcher.new(name)
12
+ def validate_attached_of(attribute_name)
13
+ AttachedValidatorMatcher.new(attribute_name)
7
14
  end
8
15
 
9
16
  class AttachedValidatorMatcher
17
+ include ASVActiveStorageable
18
+ include ASVAttachable
19
+ include ASVContextable
20
+ include ASVMessageable
21
+ include ASVRspecable
22
+ include ASVValidatable
23
+
10
24
  def initialize(attribute_name)
25
+ initialize_contextable
26
+ initialize_messageable
27
+ initialize_rspecable
11
28
  @attribute_name = attribute_name
12
29
  end
13
30
 
14
31
  def description
15
- "validate #{@attribute_name} must be attached"
32
+ "validate that :#{@attribute_name} must be attached"
33
+ end
34
+
35
+ def failure_message
36
+ "is expected to validate attachment of :#{@attribute_name}"
16
37
  end
17
38
 
18
39
  def matches?(subject)
19
40
  @subject = subject.is_a?(Class) ? subject.new : subject
20
- responds_to_methods && valid_when_attached && invalid_when_not_attached
41
+
42
+ is_a_valid_active_storage_attribute? &&
43
+ is_context_valid? &&
44
+ is_custom_message_valid? &&
45
+ is_valid_when_file_attached? &&
46
+ is_invalid_when_file_not_attached?
21
47
  end
22
48
 
23
- def failure_message
24
- "is expected to validate attached of #{@attribute_name}"
49
+ private
50
+
51
+ def is_valid_when_file_attached?
52
+ attach_file unless file_attached?
53
+ validate
54
+ is_valid?
25
55
  end
26
56
 
27
- def failure_message_when_negated
28
- "is expected to not validate attached of #{@attribute_name}"
57
+ def is_invalid_when_file_not_attached?
58
+ detach_file if file_attached?
59
+ validate
60
+ !is_valid?
29
61
  end
30
62
 
31
- private
63
+ def is_custom_message_valid?
64
+ return true unless @custom_message
32
65
 
33
- def responds_to_methods
34
- @subject.respond_to?(@attribute_name) &&
35
- @subject.public_send(@attribute_name).respond_to?(:attach) &&
36
- @subject.public_send(@attribute_name).respond_to?(:detach)
66
+ detach_file if file_attached?
67
+ validate
68
+ has_an_error_message_which_is_custom_message?
37
69
  end
38
70
 
39
- def valid_when_attached
40
- @subject.public_send(@attribute_name).attach(attachable) unless @subject.public_send(@attribute_name).attached?
41
- @subject.validate
42
- @subject.errors.details[@attribute_name].exclude?(error: :blank)
71
+ def file_attached?
72
+ @subject.public_send(@attribute_name).attached?
43
73
  end
44
74
 
45
- def invalid_when_not_attached
75
+ def detach_file
46
76
  @subject.public_send(@attribute_name).detach
47
77
  # Unset the direct relation since `detach` on an unpersisted record does not set `attached?` to false.
48
78
  @subject.public_send("#{@attribute_name}=", nil)
49
-
50
- @subject.validate
51
- @subject.errors.details[@attribute_name].include?(error: :blank)
52
- end
53
-
54
- def attachable
55
- { io: Tempfile.new('.'), filename: 'dummy.txt', content_type: 'text/plain' }
56
79
  end
57
80
  end
58
81
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Big thank you to the paperclip validation matchers:
4
+ # https://github.com/thoughtbot/paperclip/blob/v6.1.0/lib/paperclip/matchers/validate_attachment_size_matcher.rb
5
+
6
+ require_relative 'shared/asv_active_storageable'
7
+ require_relative 'shared/asv_allow_blankable'
8
+ require_relative 'shared/asv_attachable'
9
+ require_relative 'shared/asv_contextable'
10
+ require_relative 'shared/asv_messageable'
11
+ require_relative 'shared/asv_rspecable'
12
+ require_relative 'shared/asv_validatable'
13
+
14
+ module ActiveStorageValidations
15
+ module Matchers
16
+ class BaseComparisonValidatorMatcher
17
+ # BaseComparisonValidatorMatcher is an abstract class and shouldn't be instantiated directly.
18
+
19
+ include ASVActiveStorageable
20
+ include ASVAllowBlankable
21
+ include ASVAttachable
22
+ include ASVContextable
23
+ include ASVMessageable
24
+ include ASVRspecable
25
+ include ASVValidatable
26
+
27
+ def initialize(attribute_name)
28
+ initialize_allow_blankable
29
+ initialize_contextable
30
+ initialize_messageable
31
+ initialize_rspecable
32
+ @attribute_name = attribute_name
33
+ @min = @max = nil
34
+ end
35
+
36
+ def less_than(value)
37
+ @max = value - smallest_measurement
38
+ self
39
+ end
40
+
41
+ def less_than_or_equal_to(value)
42
+ @max = value
43
+ self
44
+ end
45
+
46
+ def greater_than(value)
47
+ @min = value + smallest_measurement
48
+ self
49
+ end
50
+
51
+ def greater_than_or_equal_to(value)
52
+ @min = value
53
+ self
54
+ end
55
+
56
+ def between(range)
57
+ @min, @max = range.first, range.last
58
+ self
59
+ end
60
+
61
+ def matches?(subject)
62
+ @subject = subject.is_a?(Class) ? subject.new : subject
63
+
64
+ is_a_valid_active_storage_attribute? &&
65
+ is_context_valid? &&
66
+ is_allowing_blank? &&
67
+ is_custom_message_valid? &&
68
+ not_lower_than_min? &&
69
+ higher_than_min? &&
70
+ lower_than_max? &&
71
+ not_higher_than_max?
72
+ end
73
+
74
+ protected
75
+
76
+ def build_failure_message(message)
77
+ return unless @failure_message_artefacts.present?
78
+
79
+ message << " but there seem to have issues with the matcher methods you used, since:"
80
+ @failure_message_artefacts.each do |error_case|
81
+ message << " validation failed when provided with a #{error_case[:value]} #{failure_message_unit} test file"
82
+ end
83
+ message << " whereas it should have passed"
84
+ end
85
+
86
+ def failure_message_unit
87
+ raise NotImplementedError
88
+ end
89
+
90
+ def not_lower_than_min?
91
+ @min.nil? || !passes_validation_with_value(@min - 1)
92
+ end
93
+
94
+ def higher_than_min?
95
+ @min.nil? || passes_validation_with_value(@min + 1)
96
+ end
97
+
98
+ def lower_than_max?
99
+ @max.nil? || @max == Float::INFINITY || passes_validation_with_value(@max - 1)
100
+ end
101
+
102
+ def not_higher_than_max?
103
+ @max.nil? || @max == Float::INFINITY || !passes_validation_with_value(@max + 1)
104
+ end
105
+
106
+ def smallest_measurement
107
+ raise NotImplementedError
108
+ end
109
+
110
+ def passes_validation_with_value(value)
111
+ mock_value_for(io, value) do
112
+ attach_file
113
+ validate
114
+ detach_file
115
+ is_valid? || add_failure_message_artefact(value)
116
+ end
117
+ end
118
+
119
+ def add_failure_message_artefact(value)
120
+ @failure_message_artefacts << { value: value }
121
+ false
122
+ end
123
+
124
+ def is_custom_message_valid?
125
+ return true unless @custom_message
126
+
127
+ mock_value_for(io, -smallest_measurement) do
128
+ attach_file
129
+ validate
130
+ detach_file
131
+ has_an_error_message_which_is_custom_message?
132
+ end
133
+ end
134
+
135
+ def mock_value_for(io, size)
136
+ raise NotImplementedError
137
+ end
138
+ end
139
+ end
140
+ end
@@ -2,109 +2,144 @@
2
2
 
3
3
  # Big thank you to the paperclip validation matchers:
4
4
  # https://github.com/thoughtbot/paperclip/blob/v6.1.0/lib/paperclip/matchers/validate_attachment_content_type_matcher.rb
5
+
6
+ require_relative 'shared/asv_active_storageable'
7
+ require_relative 'shared/asv_allow_blankable'
8
+ require_relative 'shared/asv_attachable'
9
+ require_relative 'shared/asv_contextable'
10
+ require_relative 'shared/asv_messageable'
11
+ require_relative 'shared/asv_rspecable'
12
+ require_relative 'shared/asv_validatable'
13
+
5
14
  module ActiveStorageValidations
6
15
  module Matchers
7
- def validate_content_type_of(name)
8
- ContentTypeValidatorMatcher.new(name)
16
+ def validate_content_type_of(attribute_name)
17
+ ContentTypeValidatorMatcher.new(attribute_name)
9
18
  end
10
19
 
11
20
  class ContentTypeValidatorMatcher
21
+ include ASVActiveStorageable
22
+ include ASVAllowBlankable
23
+ include ASVAttachable
24
+ include ASVContextable
25
+ include ASVMessageable
26
+ include ASVRspecable
27
+ include ASVValidatable
28
+
12
29
  def initialize(attribute_name)
30
+ initialize_allow_blankable
31
+ initialize_contextable
32
+ initialize_messageable
33
+ initialize_rspecable
13
34
  @attribute_name = attribute_name
35
+ @allowed_content_types = @rejected_content_types = []
14
36
  end
15
37
 
16
38
  def description
17
- "validate the content types allowed on attachment #{@attribute_name}"
39
+ "validate the content types allowed on :#{@attribute_name}"
40
+ end
41
+
42
+ def failure_message
43
+ message = ["is expected to validate the content types of :#{@attribute_name}"]
44
+ build_failure_message(message)
45
+ message.join("\n")
18
46
  end
19
47
 
20
- def allowing(*types)
21
- @allowed_types = types.flatten
48
+ def allowing(*content_types)
49
+ @allowed_content_types = content_types.map { |content_type| normalize_content_type(content_type) }.flatten
22
50
  self
23
51
  end
24
52
 
25
- def rejecting(*types)
26
- @rejected_types = types.flatten
53
+ def rejecting(*content_types)
54
+ @rejected_content_types = content_types.map { |content_type| normalize_content_type(content_type) }.flatten
27
55
  self
28
56
  end
29
57
 
30
58
  def matches?(subject)
31
59
  @subject = subject.is_a?(Class) ? subject.new : subject
32
- responds_to_methods && allowed_types_allowed? && rejected_types_rejected?
33
- end
34
-
35
- def failure_message
36
- <<~MESSAGE
37
- Expected #{@attribute_name}
38
60
 
39
- Accept content types: #{allowed_types.join(", ")}
40
- #{accepted_types_and_failures}
41
-
42
- Reject content types: #{rejected_types.join(", ")}
43
- #{rejected_types_and_failures}
44
- MESSAGE
61
+ is_a_valid_active_storage_attribute? &&
62
+ is_context_valid? &&
63
+ is_allowing_blank? &&
64
+ is_custom_message_valid? &&
65
+ all_allowed_content_types_allowed? &&
66
+ all_rejected_content_types_rejected?
45
67
  end
46
68
 
47
69
  protected
48
70
 
49
- def responds_to_methods
50
- @subject.respond_to?(@attribute_name) &&
51
- @subject.public_send(@attribute_name).respond_to?(:attach) &&
52
- @subject.public_send(@attribute_name).respond_to?(:detach)
71
+ def build_failure_message(message)
72
+ if @allowed_content_types_not_allowed.present?
73
+ message << " the following content type#{'s' if @allowed_content_types.count > 1} should be allowed: :#{@allowed_content_types.join(", :")}"
74
+ message << " but #{pluralize(@allowed_content_types_not_allowed)} rejected"
75
+ end
76
+
77
+ if @rejected_content_types_not_rejected.present?
78
+ message << " the following content type#{'s' if @rejected_content_types.count > 1} should be rejected: :#{@rejected_content_types.join(", :")}"
79
+ message << " but #{pluralize(@rejected_content_types_not_rejected)} accepted"
80
+ end
53
81
  end
54
82
 
55
- def allowed_types
56
- @allowed_types || []
83
+ def pluralize(types)
84
+ if types.count == 1
85
+ ":#{types[0]} was"
86
+ else
87
+ ":#{types.join(", :")} were"
88
+ end
57
89
  end
58
90
 
59
- def rejected_types
60
- @rejected_types || (content_type_keys - allowed_types)
91
+ def normalize_content_type(content_type)
92
+ Marcel::MimeType.for(declared_type: content_type.to_s, extension: content_type.to_s)
61
93
  end
62
94
 
63
- def allowed_types_allowed?
64
- @missing_allowed_types ||= allowed_types.reject { |type| type_allowed?(type) }
65
- @missing_allowed_types.none?
95
+ def all_allowed_content_types_allowed?
96
+ @allowed_content_types_not_allowed ||= @allowed_content_types.reject { |type| type_allowed?(type) }
97
+ @allowed_content_types_not_allowed.empty?
66
98
  end
67
99
 
68
- def rejected_types_rejected?
69
- @missing_rejected_types ||= rejected_types.select { |type| type_allowed?(type) }
70
- @missing_rejected_types.none?
100
+ def all_rejected_content_types_rejected?
101
+ @rejected_content_types_not_rejected ||= @rejected_content_types.select { |type| type_allowed?(type) }
102
+ @rejected_content_types_not_rejected.empty?
71
103
  end
72
104
 
73
- def accepted_types_and_failures
74
- if @missing_allowed_types.present?
75
- "#{@missing_allowed_types.join(", ")} were rejected."
76
- else
77
- "All were accepted successfully."
78
- end
105
+ def type_allowed?(content_type)
106
+ attach_file_with_content_type(content_type)
107
+ validate
108
+ detach_file
109
+ is_valid?
79
110
  end
80
111
 
81
- def rejected_types_and_failures
82
- if @missing_rejected_types.present?
83
- "#{@missing_rejected_types.join(", ")} were accepted."
84
- else
85
- "All were rejected successfully."
86
- end
112
+ def attach_file_with_content_type(content_type)
113
+ @subject.public_send(@attribute_name).attach(attachment_for(content_type))
87
114
  end
88
115
 
89
- def type_allowed?(type)
90
- @subject.public_send(@attribute_name).attach(attachment_for(type))
91
- @subject.validate
92
- @subject.errors.details[@attribute_name].all? { |error| error[:error] != :content_type_invalid }
116
+ def is_custom_message_valid?
117
+ return true unless @custom_message
118
+
119
+ attach_invalid_content_type_file
120
+ validate
121
+ has_an_error_message_which_is_custom_message?
93
122
  end
94
123
 
95
- def attachment_for(type)
96
- suffix = type.to_s.split('/').last
97
- { io: Tempfile.new('.'), filename: "test.#{suffix}", content_type: type }
124
+ def attach_invalid_content_type_file
125
+ @subject.public_send(@attribute_name).attach(attachment_for('fake/fake'))
98
126
  end
99
127
 
100
- private
128
+ def attachment_for(content_type)
129
+ suffix = Marcel::TYPE_EXTS[content_type.to_s]&.first || 'fake'
101
130
 
102
- def content_type_keys
103
- if Rails.gem_version < Gem::Version.new('6.1.0')
104
- Mime::LOOKUP.keys
105
- else
106
- Marcel::TYPES.keys
107
- end
131
+ {
132
+ io: Tempfile.new('.'),
133
+ filename: "test.#{suffix}",
134
+ content_type: content_type
135
+ }
136
+ end
137
+
138
+ # Due to the way we build test attachments in #attachment_for
139
+ # (ie spoofed file basically), we need to ignore the error related to
140
+ # content type spoofing in our matcher to pass the tests
141
+ def validator_errors_for_attribute
142
+ super.reject { |hash| hash[:error] == :content_type_spoofed }
108
143
  end
109
144
  end
110
145
  end