resteze 0.3.1 → 1.0.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/CHANGELOG.md +38 -0
  4. data/CLAUDE.md +83 -0
  5. data/README.md +75 -3
  6. data/docs/ACTIONS.md +329 -0
  7. data/docs/ADVANCED_USAGE.md +744 -0
  8. data/docs/API.md +404 -0
  9. data/docs/CONFIGURATION.md +962 -0
  10. data/docs/ERROR_HANDLING.md +676 -0
  11. data/docs/TESTING.md +789 -0
  12. data/lib/resteze/action.rb +99 -0
  13. data/lib/resteze/actions/validation.rb +56 -0
  14. data/lib/resteze/actions.rb +117 -0
  15. data/lib/resteze/api_module.rb +11 -5
  16. data/lib/resteze/api_resource.rb +28 -3
  17. data/lib/resteze/authenticating.rb +28 -0
  18. data/lib/resteze/authenticator/api_key.rb +45 -0
  19. data/lib/resteze/authenticator/basic.rb +40 -0
  20. data/lib/resteze/authenticator/bearer.rb +37 -0
  21. data/lib/resteze/authenticator/mtls.rb +73 -0
  22. data/lib/resteze/authenticator/strategy.rb +133 -0
  23. data/lib/resteze/authenticator.rb +187 -0
  24. data/lib/resteze/client.rb +60 -11
  25. data/lib/resteze/errors.rb +125 -4
  26. data/lib/resteze/instrumentation.rb +49 -0
  27. data/lib/resteze/list.rb +2 -2
  28. data/lib/resteze/middleware/raise_error.rb +38 -6
  29. data/lib/resteze/rate_limit.rb +44 -0
  30. data/lib/resteze/rate_limiter/fixed_window.rb +107 -0
  31. data/lib/resteze/rate_limiter/leaky_bucket.rb +40 -0
  32. data/lib/resteze/rate_limiter/sliding_window.rb +37 -0
  33. data/lib/resteze/rate_limiter/strategy.rb +58 -0
  34. data/lib/resteze/rate_limiter/token_bucket.rb +39 -0
  35. data/lib/resteze/rate_limiter.rb +229 -0
  36. data/lib/resteze/rate_limiting.rb +30 -0
  37. data/lib/resteze/response.rb +37 -1
  38. data/lib/resteze/result.rb +18 -0
  39. data/lib/resteze/save.rb +25 -3
  40. data/lib/resteze/synchronized_state.rb +29 -0
  41. data/lib/resteze/testing/minitest/configuration.rb +3 -1
  42. data/lib/resteze/testing/minitest/object.rb +30 -0
  43. data/lib/resteze/testing/rspec/configure.rb +3 -1
  44. data/lib/resteze/testing/rspec/object.rb +32 -2
  45. data/lib/resteze/version.rb +1 -1
  46. data/lib/resteze.rb +102 -10
  47. metadata +30 -2
