Skip to content

Commit 1caf9d4

Browse files
authored
Make the test environment match production and let before_all work in specs (#814)
1 parent 7f20123 commit 1caf9d4

11 files changed

Lines changed: 185 additions & 13 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ error 405 do |env|
1717
end
1818
```
1919

20+
- Make the `test` environment behave like every other one, and let `before_all`/`after_all` work in specs [#788](https://github.com/kemalcr/kemal/issues/788). Three things differed between a spec run and production:
21+
22+
- An unmatched route answered an empty `200` under `KEMAL_ENV=test`. `Kemal.run` registered the default 404 page only outside that environment, and without a registered `error 404` a `RouteNotFound` rendered nothing. The page is now registered in every environment, and a `RouteNotFound` with no handler renders a plain `404 Not Found` like the other built-in errors do.
23+
- `before_all` ran for an unmatched path — a 404, a 405, or a WebSocket path reached without an upgrade — only when a custom `error 404`/`405` handler was registered, which `Kemal.run` did in production and nothing did under spec. An authentication guard in `before_all` therefore held in production and not in the tests that were supposed to prove it. It runs for every unmatched path now, in every environment.
24+
- Kemal's top-level `before_all` and `after_all` shadow the `describe`-level hooks of the `spec` library, so a spec calling `before_all { seed }` inside a `describe` registered a request filter and never ran the block, with no error. Called inside a `describe` they now register the spec hook; called anywhere else — an application file, a route, an example — they register Kemal's filter as before.
25+
26+
Anything asserting an empty `200` for an unknown path in the test environment, or relying on `before_all` not running for unmatched paths under spec, has to be updated.
2027
- Build the `Content-Disposition` of `send_file` per [RFC 6266](https://www.rfc-editor.org/rfc/rfc6266#section-4.3) and [RFC 8187](https://www.rfc-editor.org/rfc/rfc8187). The filename was dropped into the quoted-string as it was: a `"` in it ended the parameter early, a non-ASCII name went out raw where the parameter is defined as ASCII, and a control character made the standard library reject the header with a `500`. `"` and `\\` are now escaped, other characters outside printable ASCII become `_` in `filename`, and when that loses anything the original name follows as `filename*=UTF-8''…`, which user agents prefer. A plain ASCII name produces the same header as before. `Kemal::Utils.content_disposition` is the builder.
2128
- `params.raw_body` returns the body of any request, not only a form or JSON one. It came back empty for `text/plain`, XML, or a request with no `Content-Type` at all, so such a body looked absent rather than unread; it is now read and cached the same way, under `max_request_body_size`. A `multipart/form-data` body is the one exception and still returns `""`, since `parse_files` streams it part by part. JSON detection now goes by media type instead of a string prefix: `application/vnd.api+json` and other `+json` types ([RFC 6839](https://www.rfc-editor.org/rfc/rfc6839)) parse into `params.json`, `Application/JSON` matches, and `application/jsonp` no longer does.
2229
- Skip the WebSocket upgrade when a `before` filter has already answered. A `halt` in a `before_all` — an authentication check answering `401` — closed the response, and `Kemal::WebSocketHandler` went on to attempt the upgrade regardless; the standard library handler raised `IO::Error: Closed stream` on the closed response. The client had its `401`, but every rejected handshake was logged as a server error. The handler now returns once it finds the response closed.

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,22 @@ Kemal.config.show_exceptions = false
210210

211211
Register `error 500` to render your own page instead; the setting only decides between Kemal's two built-in ones.
212212

213+
**Can I use `before_all` in my specs?**
214+
215+
Yes. Kemal's `before_all` and `after_all` share their names with the `describe`-level hooks of Crystal's `spec` library, and as top-level definitions they take precedence. Called inside a `describe` block they act as the spec hooks — the block runs once around the group's examples — so a spec file reads the same with or without Kemal loaded. Called anywhere else, including inside an example, they register Kemal's filter.
216+
217+
```crystal
218+
describe "Users" do
219+
before_all { seed_users } # the spec hook: once, before the examples
220+
221+
it "lists them" do
222+
before_all { |env| env.set "user", "bob" } # Kemal's filter, for this example's requests
223+
get "/users" { |env| env.get("user").to_s }
224+
call_request_on_app(HTTP::Request.new("GET", "/users")).body.should eq("bob")
225+
end
226+
end
227+
```
228+
213229
**Does Kemal work with any ORM?**
214230

215231
Yes. You can use any Crystal ORM or database library. No forced dependencies.

spec/exception_handler_spec.cr

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ describe "Kemal::ExceptionHandler" do
1818
response.status_code.should eq 404
1919
end
2020

21+
it "answers an unmatched route with 404 through the full chain without a custom handler" do
22+
# Without a registered `error 404` this used to fall through as an empty
23+
# `200` - which is what every request in the `test` environment got, since
24+
# only `Kemal.run` outside it registered the page.
25+
get "/" do
26+
"Hello"
27+
end
28+
29+
response = call_request_on_app(HTTP::Request.new("GET", "/nope"))
30+
response.status_code.should eq 404
31+
response.headers["Content-Type"].should eq "text/plain"
32+
response.body.should eq "Not Found"
33+
end
34+
2135
it "does not reflect the request in the message a 404 handler receives" do
2236
# Echoing `ex.message` is the obvious thing to write in an `error 404`
2337
# handler, and the response is `text/html`, so the message must not carry

spec/filters_spec.cr

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,4 +251,33 @@ describe "Kemal::FilterHandler" do
251251
client_response.body.should eq(body)
252252
end
253253
end
254+
255+
it "runs before_all for an unmatched path whether or not an error handler is registered" do
256+
# `Kemal.run` registers an `error 404` outside the `test` environment and
257+
# nothing does inside it; `before_all` used to run only when one existed, so
258+
# an authentication guard ran in production and not under spec.
259+
Kemal.config.error_handlers.has_key?(404).should be_false
260+
261+
filter_handler = Kemal::FilterHandler.new
262+
filter_handler._add_route_filter("ALL", "*", :before) do |env|
263+
env.response.headers["X-Seen"] = "1"
264+
end
265+
Kemal.config.add_filter_handler(filter_handler)
266+
267+
response = call_request_on_app(HTTP::Request.new("GET", "/nope"))
268+
response.status_code.should eq(404)
269+
response.headers["X-Seen"].should eq("1")
270+
end
271+
272+
it "lets before_all answer an unmatched path itself" do
273+
filter_handler = Kemal::FilterHandler.new
274+
filter_handler._add_route_filter("ALL", "*", :before) do |env|
275+
halt env, status_code: 401, response: "auth required"
276+
end
277+
Kemal.config.add_filter_handler(filter_handler)
278+
279+
response = call_request_on_app(HTTP::Request.new("GET", "/nope"))
280+
response.status_code.should eq(401)
281+
response.body.should eq("auth required")
282+
end
254283
end

spec/run_spec.cr

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,13 @@ describe "Run" do
143143
CR
144144
end
145145

146+
it "registers the default 404 page in the test environment too" do
147+
run(<<-'CR').should contain("404 handler: true")
148+
Kemal.run { }
149+
puts "404 handler: #{Kemal.config.error_handlers.has_key?(404)}"
150+
CR
151+
end
152+
146153
it "runs without a block being specified" do
147154
run(<<-CR).should contain "[test] Kemal is running in test mode."
148155
Kemal.run

spec/spec_hooks_spec.cr

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
require "./spec_helper"
2+
3+
# Kemal's top-level `before_all`/`after_all` shadow the `describe` hooks of the
4+
# spec library. Inside a `describe` they must behave as the spec hooks: run once
5+
# around the group's examples, never register a request filter.
6+
AFTER_ALL_RAN = [] of Symbol
7+
8+
describe "before_all inside a describe" do
9+
runs = 0
10+
11+
before_all do
12+
runs += 1
13+
end
14+
15+
after_all do
16+
AFTER_ALL_RAN << :ran
17+
end
18+
19+
it "has run once before the first example" do
20+
runs.should eq(1)
21+
end
22+
23+
it "does not run again for the next example" do
24+
runs.should eq(1)
25+
end
26+
27+
it "registered no request filter" do
28+
# A filter would have run for this request and bumped the counter.
29+
get("/") { "ok" }
30+
call_request_on_app(HTTP::Request.new("GET", "/")).body.should eq("ok")
31+
runs.should eq(1)
32+
end
33+
end
34+
35+
describe "after_all inside a describe" do
36+
it "has run once the previous group finished" do
37+
AFTER_ALL_RAN.should eq([:ran])
38+
end
39+
end
40+
41+
describe "before_all outside a describe body" do
42+
it "registers a request filter when called from an example" do
43+
# Examples run after every `describe` block has been evaluated, so this is
44+
# not a `describe` body; it is Kemal's filter, as in an application file.
45+
before_all do |env|
46+
env.response.headers["X-Filtered"] = "1"
47+
end
48+
get("/") { "ok" }
49+
50+
call_request_on_app(HTTP::Request.new("GET", "/")).headers["X-Filtered"].should eq("1")
51+
end
52+
end

src/kemal.cr

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,9 @@ module Kemal
4242
config.setup
4343
config.port = port if port
4444

45-
# Test environment doesn't need to have signal trap and logging.
46-
if config.env != "test"
47-
setup_404
48-
setup_trap_signal if trap_signal
49-
end
45+
setup_404
46+
# A test environment does not listen, so it has no signal to trap.
47+
setup_trap_signal if trap_signal && config.env != "test"
5048

5149
server = config.server ||= HTTP::Server.new(config.handlers)
5250

src/kemal/dsl.cr

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,28 @@ end
147147
#
148148
# NOTE: Response headers must be set before the response body is written,
149149
# so use `before_*` filters for header changes.
150+
#
151+
# `before_all` and `after_all` share their names with the `describe`-level hooks
152+
# of Crystal's `spec` library, and as top-level definitions they win. Called
153+
# directly inside a `describe` block they therefore register the spec hook, as
154+
# a spec author expects (see `Kemal::SpecHooks`); called anywhere else - the top
155+
# level of a file, a route, an example - they register Kemal's filter.
150156
{% for type in ["before", "after"] %}
151157
{% for method in FILTER_METHODS %}
152158
def {{ type.id }}_{{ method.id }}(path : String = "*", &block : HTTP::Server::Context -> _)
153-
Kemal::FilterHandler::INSTANCE.{{ type.id }}({{ method }}.upcase, path, &block)
159+
{% if method == "all" %}
160+
filter = block
161+
{% if @top_level.has_constant?("Spec") %}
162+
unless Spec.cli.current_context.is_a?(Spec::RootContext)
163+
# A spec hook takes no argument; the filter block was typed to take a
164+
# context, so it gets a blank one it will never look at.
165+
return Spec::KemalHooks.{{ type.id }}_all do
166+
filter.call(HTTP::Server::Context.new(HTTP::Request.new("GET", "/"), HTTP::Server::Response.new(IO::Memory.new)))
167+
end
168+
end
169+
{% end %}
170+
{% end %}
171+
Kemal::FilterHandler::INSTANCE.{{ type.id }}({{ method }}.upcase, path, &block)
154172
end
155173

156174
def {{ type.id }}_{{ method.id }}(paths : Enumerable(String), &block : HTTP::Server::Context -> _)

src/kemal/exception_handler.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module Kemal
77
def call(context : HTTP::Server::Context)
88
call_next(context)
99
rescue ex : Kemal::Exceptions::RouteNotFound
10-
call_exception_with_status_code(context, ex, 404)
10+
call_fixed_status(context, ex, 404)
1111
rescue ex : Kemal::Exceptions::MethodNotAllowed
1212
call_method_not_allowed(context, ex)
1313
rescue ex : Kemal::Exceptions::CustomException

src/kemal/filter_handler.cr

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ module Kemal
4040
# The call order of the filters is `before_all -> before_x -> X -> after_x -> after_all`.
4141
def call(context : HTTP::Server::Context)
4242
if !context.route_found?
43-
# A request that matched no route can still reach a custom `error`
44-
# handler - 404, or 405 when the path is routed for another method - and
45-
# that handler expects the same `before_all` setup a route gets.
46-
if Kemal.config.error_handlers.has_key?(404) || Kemal.config.error_handlers.has_key?(405)
47-
call_block_for_path_type("ALL", context.request.path, :before, context)
48-
end
43+
# A request that matched no route still gets the `before_all` filters: it
44+
# ends in a 404 or 405 page, or in a WebSocket upgrade, and the setup those
45+
# expect - an authentication check above all - is the same a route gets.
46+
# This used to depend on whether a custom `error 404`/`405` handler was
47+
# registered, which `Kemal.run` does outside the `test` environment and
48+
# nothing does inside it, so a `before_all` guard ran in production and
49+
# not under spec.
50+
call_block_for_path_type("ALL", context.request.path, :before, context)
4951
return call_next(context)
5052
end
5153

0 commit comments

Comments
 (0)