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 +4 -4
- data/CHANGELOG.md +25 -2
- data/CONTRIBUTING.md +20 -48
- data/README.md +101 -9
- data/Rakefile +10 -1
- data/lib/http_decoy/body_matcher.rb +27 -0
- data/lib/http_decoy/definition.rb +18 -0
- data/lib/http_decoy/handler_context.rb +5 -1
- data/lib/http_decoy/minitest.rb +185 -0
- data/lib/http_decoy/rspec.rb +11 -21
- data/lib/http_decoy/server.rb +34 -0
- data/lib/http_decoy/version.rb +1 -1
- data/lib/http_decoy.rb +7 -2
- metadata +7 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c07411035e25bff04ba5ca53164ae73c6848e6431823f2e47332548c8214a7d
|
|
4
|
+
data.tar.gz: f1a9a1c4ee898db259bd4cafae877647d7a91f8066cee5e89378acdcb8e9866d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
39
|
-
[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
|
-
|
|
3
|
+
First: thank you. It genuinely means a lot.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Quick start
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
git clone https://github.com/
|
|
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
|
-
##
|
|
16
|
+
## How to contribute
|
|
14
17
|
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
**Docs or README improvements?** Also very welcome — just send the PR directly.
|
|
49
25
|
|
|
50
|
-
##
|
|
26
|
+
## Before you submit
|
|
51
27
|
|
|
52
|
-
- `
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
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
|
-
##
|
|
33
|
+
## What happens next
|
|
58
34
|
|
|
59
|
-
|
|
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
|
-
##
|
|
37
|
+
## License
|
|
66
38
|
|
|
67
|
-
|
|
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
|
-
[](https://github.com/jibranusman95/http_decoy/actions)
|
|
6
6
|
[](https://badge.fury.io/rb/http_decoy)
|
|
7
7
|
[](https://rubygems.org/gems/http_decoy)
|
|
8
8
|
[](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 |
|
|
359
|
-
| Request contract validation |
|
|
360
|
-
| Dynamic responses |
|
|
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 |
|
|
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/
|
|
532
|
+
git clone https://github.com/jibranusman95/http_decoy
|
|
462
533
|
cd http_decoy
|
|
463
534
|
bundle install
|
|
464
|
-
bundle exec rspec
|
|
465
|
-
bundle exec
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/http_decoy/rspec.rb
CHANGED
|
@@ -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|
|
|
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
|
-
#
|
|
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
|
|
data/lib/http_decoy/server.rb
CHANGED
|
@@ -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
|
data/lib/http_decoy/version.rb
CHANGED
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.
|
|
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-
|
|
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.
|
|
95
|
-
cassettes.
|
|
97
|
+
summary: Declarative fake HTTP servers for RSpec/Minitest. Real server. Real requests.
|
|
98
|
+
Zero cassettes.
|
|
96
99
|
test_files: []
|