data/docs/TESTING.md ADDED
@@ -0,0 +1,789 @@
1
+ # Testing Guide
2
+
3
+ ## Table of Contents
4
+ - [Testing Setup](#testing-setup)
5
+ - [Unit Testing](#unit-testing)
6
+ - [Integration Testing](#integration-testing)
7
+ - [Mocking and Stubbing](#mocking-and-stubbing)
8
+ - [Testing with VCR](#testing-with-vcr)
9
+ - [Testing Best Practices](#testing-best-practices)
10
+ - [Continuous Integration](#continuous-integration)
11
+
12
+ ## Testing Setup
13
+
14
+ ### Minitest Setup
15
+
16
+ ```ruby
17
+ # test/test_helper.rb
18
+ require 'minitest/autorun'
19
+ require 'minitest/spec'
20
+ require 'webmock/minitest'
21
+ require 'vcr'
22
+ require 'resteze'
23
+ require 'resteze/testing/minitest'
24
+
25
+ # Configure VCR
26
+ VCR.configure do |config|
27
+ config.cassette_library_dir = 'test/fixtures/vcr_cassettes'
28
+ config.hook_into :webmock
29
+ config.filter_sensitive_data('<API_KEY>') { ENV['API_KEY'] }
30
+ config.filter_sensitive_data('<API_SECRET>') { ENV['API_SECRET'] }
31
+ end
32
+
33
+ # Test API module
34
+ module TestApi
35
+ include Resteze
36
+
37
+ configure do |config|
38
+ config.api_base = 'http://test.example.com/'
39
+ config.api_key = 'test-key'
40
+ config.logger = Logger.new(nil) # Disable logging in tests
41
+ end
42
+
43
+ class User < ApiResource
44
+ property :id
45
+ property :email
46
+ property :name
47
+ end
48
+ end
49
+
50
+ # Base test class
51
+ class ApiTestCase < Minitest::Test
52
+ include Resteze::Testing::Minitest
53
+
54
+ def setup
55
+ WebMock.disable_net_connect!(allow_localhost: true)
56
+ end
57
+
58
+ def teardown
59
+ WebMock.reset!
60
+ end
61
+
62
+ def stub_api_request(method, path)
63
+ stub_request(method, "#{TestApi.api_base}#{path.sub(/^\//, '')}")
64
+ end
65
+ end
66
+ ```
67
+
68
+ ### RSpec Setup
69
+
70
+ ```ruby
71
+ # spec/spec_helper.rb
72
+ require 'rspec'
73
+ require 'webmock/rspec'
74
+ require 'vcr'
75
+ require 'resteze'
76
+ require 'resteze/testing/rspec'
77
+
78
+ RSpec.configure do |config|
79
+ config.include Resteze::Testing::RSpec
80
+
81
+ config.before(:each) do
82
+ WebMock.disable_net_connect!(allow_localhost: true)
83
+ end
84
+
85
+ config.after(:each) do
86
+ WebMock.reset!
87
+ end
88
+ end
89
+
90
+ # Configure VCR
91
+ VCR.configure do |config|
92
+ config.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
93
+ config.hook_into :webmock
94
+ config.configure_rspec_metadata!
95
+ config.filter_sensitive_data('<API_KEY>') { ENV['API_KEY'] }
96
+ end
97
+
98
+ # Shared context for API testing
99
+ RSpec.shared_context 'api testing' do
100
+ let(:test_api) do
101
+ Module.new do
102
+ include Resteze
103
+
104
+ configure do |config|
105
+ config.api_base = 'http://test.example.com/'
106
+ config.api_key = 'test-key'
107
+ config.logger = Logger.new(nil)
108
+ end
109
+ end
110
+ end
111
+
112
+ def stub_api_request(method, path)
113
+ stub_request(method, "http://test.example.com#{path}")
114
+ end
115
+ end
116
+ ```
117
+
118
+ ## Unit Testing
119
+
120
+ ### Testing Resources
121
+
122
+ ```ruby
123
+ # test/api/user_test.rb
124
+ class UserTest < ApiTestCase
125
+ def test_retrieve_user
126
+ stub_api_request(:get, '/users/123')
127
+ .to_return(
128
+ status: 200,
129
+ body: { id: 123, email: 'user@example.com', name: 'John Doe' }.to_json,
130
+ headers: { 'Content-Type' => 'application/json' }
131
+ )
132
+
133
+ user = TestApi::User.retrieve('123')
134
+
135
+ assert_equal 123, user.id
136
+ assert_equal 'user@example.com', user.email
137
+ assert_equal 'John Doe', user.name
138
+ end
139
+
140
+ def test_user_not_found
141
+ stub_api_request(:get, '/users/999')
142
+ .to_return(status: 404, body: { error: 'Not found' }.to_json)
143
+
144
+ assert_raises(TestApi::ResourceNotFound) do
145
+ TestApi::User.retrieve('999')
146
+ end
147
+ end
148
+
149
+ def test_refresh_user
150
+ user = TestApi::User.new('123')
151
+
152
+ stub_api_request(:get, '/users/123')
153
+ .to_return(
154
+ status: 200,
155
+ body: { id: 123, email: 'updated@example.com' }.to_json
156
+ )
157
+
158
+ user.refresh
159
+ assert_equal 'updated@example.com', user.email
160
+ end
161
+ end
162
+ ```
163
+
164
+ ### Testing Actions
165
+
166
+ `has_action` (from `Resteze::Testing::Minitest::Object` or
167
+ `Resteze::Testing::RSpec::Object`) asserts an action is declared and checks any
168
+ options you pass. `path:` is the full routed path, with the id `"ID"` for member
169
+ actions.
170
+
171
+ ```ruby
172
+ class CampaignTest < Minitest::Spec
173
+ include Resteze::Testing::Minitest::Object
174
+
175
+ subject { TestApi::Campaign }
176
+
177
+ it "routes its actions" do
178
+ has_action :data_series, method: :get, on: :collection, path: "/campaigns/data_series"
179
+ has_action :duplicate, method: :post, on: :member, id: :param, path: "/campaigns/duplicate"
180
+ has_action :retrieve, path: "/campaigns/details"
181
+ end
182
+ end
183
+ ```
184
+
185
+ ### Testing Lists
186
+
187
+ ```ruby
188
+ class UserListTest < ApiTestCase
189
+ def setup
190
+ super
191
+ @list_response = {
192
+ data: [
193
+ { id: 1, email: 'user1@example.com' },
194
+ { id: 2, email: 'user2@example.com' }
195
+ ],
196
+ total: 2,
197
+ page: 1,
198
+ per_page: 10
199
+ }
200
+ end
201
+
202
+ def test_list_users
203
+ stub_api_request(:get, '/users')
204
+ .with(query: { page: 1, per_page: 10 })
205
+ .to_return(
206
+ status: 200,
207
+ body: @list_response.to_json
208
+ )
209
+
210
+ users = TestApi::User.list(page: 1, per_page: 10)
211
+
212
+ assert_equal 2, users.count
213
+ assert_equal 'user1@example.com', users.first.email
214
+ assert_equal 2, users.metadata[:total]
215
+ end
216
+
217
+ def test_empty_list
218
+ stub_api_request(:get, '/users')
219
+ .to_return(
220
+ status: 200,
221
+ body: { data: [], total: 0 }.to_json
222
+ )
223
+
224
+ users = TestApi::User.list
225
+ assert_empty users
226
+ assert_equal 0, users.metadata[:total]
227
+ end
228
+ end
229
+ ```
230
+
231
+ ### Testing Save Operations
232
+
233
+ ```ruby
234
+ class UserSaveTest < ApiTestCase
235
+ def test_create_user
236
+ stub_api_request(:post, '/users')
237
+ .with(body: { email: 'new@example.com', name: 'New User' })
238
+ .to_return(
239
+ status: 201,
240
+ body: { id: 123, email: 'new@example.com', name: 'New User' }.to_json
241
+ )
242
+
243
+ user = TestApi::User.new
244
+ user.email = 'new@example.com'
245
+ user.name = 'New User'
246
+
247
+ refute user.persisted?
248
+ user.save
249
+
250
+ assert user.persisted?
251
+ assert_equal 123, user.id
252
+ end
253
+
254
+ def test_update_user
255
+ user = TestApi::User.new('123', values: { email: 'old@example.com' })
256
+
257
+ stub_api_request(:patch, '/users/123')
258
+ .with(body: { email: 'updated@example.com' })
259
+ .to_return(
260
+ status: 200,
261
+ body: { id: 123, email: 'updated@example.com' }.to_json
262
+ )
263
+
264
+ user.email = 'updated@example.com'
265
+ user.save
266
+
267
+ assert_equal 'updated@example.com', user.email
268
+ end
269
+
270
+ def test_validation_error
271
+ stub_api_request(:post, '/users')
272
+ .with(body: { email: 'invalid' })
273
+ .to_return(
274
+ status: 422,
275
+ body: {
276
+ error: 'Validation failed',
277
+ errors: { email: ['is invalid'] }
278
+ }.to_json
279
+ )
280
+
281
+ user = TestApi::User.new
282
+ user.email = 'invalid'
283
+
284
+ assert_raises(TestApi::UnprocessableEntityError) do
285
+ user.save
286
+ end
287
+ end
288
+ end
289
+ ```
290
+
291
+ ## Integration Testing
292
+
293
+ ### Full Request Cycle Testing
294
+
295
+ ```ruby
296
+ class IntegrationTest < ApiTestCase
297
+ def test_complete_user_lifecycle
298
+ # Create user
299
+ stub_api_request(:post, '/users')
300
+ .to_return(
301
+ status: 201,
302
+ body: { id: 123, email: 'test@example.com' }.to_json
303
+ )
304
+
305
+ user = TestApi::User.new
306
+ user.email = 'test@example.com'
307
+ user.save
308
+
309
+ assert_equal 123, user.id
310
+
311
+ # Update user
312
+ stub_api_request(:patch, '/users/123')
313
+ .to_return(
314
+ status: 200,
315
+ body: { id: 123, email: 'updated@example.com' }.to_json
316
+ )
317
+
318
+ user.email = 'updated@example.com'
319
+ user.save
320
+
321
+ # Delete user
322
+ stub_api_request(:delete, '/users/123')
323
+ .to_return(status: 204)
324
+
325
+ response = TestApi::User.request(:delete, user.resource_path)
326
+ assert_equal 204, response.status
327
+ end
328
+
329
+ def test_pagination_flow
330
+ # First page
331
+ stub_api_request(:get, '/users')
332
+ .with(query: { page: 1, per_page: 2 })
333
+ .to_return(
334
+ body: {
335
+ data: [{ id: 1 }, { id: 2 }],
336
+ has_more: true,
337
+ page: 1
338
+ }.to_json
339
+ )
340
+
341
+ # Second page
342
+ stub_api_request(:get, '/users')
343
+ .with(query: { page: 2, per_page: 2 })
344
+ .to_return(
345
+ body: {
346
+ data: [{ id: 3 }],
347
+ has_more: false,
348
+ page: 2
349
+ }.to_json
350
+ )
351
+
352
+ all_users = []
353
+ page = 1
354
+ loop do
355
+ users = TestApi::User.list(page: page, per_page: 2)
356
+ all_users.concat(users)
357
+ break unless users.metadata[:has_more]
358
+ page += 1
359
+ end
360
+
361
+ assert_equal 3, all_users.count
362
+ assert_equal [1, 2, 3], all_users.map(&:id)
363
+ end
364
+ end
365
+ ```
366
+
367
+ ### Testing Error Recovery
368
+
369
+ ```ruby
370
+ class ErrorRecoveryTest < ApiTestCase
371
+ def test_retry_on_server_error
372
+ call_count = 0
373
+
374
+ stub_api_request(:get, '/users/123')
375
+ .to_return do |request|
376
+ call_count += 1
377
+ if call_count < 3
378
+ { status: 503 }
379
+ else
380
+ {
381
+ status: 200,
382
+ body: { id: 123 }.to_json
383
+ }
384
+ end
385
+ end
386
+
387
+ user = RetryableRequest.execute { TestApi::User.retrieve('123') }
388
+ assert_equal 123, user.id
389
+ assert_equal 3, call_count
390
+ end
391
+
392
+ def test_circuit_breaker
393
+ # Multiple failures trigger circuit breaker
394
+ 5.times do
395
+ stub_api_request(:get, '/users/123').to_return(status: 503)
396
+
397
+ assert_raises(TestApi::ApiError) do
398
+ TestApi::User.retrieve('123')
399
+ end
400
+ end
401
+
402
+ # Circuit is now open
403
+ assert_raises(TestApi::ApiConnectionError) do
404
+ TestApi::User.retrieve('123')
405
+ end
406
+ end
407
+ end
408
+ ```
409
+
410
+ ## Mocking and Stubbing
411
+
412
+ ### Stubbing HTTP Requests
413
+
414
+ ```ruby
415
+ class StubExamplesTest < ApiTestCase
416
+ def test_stub_with_headers
417
+ stub_api_request(:get, '/users/123')
418
+ .with(headers: { 'Authorization' => 'Bearer test-key' })
419
+ .to_return(
420
+ status: 200,
421
+ body: { id: 123 }.to_json,
422
+ headers: { 'X-Request-Id' => 'abc123' }
423
+ )
424
+
425
+ user = TestApi::User.retrieve('123')
426
+ assert_equal 123, user.id
427
+ end
428
+
429
+ def test_stub_with_query_params
430
+ stub_api_request(:get, '/users')
431
+ .with(query: hash_including({ status: 'active' }))
432
+ .to_return(body: { data: [] }.to_json)
433
+
434
+ TestApi::User.list(status: 'active', page: 1)
435
+ # Test passes if request matches
436
+ end
437
+
438
+ def test_stub_sequence
439
+ stub_api_request(:get, '/users/123')
440
+ .to_return(status: 503)
441
+ .then.to_return(status: 200, body: { id: 123 }.to_json)
442
+
443
+ # First call fails
444
+ assert_raises(TestApi::ApiError) do
445
+ TestApi::User.retrieve('123')
446
+ end
447
+
448
+ # Second call succeeds
449
+ user = TestApi::User.retrieve('123')
450
+ assert_equal 123, user.id
451
+ end
452
+ end
453
+ ```
454
+
455
+ ### Mocking Client Behavior
456
+
457
+ ```ruby
458
+ class ClientMockTest < ApiTestCase
459
+ def test_mock_client
460
+ mock_client = Minitest::Mock.new
461
+ mock_response = TestApi::Response.new(
462
+ status: 200,
463
+ body: { id: 123 }.to_json,
464
+ headers: {}
465
+ )
466
+
467
+ mock_client.expect(:execute_request, mock_response, [
468
+ :get, '/users/123', { params: {}, headers: {} }
469
+ ])
470
+
471
+ TestApi::Client.stub :active_client, mock_client do
472
+ user = TestApi::User.retrieve('123')
473
+ assert_equal 123, user.id
474
+ end
475
+
476
+ mock_client.verify
477
+ end
478
+
479
+ def test_mock_connection
480
+ mock_connection = Minitest::Mock.new
481
+
482
+ TestApi::Client.stub :default_connection, mock_connection do
483
+ client = TestApi::Client.new
484
+ assert_equal mock_connection, client.connection
485
+ end
486
+ end
487
+ end
488
+ ```
489
+
490
+ ## Testing with VCR
491
+
492
+ ### Basic VCR Usage
493
+
494
+ ```ruby
495
+ class VcrTest < ApiTestCase
496
+ def test_real_api_call
497
+ VCR.use_cassette('user_retrieve') do
498
+ user = TestApi::User.retrieve('123')
499
+ assert_equal 'John Doe', user.name
500
+ end
501
+ end
502
+
503
+ def test_list_with_vcr
504
+ VCR.use_cassette('user_list', record: :new_episodes) do
505
+ users = TestApi::User.list
506
+ assert users.count > 0
507
+ end
508
+ end
509
+ end
510
+ ```
511
+
512
+ ### RSpec with VCR Metadata
513
+
514
+ ```ruby
515
+ RSpec.describe TestApi::User, :vcr do
516
+ describe '.retrieve' do
517
+ it 'fetches user from API' do
518
+ # Automatically uses cassette named after test
519
+ user = described_class.retrieve('123')
520
+ expect(user.name).to eq('John Doe')
521
+ end
522
+ end
523
+
524
+ describe '.list', vcr: { cassette_name: 'custom_user_list' } do
525
+ it 'returns list of users' do
526
+ users = described_class.list
527
+ expect(users).not_to be_empty
528
+ end
529
+ end
530
+ end
531
+ ```
532
+
533
+ ### VCR Configuration for Different Environments
534
+
535
+ ```ruby
536
+ VCR.configure do |config|
537
+ config.cassette_library_dir = 'test/fixtures/vcr_cassettes'
538
+ config.hook_into :webmock
539
+
540
+ # Filter sensitive data
541
+ config.filter_sensitive_data('<API_KEY>') { ENV['API_KEY'] }
542
+ config.filter_sensitive_data('<API_SECRET>') { ENV['API_SECRET'] }
543
+
544
+ # Custom matchers
545
+ config.default_cassette_options = {
546
+ match_requests_on: [:method, :uri, :body],
547
+ record: ENV['VCR_RECORD_MODE'] || :once,
548
+ allow_playback_repeats: true
549
+ }
550
+
551
+ # Ignore localhost
552
+ config.ignore_localhost = true
553
+
554
+ # Allow real requests in CI
555
+ config.allow_http_connections_when_no_cassette = ENV['CI'].nil?
556
+
557
+ # Re-record cassettes periodically
558
+ config.before_record do |interaction|
559
+ # Remove dynamic headers
560
+ interaction.request.headers.delete('X-Request-Id')
561
+ interaction.response.headers.delete('X-Request-Id')
562
+
563
+ # Expire cassettes after 7 days
564
+ interaction.response.headers['X-VCR-Recorded-At'] = Time.now.utc.to_s
565
+ end
566
+ end
567
+ ```
568
+
569
+ ## Testing Best Practices
570
+
571
+ ### 1. Test Organization
572
+
573
+ ```ruby
574
+ # test/unit/resources/user_test.rb
575
+ class UserResourceTest < ApiTestCase
576
+ # Test resource methods
577
+ end
578
+
579
+ # test/unit/client_test.rb
580
+ class ClientTest < ApiTestCase
581
+ # Test client behavior
582
+ end
583
+
584
+ # test/integration/user_workflow_test.rb
585
+ class UserWorkflowTest < ApiTestCase
586
+ # Test complete workflows
587
+ end
588
+
589
+ # test/performance/api_performance_test.rb
590
+ class ApiPerformanceTest < ApiTestCase
591
+ # Test performance characteristics
592
+ end
593
+ ```
594
+
595
+ ### 2. Test Helpers
596
+
597
+ ```ruby
598
+ module ApiTestHelpers
599
+ def create_test_user(attributes = {})
600
+ default_attributes = {
601
+ id: rand(1000),
602
+ email: "test#{rand(1000)}@example.com",
603
+ name: 'Test User'
604
+ }
605
+
606
+ TestApi::User.new(
607
+ default_attributes[:id],
608
+ values: default_attributes.merge(attributes)
609
+ )
610
+ end
611
+
612
+ def stub_successful_response(method, path, response_body)
613
+ stub_api_request(method, path).to_return(
614
+ status: 200,
615
+ body: response_body.to_json,
616
+ headers: { 'Content-Type' => 'application/json' }
617
+ )
618
+ end
619
+
620
+ def stub_error_response(method, path, status, error_message = nil)
621
+ body = error_message ? { error: error_message } : {}
622
+ stub_api_request(method, path).to_return(
623
+ status: status,
624
+ body: body.to_json
625
+ )
626
+ end
627
+
628
+ def assert_api_called(method, path, times: 1)
629
+ assert_requested(method, "#{TestApi.api_base}#{path.sub(/^\//, '')}", times: times)
630
+ end
631
+ end
632
+
633
+ class ApiTestCase < Minitest::Test
634
+ include ApiTestHelpers
635
+ end
636
+ ```
637
+
638
+ ### 3. Testing Custom Configurations
639
+
640
+ ```ruby
641
+ class ConfigurationTest < ApiTestCase
642
+ def test_custom_configuration
643
+ original_base = TestApi.api_base
644
+
645
+ TestApi.configure do |config|
646
+ config.api_base = 'https://custom.example.com/'
647
+ end
648
+
649
+ assert_equal 'https://custom.example.com/', TestApi.api_base
650
+ ensure
651
+ TestApi.api_base = original_base
652
+ end
653
+
654
+ def test_environment_specific_config
655
+ ENV['API_ENV'] = 'staging'
656
+
657
+ TestApi.configure do |config|
658
+ config.api_base = case ENV['API_ENV']
659
+ when 'staging' then 'https://staging.example.com/'
660
+ else 'https://prod.example.com/'
661
+ end
662
+ end
663
+
664
+ assert_equal 'https://staging.example.com/', TestApi.api_base
665
+ ensure
666
+ ENV.delete('API_ENV')
667
+ end
668
+ end
669
+ ```
670
+
671
+ ### 4. Testing Middleware
672
+
673
+ ```ruby
674
+ class MiddlewareTest < ApiTestCase
675
+ def test_custom_middleware
676
+ TestApi::Client.class_eval do
677
+ def self.default_connection
678
+ @default_connection ||= Faraday.new do |conn|
679
+ conn.use TestMiddleware
680
+ conn.adapter :test do |stub|
681
+ stub.get('/test') { [200, {}, 'OK'] }
682
+ end
683
+ end
684
+ end
685
+ end
686
+
687
+ response = TestApi::Client.active_client.execute_request(:get, '/test')
688
+ assert_equal 'Modified by middleware', response.headers['X-Test-Header']
689
+ end
690
+ end
691
+
692
+ class TestMiddleware < Faraday::Middleware
693
+ def call(env)
694
+ @app.call(env).on_complete do |response_env|
695
+ response_env[:response_headers]['X-Test-Header'] = 'Modified by middleware'
696
+ end
697
+ end
698
+ end
699
+ ```
700
+
701
+ ## Continuous Integration
702
+
703
+ ### GitHub Actions Configuration
704
+
705
+ ```yaml
706
+ # .github/workflows/test.yml
707
+ name: Tests
708
+
709
+ on: [push, pull_request]
710
+
711
+ jobs:
712
+ test:
713
+ runs-on: ubuntu-latest
714
+ strategy:
715
+ matrix:
716
+ ruby-version: ['3.0', '3.1', '3.2', '3.3']
717
+
718
+ steps:
719
+ - uses: actions/checkout@v2
720
+
721
+ - name: Set up Ruby
722
+ uses: ruby/setup-ruby@v1
723
+ with:
724
+ ruby-version: ${{ matrix.ruby-version }}
725
+ bundler-cache: true
726
+
727
+ - name: Run tests
728
+ env:
729
+ API_KEY: test-key
730
+ API_SECRET: test-secret
731
+ run: |
732
+ bundle exec rake test
733
+ bundle exec rake test:integration
734
+
735
+ - name: Upload coverage
736
+ uses: codecov/codecov-action@v2
737
+ with:
738
+ files: ./coverage/coverage.xml
739
+ ```
740
+
741
+ ### Test Coverage
742
+
743
+ ```ruby
744
+ # test/test_helper.rb
745
+ require 'simplecov'
746
+ SimpleCov.start do
747
+ add_filter '/test/'
748
+ add_filter '/spec/'
749
+ add_group 'Resources', 'lib/api/resources'
750
+ add_group 'Client', 'lib/api/client'
751
+ add_group 'Middleware', 'lib/api/middleware'
752
+ end
753
+
754
+ # Ensure minimum coverage
755
+ SimpleCov.minimum_coverage 90
756
+ SimpleCov.minimum_coverage_by_file 80
757
+ ```
758
+
759
+ ### Performance Testing
760
+
761
+ ```ruby
762
+ require 'benchmark'
763
+
764
+ class PerformanceTest < ApiTestCase
765
+ def test_resource_creation_performance
766
+ time = Benchmark.realtime do
767
+ 1000.times do
768
+ TestApi::User.new('123', values: { email: 'test@example.com' })
769
+ end
770
+ end
771
+
772
+ assert time < 1.0, "Resource creation took #{time}s, expected < 1s"
773
+ end
774
+
775
+ def test_parallel_requests
776
+ stub_api_request(:get, '/users/123')
777
+ .to_return(body: { id: 123 }.to_json)
778
+
779
+ time = Benchmark.realtime do
780
+ threads = 10.times.map do
781
+ Thread.new { TestApi::User.retrieve('123') }
782
+ end
783
+ threads.each(&:join)
784
+ end
785
+
786
+ assert time < 2.0, "Parallel requests took #{time}s"
787
+ end
788
+ end
789
+ ```