active_storage_validations 0.9.7 → 2.0.2

Sign up to get free protection for your applications and to get access to all the features.
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