haveapi 0.28.3 → 0.29.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +139 -0
  3. data/Rakefile +1 -0
  4. data/haveapi.gemspec +2 -1
  5. data/lib/haveapi/action.rb +26 -9
  6. data/lib/haveapi/actions/default.rb +5 -2
  7. data/lib/haveapi/actions/paginable.rb +8 -4
  8. data/lib/haveapi/authentication/oauth2/provider.rb +1 -1
  9. data/lib/haveapi/authentication/token/config.rb +10 -3
  10. data/lib/haveapi/authentication/token/provider.rb +22 -21
  11. data/lib/haveapi/context.rb +4 -1
  12. data/lib/haveapi/extensions/exception_mailer.rb +136 -17
  13. data/lib/haveapi/i18n.rb +125 -0
  14. data/lib/haveapi/locales/cs.yml +167 -0
  15. data/lib/haveapi/locales/en.yml +168 -0
  16. data/lib/haveapi/metadata.rb +25 -3
  17. data/lib/haveapi/model_adapters/active_record.rb +40 -26
  18. data/lib/haveapi/output_formatter.rb +2 -2
  19. data/lib/haveapi/parameters/metadata_i18n.rb +179 -0
  20. data/lib/haveapi/parameters/resource.rb +18 -7
  21. data/lib/haveapi/parameters/typed.rb +27 -20
  22. data/lib/haveapi/params.rb +76 -7
  23. data/lib/haveapi/resource.rb +1 -1
  24. data/lib/haveapi/resources/action_state.rb +47 -27
  25. data/lib/haveapi/server.rb +287 -94
  26. data/lib/haveapi/spec/api_builder.rb +25 -0
  27. data/lib/haveapi/spec/spec_methods.rb +10 -0
  28. data/lib/haveapi/tasks/i18n.rb +198 -0
  29. data/lib/haveapi/validator_chain.rb +1 -1
  30. data/lib/haveapi/validators/acceptance.rb +5 -2
  31. data/lib/haveapi/validators/confirmation.rb +5 -2
  32. data/lib/haveapi/validators/exclusion.rb +2 -2
  33. data/lib/haveapi/validators/format.rb +5 -2
  34. data/lib/haveapi/validators/inclusion.rb +2 -2
  35. data/lib/haveapi/validators/length.rb +5 -5
  36. data/lib/haveapi/validators/numericality.rb +20 -22
  37. data/lib/haveapi/validators/presence.rb +4 -2
  38. data/lib/haveapi/version.rb +1 -1
  39. data/lib/haveapi.rb +1 -0
  40. data/spec/authentication/oauth2_spec.rb +10 -0
  41. data/spec/extensions/exception_mailer_spec.rb +195 -0
  42. data/spec/i18n_spec.rb +520 -0
  43. data/spec/params_spec.rb +183 -0
  44. data/spec/server/integration_spec.rb +34 -0
  45. metadata +30 -7
  46. data/doc/create-client.md +0 -107
  47. data/doc/json-schema.html +0 -1182
  48. data/doc/protocol.md +0 -535
  49. data/doc/protocol.png +0 -0
