mailerlite-ruby 1.0.6 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +2 -0
  3. data/.github/workflows/main.yml +4 -4
  4. data/.github/workflows/publish_gem.yml +5 -5
  5. data/.gitignore +2 -1
  6. data/.rubocop.yml +1 -1
  7. data/CHANGELOG.md +21 -0
  8. data/README.md +583 -1
  9. data/flake.lock +27 -0
  10. data/flake.nix +25 -0
  11. data/lefthook.yml +14 -0
  12. data/lib/mailerlite/api_response.rb +24 -0
  13. data/lib/mailerlite/automations/automation_emails.rb +57 -0
  14. data/lib/mailerlite/automations/automation_reports.rb +114 -0
  15. data/lib/mailerlite/automations/automation_steps.rb +105 -0
  16. data/lib/mailerlite/automations/automation_templates.rb +34 -0
  17. data/lib/mailerlite/automations/automation_triggers.rb +62 -0
  18. data/lib/mailerlite/automations/automations.rb +177 -8
  19. data/lib/mailerlite/brand_styles/brand_styles.rb +24 -0
  20. data/lib/mailerlite/campaigns/campaigns.rb +33 -1
  21. data/lib/mailerlite/client.rb +15 -5
  22. data/lib/mailerlite/ecommerce/ecommerce.rb +316 -0
  23. data/lib/mailerlite/emails/emails.rb +306 -0
  24. data/lib/mailerlite/errors.rb +88 -0
  25. data/lib/mailerlite/files/files.rb +45 -0
  26. data/lib/mailerlite/groups/groups.rb +16 -8
  27. data/lib/mailerlite/params.rb +58 -0
  28. data/lib/mailerlite/query_params.rb +33 -0
  29. data/lib/mailerlite/request.rb +24 -0
  30. data/lib/mailerlite/segments/segments.rb +10 -10
  31. data/lib/mailerlite/subscribers/subscribers.rb +63 -8
  32. data/lib/mailerlite/version.rb +1 -1
  33. data/lib/mailerlite.rb +14 -0
  34. data/mailerlite-ruby.gemspec +8 -8
  35. data/spec/api_response_rspec.rb +140 -0
  36. data/spec/automation_emails_rspec.rb +40 -0
  37. data/spec/automation_reports_rspec.rb +92 -0
  38. data/spec/automation_steps_rspec.rb +76 -0
  39. data/spec/automation_templates_rspec.rb +24 -0
  40. data/spec/automation_triggers_rspec.rb +48 -0
  41. data/spec/automations_rspec.rb +143 -0
  42. data/spec/brand_styles_rspec.rb +49 -0
  43. data/spec/campaigns_rspec.rb +42 -0
  44. data/spec/ecommerce_rspec.rb +141 -0
  45. data/spec/emails_rspec.rb +692 -0
  46. data/spec/files_rspec.rb +90 -0
  47. data/spec/fixtures/brand_styles.json +50 -0
  48. data/spec/fixtures/campaign_simple_editor.json +24 -0
  49. data/spec/fixtures/email.json +81 -0
  50. data/spec/fixtures/email_block.json +12 -0
  51. data/spec/fixtures/email_block_created.json +12 -0
  52. data/spec/fixtures/email_block_created_rejected.json +15 -0
  53. data/spec/fixtures/email_block_moved.json +12 -0
  54. data/spec/fixtures/email_block_updated.json +9 -0
  55. data/spec/fixtures/email_block_updated_rejected.json +13 -0
  56. data/spec/fixtures/email_block_validation_invalid.json +5 -0
  57. data/spec/fixtures/email_etag.json +1 -0
  58. data/spec/fixtures/email_html.json +1 -0
  59. data/spec/fixtures/email_settings.json +111 -0
  60. data/spec/fixtures/email_settings_validation.json +9 -0
  61. data/spec/fixtures/email_template.json +1 -0
  62. data/spec/fixtures/email_version.json +1 -0
  63. data/spec/fixtures/email_versions.json +26 -0
  64. data/spec/fixtures/files.json +44 -0
  65. data/spec/groups_rspec.rb +21 -0
  66. data/spec/params_rspec.rb +126 -0
  67. data/spec/request_rspec.rb +139 -0
  68. data/spec/segments_rspec.rb +39 -0
  69. data/spec/spec_helper.rb +1 -0
  70. data/spec/subscribers_rspec.rb +57 -0
  71. data/spec/support/json_fixture.rb +27 -0
  72. data/spec/support/request_stub.rb +25 -0
  73. metadata +69 -22
  74. /data/{renovate.json → .github/renovate.json} +0 -0
