http_decoy 0.1.1 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 975527fe6fe58ce43dc77b5a0889d359d3cc55c120e04b5f93de51783e12d71d
4
- data.tar.gz: 5d2bd7cc5c51adb9a65f3af5768c892b410b4973b9498d6b378f2efd261cd4f5
3
+ metadata.gz: 6c07411035e25bff04ba5ca53164ae73c6848e6431823f2e47332548c8214a7d
4
+ data.tar.gz: f1a9a1c4ee898db259bd4cafae877647d7a91f8066cee5e89378acdcb8e9866d
5
5
  SHA512:
6
- metadata.gz: 8f12507d96e3dc4d2a2d54a2067cc06c7da17a20d1d2460dd89738d2d166efb8f470399d51a56c7980f154589aee9304f528b27e1d65331686cc44e79446032d
7
- data.tar.gz: 613d2362f5fdaa48632fb90dafe49ed81172df3cb62d0f53b7fd8bc93dda10835fc08bba1c23d5ac2246ffb573606b352df292c695a988c3b98ec5069299c8bf
6
+ metadata.gz: 0f28725b5310aa60d176203562a2120aa9a3b115dcaa2acc86f5b25a1e4c3497dd37acc9b0e8b7a470c605f56edb6ab36e96f9c50d5fde52452b7871fc089066
7
+ data.tar.gz: 5480510e433a2c1719191c19fa275c6bd5fe476831eb7a578d19b782d85d363e93ea259faab4bf91f355078cc93f4957a5c5d5f07df322f4655e46712ae6fbac
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] — 2026-07-09
11
+
12
+ ### Added
13
+
14
+ - `HttpDecoy::Minitest` — Minitest integration, parallel to `HttpDecoy::RSpec`:
15
+ - `include HttpDecoy::Minitest` + `fake_server(name) { ... }` class macro (inline pattern)
16
+ - `HttpDecoy.define(:name) { ... }.minitest_helpers` — suite-wide helper module, shared with RSpec via the same `Definition`
17
+ - `assert_received_request(server, method, path, times:, body:)` / `refute_received_request(server, method, path)` assertions
18
+ - `with_scenario(:name) { ... }` instance method, same semantics as the RSpec version
19
+ - `require "http_decoy/minitest"` never loads RSpec, and `require "http_decoy/rspec"` never loads Minitest
20
+ - `respond(status, ..., after: seconds)` — delay a response by a real, measurable amount of time; useful for testing timeout thresholds and loading states against a wall clock rather than a raised exception. Works with `respond_sequence` entries too.
21
+ - `raise_error(:timeout | :reset | :refused)` now actually terminates the TCP connection when the fake server is reached over a real socket (not via WebMock interception) — previously this path silently returned a normal `500` response instead of simulating a dropped connection, so code that specifically handles `Errno::ECONNRESET`/timeouts was never exercised unless WebMock was in the loop. Uses `SO_LINGER` to force a real RST rather than a clean EOF.
22
+
23
+ ### Fixed
24
+
25
+ - `HttpDecoy.define(:name) { ... }.rspec_helpers` used standalone via `RSpec.configure { |c| c.include Foo.rspec_helpers }` (the primary documented usage) raised `NoMethodError` on `_http_decoy_register` unless the example group also separately did `include HttpDecoy::RSpec`. Ruby's `included` hook doesn't cascade through nested `include`s, so the generated helper module's `included` callback now explicitly extends `ClassMethods` onto the includer.
26
+
27
+ ### Changed
28
+
29
+ - `HttpDecoy::Definition` moved to its own file (`definition.rb`) so `HttpDecoy.define` no longer force-loads RSpec — a Minitest-only project including `http_decoy/minitest` never pulls in `rspec/core`, and vice versa.
30
+ - Deduplicated the request-body matcher shared by the RSpec `have_received_request(...).with(body:)` chain and the new Minitest `assert_received_request(..., body:)` into `HttpDecoy::BodyMatcher`.
31
+
10
32
  ## [0.1.0] — 2026-06-02
11
33
 
12
34
  ### Added
@@ -35,5 +57,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
35
57
  - Ruby 3.1+ support
36
58
  - 92% test coverage (SimpleCov), 66 examples
37
59
 