@@ -0,0 +1,198 @@
1
+ require 'yaml'
2
+
3
+ module HaveAPI
4
+ module Tasks
5
+ class I18nHealth
6
+ KEY_LITERAL_PATTERN = /['"](haveapi\.[a-z0-9_.]+)['"]/
7
+
8
+ RAW_MESSAGE_PATTERNS = [
9
+ /report_error\([^#\n]*,\s*['"]/,
10
+ /error!\(\s*['"]/,
11
+ /HaveAPI::ValidationError(?:\.new)?\(\s*['"]/,
12
+ /raise\s+HaveAPI::ValidationError,\s*['"]/,
13
+ /ret\[:message\]\s*=\s*['"]/,
14
+ /\|\|\s*['"][^'"]+(?:failed|error|denied|not found|invalid|requires|unsupported|missing)/,
15
+ /@message\s*=\s*take\(\s*:message\s*,\s*['"]/
16
+ ].freeze
17
+
18
+ PARAMETER_METHODS = %w[
19
+ bool custom datetime float id integer password resource string text
20
+ ].freeze
21
+ PARAMETER_CALL_PATTERN = /\A\s*(?:#{PARAMETER_METHODS.join('|')})\b/
22
+ RAW_PARAMETER_METADATA_PATTERN = /\b(?:label|desc):\s*['"]/
23
+
24
+ def initialize(root:)
25
+ @root = root
26
+ end
27
+
28
+ def check!
29
+ errors = []
30
+ errors.concat(missing_key_errors)
31
+ errors.concat(unused_key_errors)
32
+ errors.concat(raw_message_errors)
33
+
34
+ return true if errors.empty?
35
+
36
+ raise "i18n health check failed:\n#{errors.join("\n")}"
37
+ end
38
+
39
+ def normalize!
40
+ locale_data.each do |locale, data|
41
+ file = File.join(locale_dir, "#{locale}.yml")
42
+ File.write(file, YAML.dump(locale.to_s => deep_sort(data)))
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def missing_key_errors
49
+ reference_locale = :en
50
+ reference_keys = locale_keys.fetch(reference_locale)
51
+
52
+ locale_keys.flat_map do |locale, keys|
53
+ next [] if locale == reference_locale
54
+
55
+ (reference_keys - keys).sort.map do |key|
56
+ "#{locale}: missing #{key}"
57
+ end
58
+ end
59
+ end
60
+
61
+ def unused_key_errors
62
+ keys = locale_keys.fetch(:en)
63
+ used = used_keys
64
+
65
+ (keys - used).sort.map { |key| "unused translation key #{key}" }
66
+ end
67
+
68
+ def raw_message_errors
69
+ source_files.flat_map do |file|
70
+ rel = relative_path(file)
71
+
72
+ File.readlines(file, chomp: true).each_with_index.filter_map do |line, index|
73
+ stripped = line.strip
74
+ next if stripped.empty? || stripped.start_with?('#')
75
+
76
+ if RAW_MESSAGE_PATTERNS.any? { |pattern| pattern.match?(line) }
77
+ "#{rel}:#{index + 1}: raw user-facing framework message"
78
+ end
79
+ end
80
+ end + raw_parameter_metadata_errors
81
+ end
82
+
83
+ def raw_parameter_metadata_errors
84
+ source_files.flat_map do |file|
85
+ rel = relative_path(file)
86
+ inside_parameter = false
87
+ remaining_lines = 0
88
+
89
+ File.readlines(file, chomp: true).each_with_index.filter_map do |line, index|
90
+ stripped = line.strip
91
+ next if stripped.empty? || stripped.start_with?('#')
92
+
93
+ if PARAMETER_CALL_PATTERN.match?(stripped)
94
+ inside_parameter = true
95
+ remaining_lines = 12
96
+ end
97
+
98
+ if inside_parameter && RAW_PARAMETER_METADATA_PATTERN.match?(line)
99
+ "#{rel}:#{index + 1}: raw action parameter label/description"
100
+ end
101
+ ensure
102
+ if inside_parameter
103
+ remaining_lines -= 1
104
+ inside_parameter = false if remaining_lines <= 0 || parameter_call_end?(stripped)
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ def parameter_call_end?(line)
111
+ return false if line.nil?
112
+
113
+ stripped = line.strip
114
+ return false if stripped.empty?
115
+ return false if stripped.end_with?(',', '\\')
116
+
117
+ true
118
+ end
119
+
120
+ def used_keys
121
+ @used_keys ||= source_files.each_with_object(Set.new) do |file, ret|
122
+ File.read(file).scan(KEY_LITERAL_PATTERN) do |match|
123
+ ret << match.first
124
+ end
125
+ end
126
+ end
127
+
128
+ def locale_keys
129
+ @locale_keys ||= locale_data.transform_values do |data|
130
+ flatten_keys(data)
131
+ end
132
+ end
133
+
134
+ def locale_data
135
+ @locale_data ||= Dir[File.join(locale_dir, '*.yml')].to_h do |file|
136
+ locale = File.basename(file, '.yml').to_sym
137
+ data = YAML.safe_load_file(file, aliases: true) || {}
138
+
139
+ [locale, data.fetch(locale.to_s)]
140
+ end
141
+ end
142
+
143
+ def flatten_keys(hash, prefix = nil)
144
+ hash.each_with_object(Set.new) do |(key, value), ret|
145
+ name = [prefix, key].compact.join('.')
146
+
147
+ if value.is_a?(Hash)
148
+ ret.merge(flatten_keys(value, name))
149
+ else
150
+ ret << name
151
+ end
152
+ end
153
+ end
154
+
155
+ def deep_sort(value)
156
+ case value
157
+ when Hash
158
+ value.keys.sort.to_h do |key|
159
+ [key, deep_sort(value[key])]
160
+ end
161
+ else
162
+ value
163
+ end
164
+ end
165
+
166
+ def source_files
167
+ @source_files ||= Dir[File.join(@root, 'lib/haveapi/**/*.rb')].reject do |file|
168
+ rel = relative_path(file)
169
+ rel.start_with?('lib/haveapi/public/') ||
170
+ rel.start_with?('lib/haveapi/views/') ||
171
+ rel.start_with?('lib/haveapi/client_examples/') ||
172
+ rel.start_with?('lib/haveapi/spec/') ||
173
+ rel.start_with?('lib/haveapi/tasks/i18n.rb')
174
+ end
175
+ end
176
+
177
+ def relative_path(file)
178
+ file.delete_prefix("#{@root}/")
179
+ end
180
+
181
+ def locale_dir
182
+ File.join(@root, 'lib/haveapi/locales')
183
+ end
184
+ end
185
+ end
186
+ end
187
+
188
+ namespace :i18n do
189
+ desc 'Check HaveAPI translation coverage and raw framework messages'
190
+ task :health do
191
+ HaveAPI::Tasks::I18nHealth.new(root: File.expand_path('../../..', __dir__)).check!
192
+ end
193
+
194
+ desc 'Normalize HaveAPI locale files'
195
+ task :normalize do
196
+ HaveAPI::Tasks::I18nHealth.new(root: File.expand_path('../../..', __dir__)).normalize!
197
+ end
198
+ end
@@ -72,7 +72,7 @@ module HaveAPI
72
72
  validator = validator.clone
73
73
  next if validator.validate(value, params)
74
74
 
75
- ret << format(validator.message, value:)
75
+ ret << HaveAPI.localize(validator.message, value:)
76
76
  end
77
77
 
78
78
  ret.empty? ? true : ret
@@ -23,13 +23,16 @@ module HaveAPI
23
23
  take(:value)
24
24
  end
25
25
 
26
- @message = take(:message, "has to be #{@value}")
26
+ @message = take(
27
+ :message,
28
+ HaveAPI.message('haveapi.validators.acceptance.accepted', value: @value)
29
+ )
27
30
  end
28
31
 
29
32
  def describe
30
33
  {
31
34
  value: @value,
32
- message: @message
35
+ message: HaveAPI.localize(@message)
33
36
  }
34
37
  end
35
38
 
@@ -24,7 +24,10 @@ module HaveAPI
24
24
  @equal = take(:equal, true)
25
25
  @message = take(
26
26
  :message,
27
- @equal ? "must be the same as #{@param}" : "must be different from #{@param}"
27
+ HaveAPI.message(
28
+ @equal ? 'haveapi.validators.confirmation.same' : 'haveapi.validators.confirmation.different',
29
+ parameter: @param
30
+ )
28
31
  )
29
32
  end
30
33
 
@@ -32,7 +35,7 @@ module HaveAPI
32
35
  {
33
36
  equal: @equal ? true : false,
34
37
  parameter: @param,
35
- message: @message
38
+ message: HaveAPI.localize(@message)
36
39
  }
37
40
  end
38
41
 
@@ -23,13 +23,13 @@ module HaveAPI
23
23
  v.is_a?(::Symbol) ? v.to_s : v
24
24
  end
25
25
 
26
- @message = take(:message, '%{value} cannot be used')
26
+ @message = take(:message, HaveAPI.message('haveapi.validators.exclusion.excluded'))
27
27
  end
28
28
 
29
29
  def describe
30
30
  {
31
31
  values: @values,
32
- message: @message
32
+ message: HaveAPI.localize(@message)
33
33
  }
34
34
  end
35
35
 
@@ -20,7 +20,10 @@ module HaveAPI
20
20
  @rx = simple? ? take : take(:rx)
21
21
  @match = take(:match, true)
22
22
  @desc = take(:desc)
23
- @message = take(:message, @desc || '%{value} is not in a valid format')
23
+ @message = take(
24
+ :message,
25
+ @desc || HaveAPI.message('haveapi.validators.format.invalid')
26
+ )
24
27
  end
25
28
 
26
29
  def describe
@@ -28,7 +31,7 @@ module HaveAPI
28
31
  rx: @rx.source,
29
32
  match: @match,
30
33
  description: @desc,
31
- message: @message
34
+ message: HaveAPI.localize(@message)
32
35
  }
33
36
  end
34
37
 
@@ -31,13 +31,13 @@ module HaveAPI
31
31
  @values = values.map { |v| v.is_a?(::Symbol) ? v.to_s : v }
32
32
  end
33
33
 
34
- @message = take(:message, '%{value} cannot be used')
34
+ @message = take(:message, HaveAPI.message('haveapi.validators.inclusion.included'))
35
35
  end
36
36
 
37
37
  def describe
38
38
  {
39
39
  values: @values,
40
- message: @message
40
+ message: HaveAPI.localize(@message)
41
41
  }
42
42
  end
43
43
 
@@ -30,16 +30,16 @@ module HaveAPI
30
30
  end
31
31
 
32
32
  msg = if @equals
33
- "length has to be #{@equals}"
33
+ HaveAPI.message('haveapi.validators.length.equals', equals: @equals)
34
34
 
35
35
  elsif @min && !@max
36
- "length has to be minimally #{@min}"
36
+ HaveAPI.message('haveapi.validators.length.min', min: @min)
37
37
 
38
38
  elsif !@min && @max
39
- "length has to be maximally #{@max}"
39
+ HaveAPI.message('haveapi.validators.length.max', max: @max)
40
40
 
41
41
  else
42
- "length has to be in range <#{@min}, #{@max}>"
42
+ HaveAPI.message('haveapi.validators.length.range', min: @min, max: @max)
43
43
  end
44
44
 
45
45
  @message = take(:message, msg)
@@ -47,7 +47,7 @@ module HaveAPI
47
47
 
48
48
  def describe
49
49
  ret = {
50
- message: @message
50
+ message: HaveAPI.localize(@message)
51
51
  }
52
52
 
53
53
  if @equals
@@ -32,49 +32,47 @@ module HaveAPI
32
32
  @even = take(:even)
33
33
  @odd = take(:odd)
34
34
 
35
- msg = if @min && !@max
36
- "has to be minimally #{@min}"
37
-
38
- elsif !@min && @max
39
- "has to be maximally #{@max}"
40
-
41
- elsif @min && @max
42
- "has to be in range <#{@min}, #{@max}>"
43
-
44
- else
45
- 'has to be a number'
46
- end
35
+ requirements = []
36
+
37
+ requirements << if @min && !@max
38
+ HaveAPI.message('haveapi.validators.numericality.min', min: @min)
39
+ elsif !@min && @max
40
+ HaveAPI.message('haveapi.validators.numericality.max', max: @max)
41
+ elsif @min && @max
42
+ HaveAPI.message('haveapi.validators.numericality.range', min: @min, max: @max)
43
+ else
44
+ HaveAPI.message('haveapi.validators.numericality.number')
45
+ end
47
46
 
48
47
  if @step
49
- msg += '; ' unless msg.empty?
50
- msg += "in steps of #{@step}"
48
+ requirements << HaveAPI.message('haveapi.validators.numericality.step', step: @step)
51
49
  end
52
50
 
53
51
  if @mod
54
- msg += '; ' unless msg.empty?
55
- msg += "mod #{@step} must equal zero"
52
+ requirements << HaveAPI.message('haveapi.validators.numericality.mod', mod: @mod)
56
53
  end
57
54
 
58
55
  if @odd
59
- msg += '; ' unless msg.empty?
60
- msg += 'odd'
56
+ requirements << HaveAPI.message('haveapi.validators.numericality.odd')
61
57
  end
62
58
 
63
59
  if @even
64
- msg += '; ' unless msg.empty?
65
- msg += 'even'
60
+ requirements << HaveAPI.message('haveapi.validators.numericality.even')
66
61
  end
67
62
 
68
63
  if @odd && @even
69
64
  raise 'cannot be both odd and even at the same time'
70
65
  end
71
66
 
72
- @message = take(:message, msg)
67
+ @message = take(
68
+ :message,
69
+ HaveAPI.message('haveapi.validators.numericality.composite', requirements:)
70
+ )
73
71
  end
74
72
 
75
73
  def describe
76
74
  ret = {
77
- message: @message
75
+ message: HaveAPI.localize(@message)
78
76
  }
79
77
 
80
78
  ret[:min] = @min if @min
@@ -21,14 +21,16 @@ module HaveAPI
21
21
  @empty = take(:empty, false)
22
22
  @message = take(
23
23
  :message,
24
- @empty ? 'must be present' : 'must be present and non-empty'
24
+ HaveAPI.message(
25
+ @empty ? 'haveapi.validators.presence.present' : 'haveapi.validators.presence.non_empty'
26
+ )
25
27
  )
26
28
  end
27
29
 
28
30
  def describe
29
31
  {
30
32
  empty: @empty,
31
- message: @message
33
+ message: HaveAPI.localize(@message)
32
34
  }
33
35
  end
34
36
 
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.28.3'.freeze
3
+ VERSION = '0.29.0'.freeze
4
4
  end
data/lib/haveapi.rb CHANGED
@@ -13,6 +13,7 @@ module HaveAPI
13
13
  module Actions; end
14
14
  end
15
15
 
16
+ require_relative 'haveapi/i18n'
16
17
  require_relative 'haveapi/params'
17
18
  require_rel 'haveapi/parameters/'
18
19
  require_rel 'haveapi/*.rb'
@@ -218,6 +218,16 @@ describe HaveAPI::Authentication::OAuth2 do
218
218
  expect(api_response.message).to match(/too many|multiple/i)
219
219
  end
220
220
 
221
+ it 'localizes multiple token conflicts' do
222
+ header 'Accept-Language', 'cs'
223
+ header 'Authorization', 'Bearer abc'
224
+ call_api(:post, '/v1/secures/ping?access_token=abc', {})
225
+
226
+ expect(last_response.status).to eq(400)
227
+ expect(api_response).to be_failed
228
+ expect(api_response.message).to eq('Bylo poskytnuto více OAuth2 tokenů')
229
+ end
230
+
221
231
  it 'ignores structured access_token query values before backend lookup' do
222
232
  expect do
223
233
  call_api(:post, '/v1/secures/ping?access_token[]=abc', {})
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'stringio'
5
+
6
+ module ExceptionMailerSpec
7
+ class RequestError < StandardError; end
8
+ class ActionError < StandardError; end
9
+ end
10
+
11
+ describe HaveAPI::Extensions::ExceptionMailer do
12
+ api do
13
+ define_resource(:Test) do
14
+ version 1
15
+ auth false
16
+
17
+ define_action(:RequestError) do
18
+ route 'request_error'
19
+ http_method :get
20
+
21
+ authorize do
22
+ raise ExceptionMailerSpec::RequestError, 'request boom'
23
+ end
24
+
25
+ def exec
26
+ ok!
27
+ end
28
+ end
29
+
30
+ define_action(:InputRequestError) do
31
+ route 'input_request_error'
32
+ http_method :post
33
+
34
+ authorize do
35
+ raise ExceptionMailerSpec::RequestError, 'request boom'
36
+ end
37
+
38
+ def exec
39
+ ok!
40
+ end
41
+ end
42
+
43
+ define_action(:ActionError) do
44
+ route 'action_error'
45
+ http_method :get
46
+ authorize { allow }
47
+
48
+ def exec
49
+ raise ExceptionMailerSpec::ActionError, 'action boom'
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ default_version 1
56
+
57
+ def with_exception_mailer(mailer)
58
+ action_hooks = HaveAPI::Hooks.hooks[HaveAPI::Action][:exec_exception]
59
+ original_action_listeners = action_hooks[:listeners].dup
60
+ server = app.settings.api_server
61
+ original_server_hooks = dup_instance_hooks(server)
62
+
63
+ mailer.enabled(server)
64
+ yield
65
+ ensure
66
+ action_hooks[:listeners] = original_action_listeners
67
+ restore_instance_hooks(server, original_server_hooks)
68
+ end
69
+
70
+ def dup_instance_hooks(server)
71
+ hooks = server.instance_variable_get(HaveAPI::Hooks::INSTANCE_VARIABLE)
72
+ return unless hooks
73
+
74
+ hooks.transform_values do |hook|
75
+ hook.merge(listeners: hook[:listeners].dup)
76
+ end
77
+ end
78
+
79
+ def restore_instance_hooks(server, hooks)
80
+ if hooks
81
+ server.instance_variable_set(HaveAPI::Hooks::INSTANCE_VARIABLE, hooks)
82
+ elsif server.instance_variable_defined?(HaveAPI::Hooks::INSTANCE_VARIABLE)
83
+ server.remove_instance_variable(HaveAPI::Hooks::INSTANCE_VARIABLE)
84
+ end
85
+ end
86
+
87
+ def collect_mail_calls(mailer)
88
+ calls = []
89
+
90
+ allow(mailer).to receive(:mail) do |context, exception, body|
91
+ calls << [context, exception, body]
92
+ end
93
+
94
+ calls
95
+ end
96
+
97
+ def new_mailer
98
+ described_class.new(
99
+ from: 'from@example.test',
100
+ to: 'to@example.test',
101
+ subject: '[spec] %s',
102
+ smtp: false
103
+ )
104
+ end
105
+
106
+ it 'mails request-level exceptions outside action execution' do
107
+ mailer = new_mailer
108
+ calls = collect_mail_calls(mailer)
109
+
110
+ with_exception_mailer(mailer) do
111
+ get '/v1/tests/request_error'
112
+ end
113
+
114
+ expect(last_response.status).to eq(500)
115
+ expect(api_response).not_to be_ok
116
+ expect(api_response.message).to eq('Server error occurred')
117
+
118
+ expect(calls.size).to eq(1)
119
+ context, exception, body = calls.first
120
+ expect(context.action.to_s).to include('RequestError')
121
+ expect(exception).to be_a(ExceptionMailerSpec::RequestError)
122
+ expect(body).to include('request boom')
123
+ end
124
+
125
+ it 'redacts secrets from request-level exception emails' do
126
+ mailer = new_mailer
127
+ calls = collect_mail_calls(mailer)
128
+ header_secret = %w[HEADER SECRET].join('_')
129
+ header_token_secret = %w[HEADER TOKEN SECRET].join('_')
130
+ cookie_secret = %w[COOKIE SECRET].join('_')
131
+ query_secret = %w[QUERY SECRET].join('_')
132
+ body_secret = %w[BODY SECRET].join('_')
133
+ nested_secret = %w[NESTED SECRET].join('_')
134
+
135
+ with_exception_mailer(mailer) do
136
+ header 'Accept', 'application/json'
137
+ header 'Content-Type', 'application/json'
138
+ header 'Authorization', "Bearer #{header_secret}"
139
+ header 'Cookie', "session=#{cookie_secret}"
140
+ header 'X-Auth-Token', header_token_secret
141
+ post "/v1/tests/input_request_error?_auth_token=#{query_secret}", JSON.generate({
142
+ password: body_secret,
143
+ nested: {
144
+ token: nested_secret,
145
+ visible: 'VISIBLE_VALUE'
146
+ }
147
+ })
148
+ end
149
+
150
+ body = calls.first[2]
151
+
152
+ expect(body).to include('VISIBLE_VALUE')
153
+ expect(body).to include(HaveAPI::Extensions::ExceptionMailer::FILTERED_VALUE)
154
+ [
155
+ header_secret,
156
+ header_token_secret,
157
+ cookie_secret,
158
+ query_secret,
159
+ body_secret,
160
+ nested_secret
161
+ ].each do |secret|
162
+ expect(body).not_to include(secret)
163
+ end
164
+ end
165
+
166
+ it 'mails action execution exceptions' do
167
+ mailer = new_mailer
168
+ calls = collect_mail_calls(mailer)
169
+
170
+ with_exception_mailer(mailer) do
171
+ get '/v1/tests/action_error'
172
+ end
173
+
174
+ expect(last_response.status).to eq(500)
175
+ expect(api_response).not_to be_ok
176
+ expect(calls.size).to eq(1)
177
+ expect(calls.first[1]).to be_a(ExceptionMailerSpec::ActionError)
178
+ expect(calls.first[2]).to include('action boom')
179
+ end
180
+
181
+ it 'does not replace the API response when mail delivery fails' do
182
+ mailer = new_mailer
183
+ allow(mailer).to receive(:mail).and_raise(StandardError, 'smtp down')
184
+
185
+ with_exception_mailer(mailer) do
186
+ expect do
187
+ get '/v1/tests/request_error'
188
+ end.to output(/ExceptionMailer failed: StandardError: smtp down/).to_stderr
189
+ end
190
+
191
+ expect(last_response.status).to eq(500)
192
+ expect(api_response).not_to be_ok
193
+ expect(api_response.message).to eq('Server error occurred')
194
+ end
195
+ end