@@ -0,0 +1,692 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'webmock/rspec'
5
+ require 'json'
6
+ require_relative 'support/json_fixture'
7
+
8
+ RSpec.describe MailerLite::Emails do
9
+ include_context 'with the dummy API token'
10
+
11
+ let(:client) { MailerLite::Client.new }
12
+ let(:emails) { described_class.new(client: client) }
13
+
14
+ # A nil `key` matches only a request without the Idempotency-Key header.
15
+ def stub_api(method, path, body: '', response: {}, status: 200, query: nil, key: nil, headers: {})
16
+ pattern = { headers: { 'Authorization' => 'Bearer dummy_token' } }
17
+ pattern[:query] = query if query
18
+ stub_request(method, "#{MAILERLITE_API_URL}#{path}")
19
+ .with(**pattern) { |req| req.body == body && req.headers['Idempotency-Key'] == key }
20
+ .to_return(status: status, body: response.to_json, headers: { 'Content-Type' => 'application/json' }.merge(headers))
21
+ end
22
+
23
+ def decoded(response)
24
+ JSON.parse(response.body.to_s)
25
+ end
26
+
27
+ def invalid_ids
28
+ [
29
+ ['', MailerLite::EmptyIdError], [nil, MailerLite::EmptyIdError],
30
+ ['123/html', MailerLite::InvalidIdError], ['123%2Fhtml', MailerLite::InvalidIdError],
31
+ ["123\n", MailerLite::InvalidIdError], ['²', MailerLite::InvalidIdError], ['١٢', MailerLite::InvalidIdError],
32
+ [-5, MailerLite::InvalidIdError], [(+"12\xFF").force_encoding('UTF-8'), MailerLite::InvalidIdError],
33
+ [true, TypeError], [1.5, TypeError]
34
+ ]
35
+ end
36
+
37
+ def expect_invalid_ids(name)
38
+ invalid_ids.each do |value, error|
39
+ expect { yield value }.to raise_error(error, /`#{name}`/)
40
+ end
41
+ expect(a_request(:any, /.*/)).not_to have_been_made
42
+ end
43
+
44
+ describe 'constants' do
45
+ it 'has the values of the API' do
46
+ expect(described_class::VERSION_CONFLICT).to eq 'version_conflict'
47
+ block_types = described_class::BlockType
48
+ expect([block_types::BLOCKQUOTE, block_types::BULLETED_LIST, block_types::BUTTON, block_types::CODE,
49
+ block_types::DIVIDER, block_types::HEADING, block_types::IMAGE, block_types::NUMBERED_LIST,
50
+ block_types::PARAGRAPH, block_types::SOCIAL_ICON, block_types::TITLE, block_types::VIDEO])
51
+ .to eq %w[blockquote bulleted_list button code divider heading image numbered_list paragraph social_icon title video]
52
+ positions = described_class::BlockPosition
53
+ expect([positions::AT_START, positions::AT_END, positions::BEFORE, positions::AFTER]).to eq %w[start end before after]
54
+ modes = described_class::BrandMode
55
+ expect([modes::ACCOUNT, modes::TEMPLATE, modes::CUSTOM]).to eq %w[account template custom]
56
+ end
57
+ end
58
+
59
+ describe '#get' do
60
+ it 'gets the email' do
61
+ stub = stub_api(:get, '/emails/123', response: load_fixture('email'))
62
+
63
+ response = emails.get('123')
64
+
65
+ expect(stub).to have_been_requested
66
+ expect(response).to be_a(HTTP::Response)
67
+ expect(response.code).to eq 200
68
+ data = decoded(response)
69
+ expect(data['etag']).to eq '42'
70
+ expect(data['data']['id']).to eq '123'
71
+ expect(data['data']['type']).to eq 'builder_inline_email'
72
+ expect(data['data']['emailable_type']).to eq 'campaigns'
73
+ expect(data['data']['settings']['brand']['mode']).to eq 'account'
74
+ expect(data['data']['settings']['footer']['show_preferences']).to be(false)
75
+ expect(data['data']['settings']['footer']['social_links'][0]['link_id']).to eq 7
76
+ expect(data['data']['blocks'][0]['editor_content']['attrs']['textAlign']).to eq 'left'
77
+ expect(data['data']['blocks'][1]['children'][0]['id']).to eq '3'
78
+ end
79
+
80
+ it 'accepts an Integer ID' do
81
+ stub = stub_api(:get, '/emails/123', response: load_fixture('email'))
82
+
83
+ emails.get(123)
84
+
85
+ expect(stub).to have_been_requested
86
+ end
87
+
88
+ it 'returns the error response and does not raise' do
89
+ stub_api(:get, '/emails/123', status: 404, response: { message: 'Resource not found.' })
90
+
91
+ response = emails.get('123')
92
+
93
+ expect(response).to be_a(HTTP::Response)
94
+ expect(response.code).to eq 404
95
+ expect(response.api_error.api_message).to eq 'Resource not found.'
96
+ end
97
+
98
+ it 'checks the email ID and sends no request' do
99
+ expect_invalid_ids('email_id') { |id| emails.get(id) }
100
+ end
101
+ end
102
+
103
+ describe '#get_html' do
104
+ it 'gets the HTML of the email' do
105
+ stub = stub_api(:get, '/emails/123/html', response: load_fixture('email_html'))
106
+
107
+ response = emails.get_html('123')
108
+
109
+ expect(stub).to have_been_requested
110
+ data = decoded(response)
111
+ expect(data['data']['html']).to eq '<!doctype html><html><body><p>Hello</p></body></html>'
112
+ expect(data['etag']).to eq '42'
113
+ end
114
+
115
+ it 'returns a 503 with an HTML body and does not raise' do
116
+ stub_request(:get, "#{MAILERLITE_API_URL}/emails/123/html")
117
+ .to_return(status: 503, body: '<html><body>Service Unavailable</body></html>', headers: { 'Content-Type' => 'text/html' })
118
+
119
+ response = emails.get_html('123')
120
+
121
+ expect(response.code).to eq 503
122
+ expect(response.api_error.status).to eq 503
123
+ expect(response.api_error.api_message).to be_nil
124
+ end
125
+
126
+ it 'checks the email ID and sends no request' do
127
+ expect_invalid_ids('email_id') { |id| emails.get_html(id) }
128
+ end
129
+ end
130
+
131
+ describe '#get_block' do
132
+ it 'gets the block and its placement' do
133
+ stub = stub_api(:get, '/emails/123/blocks/57', response: load_fixture('email_block'))
134
+
135
+ response = emails.get_block(email_id: '123', block_id: 57)
136
+
137
+ expect(stub).to have_been_requested
138
+ data = decoded(response)
139
+ expect(data['etag']).to eq '43'
140
+ expect(data['data']['id']).to eq '57'
141
+ expect(data['data']['type']).to eq 'paragraph'
142
+ expect(data['data']['editor_content']['attrs']['textAlign']).to eq 'left'
143
+ expect(data['data']['placement']['after']).to eq '56'
144
+ expect(data['data']['placement']).to have_key('before')
145
+ expect(data['data']['placement']['before']).to be_nil
146
+ end
147
+
148
+ it 'checks the IDs and sends no request' do
149
+ expect_invalid_ids('email_id') { |id| emails.get_block(email_id: id, block_id: '57') }
150
+ expect_invalid_ids('block_id') { |id| emails.get_block(email_id: '123', block_id: id) }
151
+ end
152
+ end
153
+
154
+ describe '#create_block' do
155
+ let(:content) { { attrs: { textAlign: 'center' }, content: [{ type: 'text', text: 'Hi' }] } }
156
+
157
+ it 'inserts the block with the placement and the etag' do
158
+ body = '{"type":"paragraph","editor_content":{"attrs":{"textAlign":"center"},"content":[{"type":"text","text":"Hi"}]},' \
159
+ '"placement":{"position":"after","block_id":57},"etag":"42"}'
160
+ stub = stub_api(:post, '/emails/123/blocks', body: body, status: 201, response: load_fixture('email_block_created'))
161
+ placement = { position: described_class::BlockPosition::AFTER, block_id: 57 }
162
+
163
+ response = emails.create_block(email_id: '123', etag: '42', type: described_class::BlockType::PARAGRAPH,
164
+ editor_content: content, placement: placement)
165
+
166
+ expect(stub).to have_been_requested
167
+ expect(response.code).to eq 201
168
+ data = decoded(response)
169
+ expect(data['etag']).to eq '43'
170
+ expect(data['data']['id']).to eq '58'
171
+ expect(data['data']['editor_content']['attrs']['textAlign']).to eq 'center'
172
+ expect(data['data']['placement']['after']).to eq '57'
173
+ expect(placement).to eq(position: 'after', block_id: 57)
174
+ end
175
+
176
+ it 'returns the rejected keys when the API drops attributes' do
177
+ body = '{"type":"paragraph","editor_content":{"attrs":{"onclick":"x"},"content":[{"type":"text","text":"Hi"}]},"etag":"42"}'
178
+ stub = stub_api(:post, '/emails/123/blocks', body: body, status: 201, response: load_fixture('email_block_created_rejected'))
179
+
180
+ response = emails.create_block(email_id: '123', etag: '42', type: 'paragraph',
181
+ editor_content: { attrs: { onclick: 'x' }, content: [{ type: 'text', text: 'Hi' }] })
182
+
183
+ expect(stub).to have_been_requested
184
+ expect(response.code).to eq 201
185
+ expect(response.api_error).to be_nil
186
+ data = decoded(response)
187
+ expect(data['data']['editor_content']).not_to have_key('attrs')
188
+ expect(data['rejected']).to eq('message' => 'Some attributes were rejected.', 'keys' => ['editor_content.attrs.onclick'])
189
+ end
190
+
191
+ it 'sends no placement without a placement, and accepts empty content' do
192
+ stub = stub_api(:post, '/emails/123/blocks', body: '{"type":"divider","editor_content":{},"etag":"42"}',
193
+ status: 201, response: load_fixture('email_block_created'))
194
+
195
+ emails.create_block(email_id: 123, etag: '42', type: 'divider', editor_content: {})
196
+
197
+ expect(stub).to have_been_requested
198
+ end
199
+
200
+ it 'returns a version conflict with the code and the current etag' do
201
+ stub_api(:post, '/emails/123/blocks', body: '{"type":"divider","editor_content":{},"etag":"41"}', status: 409,
202
+ response: { message: 'The email was changed.', code: 'version_conflict', etag: '42' })
203
+
204
+ response = emails.create_block(email_id: '123', etag: '41', type: 'divider', editor_content: {})
205
+
206
+ expect(response.code).to eq 409
207
+ expect(response.api_error.status).to eq 409
208
+ expect(response.api_error.code).to eq described_class::VERSION_CONFLICT
209
+ expect(response.api_error.etag).to eq '42'
210
+ end
211
+
212
+ it 'returns the field errors of a 422' do
213
+ stub_api(:post, '/emails/123/blocks', body: '{"type":"title","editor_content":{},"etag":"42"}', status: 422,
214
+ response: { message: 'The selected type is invalid.', errors: { type: ['The selected type is invalid.'] } })
215
+
216
+ response = emails.create_block(email_id: '123', etag: '42', type: 'title', editor_content: {})
217
+
218
+ expect(response.api_error.errors).to eq('type' => ['The selected type is invalid.'])
219
+ end
220
+
221
+ it 'checks the email ID and sends no request' do
222
+ expect_invalid_ids('email_id') { |id| emails.create_block(email_id: id, etag: '42', type: 'divider', editor_content: {}) }
223
+ end
224
+
225
+ it 'sends an after placement without a block_id as given, and returns the API error' do
226
+ stub = stub_api(:post, '/emails/123/blocks', body: '{"type":"divider","editor_content":{},"placement":{"position":"after"},"etag":"42"}',
227
+ status: 422, response: { message: 'Invalid.', errors: { 'placement.block_id' => ['Required.'] } })
228
+
229
+ response = emails.create_block(email_id: '123', etag: '42', type: 'divider', editor_content: {}, placement: { position: 'after' })
230
+
231
+ expect(stub).to have_been_requested
232
+ expect(response.api_error.errors).to eq('placement.block_id' => ['Required.'])
233
+ end
234
+
235
+ it 'sends the values that the API checks, and returns the API error' do
236
+ calls = {
237
+ '{"type":"divider","editor_content":{},"etag":""}' => -> { emails.create_block(email_id: '123', etag: '', type: 'divider', editor_content: {}) },
238
+ '{"type":"paragraph","editor_content":{},"etag":"42"}' => -> { emails.create_block(email_id: '123', etag: '42', type: :paragraph, editor_content: {}) },
239
+ '{"type":"divider","editor_content":null,"etag":"42"}' => -> { emails.create_block(email_id: '123', etag: '42', type: 'divider', editor_content: nil) },
240
+ '{"type":"divider","editor_content":{},"placement":"end","etag":"42"}' =>
241
+ -> { emails.create_block(email_id: '123', etag: '42', type: 'divider', editor_content: {}, placement: 'end') }
242
+ }
243
+ calls.each do |body, call|
244
+ stub = stub_api(:post, '/emails/123/blocks', body: body, status: 422, response: { message: 'Invalid.' })
245
+ expect(call.call.api_error.status).to eq 422
246
+ expect(stub).to have_been_requested
247
+ end
248
+ end
249
+ end
250
+
251
+ describe '#update_block' do
252
+ it 'sends a PATCH request with the content and the etag' do
253
+ body = '{"editor_content":{"content":[{"type":"text","text":"Hello again"}]},"etag":"43"}'
254
+ stub = stub_api(:patch, '/emails/123/blocks/57', body: body, response: load_fixture('email_block_updated'))
255
+
256
+ response = emails.update_block(email_id: '123', block_id: '57', etag: '43',
257
+ editor_content: { content: [{ type: 'text', text: 'Hello again' }] })
258
+
259
+ expect(stub).to have_been_requested
260
+ data = decoded(response)
261
+ expect(data['etag']).to eq '44'
262
+ expect(data['data']['editor_content']['content'][0]['text']).to eq 'Hello again'
263
+ expect(data['data']['placement']).to eq('after' => '56', 'before' => '58')
264
+ end
265
+
266
+ it 'returns the rejected keys when the API drops attributes and mobile keys' do
267
+ body = '{"editor_content":{"content":[{"type":"text","text":"Hello again"}],"mobile":{"onclick":"x"}},"etag":"43"}'
268
+ stub = stub_api(:patch, '/emails/123/blocks/57', body: body, response: load_fixture('email_block_updated_rejected'))
269
+
270
+ response = emails.update_block(email_id: '123', block_id: '57', etag: '43',
271
+ editor_content: { content: [{ type: 'text', text: 'Hello again' }], mobile: { onclick: 'x' } })
272
+
273
+ expect(stub).to have_been_requested
274
+ expect(response.api_error).to be_nil
275
+ data = decoded(response)
276
+ expect(data['data']['editor_content']).not_to have_key('mobile')
277
+ expect(data['rejected']).to eq('message' => 'Some attributes were rejected.', 'keys' => ['editor_content.mobile.onclick'])
278
+ end
279
+
280
+ it 'checks the IDs and sends no request' do
281
+ expect_invalid_ids('email_id') { |id| emails.update_block(email_id: id, block_id: '57', etag: '43', editor_content: {}) }
282
+ expect_invalid_ids('block_id') { |id| emails.update_block(email_id: '123', block_id: id, etag: '43', editor_content: {}) }
283
+ end
284
+
285
+ it 'sends an empty etag and content that is not a Hash, and returns the API error' do
286
+ stub = stub_api(:patch, '/emails/123/blocks/57', body: '{"editor_content":[],"etag":""}', status: 422, response: { message: 'Invalid.' })
287
+
288
+ response = emails.update_block(email_id: '123', block_id: '57', etag: '', editor_content: [])
289
+
290
+ expect(stub).to have_been_requested
291
+ expect(response.api_error.status).to eq 422
292
+ end
293
+ end
294
+
295
+ describe '#delete_block' do
296
+ it 'sends a DELETE request with the etag in the body, and returns the new etag' do
297
+ stub = stub_api(:delete, '/emails/123/blocks/57', body: '{"etag":"44"}', response: load_fixture('email_etag'))
298
+
299
+ response = emails.delete_block(email_id: '123', block_id: '57', etag: '44')
300
+
301
+ expect(stub).to have_been_requested
302
+ expect(response).to be_a(HTTP::Response)
303
+ expect(decoded(response)).to eq('etag' => '45')
304
+ end
305
+
306
+ it 'checks the IDs and sends no request' do
307
+ expect_invalid_ids('email_id') { |id| emails.delete_block(email_id: id, block_id: '57', etag: '44') }
308
+ expect_invalid_ids('block_id') { |id| emails.delete_block(email_id: '123', block_id: id, etag: '44') }
309
+ end
310
+
311
+ it 'sends a nil etag as null' do
312
+ stub = stub_api(:delete, '/emails/123/blocks/57', body: '{"etag":null}', status: 422, response: { message: 'Invalid.' })
313
+
314
+ emails.delete_block(email_id: '123', block_id: '57', etag: nil)
315
+
316
+ expect(stub).to have_been_requested
317
+ end
318
+ end
319
+
320
+ describe '#move_block' do
321
+ it 'sends the placement and the etag' do
322
+ stub = stub_api(:post, '/emails/123/blocks/57/move', body: '{"placement":{"position":"before","block_id":1},"etag":"45"}',
323
+ response: load_fixture('email_block_moved'))
324
+
325
+ response = emails.move_block(email_id: '123', block_id: '57', etag: '45',
326
+ placement: { 'position' => described_class::BlockPosition::BEFORE, 'block_id' => 1 })
327
+
328
+ expect(stub).to have_been_requested
329
+ data = decoded(response)
330
+ expect(data['etag']).to eq '46'
331
+ expect(data['data']['placement']).to have_key('after')
332
+ expect(data['data']['placement']['after']).to be_nil
333
+ expect(data['data']['placement']['before']).to eq '1'
334
+ end
335
+
336
+ it 'sends a placement without a block_id' do
337
+ stub = stub_api(:post, '/emails/123/blocks/57/move', body: '{"placement":{"position":"start"},"etag":"45"}',
338
+ response: load_fixture('email_block_moved'))
339
+
340
+ emails.move_block(email_id: '123', block_id: '57', etag: '45', placement: { position: described_class::BlockPosition::AT_START })
341
+
342
+ expect(stub).to have_been_requested
343
+ end
344
+
345
+ it 'sends the block_id for the start and end positions' do
346
+ stub = stub_api(:post, '/emails/123/blocks/57/move', body: '{"placement":{"position":"start","block_id":"12"},"etag":"45"}',
347
+ response: load_fixture('email_block_moved'))
348
+
349
+ emails.move_block(email_id: '123', block_id: '57', etag: '45',
350
+ placement: { position: described_class::BlockPosition::AT_START, block_id: '12' })
351
+
352
+ expect(stub).to have_been_requested
353
+ end
354
+
355
+ it 'checks the IDs in the URL and sends no request' do
356
+ expect_invalid_ids('email_id') { |id| emails.move_block(email_id: id, block_id: '57', etag: '45', placement: { position: 'end' }) }
357
+ expect_invalid_ids('block_id') { |id| emails.move_block(email_id: '123', block_id: id, etag: '45', placement: { position: 'end' }) }
358
+ end
359
+
360
+ it 'sends the placement and the etag as given, and returns the API error' do
361
+ calls = {
362
+ '{"placement":{"position":"after","block_id":"1/2"},"etag":"45"}' =>
363
+ -> { emails.move_block(email_id: '123', block_id: '57', etag: '45', placement: { position: 'after', block_id: '1/2' }) },
364
+ '{"placement":{"position":"before"},"etag":"45"}' =>
365
+ -> { emails.move_block(email_id: '123', block_id: '57', etag: '45', placement: { 'position' => 'before' }) },
366
+ '{"placement":null,"etag":"45"}' => -> { emails.move_block(email_id: '123', block_id: '57', etag: '45', placement: nil) },
367
+ '{"placement":{"position":"end"},"etag":""}' => -> { emails.move_block(email_id: '123', block_id: '57', etag: '', placement: { position: 'end' }) }
368
+ }
369
+ calls.each do |body, call|
370
+ stub = stub_api(:post, '/emails/123/blocks/57/move', body: body, status: 422, response: { message: 'Invalid.' })
371
+ expect(call.call.api_error.status).to eq 422
372
+ expect(stub).to have_been_requested
373
+ end
374
+ end
375
+ end
376
+
377
+ describe '#validate_block' do
378
+ it 'sends the block without an etag and returns the errors of the content' do
379
+ body = '{"type":"paragraph","editor_content":{"attrs":{"textAlign":"sideways"}},"placement":{"position":"end"}}'
380
+ result = { valid: false, errors: { 'editor_content.attrs.textAlign' => ['The value is not valid.'] },
381
+ schema: { version: '2.73.2', hash: '0f3a9c' } }
382
+ stub = stub_api(:post, '/emails/blocks/validate', body: body, response: result)
383
+
384
+ response = emails.validate_block(type: described_class::BlockType::PARAGRAPH, editor_content: { attrs: { textAlign: 'sideways' } },
385
+ placement: { position: described_class::BlockPosition::AT_END })
386
+
387
+ expect(stub).to have_been_requested
388
+ expect(response.api_error).to be_nil
389
+ data = decoded(response)
390
+ expect(data['valid']).to be(false)
391
+ expect(data['errors']).to eq('editor_content.attrs.textAlign' => ['The value is not valid.'])
392
+ expect(data['schema']['version']).to eq '2.73.2'
393
+ end
394
+
395
+ it 'returns an invalid result as a 200 response' do
396
+ stub = stub_api(:post, '/emails/blocks/validate', body: '{"type":"unknown","editor_content":{}}',
397
+ response: load_fixture('email_block_validation_invalid'))
398
+
399
+ response = emails.validate_block(type: 'unknown', editor_content: {})
400
+
401
+ expect(stub).to have_been_requested
402
+ expect(response.api_error).to be_nil
403
+ data = decoded(response)
404
+ expect(data['valid']).to be(false)
405
+ expect(data['errors']).to eq('type' => ['The selected type is invalid.'])
406
+ end
407
+
408
+ it 'sends a block that is not valid, and returns the result of the API' do
409
+ calls = {
410
+ '{"type":"divider","editor_content":{},"placement":{"position":"after"}}' =>
411
+ -> { emails.validate_block(type: 'divider', editor_content: {}, placement: { position: 'after' }) },
412
+ '{"type":"divider","editor_content":{},"placement":{"position":"before","block_id":"x"}}' =>
413
+ -> { emails.validate_block(type: 'divider', editor_content: {}, placement: { position: 'before', block_id: 'x' }) },
414
+ '{"type":null,"editor_content":{}}' => -> { emails.validate_block(type: nil, editor_content: {}) },
415
+ '{"type":"divider","editor_content":"x"}' => -> { emails.validate_block(type: 'divider', editor_content: 'x') }
416
+ }
417
+ calls.each do |body, call|
418
+ stub = stub_api(:post, '/emails/blocks/validate', body: body, response: load_fixture('email_block_validation_invalid'))
419
+ expect(decoded(call.call)['valid']).to be(false)
420
+ expect(stub).to have_been_requested
421
+ end
422
+ end
423
+ end
424
+
425
+ describe '#update_settings' do
426
+ it 'sends a PATCH request with only the keys of the caller and the etag' do
427
+ body = '{"brand":{"mode":"custom","background_color":"#f4f4f4"},"footer":{"company_name":""},"etag":"46"}'
428
+ stub = stub_api(:patch, '/emails/123/settings', body: body, response: load_fixture('email_settings'))
429
+ settings = { brand: { mode: described_class::BrandMode::CUSTOM, background_color: '#f4f4f4' }, footer: { company_name: '' } }
430
+
431
+ response = emails.update_settings(email_id: '123', etag: '46', settings: settings)
432
+
433
+ expect(stub).to have_been_requested
434
+ expect(settings).to eq(brand: { mode: 'custom', background_color: '#f4f4f4' }, footer: { company_name: '' })
435
+ data = decoded(response)
436
+ expect(data['etag']).to eq '47'
437
+ expect(data['data']['brand']['mode']).to eq 'custom'
438
+ expect(data['data']['brand']['background_color']).to eq '#f4f4f4'
439
+ expect(data['data']['typography']['paragraph']['p1']['size']).to eq '16'
440
+ expect(data['rejected']['keys']).to eq ['footer.social_links[0].url']
441
+ end
442
+
443
+ it 'sends a nil value as null' do
444
+ stub = stub_api(:patch, '/emails/123/settings', body: '{"layout":{"alignment":null},"etag":"46"}', response: load_fixture('email_settings'))
445
+
446
+ emails.update_settings(email_id: '123', etag: '46', settings: { 'layout' => { 'alignment' => nil } })
447
+
448
+ expect(stub).to have_been_requested
449
+ end
450
+
451
+ it 'returns a version conflict' do
452
+ stub_api(:patch, '/emails/123/settings', body: '{"layout":{"alignment":"left"},"etag":"40"}', status: 409,
453
+ response: { message: 'The email was changed.', code: 'version_conflict', etag: '46' })
454
+
455
+ response = emails.update_settings(email_id: '123', etag: '40', settings: { layout: { alignment: 'left' } })
456
+
457
+ expect(response.api_error.code).to eq described_class::VERSION_CONFLICT
458
+ expect(response.api_error.etag).to eq '46'
459
+ end
460
+
461
+ it 'checks the arguments and sends no request' do
462
+ expect_invalid_ids('email_id') { |id| emails.update_settings(email_id: id, etag: '46', settings: {}) }
463
+ expect { emails.update_settings(email_id: '123', etag: '46', settings: nil) }.to raise_error(TypeError, /`settings`/)
464
+ expect { emails.update_settings(email_id: '123', etag: '46', settings: { etag: '1' }) }.to raise_error(ArgumentError, /etag/)
465
+ expect { emails.update_settings(email_id: '123', etag: '46', settings: { 'etag' => '1' }) }.to raise_error(ArgumentError, /etag/)
466
+ expect(a_request(:any, /.*/)).not_to have_been_made
467
+ end
468
+
469
+ it 'sends an empty etag, and returns the API error' do
470
+ stub = stub_api(:patch, '/emails/123/settings', body: '{"etag":""}', status: 422, response: { message: 'Invalid.' })
471
+
472
+ response = emails.update_settings(email_id: '123', etag: '', settings: {})
473
+
474
+ expect(stub).to have_been_requested
475
+ expect(response.api_error.status).to eq 422
476
+ end
477
+ end
478
+
479
+ describe '#validate_settings' do
480
+ it 'sends the settings without an etag' do
481
+ stub = stub_api(:post, '/emails/123/settings/validate', body: '{"layout":{"width":"700"}}', response: load_fixture('email_settings_validation'))
482
+
483
+ response = emails.validate_settings(email_id: '123', settings: { layout: { width: '700' } })
484
+
485
+ expect(stub).to have_been_requested
486
+ expect(response.api_error).to be_nil
487
+ data = decoded(response)
488
+ expect(data['valid']).to be(false)
489
+ expect(data['errors']).to eq('layout.width' => ['The layout.width field cannot be changed.'])
490
+ expect(data['rejected']['message']).to eq 'Some attributes were rejected.'
491
+ end
492
+
493
+ it 'checks the email ID and sends no request' do
494
+ expect_invalid_ids('email_id') { |id| emails.validate_settings(email_id: id, settings: {}) }
495
+ end
496
+
497
+ it 'sends the settings as given, also with an etag key' do
498
+ calls = {
499
+ '{"etag":"1"}' => -> { emails.validate_settings(email_id: '123', settings: { etag: '1' }) },
500
+ '"x"' => -> { emails.validate_settings(email_id: '123', settings: 'x') }
501
+ }
502
+ calls.each do |body, call|
503
+ stub = stub_api(:post, '/emails/123/settings/validate', body: body, response: load_fixture('email_settings_validation'))
504
+ expect(decoded(call.call)['valid']).to be(false)
505
+ expect(stub).to have_been_requested
506
+ end
507
+ end
508
+ end
509
+
510
+ describe '#create_template' do
511
+ it 'sends the name and returns the template' do
512
+ stub = stub_api(:post, '/emails/123/templates', body: '{"name":"Newsletter"}', status: 201, response: load_fixture('email_template'))
513
+
514
+ response = emails.create_template(email_id: '123', name: 'Newsletter')
515
+
516
+ expect(stub).to have_been_requested
517
+ expect(response.code).to eq 201
518
+ data = decoded(response)
519
+ expect(data['data']).to eq('id' => '789', 'name' => 'Newsletter', 'type' => 'email', 'created_at' => '2026-10-28 12:00:00')
520
+ expect(data).not_to have_key('etag')
521
+ end
522
+
523
+ it 'checks the email ID and sends no request' do
524
+ expect_invalid_ids('email_id') { |id| emails.create_template(email_id: id, name: 'Newsletter') }
525
+ end
526
+
527
+ it 'sends a name that is not a String, and returns the API error' do
528
+ { '{"name":null}' => nil, '{"name":1}' => 1 }.each do |body, name|
529
+ stub = stub_api(:post, '/emails/123/templates', body: body, status: 422, response: { message: 'Invalid.' })
530
+ expect(emails.create_template(email_id: '123', name: name).api_error.status).to eq 422
531
+ expect(stub).to have_been_requested
532
+ end
533
+ end
534
+ end
535
+
536
+ describe '#list_versions' do
537
+ it 'sends no query without options' do
538
+ stub = stub_api(:get, '/emails/123/versions', response: load_fixture('email_versions'))
539
+
540
+ response = emails.list_versions('123')
541
+
542
+ expect(stub).to have_been_requested
543
+ data = decoded(response)
544
+ expect(data['data'].map { |version| version['id'] }).to eq %w[902 901]
545
+ expect(data['data'][0]['current']).to be(true)
546
+ expect(data['data'][1]['current']).to be(false)
547
+ expect(data['links']).to have_key('prev')
548
+ expect(data['links']['prev']).to be_nil
549
+ expect(data['meta']['total']).to eq 2
550
+ end
551
+
552
+ it 'sends the page and the limit' do
553
+ stub = stub_api(:get, '/emails/123/versions', query: { 'page' => '2', 'limit' => '10' }, response: load_fixture('email_versions'))
554
+
555
+ emails.list_versions(123, page: 2, limit: 10)
556
+
557
+ expect(stub).to have_been_requested
558
+ end
559
+
560
+ it 'checks the email ID and sends no request' do
561
+ expect_invalid_ids('email_id') { |id| emails.list_versions(id) }
562
+ end
563
+ end
564
+
565
+ describe '#create_version' do
566
+ it 'sends no body and returns 201 for a new version' do
567
+ stub = stub_api(:post, '/emails/123/versions', body: '', status: 201, response: load_fixture('email_version'))
568
+
569
+ response = emails.create_version('123')
570
+
571
+ expect(stub).to have_been_requested
572
+ expect(response.code).to eq 201
573
+ expect(decoded(response)['data']).to eq('id' => '903', 'created_at' => '2026-10-28 12:10:00', 'current' => true)
574
+ end
575
+
576
+ it 'returns 200 for the last version when nothing changed' do
577
+ stub_api(:post, '/emails/123/versions', body: '', status: 200, response: load_fixture('email_version'))
578
+
579
+ response = emails.create_version(123)
580
+
581
+ expect(response.code).to eq 200
582
+ expect(response.api_error).to be_nil
583
+ expect(decoded(response)['data']['id']).to eq '903'
584
+ end
585
+
586
+ it 'checks the email ID and sends no request' do
587
+ expect_invalid_ids('email_id') { |id| emails.create_version(id) }
588
+ end
589
+ end
590
+
591
+ describe '#restore_version' do
592
+ it 'sends the etag and returns the email' do
593
+ stub = stub_api(:post, '/emails/123/versions/901/restore', body: '{"etag":"47"}', response: load_fixture('email'))
594
+
595
+ response = emails.restore_version(email_id: '123', version_id: 901, etag: '47')
596
+
597
+ expect(stub).to have_been_requested
598
+ data = decoded(response)
599
+ expect(data['etag']).to eq '42'
600
+ expect(data['data']['id']).to eq '123'
601
+ expect(data['data']['blocks'].size).to eq 2
602
+ end
603
+
604
+ it 'checks the IDs and sends no request' do
605
+ expect_invalid_ids('email_id') { |id| emails.restore_version(email_id: id, version_id: '901', etag: '47') }
606
+ expect_invalid_ids('version_id') { |id| emails.restore_version(email_id: '123', version_id: id, etag: '47') }
607
+ end
608
+
609
+ it 'sends an empty etag, and returns the API error' do
610
+ stub = stub_api(:post, '/emails/123/versions/901/restore', body: '{"etag":""}', status: 422, response: { message: 'Invalid.' })
611
+
612
+ response = emails.restore_version(email_id: '123', version_id: '901', etag: '')
613
+
614
+ expect(stub).to have_been_requested
615
+ expect(response.api_error.status).to eq 422
616
+ end
617
+ end
618
+
619
+ describe 'idempotency keys' do
620
+ # Each item: the method, the path, the raw body, and a call with the key.
621
+ def writes
622
+ [
623
+ [:post, '/emails/123/blocks', '{"type":"divider","editor_content":{},"etag":"42"}',
624
+ ->(key) { emails.create_block(email_id: '123', etag: '42', type: 'divider', editor_content: {}, idempotency_key: key) }],
625
+ [:patch, '/emails/123/blocks/57', '{"editor_content":{},"etag":"42"}',
626
+ ->(key) { emails.update_block(email_id: '123', block_id: '57', etag: '42', editor_content: {}, idempotency_key: key) }],
627
+ [:delete, '/emails/123/blocks/57', '{"etag":"42"}',
628
+ ->(key) { emails.delete_block(email_id: '123', block_id: '57', etag: '42', idempotency_key: key) }],
629
+ [:post, '/emails/123/blocks/57/move', '{"placement":{"position":"end"},"etag":"42"}',
630
+ ->(key) { emails.move_block(email_id: '123', block_id: '57', etag: '42', placement: { position: 'end' }, idempotency_key: key) }],
631
+ [:patch, '/emails/123/settings', '{"etag":"42"}',
632
+ ->(key) { emails.update_settings(email_id: '123', etag: '42', settings: {}, idempotency_key: key) }],
633
+ [:post, '/emails/123/templates', '{"name":"Newsletter"}',
634
+ ->(key) { emails.create_template(email_id: '123', name: 'Newsletter', idempotency_key: key) }],
635
+ [:post, '/emails/123/versions', '',
636
+ ->(key) { emails.create_version('123', idempotency_key: key) }],
637
+ [:post, '/emails/123/versions/901/restore', '{"etag":"42"}',
638
+ ->(key) { emails.restore_version(email_id: '123', version_id: '901', etag: '42', idempotency_key: key) }]
639
+ ]
640
+ end
641
+
642
+ it 'sends the Idempotency-Key header on each write' do
643
+ writes.each do |method, path, body, call|
644
+ stub = stub_api(method, path, body: body, key: 'key-1', response: { etag: '43' })
645
+ call.call('key-1')
646
+ expect(stub).to have_been_requested
647
+ end
648
+ end
649
+
650
+ it 'sends no Idempotency-Key header without a key' do
651
+ writes.each do |method, path, body, call|
652
+ stub = stub_api(method, path, body: body, response: { etag: '43' })
653
+ call.call(nil)
654
+ expect(stub).to have_been_requested
655
+ end
656
+ end
657
+
658
+ it 'accepts a key with 255 characters' do
659
+ stub = stub_api(:post, '/emails/123/versions', key: 'k' * 255, status: 201, response: load_fixture('email_version'))
660
+
661
+ emails.create_version('123', idempotency_key: 'k' * 255)
662
+
663
+ expect(stub).to have_been_requested
664
+ end
665
+
666
+ it 'sends the key as given, and returns the API error' do
667
+ writes.each do |method, path, body, call|
668
+ ['', ' k', 'k ', 'k' * 256].each do |key|
669
+ stub = stub_api(method, path, body: body, key: key, status: 400, response: { message: 'The idempotency key is not valid.' })
670
+ expect(call.call(key).api_error.status).to eq 400
671
+ expect(stub).to have_been_requested
672
+ end
673
+ end
674
+ end
675
+
676
+ it 'shows a replay of a stored response' do
677
+ stub_api(:post, '/emails/123/blocks', body: '{"type":"divider","editor_content":{},"etag":"42"}', key: 'key-1', status: 201,
678
+ response: load_fixture('email_block_created'), headers: { 'Idempotent-Replayed' => 'True' })
679
+
680
+ response = emails.create_block(email_id: '123', etag: '42', type: 'divider', editor_content: {}, idempotency_key: 'key-1')
681
+
682
+ expect(response.code).to eq 201
683
+ expect(response.idempotent_replay?).to be(true)
684
+ expect(decoded(response)['data']['id']).to eq '58'
685
+ end
686
+
687
+ it 'does not accept a key on the validate methods' do
688
+ expect { emails.validate_block(type: 'divider', editor_content: {}, idempotency_key: 'k') }.to raise_error(ArgumentError, /idempotency_key/)
689
+ expect { emails.validate_settings(email_id: '123', settings: {}, idempotency_key: 'k') }.to raise_error(ArgumentError, /idempotency_key/)
690
+ end
691
+ end
692
+ end