38
- [Unreleased]: https://github.com/jibranusman/http_decoy/compare/v0.1.0...HEAD
39
- [0.1.0]: https://github.com/jibranusman/http_decoy/releases/tag/v0.1.0
60
+ [Unreleased]: https://github.com/jibranusman95/http_decoy/compare/v0.2.0...HEAD
61
+ [0.2.0]: https://github.com/jibranusman95/http_decoy/compare/v0.1.0...v0.2.0
62
+ [0.1.0]: https://github.com/jibranusman95/http_decoy/releases/tag/v0.1.0
data/CONTRIBUTING.md CHANGED
@@ -1,67 +1,39 @@
1
1
  # Contributing to http_decoy
2
2
 
3
- Thanks for taking the time. Bug reports, documentation improvements, and feature proposals are all welcome.
3
+ First: thank you. It genuinely means a lot.
4
4
 
5
- ## Setup
5
+ ## Quick start
6
6
 
7
7
  ```bash
8
- git clone https://github.com/jibranusman/http_decoy
8
+ git clone https://github.com/jibranusman95/http_decoy
9
9
  cd http_decoy
10
10
  bundle install
11
+ bundle exec rspec # RSpec suite — make sure everything is green before you start
12
+ bundle exec rake test # Minitest suite
13
+ bundle exec rubocop # no existing offenses to inherit
11
14
  ```
12
15
 
13
- ## Running the tests
16
+ ## How to contribute
14
17
 
15
- ```bash
16
- bundle exec rspec # full suite
17
- bundle exec rspec spec/http_decoy/server_spec.rb # single file
18
- ```
19
-
20
- ## Linting
21
-
22
- ```bash
23
- bundle exec rubocop # check
24
- bundle exec rubocop -a # autocorrect safe offenses
25
- ```
26
-
27
- Both must be green before a PR can be merged. CI enforces this on every push.
28
-
29
- ## Submitting a pull request
30
-
31
- 1. Fork the repo and create a branch from `main`
32
- 2. Write a failing test that describes the bug or feature
33
- 3. Make it pass
34
- 4. Run `bundle exec rspec` and `bundle exec rubocop` — both must be clean
35
- 5. Update `CHANGELOG.md` under `[Unreleased]`
36
- 6. Open a PR with a clear description of what and why
37
-
38
- ## What we're looking for
18
+ **Found a bug?** Open an issue. Include a minimal reproduction if you can — it'll get fixed faster.
39
19
 
