belt 0.4.7 → 0.5.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 +33 -0
- data/README.md +40 -0
- data/lib/belt/cli/deploy_command.rb +80 -2
- data/lib/belt/cli/explain_command.rb +8 -1
- data/lib/belt/docs/testing.md +145 -0
- data/lib/belt/testing/e2e.rb +226 -0
- data/lib/belt/testing.rb +18 -0
- data/lib/belt/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5247752ecef8dc1c7241d3098a0896005f1533b2e1315914bf58c7534fa315f6
|
|
4
|
+
data.tar.gz: 429bb361f14e70fd06988c6f0b026988e6623467ded6b8a3b7ee27849f607452
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: da12badab39e69547f46b13c833c59a2a61902e62ba9a0442804586aaff9e7c8fe87bca9af9ffa970ea2e0ed68d0b9cc03b1721b71092661ec7d60fede9cb58f
|
|
7
|
+
data.tar.gz: 13984899af7ad0f1b0e2badb0cc244a084ad75856601b0bde24b4a03eedab642dfbb8d03016392b8d941733861f5c3bb9de79ebab7e0ba17c74dca42c96ba1b8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **In-process e2e test harness (`require 'belt/testing'`).** Belt now ships the real
|
|
8
|
+
request path as a reusable test tier: a synthetic API Gateway event routed through the
|
|
9
|
+
real `Belt::ActionRouter` — no AWS, no HTTP server, no browser. It sits between
|
|
10
|
+
controller unit tests (which dispatch an action directly and never touch routing) and
|
|
11
|
+
full cloud e2e. `Belt::Testing::E2E::Client` wraps one router and returns a parsed
|
|
12
|
+
`Response` (`status` / `headers` / `body` / `json`, with `ok?` and `[]`);
|
|
13
|
+
`Belt::Testing::E2E::Helpers` mixes `api_get` / `api_post` / `api_put` / `api_patch` /
|
|
14
|
+
`api_delete` / `api_request` and a `cognito_claims` builder into `Minitest::Test` or
|
|
15
|
+
RSpec example groups; and `Belt::Testing::E2E.manifest_from_belt_routes(app_root:)`
|
|
16
|
+
builds the router manifest from the app's own `belt routes -f json` so the harness can't
|
|
17
|
+
drift from Belt's route-building internals. The module is opt-in and never loaded by
|
|
18
|
+
`require 'belt'`, so it stays out of the production Lambda path. App-specific seams
|
|
19
|
+
(DynamoDB Local, Cognito claims, external services like Bedrock/Stripe/S3) stay in the
|
|
20
|
+
app's own test boot — Belt owns only the generic request mechanics. See
|
|
21
|
+
`belt explain testing`.
|
|
22
|
+
|
|
23
|
+
## 0.4.8
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`belt deploy` auto-bootstraps the ACM certificate on first deploy.** First-time
|
|
28
|
+
deploys hit the classic ACM chicken-and-egg problem: `terraform plan` fails because
|
|
29
|
+
`domain_validation_options` aren't known until the certificate is applied. Belt now
|
|
30
|
+
detects that error pattern, runs
|
|
31
|
+
`terraform apply -target=module.app.aws_acm_certificate.app` to create the certificate,
|
|
32
|
+
and re-runs the plan automatically. No manual terraform commands needed — first-time
|
|
33
|
+
deploys just work. This is a one-time bootstrap; subsequent deploys run normally since
|
|
34
|
+
the certificate already exists.
|
|
35
|
+
|
|
3
36
|
## 0.4.7
|
|
4
37
|
|
|
5
38
|
### Bug Fix
|
data/README.md
CHANGED
|
@@ -731,6 +731,46 @@ puts "Created post: #{post.id}"
|
|
|
731
731
|
|
|
732
732
|
Refuses to run against an environment that already has data (pass `--force` to override). `belt new` scaffolds a starter `config/seeds.rb`. See `belt explain data_seeding` for details.
|
|
733
733
|
|
|
734
|
+
## Testing
|
|
735
|
+
|
|
736
|
+
Belt ships an in-process **end-to-end harness** that drives the real router — no AWS, no HTTP server, no browser. It's the tier between controller unit tests (dispatch an action directly, never touch routing) and full cloud e2e (needs a deployed stack).
|
|
737
|
+
|
|
738
|
+
Opt-in — it is **not** loaded by `require 'belt'`, so it never ships in the production Lambda path:
|
|
739
|
+
|
|
740
|
+
```ruby
|
|
741
|
+
require "belt"
|
|
742
|
+
require "belt/testing"
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
The harness builds a synthetic API Gateway event and routes it through the real `Belt::ActionRouter`, exactly as API Gateway delivers it: the router finds the route, extracts path params, instantiates the real controller, and runs the real `before_action` chain. Router, controllers, models, validations, and authorization are **real**; DynamoDB (e.g. DynamoDB Local), Cognito (a claims hash), and external services stay seams you wire in your own test boot.
|
|
746
|
+
|
|
747
|
+
```ruby
|
|
748
|
+
# e2e_helper.rb — after booting your app (ENV, require 'belt', models, controllers, DynamoDB Local)
|
|
749
|
+
router = Belt::ActionRouter.new(
|
|
750
|
+
routes: Belt::Testing::E2E.manifest_from_belt_routes(app_root: APP_ROOT),
|
|
751
|
+
gateway: "api"
|
|
752
|
+
)
|
|
753
|
+
Belt::Testing::E2E.client = Belt::Testing::E2E::Client.new(router: router)
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
```ruby
|
|
757
|
+
class ProjectsE2ETest < Minitest::Test
|
|
758
|
+
include Belt::Testing::E2E::Helpers
|
|
759
|
+
|
|
760
|
+
def test_creates_a_project
|
|
761
|
+
res = api_post("/projects", body: { slug: "alpha" }, claims: cognito_claims(groups: "admins"))
|
|
762
|
+
assert_equal 201, res.status
|
|
763
|
+
assert_equal "alpha", res["project"]["slug"]
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def test_rejects_anonymous
|
|
767
|
+
assert_equal 401, api_get("/projects").status
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
`api_get` / `api_post` / `api_put` / `api_patch` / `api_delete` (and `api_request`) accept `body:`, `claims:`, `token:`, `headers:`, and `query:`. The returned `Response` exposes `status`, `headers`, `body`, `json`, `ok?`, and `[]` (a top-level key from the parsed JSON body). Full details — the real-vs-seamed boundary, per-test clients, and keeping the tier out of your normal suite — in `belt explain testing`.
|
|
773
|
+
|
|
734
774
|
## Plugins
|
|
735
775
|
|
|
736
776
|
Belt is designed to stay lean. Optional capabilities ship as **separate gems** that plug into the CLI and runtime the same way Rails engines and generators do.
|
|
@@ -731,9 +731,87 @@ module Belt
|
|
|
731
731
|
|
|
732
732
|
def run_plan
|
|
733
733
|
puts '━━━ terraform plan ━━━'
|
|
734
|
-
|
|
735
|
-
|
|
734
|
+
|
|
735
|
+
stderr_buffer = run_terraform_plan
|
|
736
|
+
return if stderr_buffer.nil? # Success
|
|
737
|
+
|
|
738
|
+
# If ACM for_each error, auto-bootstrap and retry
|
|
739
|
+
return if acm_for_each_error?(stderr_buffer) && acm_bootstrap_succeeded?
|
|
740
|
+
|
|
741
|
+
abort "\n✗ terraform plan failed"
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# Returns nil on success, stderr buffer on failure
|
|
745
|
+
def run_terraform_plan
|
|
746
|
+
require 'open3'
|
|
747
|
+
stderr_buffer = +''
|
|
748
|
+
|
|
749
|
+
Open3.popen3('terraform', 'plan', '-out=tfplan', *@extra_args) do |_stdin, stdout, stderr, wait_thr|
|
|
750
|
+
stdout_thread = Thread.new do
|
|
751
|
+
while (line = stdout.gets)
|
|
752
|
+
print line
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
stderr_thread = Thread.new do
|
|
757
|
+
while (line = stderr.gets)
|
|
758
|
+
stderr_buffer << line
|
|
759
|
+
$stderr.print line
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
stdout_thread.join
|
|
764
|
+
stderr_thread.join
|
|
765
|
+
|
|
766
|
+
return nil if wait_thr.value.success?
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
stderr_buffer
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def acm_for_each_error?(output)
|
|
773
|
+
# Detect the ACM certificate for_each chicken-and-egg error
|
|
774
|
+
output.include?('for_each') &&
|
|
775
|
+
output.include?('domain_validation_options') &&
|
|
776
|
+
output.include?('cannot be determined until apply')
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# Returns true if bootstrap succeeded and retry plan passed
|
|
780
|
+
def acm_bootstrap_succeeded?
|
|
781
|
+
puts ''
|
|
782
|
+
puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
|
|
783
|
+
puts ' ACM CERTIFICATE BOOTSTRAP'
|
|
784
|
+
puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
|
|
785
|
+
puts ''
|
|
786
|
+
puts ' First-time deploy detected. Terraform needs the ACM certificate'
|
|
787
|
+
puts ' to exist before it can create DNS validation records.'
|
|
788
|
+
puts ''
|
|
789
|
+
puts ' Creating certificate now...'
|
|
736
790
|
puts ''
|
|
791
|
+
|
|
792
|
+
# Phase 1: Create the certificate only
|
|
793
|
+
success = system('terraform', 'apply', '-target=module.app.aws_acm_certificate.app', '-auto-approve')
|
|
794
|
+
unless success
|
|
795
|
+
puts ''
|
|
796
|
+
puts ' ✗ Certificate creation failed.'
|
|
797
|
+
return false
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
puts ''
|
|
801
|
+
puts ' ✓ Certificate created. Re-running plan...'
|
|
802
|
+
puts ''
|
|
803
|
+
|
|
804
|
+
# Phase 2: Retry the plan
|
|
805
|
+
stderr_buffer = run_terraform_plan
|
|
806
|
+
if stderr_buffer.nil?
|
|
807
|
+
puts ''
|
|
808
|
+
return true # Success!
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# Still failing - something else is wrong
|
|
812
|
+
puts ''
|
|
813
|
+
puts ' ✗ Plan still failing after bootstrap. Check the errors above.'
|
|
814
|
+
false
|
|
737
815
|
end
|
|
738
816
|
|
|
739
817
|
def confirm_apply?
|
|
@@ -51,7 +51,13 @@ module Belt
|
|
|
51
51
|
'db:seed' => 'data_seeding',
|
|
52
52
|
'db:copy' => 'data_seeding',
|
|
53
53
|
'copy' => 'data_seeding',
|
|
54
|
-
'db_copy' => 'data_seeding'
|
|
54
|
+
'db_copy' => 'data_seeding',
|
|
55
|
+
'e2e' => 'testing',
|
|
56
|
+
'test' => 'testing',
|
|
57
|
+
'tests' => 'testing',
|
|
58
|
+
'harness' => 'testing',
|
|
59
|
+
'spec' => 'testing',
|
|
60
|
+
'specs' => 'testing'
|
|
55
61
|
}.freeze
|
|
56
62
|
|
|
57
63
|
def self.run(args)
|
|
@@ -114,6 +120,7 @@ module Belt
|
|
|
114
120
|
irb, repl → console
|
|
115
121
|
seeds, seed, db:seed → data_seeding
|
|
116
122
|
db:copy, copy → data_seeding
|
|
123
|
+
e2e, test, harness → testing
|
|
117
124
|
|
|
118
125
|
Examples:
|
|
119
126
|
belt explain routing
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
Belt ships an in-process **end-to-end harness** that drives the real router — no AWS, no
|
|
4
|
+
HTTP server, no browser. It's the tier between controller unit tests (which dispatch an
|
|
5
|
+
action directly and never touch routing) and full cloud e2e (which needs a deployed stack).
|
|
6
|
+
|
|
7
|
+
Load it opt-in — it is **not** required by `require 'belt'`, so it never ships in the
|
|
8
|
+
production Lambda path:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
require 'belt'
|
|
12
|
+
require 'belt/testing'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What it exercises
|
|
16
|
+
|
|
17
|
+
The harness builds a synthetic API Gateway proxy event (method, path, body, and a
|
|
18
|
+
`requestContext` carrying Cognito-style claims) and routes it through the real
|
|
19
|
+
`Belt::ActionRouter` — exactly as API Gateway delivers it in production. The router finds
|
|
20
|
+
the route, extracts path params, resolves and instantiates the real controller, and runs
|
|
21
|
+
the real `before_action` chain. You assert on the HTTP-shaped response.
|
|
22
|
+
|
|
23
|
+
| Layer | In the harness |
|
|
24
|
+
|-------|----------------|
|
|
25
|
+
| Router, controllers, models, validations, authorization | **Real** |
|
|
26
|
+
| DynamoDB | Your choice (e.g. DynamoDB Local) — wired in your own test boot |
|
|
27
|
+
| Cognito | A claims hash in `requestContext.authorizer.claims` (the seam API Gateway's authorizer fills in production) |
|
|
28
|
+
| API Gateway | The synthetic event **is** the seam — the router is what APIGW dispatches to |
|
|
29
|
+
|
|
30
|
+
Anything genuinely external (Cognito token verification, Bedrock, Stripe, S3) stays a seam
|
|
31
|
+
you stub in your own boot file. Belt owns the generic request mechanics; your app owns its
|
|
32
|
+
data-store lifecycle and external stubs.
|
|
33
|
+
|
|
34
|
+
## The pieces
|
|
35
|
+
|
|
36
|
+
- **`Belt::Testing::E2E::Client`** — wraps one `Belt::ActionRouter`, builds synthetic
|
|
37
|
+
events, dispatches, and returns a `Response`.
|
|
38
|
+
- **`Belt::Testing::E2E::Response`** — a parsed `{ status, headers, body, json }` struct
|
|
39
|
+
with `ok?` and `[]` (reads a top-level key out of the parsed JSON body).
|
|
40
|
+
- **`Belt::Testing::E2E::Helpers`** — a mixin for `Minitest::Test` or RSpec example groups
|
|
41
|
+
giving you `api_get` / `api_post` / `api_put` / `api_patch` / `api_delete` / `api_request`
|
|
42
|
+
plus a `cognito_claims` builder.
|
|
43
|
+
- **`Belt::Testing::E2E.manifest_from_belt_routes(app_root:)`** — loads the route manifest
|
|
44
|
+
via your app's own `belt routes -f json`, the same canonical path the deployed Lambda's
|
|
45
|
+
manifest comes from, so the harness can't drift from Belt's route-building internals.
|
|
46
|
+
|
|
47
|
+
## Setup
|
|
48
|
+
|
|
49
|
+
Build the router once per process from your app's real manifest, then expose a client:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
require 'belt'
|
|
53
|
+
require 'belt/testing'
|
|
54
|
+
|
|
55
|
+
# ... boot your app: set test ENV, require 'belt', load models + controllers,
|
|
56
|
+
# point Aws::DynamoDB::Client at DynamoDB Local, etc. ...
|
|
57
|
+
|
|
58
|
+
router = Belt::ActionRouter.new(
|
|
59
|
+
routes: Belt::Testing::E2E.manifest_from_belt_routes(app_root: APP_ROOT),
|
|
60
|
+
gateway: 'api'
|
|
61
|
+
)
|
|
62
|
+
Belt::Testing::E2E.client = Belt::Testing::E2E::Client.new(router: router)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Writing tests
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class ProjectsE2ETest < Minitest::Test
|
|
69
|
+
include Belt::Testing::E2E::Helpers
|
|
70
|
+
|
|
71
|
+
def test_lists_projects_for_an_admin
|
|
72
|
+
res = api_get('/projects', claims: cognito_claims(groups: 'admins'))
|
|
73
|
+
|
|
74
|
+
assert res.ok? # 2xx
|
|
75
|
+
assert_equal 200, res.status
|
|
76
|
+
assert_kind_of Array, res['projects'] # top-level key out of the JSON body
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_creates_a_project
|
|
80
|
+
res = api_post('/projects', body: { slug: 'alpha', name: 'Alpha' },
|
|
81
|
+
claims: cognito_claims(groups: 'admins'))
|
|
82
|
+
|
|
83
|
+
assert_equal 201, res.status
|
|
84
|
+
assert_equal 'alpha', res['project']['slug']
|
|
85
|
+
|
|
86
|
+
# The write really happened — re-read it through the router.
|
|
87
|
+
show = api_get('/projects/alpha', claims: cognito_claims(groups: 'admins'))
|
|
88
|
+
assert show.ok?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_rejects_an_unauthenticated_request
|
|
92
|
+
res = api_get('/projects') # no claims → unauthenticated
|
|
93
|
+
assert_equal 401, res.status
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Request options
|
|
99
|
+
|
|
100
|
+
Every `api_*` helper (and `Client#request`) accepts:
|
|
101
|
+
|
|
102
|
+
| Option | Meaning |
|
|
103
|
+
|--------|---------|
|
|
104
|
+
| `body:` | request body as a **parsed Hash** (the LambdaHandler JSON-parses it in production) |
|
|
105
|
+
| `claims:` | Cognito claims hash injected into `requestContext.authorizer.claims`; omit for an unauthenticated request |
|
|
106
|
+
| `token:` | a Bearer token (e.g. an API key) set as the `Authorization` header, for non-Cognito auth |
|
|
107
|
+
| `headers:` | extra request headers |
|
|
108
|
+
| `query:` | query string parameters (`event['queryStringParameters']`) |
|
|
109
|
+
|
|
110
|
+
### Claims
|
|
111
|
+
|
|
112
|
+
`cognito_claims` builds the hash API Gateway's authorizer would inject:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
cognito_claims # { 'sub' => 'e2e-user-sub', 'email' => '...' }
|
|
116
|
+
cognito_claims(sub: 'u-1', groups: 'admins') # platform staff
|
|
117
|
+
cognito_claims(groups: %w[admins editors]) # array → space-joined 'cognito:groups'
|
|
118
|
+
cognito_claims(tenant: 't-1') # extra claims merge straight in
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Per-test client
|
|
122
|
+
|
|
123
|
+
By default the helpers use the process-wide `Belt::Testing::E2E.client`. To use a different
|
|
124
|
+
client (e.g. a second gateway), override `#e2e_client` in your test base class:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
class OpsE2ETest < Minitest::Test
|
|
128
|
+
include Belt::Testing::E2E::Helpers
|
|
129
|
+
|
|
130
|
+
def e2e_client
|
|
131
|
+
@e2e_client ||= Belt::Testing::E2E::Client.new(router: OPS_ROUTER)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Keeping it out of the normal suite
|
|
137
|
+
|
|
138
|
+
The harness needs the **real** Belt stack (`require 'belt'`). If your unit suite shadows
|
|
139
|
+
`BeltController::Base` with a stub, keep the e2e tier in its own directory (e.g. `e2e/`,
|
|
140
|
+
not `spec/`) and run it as its own process so the two don't collide:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
cd lambda
|
|
144
|
+
bundle exec ruby -Ie2e -e "Dir.glob('e2e/**/*_test.rb').each { |f| require File.expand_path(f) }"
|
|
145
|
+
```
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Belt
|
|
6
|
+
module Testing
|
|
7
|
+
# In-process end-to-end harness — the real request path, no AWS.
|
|
8
|
+
#
|
|
9
|
+
# Most backend tests stop at the controller: they instantiate a controller and
|
|
10
|
+
# dispatch an action directly. That proves action logic, but it never exercises the
|
|
11
|
+
# router, the route manifest, path-param extraction, or the Lambda-shaped event the
|
|
12
|
+
# controller actually receives in production.
|
|
13
|
+
#
|
|
14
|
+
# This harness closes that gap. It drives the REAL {Belt::ActionRouter} with a
|
|
15
|
+
# synthetic API Gateway proxy event — method, path, body, and a requestContext
|
|
16
|
+
# carrying Cognito-style claims — exactly as API Gateway would deliver it. The router
|
|
17
|
+
# finds the route, extracts path params, instantiates the real controller, and runs
|
|
18
|
+
# the real before_action chain. You assert on the HTTP-shaped response.
|
|
19
|
+
#
|
|
20
|
+
# What's real vs. seamed (the deliberate boundary):
|
|
21
|
+
# - Router, controllers, models, validations, authorization .... REAL
|
|
22
|
+
# - DynamoDB ............................................ your choice (e.g. DynamoDB Local)
|
|
23
|
+
# - Cognito ............................................ a claims hash in requestContext
|
|
24
|
+
# (the seam API Gateway's
|
|
25
|
+
# authorizer fills in production)
|
|
26
|
+
# - API Gateway ........................................ the synthetic event IS the seam
|
|
27
|
+
#
|
|
28
|
+
# The harness is framework-agnostic: use the {Client} directly, or mix {Helpers}
|
|
29
|
+
# into your Minitest::Test / RSpec example group for `api_get`/`api_post`/... sugar.
|
|
30
|
+
#
|
|
31
|
+
# @example Minitest
|
|
32
|
+
# router = Belt::ActionRouter.new(
|
|
33
|
+
# routes: Belt::Testing::E2E.manifest_from_belt_routes(app_root: APP_ROOT),
|
|
34
|
+
# gateway: 'api'
|
|
35
|
+
# )
|
|
36
|
+
# Belt::Testing::E2E.client = Belt::Testing::E2E::Client.new(router: router)
|
|
37
|
+
#
|
|
38
|
+
# class ProjectsE2ETest < Minitest::Test
|
|
39
|
+
# include Belt::Testing::E2E::Helpers
|
|
40
|
+
#
|
|
41
|
+
# def test_create_project
|
|
42
|
+
# res = api_post('/projects', body: { slug: 'x' }, claims: cognito_claims(groups: 'admins'))
|
|
43
|
+
# assert res.ok?
|
|
44
|
+
# assert_equal 'x', res['project']['slug']
|
|
45
|
+
# end
|
|
46
|
+
# end
|
|
47
|
+
module E2E
|
|
48
|
+
# A parsed response from the router. `json` is the body parsed as JSON (Hash) when
|
|
49
|
+
# possible; `[]` reads a top-level key out of that parsed body.
|
|
50
|
+
Response = Struct.new(:status, :headers, :body, :json, keyword_init: true) do
|
|
51
|
+
def ok?
|
|
52
|
+
status.is_a?(Integer) && status.between?(200, 299)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def [](key)
|
|
56
|
+
json&.[](key.to_s)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
# A process-wide default client, so {Helpers} works without per-test setup.
|
|
62
|
+
# Assign one after booting your stack and building a router.
|
|
63
|
+
attr_accessor :client
|
|
64
|
+
|
|
65
|
+
# Build a route manifest from the app's own `belt routes -f json`.
|
|
66
|
+
#
|
|
67
|
+
# This is the app's canonical, supported way to produce a manifest — the same
|
|
68
|
+
# path the deployed Lambda's manifest comes from — so the harness can't drift
|
|
69
|
+
# from Belt's route-building internals.
|
|
70
|
+
#
|
|
71
|
+
# @param app_root [String] directory to run `belt routes` from (the app's `lambda/`)
|
|
72
|
+
# @param command [Array<String>] override the command (mainly for testing)
|
|
73
|
+
# @return [Array<Hash>] routes shaped as { verb:, path:, controller:, action: }
|
|
74
|
+
def manifest_from_belt_routes(app_root:, command: %w[bundle exec belt routes -f json])
|
|
75
|
+
require 'open3'
|
|
76
|
+
out, err, status = Open3.capture3(*command, chdir: app_root)
|
|
77
|
+
raise Error, "`#{command.join(' ')}` failed (#{status.exitstatus}): #{err.strip}" unless status.success?
|
|
78
|
+
|
|
79
|
+
parse_manifest(out)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Parse the JSON produced by `belt routes -f json` into the manifest shape the
|
|
83
|
+
# router expects. Public so callers that already have the JSON (CI artifact,
|
|
84
|
+
# cached file) can reuse it without shelling out.
|
|
85
|
+
#
|
|
86
|
+
# @param json [String] output of `belt routes -f json`
|
|
87
|
+
# @return [Array<Hash>]
|
|
88
|
+
def parse_manifest(json)
|
|
89
|
+
JSON.parse(json).fetch('routes').map do |r|
|
|
90
|
+
{
|
|
91
|
+
verb: r['verb'],
|
|
92
|
+
path: r['path'],
|
|
93
|
+
controller: r['controller'],
|
|
94
|
+
action: r['action']
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
rescue KeyError => e
|
|
98
|
+
raise Error, "belt routes JSON missing expected key: #{e.message}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Raised when the harness can't do its job (manifest load failure, missing client).
|
|
103
|
+
class Error < StandardError; end
|
|
104
|
+
|
|
105
|
+
# Drives a {Belt::ActionRouter} with synthetic API Gateway proxy events.
|
|
106
|
+
#
|
|
107
|
+
# One client wraps one router. Build it once per process (routing is immutable) and
|
|
108
|
+
# reuse it across tests — isolation comes from your data-store teardown, not from
|
|
109
|
+
# rebuilding the router.
|
|
110
|
+
class Client
|
|
111
|
+
attr_reader :router
|
|
112
|
+
|
|
113
|
+
# @param router [Belt::ActionRouter] the real router to dispatch through
|
|
114
|
+
def initialize(router:)
|
|
115
|
+
@router = router
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Issue a request through the real router.
|
|
119
|
+
#
|
|
120
|
+
# @param method [Symbol, String] HTTP verb (:get, :post, ...)
|
|
121
|
+
# @param path [String] request path, e.g. '/projects/ws-1'
|
|
122
|
+
# @param body [Hash, nil] request body, already parsed (as the LambdaHandler
|
|
123
|
+
# delivers it in production). `nil` becomes `{}` inside the controller.
|
|
124
|
+
# @param claims [Hash, nil] Cognito claims the API Gateway authorizer would inject
|
|
125
|
+
# into `requestContext.authorizer.claims`. `nil` means an unauthenticated request.
|
|
126
|
+
# @param token [String, nil] a Bearer token (e.g. an API key) set as the
|
|
127
|
+
# Authorization header, for non-Cognito auth paths.
|
|
128
|
+
# @param headers [Hash] extra request headers.
|
|
129
|
+
# @param query [Hash, nil] query string parameters.
|
|
130
|
+
# @return [Response]
|
|
131
|
+
# rubocop:disable Metrics/ParameterLists -- these are the request's distinct, documented dimensions
|
|
132
|
+
def request(method, path, body: nil, claims: nil, token: nil, headers: {}, query: nil)
|
|
133
|
+
# rubocop:enable Metrics/ParameterLists
|
|
134
|
+
event = {
|
|
135
|
+
'httpMethod' => method.to_s.upcase,
|
|
136
|
+
'path' => path,
|
|
137
|
+
'headers' => build_headers(headers, token),
|
|
138
|
+
'pathParameters' => {},
|
|
139
|
+
'queryStringParameters' => query,
|
|
140
|
+
'requestContext' => request_context(claims)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
result = router.route(event: event, body: body)
|
|
144
|
+
parse_response(result)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def get(path, **) = request(:get, path, **)
|
|
148
|
+
def post(path, **) = request(:post, path, **)
|
|
149
|
+
def put(path, **) = request(:put, path, **)
|
|
150
|
+
def patch(path, **) = request(:patch, path, **)
|
|
151
|
+
def delete(path, **) = request(:delete, path, **)
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def build_headers(headers, token)
|
|
156
|
+
h = (headers || {}).dup
|
|
157
|
+
h['Authorization'] = "Bearer #{token}" if token
|
|
158
|
+
h
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def request_context(claims)
|
|
162
|
+
return {} if claims.nil?
|
|
163
|
+
|
|
164
|
+
{ 'authorizer' => { 'claims' => claims } }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The real controller always returns a Lambda-shaped Hash:
|
|
168
|
+
# { statusCode:, headers:, body: <JSON string> }. So does the router's own error
|
|
169
|
+
# path. Normalize into a Response, tolerating string or symbol keys.
|
|
170
|
+
def parse_response(result)
|
|
171
|
+
status = result[:statusCode] || result['statusCode']
|
|
172
|
+
headers = result[:headers] || result['headers'] || {}
|
|
173
|
+
raw = result[:body] || result['body']
|
|
174
|
+
|
|
175
|
+
json =
|
|
176
|
+
begin
|
|
177
|
+
raw.is_a?(String) && !raw.empty? ? JSON.parse(raw) : nil
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
Response.new(status: status, headers: headers, body: raw, json: json)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Mixin for test classes (Minitest::Test or RSpec example groups).
|
|
187
|
+
#
|
|
188
|
+
# Provides `api_get`/`api_post`/`api_put`/`api_patch`/`api_delete`/`api_request`
|
|
189
|
+
# delegating to a client, plus a `cognito_claims` builder. By default it uses the
|
|
190
|
+
# process-wide {E2E.client}; override {#e2e_client} to supply a per-test client.
|
|
191
|
+
module Helpers
|
|
192
|
+
# The client requests route through. Override in your test base class to use a
|
|
193
|
+
# different client than the process-wide default.
|
|
194
|
+
#
|
|
195
|
+
# @return [Client]
|
|
196
|
+
def e2e_client
|
|
197
|
+
E2E.client || raise(Error, 'No Belt::Testing::E2E client configured. ' \
|
|
198
|
+
'Set Belt::Testing::E2E.client or override #e2e_client.')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def api_request(method, path, **) = e2e_client.request(method, path, **)
|
|
202
|
+
def api_get(path, **) = e2e_client.get(path, **)
|
|
203
|
+
def api_post(path, **) = e2e_client.post(path, **)
|
|
204
|
+
def api_put(path, **) = e2e_client.put(path, **)
|
|
205
|
+
def api_patch(path, **) = e2e_client.patch(path, **)
|
|
206
|
+
def api_delete(path, **) = e2e_client.delete(path, **)
|
|
207
|
+
|
|
208
|
+
# Build a Cognito claims hash — what API Gateway's authorizer injects for an
|
|
209
|
+
# authenticated human.
|
|
210
|
+
#
|
|
211
|
+
# @param sub [String] the Cognito subject (user id)
|
|
212
|
+
# @param email [String, nil] the user's email claim
|
|
213
|
+
# @param groups [String, Array<String>, nil] `cognito:groups` (platform roles).
|
|
214
|
+
# Belt reads this as a space/comma-delimited string; an array is joined with ' '.
|
|
215
|
+
# @param extra [Hash] any additional claims to merge in
|
|
216
|
+
# @return [Hash]
|
|
217
|
+
def cognito_claims(sub: 'e2e-user-sub', email: 'user@example.com', groups: nil, **extra)
|
|
218
|
+
claims = { 'sub' => sub }
|
|
219
|
+
claims['email'] = email if email
|
|
220
|
+
claims['cognito:groups'] = groups.is_a?(Array) ? groups.join(' ') : groups if groups
|
|
221
|
+
claims.merge(extra.transform_keys(&:to_s))
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
data/lib/belt/testing.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Belt's test support. Opt-in: this is NOT loaded by `require 'belt'`, so it never ships
|
|
4
|
+
# in the production Lambda path. Require it from your test harness instead:
|
|
5
|
+
#
|
|
6
|
+
# require 'belt'
|
|
7
|
+
# require 'belt/testing'
|
|
8
|
+
#
|
|
9
|
+
# See Belt::Testing::E2E for the in-process, real-router end-to-end harness.
|
|
10
|
+
|
|
11
|
+
require_relative 'action_router'
|
|
12
|
+
require_relative 'testing/e2e'
|
|
13
|
+
|
|
14
|
+
module Belt
|
|
15
|
+
# Namespace for Belt's test-support helpers (opt-in via `require 'belt/testing'`).
|
|
16
|
+
module Testing
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/belt/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: belt
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
@@ -173,6 +173,7 @@ files:
|
|
|
173
173
|
- lib/belt/docs/plugins.md
|
|
174
174
|
- lib/belt/docs/routing.md
|
|
175
175
|
- lib/belt/docs/structure.md
|
|
176
|
+
- lib/belt/docs/testing.md
|
|
176
177
|
- lib/belt/errors.rb
|
|
177
178
|
- lib/belt/helpers/cors_origin.rb
|
|
178
179
|
- lib/belt/helpers/error_logging.rb
|
|
@@ -186,6 +187,8 @@ files:
|
|
|
186
187
|
- lib/belt/root.rb
|
|
187
188
|
- lib/belt/route_dsl.rb
|
|
188
189
|
- lib/belt/table_inference.rb
|
|
190
|
+
- lib/belt/testing.rb
|
|
191
|
+
- lib/belt/testing/e2e.rb
|
|
189
192
|
- lib/belt/version.rb
|
|
190
193
|
- lib/belt/views/welcome/show.html.erb
|
|
191
194
|
- lib/belt_controller/base.rb
|