40
- - Bug fixes with a reproducing spec
41
- - New DSL features (propose in an issue first if it's non-trivial)
42
- - Additional body content-type support
43
- - Better error messages
44
- - Real-world usage examples in the README
20
+ **Want a feature?** Open an issue first so we can align before you build. Nothing worse than putting work into something that doesn't fit.
45
21
 
46
- ## Good first issues
22
+ **Have a fix ready?** Just open a PR. One thing per PR. Include a failing test if the change is non-trivial.
47
23
 
48
- Check the [`good first issue`](https://github.com/jibranusman/http_decoy/issues?q=label%3A%22good+first+issue%22) label for beginner-friendly tasks.
24
+ **Docs or README improvements?** Also very welcome just send the PR directly.
49
25
 
50
- ## Code style
26
+ ## Before you submit
51
27
 
52
- - `frozen_string_literal: true` on every file
53
- - Follow the existing RuboCop config (`.rubocop.yml`)
54
- - Keep handler blocks and DSL methods small and focused
55
- - No clever metaprogramming without a comment explaining why
28
+ - `bundle exec rspec` all green
29
+ - `bundle exec rake test` all green (Minitest suite)
30
+ - `bundle exec rubocop` no new offenses
31
+ - Focused scope one fix or feature per PR
56
32
 
57
- ## Reporting bugs
33
+ ## What happens next
58
34
 
59
- Open a GitHub issue with:
60
- - Ruby version (`ruby --version`)
61
- - http_decoy version
62
- - Minimal reproduction case (ideally a failing RSpec example)
63
- - What you expected vs what happened
35
+ I review promptly — usually within a day or two, often faster. I'll either merge it, ask for changes with clear reasoning, or explain why it doesn't fit. No ghosting.
64
36
 
65
- ## Security issues
37
+ ## License
66
38
 
67
- Do not open a public issue for security vulnerabilities. Email the maintainer directly (address in the gemspec).
39
+ By contributing, you agree your changes are released under the same [MIT License](LICENSE) as this project.
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **A real fake HTTP server. For real tests.**
4
4
 
5
- [![CI](https://github.com/jibranusman/http_decoy/actions/workflows/ci.yml/badge.svg)](https://github.com/jibranusman/http_decoy/actions)
5
+ [![CI](https://github.com/jibranusman95/http_decoy/actions/workflows/ci.yml/badge.svg)](https://github.com/jibranusman95/http_decoy/actions)
6
6
  [![Gem Version](https://badge.fury.io/rb/http_decoy.svg)](https://badge.fury.io/rb/http_decoy)
7
7
  [![Downloads](https://img.shields.io/gem/dt/http_decoy)](https://rubygems.org/gems/http_decoy)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
@@ -279,6 +279,25 @@ end
279
279
 
280
280
  Available transport errors: `:timeout`, `:reset`, `:refused`.
281
281
 
282
+ How the client observes this depends on how the request reached http_decoy:
283
+
284
+ - **Through WebMock's interception** (the default, whenever `base_url` is declared) — the exact matching Ruby exception (`Timeout::Error`, `Errno::ECONNRESET`, `Errno::ECONNREFUSED`) is raised directly at your HTTP client's call site, so `rescue Errno::ECONNRESET` in your code sees a real instance of that class.
285
+ - **Hitting the server directly over a real socket** (`server.base_url` with no WebMock in between — e.g. an SDK-under-test that owns its own connection) — there's no HTTP status code for "the connection died," so http_decoy actually terminates the TCP connection (via `SO_LINGER`, which forces the kernel to send RST) rather than returning a response. Your client sees a genuine connection failure, not a 500.
286
+
287
+ Either way, an unhandled exception from your *own* handler code (a real bug, not `raise_error`) still returns a normal `500` with the error message in the body — that path is unchanged.
288
+
289
+ ### Simulating latency
290
+
291
+ `respond` accepts `after:` (seconds) to delay the response — useful for testing timeout thresholds, loading states, or spinners against a real clock instead of a raised exception:
292
+
293
+ ```ruby
294
+ get "/slow-report" do
295
+ respond 200, json: { status: "ready" }, after: 2.5
296
+ end
297
+ ```
298
+
299
+ Works with `respond_sequence` too — set `after:` per entry to simulate a service that degrades over successive calls.
300
+
282
301
  ### Stateful sequences
283
302
 
284
303
  ```ruby
@@ -340,6 +359,45 @@ end
340
359
 
341
360
  ---
342
361
 
362
+ ## Minitest integration
363
+
364
+ Same DSL, same `HttpDecoy.define` definitions — `require "http_decoy/minitest"` never loads RSpec, and vice versa.
365
+
366
+ Suite-wide (recommended) — same `FakeStripe` definition as above, shared across both frameworks:
367
+
368
+ ```ruby
369
+ class ChargeTest < Minitest::Test
370
+ include FakeStripe.minitest_helpers
371
+
372
+ def test_charges_the_card
373
+ StripeService.charge(500)
374
+ assert_received_request fake_server(:stripe), :post, "/v1/charges"
375
+ end
376
+ end
377
+ ```
378
+
379
+ Inline per test class:
380
+
381
+ ```ruby
382
+ class DegradedUpstreamTest < Minitest::Test
383
+ include HttpDecoy::Minitest
384
+
385
+ fake_server(:api) do
386
+ get "/status" do
387
+ respond 503, json: { status: "degraded" }
388
+ end
389
+ end
390
+
391
+ def test_handles_it_gracefully
392
+ assert_equal :degraded, MyApp.health_check
393
+ end
394
+ end
395
+ ```
396
+
397
+ Assertions: `assert_received_request(server, method, path, times:, body:)` and `refute_received_request(server, method, path)` mirror the RSpec `have_received_request` matcher's chains.
398
+
399
+ ---
400
+
343
401
  ## Configuration
344
402
 
345
403
  ```ruby
@@ -355,15 +413,15 @@ end
355
413
 
356
414
  | | WebMock | VCR | **http_decoy** |
357
415
  |---|---|---|---|
358
- | Real server | No | No | **Yes** |
359
- | Request contract validation | No | No | **Yes** |
360
- | Dynamic responses | No | No | **Yes** |
416
+ | Real server | optional | No | **Yes** |
417
+ | Request contract validation | possible | No | **Yes** |
418
+ | Dynamic responses | yes | No | **Yes** |
361
419
  | Failure scenario testing | Verbose | Very hard | **One line** |
362
420
  | Works offline | Yes | First run: No | **Yes** |
363
421
  | Secrets in version control | No | **Risk** | No |
364
422
  | Cassettes to maintain | No | **Yes** | No |
365
423
  | Define once, use everywhere | Requires setup | Yes | **Yes** |
366
- | Catches API drift | No | No | **Yes** |
424
+ | Catches API drift | possible | No | **Yes** |
367
425
 
368
426
  http_decoy uses WebMock internally to intercept requests — complementary, not a replacement.
369
427
 
@@ -452,23 +510,57 @@ end
452
510
  - Ruby 3.1+
453
511
  - Runtime dependencies: `webrick`, `rack` (both lightweight)
454
512
  - Optional: `webmock` for URL interception
513
+ - Works with RSpec or Minitest — neither is a runtime dependency; only the integration you `require` gets loaded
514
+
515
+ ---
516
+
517
+ ## Further Reading
518
+
519
+ - [Why WebMock Stubs Lie (And What To Do About It)](https://dev.to/jibranusman95/why-webmock-stubs-lie-and-what-to-do-about-it-4990) — the problem http_decoy was built to solve, with examples
455
520
 
456
521
  ---
457
522
 
458
523
  ## Contributing
459
524
 
525
+ I built this myself — which means it works great for the cases I thought of, and probably has rough edges for the ones I didn't. If you hit something weird, **open an issue**. I read them all and respond fast.
526
+
527
+ Want to fix something or add a feature? **Send a PR.** No CLA, no process overhead, no committee review. If the tests pass and the change makes sense, it's getting merged. I'm one person and I genuinely appreciate the help — you can take this further than I can alone.
528
+
529
+ Not sure where to start? Look for [`good first issue`](https://github.com/jibranusman95/http_decoy/issues?q=label%3A%22good+first+issue%22) labels, or just open an issue and ask.
530
+
460
531
  ```bash
461
- git clone https://github.com/jibranusman/http_decoy
532
+ git clone https://github.com/jibranusman95/http_decoy
462
533
  cd http_decoy
463
534
  bundle install
464
- bundle exec rspec # run all tests
465
- bundle exec rubocop # lint
535
+ bundle exec rspec # RSpec suite
536
+ bundle exec rake test # Minitest suite
537
+ bundle exec rubocop # no new offenses
466
538
  ```
467
539
 
468
- See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines. Good first issues are labeled [`good first issue`](https://github.com/jibranusman/http_decoy/issues?q=label%3A%22good+first+issue%22).
540
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines.
541
+
542
+ ### Contributors
543
+
544
+ Everyone who's made this better:
545
+
546
+ <a href="https://github.com/jibranusman95/http_decoy/graphs/contributors">
547
+ <img src="https://contrib.rocks/image?repo=jibranusman95/http_decoy" />
548
+ </a>
469
549
 
470
550
  ---
471
551
 
552
+ ## From the same author
553
+
554
+ Small, sharp Ruby gems built to the same standard — 100% test coverage, zero dependencies beyond what's needed.
555
+
556
+ | Gem | What it does |
557
+ |-----|-------------|
558
+ | [llm_cassette](https://github.com/jibranusman95/llm_cassette) | VCR for LLMs — streaming-aware cassette recorder for OpenAI and Anthropic |
559
+ | [turbo_presence](https://github.com/jibranusman95/turbo_presence) | Figma-style live cursors, avatar stacks, and typing indicators for Rails/Hotwire |
560
+ | [promptscrub](https://github.com/jibranusman95/promptscrub) | PII redaction middleware for LLM calls — strip sensitive data from prompts, rehydrate in responses |
561
+ | [webhook_inbox](https://github.com/jibranusman95/webhook_inbox) | Transactional inbox for Rails webhook receivers — deduplication, async processing, replay, dashboard |
562
+ | [agent_jail](https://github.com/jibranusman95/agent_jail) | Fork-based sandbox for LLM tool calls — timeout, memory limit, and filesystem restrictions |
563
+
472
564
  ## License
473
565
 
474
566
  MIT. See [LICENSE](LICENSE).
data/Rakefile CHANGED
@@ -2,7 +2,16 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
+ require "rake/testtask"
5
6
 
6
7
  RSpec::Core::RakeTask.new(:spec)
7
8
 
8
- task default: :spec
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << "test" << "lib"
11
+ t.test_files = FileList["test/**/*_test.rb"]
12
+ # Default -w flag surfaces a harmless "circular require" warning from
13
+ # webmock/minitest loading against minitest's own loader — not our bug.
14
+ t.warning = false
15
+ end
16
+
17
+ task default: %i[spec test]
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpDecoy
4
+ # Shared by the RSpec `have_received_request(...).with(body:)` chain and the
5
+ # Minitest `assert_received_request(..., body:)` keyword — matches a logged
6
+ # request body against either an exact Hash of expected key/value matchers
7
+ # (each value tested via `===`, so RSpec matchers, classes, and regexes all
8
+ # work) or a single matcher applied to the whole body.
9
+ module BodyMatcher
10
+ module_function
11
+
12
+ # rubocop:disable Style/CaseEquality -- `===` is the point: callers pass
13
+ # RSpec matchers, Regexp, Class, or plain values as `matcher`/`v`.
14
+ def matches?(actual, matcher)
15
+ case matcher
16
+ when Hash
17
+ actual.is_a?(Hash) && matcher.all? do |k, v|
18
+ actual_val = actual[k] || actual[k.to_s]
19
+ v === actual_val
20
+ end
21
+ else
22
+ matcher === actual
23
+ end
24
+ end
25
+ # rubocop:enable Style/CaseEquality
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpDecoy
4
+ # Wraps a named RouteMap so it can be shared across multiple test files
5
+ # and reused across test frameworks.
6
+ #
7
+ # Framework-specific helper methods are added by whichever integration
8
+ # file is loaded: `require "http_decoy/rspec"` adds #rspec_helpers,
9
+ # `require "http_decoy/minitest"` adds #minitest_helpers.
10
+ class Definition
11
+ attr_reader :name, :route_map
12
+
13
+ def initialize(name, route_map)
14
+ @name = name
15
+ @route_map = route_map
16
+ end
17
+ end
18
+ end
@@ -42,7 +42,11 @@ module HttpDecoy
42
42
  end
43
43
 
44
44
  # Build and store the response tuple for this request.
45
- def respond(status, json: nil, text: nil, headers: {})
45
+ # `after:` delays the response by the given number of seconds — useful
46
+ # for testing timeout thresholds and loading states against a real clock,
47
+ # not just a raised exception.
48
+ def respond(status, json: nil, text: nil, headers: {}, after: nil)
49
+ sleep(after) if after&.positive?
46
50
  body_str = json ? JSON.generate(resolve(json)) : text.to_s
47
51
  content_type = json ? "application/json" : "text/plain"
48
52
  @_response = [status.to_i, { "Content-Type" => content_type }.merge(headers), [body_str]]
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "route_map"
4
+ require_relative "server"
5
+ require_relative "webmock_integration"
6
+ require_relative "definition"
7
+ require_relative "body_matcher"
8
+
9
+ module HttpDecoy
10
+ # Minitest integration. Same server lifecycle and semantics as
11
+ # HttpDecoy::RSpec (fresh Server per test, WebMock stub scoped to that
12
+ # server, teardown never calls WebMock.reset!) hooked into Minitest's
13
+ # setup/teardown instead of RSpec's before/after.
14
+ #
15
+ # Pattern A — inline (per test class):
16
+ #
17
+ # class ChargeTest < Minitest::Test
18
+ # include HttpDecoy::Minitest
19
+ #
20
+ # fake_server(:payments) do
21
+ # post "/charges" do
22
+ # respond 201, json: { id: "ch_abc" }
23
+ # end
24
+ # end
25
+ #
26
+ # def test_creates_a_charge
27
+ # MyService.charge(100)
28
+ # assert_received_request fake_server(:payments), :post, "/charges"
29
+ # end
30
+ # end
31
+ #
32
+ # Pattern B — suite-wide definition (shared with RSpec):
33
+ #
34
+ # FakeStripe = HttpDecoy.define(:stripe) do
35
+ # base_url "https://api.stripe.com"
36
+ # post "/v1/charges" do
37
+ # respond 200, json: { id: "ch_123" }
38
+ # end
39
+ # end
40
+ #
41
+ # class ChargeTest < Minitest::Test
42
+ # include FakeStripe.minitest_helpers
43
+ #
44
+ # def test_charges_the_card
45
+ # StripeService.charge(500)
46
+ # assert_received_request fake_server(:stripe), :post, "/v1/charges"
47
+ # end
48
+ # end
49
+ module Minitest
50
+ def self.included(base)
51
+ base.extend(ClassMethods)
52
+ base.include(Assertions)
53
+ end
54
+
55
+ module ClassMethods
56
+ # Class-level macro. Evaluates the block into a RouteMap once at class-load
57
+ # time, then registers it for the shared setup/teardown hooks.
58
+ def fake_server(name, &)
59
+ route_map = RouteMap.new
60
+ route_map.instance_eval(&)
61
+ _http_decoy_register(name, route_map)
62
+ end
63
+
64
+ # Internal: register a pre-built RouteMap and install setup/teardown
65
+ # (once per class) that start/stop every registered server.
66
+ # Called by both the fake_server macro and Definition#minitest_helpers.
67
+ def _http_decoy_register(name, route_map)
68
+ _http_decoy_route_maps[name] = route_map
69
+ _http_decoy_install_hooks
70
+ end
71
+
72
+ def _http_decoy_route_maps
73
+ @_http_decoy_route_maps ||= {}
74
+ end
75
+
76
+ def _http_decoy_install_hooks
77
+ return if @_http_decoy_hooks_installed
78
+
79
+ @_http_decoy_hooks_installed = true
80
+
81
+ define_method(:setup) do
82
+ super()
83
+ @_http_decoy_servers = {}
84
+ @_http_decoy_webmock_stubs = {}
85
+
86
+ self.class._http_decoy_route_maps.each do |server_name, route_map|
87
+ server = Server.new(route_map)
88
+ server.start
89
+ stub = WebMockIntegration.setup(server)
90
+ @_http_decoy_servers[server_name] = server
91
+ @_http_decoy_webmock_stubs[server_name] = stub
92
+ end
93
+ end
94
+
95
+ define_method(:teardown) do
96
+ (@_http_decoy_servers || {}).each_key do |server_name|
97
+ server = @_http_decoy_servers[server_name]
98
+ stub = @_http_decoy_webmock_stubs[server_name]
99
+ WebMockIntegration.teardown(stub)
100
+ server&.stop
101
+ end
102
+ super()
103
+ end
104
+ end
105
+ end
106
+
107
+ # Instance-level accessor — returns the live Server for this test.
108
+ def fake_server(name)
109
+ @_http_decoy_servers[name]
110
+ end
111
+
112
+ # Run a block with a named scenario active.
113
+ # server_name defaults to the only server if exactly one is registered.
114
+ def with_scenario(scenario_name, server_name = nil, &)
115
+ name = server_name || begin
116
+ servers = @_http_decoy_servers || {}
117
+ raise ArgumentError, "server_name required when multiple fake servers are active" if servers.size > 1
118
+ raise ArgumentError, "No fake servers are active" if servers.empty?
119
+
120
+ servers.keys.first
121
+ end
122
+
123
+ server = @_http_decoy_servers[name]
124
+ raise ArgumentError, "No fake server named #{name.inspect}" unless server
125
+
126
+ server.with_scenario(scenario_name, &)
127
+ end
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Minitest assertions
131
+ # ---------------------------------------------------------------------------
132
+ module Assertions
133
+ def assert_received_request(server, method, path, times: nil, body: nil)
134
+ entries = server.request_log.for(method, path)
135
+ description = "#{method.to_s.upcase} #{path}"
136
+
137
+ if times
138
+ assert_equal times, entries.count,
139
+ "expected #{description} to have been received #{times} time(s), " \
140
+ "but it was received #{entries.count} time(s)"
141
+ else
142
+ assert entries.any?, "expected #{description} to have been received, but it was never called"
143
+ end
144
+
145
+ return unless body
146
+
147
+ assert entries.any? { |e| HttpDecoy::BodyMatcher.matches?(e.body, body) },
148
+ "expected #{description} body to match #{body.inspect}, but received: #{entries.map(&:body).inspect}"
149
+ end
150
+
151
+ def refute_received_request(server, method, path)
152
+ entries = server.request_log.for(method, path)
153
+ assert entries.empty?, "expected #{method.to_s.upcase} #{path} not to have been received"
154
+ end
155
+ end
156
+ end
157
+
158
+ # Reopens Definition (see definition.rb) to add the Minitest-specific helper.
159
+ class Definition
160
+ # Returns an anonymous module. Include it in a Minitest::Test subclass to
161
+ # register the server lifecycle for every test in that class.
162
+ #
163
+ # include FakeStripe.minitest_helpers
164
+ #
165
+ def minitest_helpers
166
+ definition = self
167
+
168
+ Module.new do
169
+ include HttpDecoy::Minitest
170
+
171
+ # See the matching comment in rspec.rb — the explicit `extend` is
172
+ # required because Ruby's `included` hook does not cascade through
173
+ # nested includes.
174
+ define_singleton_method(:included) do |base|
175
+ super(base)
176
+ base.extend(HttpDecoy::Minitest::ClassMethods)
177
+ base.include(HttpDecoy::Minitest::Assertions)
178
+ base._http_decoy_register(definition.name, definition.route_map)
179
+ end
180
+
181
+ define_method(:_http_decoy_definition) { definition }
182
+ end
183
+ end
184
+ end
185
+ end
@@ -4,6 +4,8 @@ require "rspec/core"
4
4
  require_relative "route_map"
5
5
  require_relative "server"
6
6
  require_relative "webmock_integration"
7
+ require_relative "definition"
8
+ require_relative "body_matcher"
7
9
 
8
10
  module HttpDecoy
9
11
  # RSpec integration.
@@ -112,7 +114,7 @@ module HttpDecoy
112
114
  entries = server.request_log.for(method, path)
113
115
  next false if entries.empty?
114
116
  next false if @times && entries.count != @times
115
- next false if @body_matcher && entries.none? { |e| body_matches?(e.body, @body_matcher) }
117
+ next false if @body_matcher && entries.none? { |e| HttpDecoy::BodyMatcher.matches?(e.body, @body_matcher) }
116
118
 
117
119
  true
118
120
  end
@@ -154,33 +156,14 @@ module HttpDecoy
154
156
  desc += " with body matching #{@body_matcher.inspect}" if @body_matcher
155
157
  desc
156
158
  end
157
-
158
- def body_matches?(actual, matcher)
159
- case matcher
160
- when Hash
161
- actual.is_a?(Hash) && matcher.all? do |k, v|
162
- actual_val = actual[k] || actual[k.to_s]
163
- v === actual_val
164
- end
165
- else
166
- matcher === actual
167
- end
168
- end
169
159
  end
170
160
 
171
161
  # ---------------------------------------------------------------------------
172
162
  # Definition — returned by HttpDecoy.define
173
163
  # ---------------------------------------------------------------------------
174
164
 
175
- # Wraps a named RouteMap and generates an anonymous RSpec helper module.
165
+ # Reopens Definition (see definition.rb) to add the RSpec-specific helper.
176
166
  class Definition
177
- attr_reader :name, :route_map
178
-
179
- def initialize(name, route_map)
180
- @name = name
181
- @route_map = route_map
182
- end
183
-
184
167
  # Returns an anonymous module. Include it in RSpec.configure to register
185
168
  # the server lifecycle for every example group in the suite.
186
169
  #
@@ -194,8 +177,15 @@ module HttpDecoy
194
177
 
195
178
  # define_singleton_method closes over `definition` from the outer scope.
196
179
  # `def self.included` would NOT — def never captures outer locals.
180
+ #
181
+ # `extend` is required here: `include HttpDecoy::RSpec` above only ran
182
+ # HttpDecoy::RSpec.included against *this anonymous module*, not against
183
+ # `base` — Ruby's included hook does not cascade through nested includes.
184
+ # Without this line, `RSpec.configure { |c| c.include Foo.rspec_helpers }`
185
+ # used on its own raises NoMethodError on `_http_decoy_register`.
197
186
  define_singleton_method(:included) do |base|
198
187
  super(base)
188
+ base.extend(HttpDecoy::RSpec::ClassMethods)
199
189
  base._http_decoy_register(definition.name, definition.route_map)
200
190
  end
201
191
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "webrick"
4
4
  require "rack"
5
+ require "socket"
5
6
  require "stringio"
6
7
  require "json"
7
8
  require_relative "request_log"
@@ -44,6 +45,13 @@ module HttpDecoy
44
45
  res.status = status.to_i
45
46
  headers.each { |k, v| res[k] = v }
46
47
  res.body = Array(body).join
48
+ rescue Timeout::Error, Errno::ECONNRESET, Errno::ECONNREFUSED
49
+ # raise_error(:timeout/:reset/:refused): over a real socket there's no
50
+ # response that means "the connection died" — the only faithful
51
+ # simulation is to actually kill the connection. (Over WebMock's
52
+ # to_rack interception this same exception instead propagates directly
53
+ # to the caller, which is the more common path and needs no help here.)
54
+ drop_connection(req)
47
55
  rescue StandardError => e
48
56
  res.status = 500
49
57
  res["Content-Type"] = "application/json"
@@ -162,5 +170,31 @@ module HttpDecoy
162
170
  def json_response(status, payload)
163
171
  [status.to_i, { "Content-Type" => "application/json" }, [JSON.generate(payload)]]
164
172
  end
173
+
174
+ # Forcibly terminate the underlying TCP connection instead of sending a
175
+ # response. WEBrick::HTTPRequest stores the raw socket in @socket once
176
+ # #parse has run (it's not exposed through the public API) — reaching in
177
+ # is the only way to make a "connection reset" simulation actually reset
178
+ # the connection rather than returning a normal 500 response.
179
+ #
180
+ # SO_LINGER 0 makes the kernel send RST on close instead of a graceful
181
+ # FIN, so the client observes a real ECONNRESET (or equivalent) rather
182
+ # than a clean EOF. Any failure here (unsupported socket, already closed)
183
+ # is swallowed — the surrounding WEBrick request loop still tries to
184
+ # write a response to the now-closed socket and fails safely on its own.
185
+ def drop_connection(req)
186
+ sock = req.instance_variable_get(:@socket)
187
+ return unless sock
188
+
189
+ begin
190
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_LINGER, [1, 0].pack("ii"))
191
+ rescue StandardError
192
+ nil
193
+ end
194
+
195
+ sock.close
196
+ rescue StandardError
197
+ nil
198
+ end
165
199
  end
166
200
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HttpDecoy
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/http_decoy.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "http_decoy/request_log"
9
9
  require_relative "http_decoy/handler_context"
10
10
  require_relative "http_decoy/server"
11
11
  require_relative "http_decoy/webmock_integration"
12
+ require_relative "http_decoy/definition"
12
13
 
13
14
  module HttpDecoy
14
15
  class << self
@@ -25,7 +26,7 @@ module HttpDecoy
25
26
  @configuration ||= Configuration.new
26
27
  end
27
28
 
28
- # Define a named fake service.
29
+ # Define a named fake service, reusable across RSpec and Minitest.
29
30
  #
30
31
  # FakeStripe = HttpDecoy.define(:stripe) do
31
32
  # base_url "https://api.stripe.com"
@@ -37,9 +38,13 @@ module HttpDecoy
37
38
  # end
38
39
  #
39
40
  # RSpec.configure { |c| c.include FakeStripe.rspec_helpers }
41
+ # # or, in a Minitest::Test subclass:
42
+ # include FakeStripe.minitest_helpers
40
43
  #
44
+ # #rspec_helpers requires "http_decoy/rspec" to be loaded; #minitest_helpers
45
+ # requires "http_decoy/minitest" — this method itself pulls in neither, so
46
+ # a Minitest-only project never loads RSpec (and vice versa).
41
47
  def define(name = :default, &)
42
- require_relative "http_decoy/rspec"
43
48
  route_map = RouteMap.new
44
49
  route_map.instance_eval(&)
45
50
  Definition.new(name, route_map)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: http_decoy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jibran Usman
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-02 00:00:00.000000000 Z
11
+ date: 2026-07-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -54,8 +54,11 @@ files:
54
54
  - README.md
55
55
  - Rakefile
56
56
  - lib/http_decoy.rb
57
+ - lib/http_decoy/body_matcher.rb
57
58
  - lib/http_decoy/configuration.rb
59
+ - lib/http_decoy/definition.rb
58
60
  - lib/http_decoy/handler_context.rb
61
+ - lib/http_decoy/minitest.rb
59
62
  - lib/http_decoy/request_log.rb
60
63
  - lib/http_decoy/route.rb
61
64
  - lib/http_decoy/route_map.rb
@@ -91,6 +94,6 @@ requirements: []
91
94
  rubygems_version: 3.5.22
92
95
  signing_key:
93
96
  specification_version: 4
94
- summary: Declarative fake HTTP servers for RSpec. Real server. Real requests. Zero
95
- cassettes.
97
+ summary: Declarative fake HTTP servers for RSpec/Minitest. Real server. Real requests.
98
+ Zero cassettes.
96
99
  test_files: []