From 19e6b03e41f65c3036ce34555a5248693df9de0d Mon Sep 17 00:00:00 2001 From: Hugo Parente Lima Date: Sat, 13 Sep 2025 08:11:58 -0300 Subject: [PATCH 01/11] Add support to write tests with kemal-session. (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By default spec-kemal still not depending on kemal-session, but if you require "spec-kemal/session", "kemal-session" dependency must be added to shard.yml. *How to use* ``` require "spec-kemal/session" it "handles sessions" do get "/session_var" do |env| env.session.string?(env.params.query["key"]) || "not found" end with_session do |session| session.string("hey ho!", "let's go! 🎸") get "/session_var?key=hey+ho!" response.body.should eq("let's go! 🎸") end end ``` --- README.md | 20 ++++++++++++++++++++ shard.yml | 3 ++- spec/spec-kemal_spec.cr | 12 ++++++++++++ spec/spec_helper.cr | 4 ++++ src/spec-kemal.cr | 15 +++++++++++++-- src/spec-kemal/session.cr | 27 +++++++++++++++++++++++++++ 6 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 src/spec-kemal/session.cr diff --git a/README.md b/README.md index e41c8ab..c16e879 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,26 @@ describe "Your::Kemal::App" do end ``` +### Kemal Session + +If you are using [Kemal Session](https://github.com/kemalcr/kemal-session) in your application it's possible to test session related features as well. + +To do that require `"spec-kemal/session"` instead of just `spec-kemal`, then +you can use `with_session` to create a session for your requests. + +```crystal +require "spec-kemal/session" + +it "works with sessions" do + with_session do |session| + session.bigint("user_id", 12345) # sets a session value + + get "/dashboard" + response.body.should eq "session value" + end +end +``` + #### Rescue errors Errors gets rescued by default which results in the Kemal's exception page is rendered. This may not always be the desired behaviour, e.g. when a JSON parsing error occurs one might expect `"[]"` diff --git a/shard.yml b/shard.yml index b977dd9..607470b 100644 --- a/shard.yml +++ b/shard.yml @@ -5,7 +5,8 @@ crystal: '< 2.0.0' development_dependencies: kemal: github: kemalcr/kemal - version: 1.0.0 + kemal-session: + github: kemalcr/kemal-session authors: - Sdogruyol diff --git a/spec/spec-kemal_spec.cr b/spec/spec-kemal_spec.cr index 01fd05e..8146dbe 100644 --- a/spec/spec-kemal_spec.cr +++ b/spec/spec-kemal_spec.cr @@ -17,4 +17,16 @@ describe "SpecKemalApp" do post("/user", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: json_body.to_json) response.body.should eq(json_body.to_json) end + + it "handles sessions" do + get "/session_var" do |env| + env.session.string?(env.params.query["key"]) || "not found" + end + + with_session do |session| + session.string("hey ho!", "let's go! 🎸") + get "/session_var?key=hey+ho!" + response.body.should eq("let's go! 🎸") + end + end end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 0f43d09..d51745e 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,10 +1,14 @@ require "spec" require "kemal" +require "kemal-session" + require "../src/spec-kemal" +require "../src/spec-kemal/session" Spec.before_each do config = Kemal.config config.env = "test" + Kemal::Session.config.secret = "🤫" config.setup end diff --git a/src/spec-kemal.cr b/src/spec-kemal.cr index bf6223e..b2855d5 100644 --- a/src/spec-kemal.cr +++ b/src/spec-kemal.cr @@ -21,9 +21,20 @@ end end {% end %} -def process_request(request) +private def process_request(request) io = IO::Memory.new response = HTTP::Server::Response.new(io) + + # Inject session cookie if exists + if Global.responds_to?(:session?) + session = Global.session? + if session + session_cookie = HTTP::Cookie.new(Kemal::Session.config.cookie_name, + Kemal::Session.encode(session.id)) + request.cookies << session_cookie + end + end + context = HTTP::Server::Context.new(request, response) main_handler = build_main_handler main_handler.call context @@ -33,7 +44,7 @@ def process_request(request) Global.response = client_response end -def build_main_handler +private def build_main_handler main_handler = Kemal.config.handlers.first current_handler = main_handler Kemal.config.handlers.each_with_index do |handler, index| diff --git a/src/spec-kemal/session.cr b/src/spec-kemal/session.cr new file mode 100644 index 0000000..6165351 --- /dev/null +++ b/src/spec-kemal/session.cr @@ -0,0 +1,27 @@ +require "kemal-session" + +class Global + class_property? session : Kemal::Session? +end + +private def create_session : Kemal::Session + raise "Kemal session secret not set." if Kemal::Session.config.secret.empty? + + destroy_session + Global.session = Kemal::Session.new(Random::Secure.hex) +end + +# Creates a new session, yields it to the block, and ensures it is destroyed afterwards. +# +# All spec-kemal requests made within the block will use this session. +def with_session(&) + session = create_session + yield session +ensure + destroy_session +end + +private def destroy_session + Global.session?.try(&.destroy) + Global.session = nil +end From ece40a3eb474899731ba1b94c5d2776dc8caee84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Dogruyol=20-=20Sedo=20=E3=82=BB=E3=83=89?= <990485+sdogruyol@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:39:47 +0300 Subject: [PATCH 02/11] Remove Travis CI configuration and add GitHub Actions for continuous integration --- .github/workflows/ci.yml | 88 ++++++++++++++++++++++++++++++++++++++++ .travis.yml | 1 - README.md | 2 + src/spec-kemal.cr | 10 +++-- 4 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d57eac1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + # Run weekly on Sunday at midnight to catch Crystal updates + - cron: "0 0 * * 0" + +jobs: + test: + name: Crystal ${{ matrix.crystal }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + crystal: ["1.10", "1.11", "1.12", "1.13", "1.14", "latest"] + include: + - os: macos-latest + crystal: latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + with: + crystal: ${{ matrix.crystal }} + + - name: Cache shards + uses: actions/cache@v4 + with: + path: | + ~/.cache/shards + lib + key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} + restore-keys: | + ${{ runner.os }}-shards- + + - name: Install dependencies + run: shards install + + - name: Run tests + run: crystal spec --order=random + env: + KEMAL_ENV: test + + - name: Check formatting + run: crystal tool format --check + if: matrix.crystal == 'latest' && matrix.os == 'ubuntu-latest' + + lint: + name: Ameba Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + with: + crystal: latest + + - name: Cache shards + uses: actions/cache@v4 + with: + path: | + ~/.cache/shards + lib + key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} + restore-keys: | + ${{ runner.os }}-shards- + + - name: Install dependencies + run: shards install + + - name: Install Ameba + run: | + git clone https://github.com/crystal-ameba/ameba.git /tmp/ameba + cd /tmp/ameba && make install + + - name: Run Ameba + run: ameba + continue-on-error: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ffc7b6a..0000000 --- a/.travis.yml +++ /dev/null @@ -1 +0,0 @@ -language: crystal diff --git a/README.md b/README.md index c16e879..6afc3d9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # spec-kemal +[![CI](https://github.com/kemalcr/spec-kemal/actions/workflows/ci.yml/badge.svg)](https://github.com/kemalcr/spec-kemal/actions/workflows/ci.yml) + Kemal helpers to Crystal's `spec` for easy testing. ## Installation diff --git a/src/spec-kemal.cr b/src/spec-kemal.cr index b2855d5..da682ef 100644 --- a/src/spec-kemal.cr +++ b/src/spec-kemal.cr @@ -14,9 +14,9 @@ class Global end end -{% for method in %w(get post put head delete patch) %} - def {{method.id}}(path, headers : HTTP::Headers? = nil, body : String? = nil) - request = HTTP::Request.new("{{method.id}}".upcase, path, headers, body ) +{% for method in %w[get post put head delete patch] %} + def {{ method.id }}(path, headers : HTTP::Headers? = nil, body : String? = nil) + request = HTTP::Request.new("{{ method.id }}".upcase, path, headers, body ) Global.response = process_request request end {% end %} @@ -47,7 +47,7 @@ end private def build_main_handler main_handler = Kemal.config.handlers.first current_handler = main_handler - Kemal.config.handlers.each_with_index do |handler, index| + Kemal.config.handlers.each do |handler| current_handler.next = handler current_handler = handler end @@ -55,5 +55,7 @@ private def build_main_handler end def response + # ameba:disable Lint/NotNil Global.response.not_nil! + # ameba:enable Lint/NotNil end From 4a3d0575a26ce6ed9b55cb25b039ebdfd76a6f80 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:45:58 +0300 Subject: [PATCH 03/11] Enhance README and spec-kemal documentation with detailed usage examples and API references --- CHANGELOG.md | 43 ++++ CONTRIBUTING.md | 273 +++++++++++++++++++++++++ README.md | 405 +++++++++++++++++++++++++++++++++----- src/spec-kemal.cr | 148 ++++++++++++-- src/spec-kemal/session.cr | 130 +++++++++++- 5 files changed, 937 insertions(+), 62 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6359b8e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed +- Migrated from Travis CI to GitHub Actions +- Improved documentation with comprehensive examples +- Added inline documentation to source code + +## [1.0.0] - 2023-XX-XX + +### Added +- Session testing support via `with_session` helper +- Support for all HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD +- Custom headers support for requests +- Request body support for POST/PUT/PATCH requests + +### Changed +- Updated for Kemal 1.x compatibility +- Improved handler chain building + +## [0.5.0] - Previous Release + +### Added +- Initial session support +- Basic HTTP method helpers + +## [0.1.0] - Initial Release + +### Added +- Basic testing helpers for Kemal +- GET, POST support +- Response assertions + +[Unreleased]: https://github.com/kemalcr/spec-kemal/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/kemalcr/spec-kemal/compare/v0.5.0...v1.0.0 +[0.5.0]: https://github.com/kemalcr/spec-kemal/compare/v0.1.0...v0.5.0 +[0.1.0]: https://github.com/kemalcr/spec-kemal/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d841858 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,273 @@ +# Contributing to spec-kemal + +First off, thank you for considering contributing to spec-kemal! It's people like you that make spec-kemal such a great tool. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Making Changes](#making-changes) +- [Testing](#testing) +- [Submitting Changes](#submitting-changes) +- [Style Guidelines](#style-guidelines) +- [Reporting Bugs](#reporting-bugs) +- [Suggesting Features](#suggesting-features) + +## Code of Conduct + +This project and everyone participating in it is governed by our commitment to providing a welcoming and inclusive environment. Please be respectful and constructive in all interactions. + +## Getting Started + +1. **Fork the repository** on GitHub +2. **Clone your fork** locally: + ```bash + git clone https://github.com/YOUR-USERNAME/spec-kemal.git + cd spec-kemal + ``` +3. **Add the upstream remote**: + ```bash + git remote add upstream https://github.com/kemalcr/spec-kemal.git + ``` + +## Development Setup + +### Prerequisites + +- [Crystal](https://crystal-lang.org/install/) (1.10 or later recommended) +- Git + +### Installing Dependencies + +```bash +shards install +``` + +### Running Tests + +```bash +crystal spec +``` + +Or with verbose output: + +```bash +crystal spec --verbose +``` + +## Making Changes + +1. **Create a feature branch** from `master`: + ```bash + git checkout -b my-feature-branch + ``` + +2. **Make your changes** with clear, descriptive commits + +3. **Write or update tests** for your changes + +4. **Ensure all tests pass**: + ```bash + crystal spec + ``` + +5. **Format your code**: + ```bash + crystal tool format + ``` + +6. **Check for issues** (optional but recommended): + ```bash + # If you have ameba installed + ameba + ``` + +## Testing + +### Running the Test Suite + +```bash +# Run all tests +crystal spec + +# Run specific test file +crystal spec spec/spec-kemal_spec.cr + +# Run with random order +crystal spec --order=random +``` + +### Writing Tests + +- Place tests in the `spec/` directory +- Name test files with `_spec.cr` suffix +- Use descriptive test names that explain what is being tested + +Example: + +```crystal +describe "HTTP Methods" do + describe "#get" do + it "sends a GET request to the specified path" do + get "/" do + "Hello" + end + + get "/" + response.body.should eq "Hello" + end + + it "includes custom headers in the request" do + # test implementation + end + end +end +``` + +## Submitting Changes + +1. **Push your branch** to your fork: + ```bash + git push origin my-feature-branch + ``` + +2. **Create a Pull Request** from your branch to `kemalcr/spec-kemal:master` + +3. **Fill out the PR template** with: + - A clear description of the changes + - Any related issues (use "Fixes #123" to auto-close) + - Screenshots if applicable (for documentation changes) + +4. **Wait for review** - maintainers will review your PR and may request changes + +### PR Guidelines + +- Keep PRs focused on a single feature or fix +- Include tests for new functionality +- Update documentation if needed +- Ensure CI passes before requesting review + +## Style Guidelines + +### Code Style + +- Follow Crystal's standard formatting (use `crystal tool format`) +- Use meaningful variable and method names +- Keep methods small and focused +- Add documentation comments for public methods + +### Documentation Style + +```crystal +# Brief description of what the method does. +# +# More detailed explanation if needed, including any +# important notes or caveats. +# +# ## Parameters +# +# - `param1` : Description of param1 +# - `param2` : Description of param2 +# +# ## Example +# +# ```crystal +# result = my_method("value") +# result.should eq expected +# ``` +# +# Returns description of return value. +def my_method(param1 : String, param2 : Int32? = nil) : ReturnType + # implementation +end +``` + +### Commit Messages + +- Use present tense ("Add feature" not "Added feature") +- Use imperative mood ("Move cursor to..." not "Moves cursor to...") +- Keep the first line under 72 characters +- Reference issues when relevant + +Good examples: +``` +Add support for custom request timeout + +Fix session cookie not being set correctly + +Update README with session testing examples + +Fixes #42 +``` + +## Reporting Bugs + +### Before Submitting + +1. **Search existing issues** to avoid duplicates +2. **Try the latest version** to see if the bug has been fixed +3. **Gather information** about your environment + +### Bug Report Template + +```markdown +## Description +A clear description of the bug. + +## Steps to Reproduce +1. Step one +2. Step two +3. ... + +## Expected Behavior +What you expected to happen. + +## Actual Behavior +What actually happened. + +## Environment +- Crystal version: [e.g., 1.10.0] +- Kemal version: [e.g., 1.1.0] +- spec-kemal version: [e.g., 1.0.0] +- OS: [e.g., Ubuntu 22.04] + +## Additional Context +Any other relevant information. +``` + +## Suggesting Features + +We welcome feature suggestions! Please: + +1. **Check existing issues** for similar suggestions +2. **Create a new issue** with the "feature request" label +3. **Describe the feature** and its use case +4. **Provide examples** of how it would be used + +### Feature Request Template + +```markdown +## Feature Description +A clear description of the feature. + +## Use Case +Why this feature would be useful. + +## Proposed API +```crystal +# How you envision using this feature +``` + +## Alternatives Considered +Other approaches you've thought about. +``` + +## Questions? + +If you have questions about contributing, feel free to: + +- Open an issue with the "question" label +- Reach out to maintainers + +Thank you for contributing to spec-kemal! 🎉 diff --git a/README.md b/README.md index 6afc3d9..fadecb1 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,419 @@ # spec-kemal [![CI](https://github.com/kemalcr/spec-kemal/actions/workflows/ci.yml/badge.svg)](https://github.com/kemalcr/spec-kemal/actions/workflows/ci.yml) +[![GitHub release](https://img.shields.io/github/release/kemalcr/spec-kemal.svg)](https://github.com/kemalcr/spec-kemal/releases) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -Kemal helpers to Crystal's `spec` for easy testing. +Testing helpers for the [Kemal](https://kemalcr.com) web framework. Write expressive and readable tests for your Kemal applications using Crystal's built-in `spec` library. + +## Table of Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [API Reference](#api-reference) + - [HTTP Methods](#http-methods) + - [Response Object](#response-object) + - [Headers](#headers) + - [Request Body](#request-body) +- [Testing Patterns](#testing-patterns) + - [JSON APIs](#json-apis) + - [Form Data](#form-data) + - [Authentication](#authentication) + - [Sessions](#sessions) +- [Configuration](#configuration) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) +- [License](#license) ## Installation -Add it to your `shard.yml`. +Add spec-kemal to your `shard.yml` as a **development dependency**: ```yaml name: your-kemal-app version: 0.1.0 dependencies: - spec-kemal: - github: kemalcr/spec-kemal - branch: master kemal: github: kemalcr/kemal - branch: master + +development_dependencies: + spec-kemal: + github: kemalcr/spec-kemal ``` -## Usage +Then run: -Just require it before your files in your `spec/spec_helper.cr` +```bash +shards install +``` + +## Quick Start + +### 1. Set up your spec helper + +Create or update `spec/spec_helper.cr`: ```crystal +require "spec" require "spec-kemal" require "../src/your-kemal-app" -``` -Your Kemal application - -```crystal -# src/your-kemal-app.cr - -require "kemal" - -get "/" do - "Hello World!" +Spec.before_each do + Kemal.config.env = "test" end -Kemal.run +Spec.after_each do + Kemal.config.clear +end ``` -Now you can easily test your `Kemal` application in your `spec`s. +### 2. Write your tests +```crystal +# spec/your-kemal-app_spec.cr +require "./spec_helper" + +describe "My Kemal App" do + it "renders the homepage" do + get "/" + response.status_code.should eq 200 + response.body.should contain "Welcome" + end + + it "creates a new user" do + post "/users", body: {name: "Crystal"}.to_json, + headers: HTTP::Headers{"Content-Type" => "application/json"} + + response.status_code.should eq 201 + end +end ``` + +### 3. Run your tests + +```bash KEMAL_ENV=test crystal spec ``` +## API Reference + +### HTTP Methods + +spec-kemal provides helper methods for all standard HTTP verbs: + +| Method | Description | +|--------|-------------| +| `get(path, headers?, body?)` | Sends a GET request | +| `post(path, headers?, body?)` | Sends a POST request | +| `put(path, headers?, body?)` | Sends a PUT request | +| `patch(path, headers?, body?)` | Sends a PATCH request | +| `delete(path, headers?, body?)` | Sends a DELETE request | +| `head(path, headers?, body?)` | Sends a HEAD request | + +**Parameters:** + +- `path : String` - The request path (e.g., `"/users"`, `"/api/v1/posts?page=2"`) +- `headers : HTTP::Headers?` - Optional HTTP headers +- `body : String?` - Optional request body + +### Response Object + +After making a request, access the response using the `response` method: + ```crystal -# spec/your-kemal-app-spec.cr +get "/users" -describe "Your::Kemal::App" do +# Status +response.status_code # => 200 +response.status # => HTTP::Status::OK +response.success? # => true - # You can use get,post,put,patch,delete to call the corresponding route. - it "renders /" do - get "/" - response.body.should eq "Hello World!" +# Body +response.body # => "{\"users\": []}" + +# Headers +response.headers # => HTTP::Headers +response.headers["Content-Type"] # => "application/json" +response.content_type # => "application/json" + +# Cookies +response.cookies # => HTTP::Cookies +response.cookies["session"] # => HTTP::Cookie +``` + +### Headers + +Pass custom headers to your requests: + +```crystal +headers = HTTP::Headers{ + "Content-Type" => "application/json", + "Authorization" => "Bearer token123", + "Accept" => "application/json" +} + +get "/protected", headers: headers +``` + +### Request Body + +Send data in the request body: + +```crystal +# JSON body +post "/api/users", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + body: {name: "John", email: "john@example.com"}.to_json + +# Form-encoded body +post "/login", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + body: "username=john&password=secret" +``` + +## Testing Patterns + +### JSON APIs + +```crystal +describe "Users API" do + it "returns users as JSON" do + get "/api/users", + headers: HTTP::Headers{"Accept" => "application/json"} + + response.status_code.should eq 200 + response.content_type.should eq "application/json" + + users = JSON.parse(response.body) + users.as_a.size.should eq 3 end + it "creates a user" do + payload = { + name: "Alice", + email: "alice@example.com" + } + + post "/api/users", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + body: payload.to_json + + response.status_code.should eq 201 + + user = JSON.parse(response.body) + user["name"].should eq "Alice" + end + + it "handles validation errors" do + post "/api/users", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + body: {name: ""}.to_json + + response.status_code.should eq 422 + end end ``` -### Kemal Session +### Form Data -If you are using [Kemal Session](https://github.com/kemalcr/kemal-session) in your application it's possible to test session related features as well. +```crystal +describe "Login" do + it "authenticates with valid credentials" do + post "/login", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + body: "email=user@example.com&password=secret123" -To do that require `"spec-kemal/session"` instead of just `spec-kemal`, then -you can use `with_session` to create a session for your requests. + response.status_code.should eq 302 + response.headers["Location"].should eq "/dashboard" + end +end +``` + +### Authentication + +```crystal +describe "Protected Routes" do + it "requires authentication" do + get "/admin/dashboard" + response.status_code.should eq 401 + end + + it "allows access with valid token" do + headers = HTTP::Headers{ + "Authorization" => "Bearer valid-jwt-token" + } + + get "/admin/dashboard", headers: headers + response.status_code.should eq 200 + end +end +``` + +### Sessions + +For testing session-based features, require the session module: ```crystal require "spec-kemal/session" +``` -it "works with sessions" do - with_session do |session| - session.bigint("user_id", 12345) # sets a session value +**Important:** Configure your session secret before tests: + +```crystal +Spec.before_each do + Kemal::Session.config.secret = "your-test-secret" +end +``` + +Use `with_session` to create an authenticated session: + +```crystal +describe "Dashboard" do + it "shows user data from session" do + with_session do |session| + session.int("user_id", 42) + session.string("username", "alice") + + get "/dashboard" + response.body.should contain "Welcome, alice" + end + end + + it "handles session expiry" do + with_session do |session| + session.int("user_id", 42) + # Session is automatically destroyed after the block + end get "/dashboard" - response.body.should eq "session value" + response.status_code.should eq 401 end end ``` -#### Rescue errors -Errors gets rescued by default which results in the Kemal's exception page is rendered. -This may not always be the desired behaviour, e.g. when a JSON parsing error occurs one might expect `"[]"` -and not Kemal's exception page. +**Available session methods:** -Set `Kemal.config.always_rescue = false` to prevent this behaviour and raise errors instead. +```crystal +session.string("key", "value") # String +session.int("key", 42) # Int32 +session.bigint("key", 12345_i64) # Int64 +session.float("key", 3.14) # Float64 +session.bool("key", true) # Bool +session.object("key", my_object) # Any serializable object +``` + +## Configuration + +### Disable Logging + +Logging is disabled by default in spec-kemal. To enable it: + +```crystal +Kemal.config.logging = true +``` + +### Error Handling + +By default, Kemal rescues errors and renders an error page. For testing, you may want exceptions to propagate: + +```crystal +Spec.before_each do + Kemal.config.always_rescue = false +end +``` + +This is useful when testing error handling: + +```crystal +it "raises on invalid input" do + expect_raises(JSON::ParseException) do + post "/api/data", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + body: "invalid json" + end +end +``` + +### Test Environment + +Always run tests with `KEMAL_ENV=test`: + +```bash +KEMAL_ENV=test crystal spec +``` + +Or set it in your spec helper: + +```crystal +ENV["KEMAL_ENV"] = "test" +``` + +## Troubleshooting + +### "response is nil" Error + +Make sure you've made a request before accessing `response`: + +```crystal +# Wrong +response.body # Error: response is nil + +# Correct +get "/" +response.body # Works! +``` + +### Tests Interfering with Each Other + +Clear Kemal's configuration between tests: + +```crystal +Spec.after_each do + Kemal.config.clear +end +``` + +### Session Not Working + +1. Ensure you've required the session module: + ```crystal + require "spec-kemal/session" + ``` + +2. Set the session secret: + ```crystal + Kemal::Session.config.secret = "test-secret" + ``` + +### Handlers Not Being Called + +Make sure `Kemal.config.setup` is called: + +```crystal +Spec.before_each do + Kemal.config.env = "test" + Kemal.config.setup +end +``` ## Contributing -1. Fork it ( https://github.com/kemalcr/spec-kemal/fork ) -2. Create your feature branch (git checkout -b my-new-feature) -3. Commit your changes (git commit -am 'Add some feature') -4. Push to the branch (git push origin my-new-feature) -5. Create a new Pull Request +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +1. Fork it () +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Write tests for your changes +4. Ensure all tests pass (`crystal spec`) +5. Ensure code is formatted (`crystal tool format`) +6. Commit your changes (`git commit -am 'Add some feature'`) +7. Push to the branch (`git push origin my-new-feature`) +8. Create a new Pull Request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## Contributors -- [sdogruyol](https://github.com/sdogruyol) Sdogruyol - creator, maintainer +- [sdogruyol](https://github.com/sdogruyol) - Creator and maintainer diff --git a/src/spec-kemal.cr b/src/spec-kemal.cr index da682ef..3b607fa 100644 --- a/src/spec-kemal.cr +++ b/src/spec-kemal.cr @@ -1,61 +1,185 @@ +# spec-kemal - Testing helpers for the Kemal web framework +# +# This module provides convenient methods for testing Kemal applications +# using Crystal's built-in spec library. +# +# ## Basic Usage +# +# ```crystal +# require "spec-kemal" +# +# describe "My App" do +# it "renders homepage" do +# get "/" +# response.status_code.should eq 200 +# end +# end +# ``` +# +# ## Available HTTP Methods +# +# - `get(path, headers?, body?)` - Send GET request +# - `post(path, headers?, body?)` - Send POST request +# - `put(path, headers?, body?)` - Send PUT request +# - `patch(path, headers?, body?)` - Send PATCH request +# - `delete(path, headers?, body?)` - Send DELETE request +# - `head(path, headers?, body?)` - Send HEAD request + require "spec" require "kemal" +# Disable logging by default for cleaner test output Kemal.config.logging = false +# Internal class for storing the response between requests. +# This allows the `response` helper method to access the last response. +# +# NOTE: This uses class variables which are not thread-safe. +# Tests should be run sequentially, not in parallel. class Global + # The last HTTP response received from a test request @@response : HTTP::Client::Response? + # Sets the response from the last request def self.response=(@@response) end + # Returns the response from the last request def self.response @@response end end +# Generate HTTP helper methods for each HTTP verb. +# Each method creates an HTTP request and processes it through Kemal's handlers. +# +# ## Parameters +# +# - `path` - The request path (e.g., "/users", "/api/posts?page=2") +# - `headers` - Optional HTTP headers to include in the request +# - `body` - Optional request body as a string +# +# ## Examples +# +# ```crystal +# # Simple GET request +# get "/" +# +# # GET with headers +# get "/api/users", headers: HTTP::Headers{"Authorization" => "Bearer token"} +# +# # POST with JSON body +# post "/api/users", +# headers: HTTP::Headers{"Content-Type" => "application/json"}, +# body: {name: "John"}.to_json +# +# # DELETE request +# delete "/api/users/1" +# ``` {% for method in %w[get post put head delete patch] %} - def {{ method.id }}(path, headers : HTTP::Headers? = nil, body : String? = nil) - request = HTTP::Request.new("{{ method.id }}".upcase, path, headers, body ) - Global.response = process_request request + # Sends a {{ method.id.upcase }} request to the specified path. + # + # ## Parameters + # + # - `path` : The URL path to request + # - `headers` : Optional HTTP headers + # - `body` : Optional request body + # + # ## Example + # + # ```crystal + # {{ method.id }} "/example" + # response.status_code.should eq 200 + # ``` + def {{ method.id }}(path : String, headers : HTTP::Headers? = nil, body : String? = nil) : HTTP::Client::Response + request = HTTP::Request.new("{{ method.id }}".upcase, path, headers, body) + Global.response = process_request(request) end {% end %} -private def process_request(request) +# Processes an HTTP request through Kemal's handler chain. +# +# This method simulates a full HTTP request/response cycle by: +# 1. Creating an in-memory IO for the response +# 2. Injecting session cookies if session support is enabled +# 3. Building and executing the Kemal handler chain +# 4. Parsing and returning the response +# +# NOTE: This is a private method used internally by the HTTP helper methods. +private def process_request(request : HTTP::Request) : HTTP::Client::Response io = IO::Memory.new response = HTTP::Server::Response.new(io) - # Inject session cookie if exists + # Inject session cookie if session support is loaded and a session exists. + # This allows testing of session-based features. if Global.responds_to?(:session?) session = Global.session? if session - session_cookie = HTTP::Cookie.new(Kemal::Session.config.cookie_name, - Kemal::Session.encode(session.id)) + session_cookie = HTTP::Cookie.new( + Kemal::Session.config.cookie_name, + Kemal::Session.encode(session.id) + ) request.cookies << session_cookie end end + # Create the server context and process through handlers context = HTTP::Server::Context.new(request, response) main_handler = build_main_handler - main_handler.call context + main_handler.call(context) + + # Close the response and parse it as a client response response.close io.rewind client_response = HTTP::Client::Response.from_io(io, decompress: false) Global.response = client_response end -private def build_main_handler +# Builds the Kemal handler chain by linking all configured handlers together. +# +# Kemal uses a chain of handlers (middleware) to process requests. +# This method links them together so each handler can call the next. +# +# NOTE: This is a private method used internally. +private def build_main_handler : HTTP::Handler main_handler = Kemal.config.handlers.first current_handler = main_handler + Kemal.config.handlers.each do |handler| current_handler.next = handler current_handler = handler end + main_handler end -def response - # ameba:disable Lint/NotNil +# Returns the response from the last HTTP request. +# +# This method provides access to the `HTTP::Client::Response` object +# from the most recent test request. Use it to make assertions about +# the response status, body, headers, and cookies. +# +# ## Example +# +# ```crystal +# get "/users" +# +# response.status_code.should eq 200 +# response.body.should contain "John" +# response.headers["Content-Type"].should eq "application/json" +# ``` +# +# ## Available Response Properties +# +# - `status_code : Int32` - HTTP status code (200, 404, etc.) +# - `status : HTTP::Status` - Status as enum (HTTP::Status::OK, etc.) +# - `body : String` - Response body content +# - `headers : HTTP::Headers` - Response headers +# - `cookies : HTTP::Cookies` - Response cookies +# - `success? : Bool` - True if status is 2xx +# - `content_type : String?` - Content-Type header value +# +# Raises `NilAssertionError` if called before making a request. +def response : HTTP::Client::Response Global.response.not_nil! - # ameba:enable Lint/NotNil end diff --git a/src/spec-kemal/session.cr b/src/spec-kemal/session.cr index 6165351..0326a53 100644 --- a/src/spec-kemal/session.cr +++ b/src/spec-kemal/session.cr @@ -1,27 +1,147 @@ +# spec-kemal/session - Session testing support for Kemal applications +# +# This module extends spec-kemal with session testing capabilities. +# It integrates with kemal-session to allow setting and testing +# session values in your specs. +# +# ## Installation +# +# Add kemal-session to your development dependencies: +# +# ```yaml +# development_dependencies: +# kemal-session: +# github: kemalcr/kemal-session +# ``` +# +# ## Usage +# +# Require this module instead of the base spec-kemal: +# +# ```crystal +# require "spec-kemal/session" +# ``` +# +# Configure the session secret in your spec helper: +# +# ```crystal +# Spec.before_each do +# Kemal::Session.config.secret = "test-secret" +# end +# ``` +# +# Use `with_session` to test session-based features: +# +# ```crystal +# it "shows user dashboard" do +# with_session do |session| +# session.int("user_id", 42) +# +# get "/dashboard" +# response.body.should contain "Welcome" +# end +# end +# ``` + +require "./version" +require "../spec-kemal" require "kemal-session" +# Extend Global class with session storage. +# This allows the session to be shared between `with_session` and +# the HTTP helper methods (get, post, etc.) class Global + # The current test session, if any. + # When set, all HTTP requests will include this session's cookie. class_property? session : Kemal::Session? end +# Creates a new Kemal session for testing. +# +# This method: +# 1. Validates that the session secret is configured +# 2. Destroys any existing session +# 3. Creates a new session with a secure random ID +# +# Raises if `Kemal::Session.config.secret` is not set. +# +# NOTE: This is a private method. Use `with_session` instead. private def create_session : Kemal::Session - raise "Kemal session secret not set." if Kemal::Session.config.secret.empty? + if Kemal::Session.config.secret.empty? + raise "Kemal session secret not set. " \ + "Set Kemal::Session.config.secret in your spec helper." + end destroy_session Global.session = Kemal::Session.new(Random::Secure.hex) end -# Creates a new session, yields it to the block, and ensures it is destroyed afterwards. +# Creates a new session, yields it to the block, and ensures cleanup. # -# All spec-kemal requests made within the block will use this session. -def with_session(&) +# All spec-kemal HTTP requests (get, post, etc.) made within the block +# will automatically include this session's cookie, simulating an +# authenticated user. +# +# The session is automatically destroyed when the block exits, +# even if an exception is raised. +# +# ## Parameters +# +# Yields a `Kemal::Session` instance for setting session values. +# +# ## Example +# +# ```crystal +# it "requires login" do +# get "/dashboard" +# response.status_code.should eq 401 +# end +# +# it "shows dashboard for logged-in user" do +# with_session do |session| +# session.int("user_id", 123) +# session.string("username", "alice") +# session.bool("admin", false) +# +# get "/dashboard" +# response.status_code.should eq 200 +# response.body.should contain "Welcome, alice" +# end +# end +# ``` +# +# ## Available Session Methods +# +# ```crystal +# session.string("key", "value") # Store a String +# session.int("key", 42) # Store an Int32 +# session.bigint("key", 123_i64) # Store an Int64 +# session.float("key", 3.14) # Store a Float64 +# session.bool("key", true) # Store a Bool +# session.object("key", user) # Store any JSON-serializable object +# ``` +# +# ## Notes +# +# - The session is destroyed after the block, simulating logout +# - Each `with_session` call creates a fresh session +# - Nested `with_session` calls will destroy the outer session +def with_session(&) : Nil session = create_session yield session ensure destroy_session end -private def destroy_session +# Destroys the current test session. +# +# This method: +# 1. Calls `destroy` on the session (clears stored data) +# 2. Sets `Global.session` to nil (stops cookie injection) +# +# NOTE: This is a private method. Sessions are automatically +# destroyed when exiting a `with_session` block. +private def destroy_session : Nil Global.session?.try(&.destroy) Global.session = nil end From 399c9073635425b495554069f8014e5e0a17fd74 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:17:19 +0300 Subject: [PATCH 04/11] format --- src/spec-kemal.cr | 6 +++--- src/spec-kemal/session.cr | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/spec-kemal.cr b/src/spec-kemal.cr index 3b607fa..fefe39a 100644 --- a/src/spec-kemal.cr +++ b/src/spec-kemal.cr @@ -5,7 +5,7 @@ # # ## Basic Usage # -# ```crystal +# ``` # require "spec-kemal" # # describe "My App" do @@ -61,7 +61,7 @@ end # # ## Examples # -# ```crystal +# ``` # # Simple GET request # get "/" # @@ -161,7 +161,7 @@ end # # ## Example # -# ```crystal +# ``` # get "/users" # # response.status_code.should eq 200 diff --git a/src/spec-kemal/session.cr b/src/spec-kemal/session.cr index 0326a53..ebb4699 100644 --- a/src/spec-kemal/session.cr +++ b/src/spec-kemal/session.cr @@ -18,13 +18,13 @@ # # Require this module instead of the base spec-kemal: # -# ```crystal +# ``` # require "spec-kemal/session" # ``` # # Configure the session secret in your spec helper: # -# ```crystal +# ``` # Spec.before_each do # Kemal::Session.config.secret = "test-secret" # end @@ -32,7 +32,7 @@ # # Use `with_session` to test session-based features: # -# ```crystal +# ``` # it "shows user dashboard" do # with_session do |session| # session.int("user_id", 42) @@ -91,7 +91,7 @@ end # # ## Example # -# ```crystal +# ``` # it "requires login" do # get "/dashboard" # response.status_code.should eq 401 @@ -112,13 +112,13 @@ end # # ## Available Session Methods # -# ```crystal -# session.string("key", "value") # Store a String -# session.int("key", 42) # Store an Int32 -# session.bigint("key", 123_i64) # Store an Int64 -# session.float("key", 3.14) # Store a Float64 -# session.bool("key", true) # Store a Bool -# session.object("key", user) # Store any JSON-serializable object +# ``` +# session.string("key", "value") # Store a String +# session.int("key", 42) # Store an Int32 +# session.bigint("key", 123_i64) # Store an Int64 +# session.float("key", 3.14) # Store a Float64 +# session.bool("key", true) # Store a Bool +# session.object("key", user) # Store any JSON-serializable object # ``` # # ## Notes From 89d0f224c5cf6589d4f8c922422b98b52ef28c5d Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:24:32 +0300 Subject: [PATCH 05/11] Expand spec-kemal tests to cover all HTTP methods including GET, POST, PUT, PATCH, DELETE, and HEAD with various scenarios such as query parameters, custom headers, and response status codes. --- spec/spec-kemal_spec.cr | 510 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 491 insertions(+), 19 deletions(-) diff --git a/spec/spec-kemal_spec.cr b/spec/spec-kemal_spec.cr index 8146dbe..4077da5 100644 --- a/spec/spec-kemal_spec.cr +++ b/spec/spec-kemal_spec.cr @@ -1,32 +1,504 @@ require "./spec_helper" -describe "SpecKemalApp" do - it "handles get" do - get "/" do - "Hello world" +describe "spec-kemal" do + describe "HTTP Methods" do + describe "GET" do + it "handles basic get request" do + get "/" do + "Hello world" + end + get "/" + response.body.should eq "Hello world" + response.status_code.should eq 200 + end + + it "handles get with query parameters" do + get "/search" do |env| + query = env.params.query["q"]? || "empty" + "Search: #{query}" + end + get "/search?q=crystal" + response.body.should eq "Search: crystal" + end + + it "handles get with multiple query parameters" do + get "/filter" do |env| + page = env.params.query["page"]? || "1" + limit = env.params.query["limit"]? || "10" + "Page: #{page}, Limit: #{limit}" + end + get "/filter?page=2&limit=25" + response.body.should eq "Page: 2, Limit: 25" + end + + it "handles get with custom headers" do + get "/auth" do |env| + auth = env.request.headers["Authorization"]? || "none" + "Auth: #{auth}" + end + get "/auth", headers: HTTP::Headers{"Authorization" => "Bearer token123"} + response.body.should eq "Auth: Bearer token123" + end + + it "handles get with multiple custom headers" do + get "/headers" do |env| + accept = env.request.headers["Accept"]? || "none" + lang = env.request.headers["Accept-Language"]? || "none" + "Accept: #{accept}, Lang: #{lang}" + end + headers = HTTP::Headers{ + "Accept" => "application/json", + "Accept-Language" => "tr-TR", + } + get "/headers", headers: headers + response.body.should eq "Accept: application/json, Lang: tr-TR" + end + + it "handles get with URL parameters" do + get "/users/:id" do |env| + id = env.params.url["id"] + "User ID: #{id}" + end + get "/users/42" + response.body.should eq "User ID: 42" + end + + it "handles get with multiple URL parameters" do + get "/posts/:post_id/comments/:comment_id" do |env| + post_id = env.params.url["post_id"] + comment_id = env.params.url["comment_id"] + "Post: #{post_id}, Comment: #{comment_id}" + end + get "/posts/10/comments/5" + response.body.should eq "Post: 10, Comment: 5" + end + end + + describe "POST" do + it "handles post with JSON body" do + post "/user" do |env| + env.params.json.to_json + end + json_body = {"name": "Serdar", "age": 27, "skills": ["crystal, kemal"]} + post("/user", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: json_body.to_json) + response.body.should eq(json_body.to_json) + end + + it "handles post with form data" do + post "/login" do |env| + username = env.params.body["username"]? || "none" + password = env.params.body["password"]? || "none" + "Login: #{username}" + end + post "/login", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + body: "username=admin&password=secret" + response.body.should eq "Login: admin" + end + + it "handles post with empty body" do + post "/ping" do + "pong" + end + post "/ping" + response.body.should eq "pong" + end + + it "handles post and returns status code" do + post "/create" do |env| + env.response.status_code = 201 + "Created" + end + post "/create" + response.status_code.should eq 201 + response.body.should eq "Created" + end + end + + describe "PUT" do + it "handles put request" do + put "/put-users/:id" do |env| + id = env.params.url["id"] + "Updated user #{id}" + end + put "/put-users/1" + response.body.should eq "Updated user 1" + end + + it "handles put with JSON body" do + put "/put-users-json/:id" do |env| + id = env.params.url["id"] + name = env.params.json["name"]?.to_s + "Updated user #{id} to #{name}" + end + put "/put-users-json/5", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + body: {name: "Alice"}.to_json + response.body.should eq "Updated user 5 to Alice" + end + end + + describe "PATCH" do + it "handles patch request" do + patch "/users/:id" do |env| + id = env.params.url["id"] + "Patched user #{id}" + end + patch "/users/3" + response.body.should eq "Patched user 3" + end + + it "handles patch with partial JSON update" do + patch "/settings" do |env| + theme = env.params.json["theme"]?.to_s + "Theme changed to #{theme}" + end + patch "/settings", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + body: {theme: "dark"}.to_json + response.body.should eq "Theme changed to dark" + end + end + + describe "DELETE" do + it "handles delete request" do + delete "/users/:id" do |env| + id = env.params.url["id"] + env.response.status_code = 204 + "" + end + delete "/users/99" + response.status_code.should eq 204 + end + + it "handles delete with confirmation body" do + delete "/account" do |env| + confirm = env.params.json["confirm"]? + if confirm == true + "Account deleted" + else + env.response.status_code = 400 + "Confirmation required" + end + end + delete "/account", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + body: {confirm: true}.to_json + response.body.should eq "Account deleted" + end + end + + describe "HEAD" do + it "handles head request" do + get "/status" do |env| + env.response.headers["X-Status"] = "OK" + "This body should not appear in HEAD" + end + head "/status" + response.status_code.should eq 200 + # HEAD requests don't return body + end end - get "/" - response.body.should eq "Hello world" end - it "handles post" do - post "/user" do |env| - env.params.json.to_json + describe "Response" do + it "returns correct status codes" do + get "/not-found" do |env| + env.response.status_code = 404 + "Not Found" + end + get "/not-found" + response.status_code.should eq 404 + end + + it "returns custom headers" do + get "/custom-header" do |env| + env.response.headers["X-Custom"] = "test-value" + env.response.headers["X-Request-Id"] = "12345" + "OK" + end + get "/custom-header" + response.headers["X-Custom"].should eq "test-value" + response.headers["X-Request-Id"].should eq "12345" + end + + it "returns content type header" do + get "/json" do |env| + env.response.content_type = "application/json" + %({"status": "ok"}) + end + get "/json" + response.headers["Content-Type"].should eq "application/json" + end + + it "handles redirect responses" do + get "/old-page" do |env| + env.response.status_code = 302 + env.response.headers["Location"] = "/new-page" + "" + end + get "/old-page" + response.status_code.should eq 302 + response.headers["Location"].should eq "/new-page" + end + + it "handles server error responses" do + get "/error" do |env| + env.response.status_code = 500 + "Internal Server Error" + end + get "/error" + response.status_code.should eq 500 end - json_body = {"name": "Serdar", "age": 27, "skills": ["crystal, kemal"]} - post("/user", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: json_body.to_json) - response.body.should eq(json_body.to_json) end - it "handles sessions" do - get "/session_var" do |env| - env.session.string?(env.params.query["key"]) || "not found" + describe "Request Context" do + it "accesses request method" do + post "/echo-method" do |env| + env.request.method + end + post "/echo-method" + response.body.should eq "POST" end - with_session do |session| - session.string("hey ho!", "let's go! 🎸") - get "/session_var?key=hey+ho!" - response.body.should eq("let's go! 🎸") + it "accesses request path" do + get "/echo-path" do |env| + env.request.path + end + get "/echo-path" + response.body.should eq "/echo-path" + end + + it "accesses full request URL with query" do + get "/echo-resource" do |env| + env.request.resource + end + get "/echo-resource?foo=bar" + response.body.should eq "/echo-resource?foo=bar" + end + end + + describe "Sessions" do + it "handles string session values" do + get "/session_var" do |env| + env.session.string?(env.params.query["key"]) || "not found" + end + + with_session do |session| + session.string("hey ho!", "let's go! 🎸") + get "/session_var?key=hey+ho!" + response.body.should eq("let's go! 🎸") + end + end + + it "handles integer session values" do + get "/session_int" do |env| + value = env.session.int?("counter") + value ? value.to_s : "not set" + end + + with_session do |session| + session.int("counter", 42) + get "/session_int" + response.body.should eq "42" + end + end + + it "handles bigint session values" do + get "/session_bigint" do |env| + value = env.session.bigint?("big_number") + value ? value.to_s : "not set" + end + + with_session do |session| + session.bigint("big_number", 9999999999_i64) + get "/session_bigint" + response.body.should eq "9999999999" + end + end + + it "handles float session values" do + get "/session_float" do |env| + value = env.session.float?("price") + value ? value.to_s : "not set" + end + + with_session do |session| + session.float("price", 19.99) + get "/session_float" + response.body.should eq "19.99" + end + end + + it "handles boolean session values" do + get "/session_bool" do |env| + value = env.session.bool?("admin") + value.nil? ? "not set" : value.to_s + end + + with_session do |session| + session.bool("admin", true) + get "/session_bool" + response.body.should eq "true" + end + end + + it "session is destroyed after with_session block" do + get "/check_session" do |env| + env.session.string?("test_key") || "no session" + end + + with_session do |session| + session.string("test_key", "test_value") + get "/check_session" + response.body.should eq "test_value" + end + + # Session should be cleared after the block + # New request without session should not have the value + end + + it "handles multiple session values" do + get "/multi_session" do |env| + name = env.session.string?("name") || "guest" + role = env.session.string?("role") || "user" + "#{name} (#{role})" + end + + with_session do |session| + session.string("name", "Alice") + session.string("role", "admin") + get "/multi_session" + response.body.should eq "Alice (admin)" + end + end + + it "persists session across multiple requests" do + get "/set_session" do |env| + env.session.string("visit", "first") + "Set" + end + + get "/get_session" do |env| + env.session.string?("visit") || "none" + end + + with_session do |session| + session.string("visit", "remembered") + get "/get_session" + response.body.should eq "remembered" + + # Make another request - session should persist + get "/get_session" + response.body.should eq "remembered" + end + end + end + + describe "Multiple Sequential Requests" do + it "handles multiple different routes" do + get "/first" do + "First" + end + + get "/second" do + "Second" + end + + get "/first" + response.body.should eq "First" + + get "/second" + response.body.should eq "Second" + end + + it "response is updated after each request" do + get "/counter/:n" do |env| + "Count: #{env.params.url["n"]}" + end + + get "/counter/1" + response.body.should eq "Count: 1" + + get "/counter/2" + response.body.should eq "Count: 2" + + get "/counter/3" + response.body.should eq "Count: 3" + end + end + + describe "Edge Cases" do + it "handles empty response body" do + get "/empty" do |env| + env.response.status_code = 204 + "" + end + get "/empty" + response.status_code.should eq 204 + response.body.should eq "" + end + + it "handles unicode in response" do + get "/unicode" do + "Hello 世界! 🌍 Merhaba Dünya!" + end + get "/unicode" + response.body.should eq "Hello 世界! 🌍 Merhaba Dünya!" + end + + it "handles unicode in request body" do + post "/echo" do |env| + env.request.body.try(&.gets_to_end) || "" + end + post "/echo", body: "Türkçe karakterler: şçğüöı" + response.body.should eq "Türkçe karakterler: şçğüöı" + end + + it "handles special characters in query params" do + get "/special" do |env| + env.params.query["msg"]? || "none" + end + get "/special?msg=hello%20world%21" + response.body.should eq "hello world!" + end + + it "handles routes with trailing slash" do + get "/api/users/" do + "Users list" + end + get "/api/users/" + response.body.should eq "Users list" + end + + it "handles deeply nested routes" do + get "/api/v1/users/:user_id/posts/:post_id/comments" do |env| + user_id = env.params.url["user_id"] + post_id = env.params.url["post_id"] + "Comments for post #{post_id} by user #{user_id}" + end + get "/api/v1/users/10/posts/20/comments" + response.body.should eq "Comments for post 20 by user 10" + end + + it "handles JSON array in request body" do + post "/batch" do |env| + body = env.request.body.try(&.gets_to_end) || "[]" + items = JSON.parse(body).as_a + "Received #{items.size} items" + end + post "/batch", + headers: HTTP::Headers{"Content-Type" => "application/json"}, + body: [1, 2, 3, 4, 5].to_json + response.body.should eq "Received 5 items" + end + + it "handles large response body" do + get "/large" do + "x" * 10000 + end + get "/large" + response.body.size.should eq 10000 end end end From 299d6fae660b98fa47141e7d737a2f80b29cb7c0 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:25:32 +0300 Subject: [PATCH 06/11] Add CHANGELOG --- CHANGELOG.md | 35 +++-------------------------------- 1 file changed, 3 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6359b8e..d7b546b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,43 +1,14 @@ -# Changelog +# 1.1.0 (02-02-2026) -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Changed +- Session testing support via `with_session` helper [#23](https://github.com/kemalcr/spec-kemal/pull/23) Thanks @hugopl :pray: - Migrated from Travis CI to GitHub Actions - Improved documentation with comprehensive examples - Added inline documentation to source code -## [1.0.0] - 2023-XX-XX +# (1.0.0) - 25-04-2021 -### Added -- Session testing support via `with_session` helper - Support for all HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD - Custom headers support for requests - Request body support for POST/PUT/PATCH requests - -### Changed - Updated for Kemal 1.x compatibility - Improved handler chain building - -## [0.5.0] - Previous Release - -### Added -- Initial session support -- Basic HTTP method helpers - -## [0.1.0] - Initial Release - -### Added -- Basic testing helpers for Kemal -- GET, POST support -- Response assertions - -[Unreleased]: https://github.com/kemalcr/spec-kemal/compare/v1.0.0...HEAD -[1.0.0]: https://github.com/kemalcr/spec-kemal/compare/v0.5.0...v1.0.0 -[0.5.0]: https://github.com/kemalcr/spec-kemal/compare/v0.1.0...v0.5.0 -[0.1.0]: https://github.com/kemalcr/spec-kemal/releases/tag/v0.1.0 From 9e8a54e00b11ca58ac8e70d950978987d5278520 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:25:48 +0300 Subject: [PATCH 07/11] Bump version to 1.1.0 --- shard.yml | 2 +- spec/spec-kemal_spec.cr | 5 ++--- src/spec-kemal.cr | 2 ++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/shard.yml b/shard.yml index 607470b..4b7c652 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spec-kemal -version: 1.0.0 +version: 1.1.0 crystal: '< 2.0.0' development_dependencies: diff --git a/spec/spec-kemal_spec.cr b/spec/spec-kemal_spec.cr index 4077da5..3bad809 100644 --- a/spec/spec-kemal_spec.cr +++ b/spec/spec-kemal_spec.cr @@ -88,12 +88,12 @@ describe "spec-kemal" do post "/login" do |env| username = env.params.body["username"]? || "none" password = env.params.body["password"]? || "none" - "Login: #{username}" + "Login: #{username} password: #{password}" end post "/login", headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, body: "username=admin&password=secret" - response.body.should eq "Login: admin" + response.body.should eq "Login: admin password: secret" end it "handles post with empty body" do @@ -163,7 +163,6 @@ describe "spec-kemal" do describe "DELETE" do it "handles delete request" do delete "/users/:id" do |env| - id = env.params.url["id"] env.response.status_code = 204 "" end diff --git a/src/spec-kemal.cr b/src/spec-kemal.cr index fefe39a..9ced605 100644 --- a/src/spec-kemal.cr +++ b/src/spec-kemal.cr @@ -181,5 +181,7 @@ end # # Raises `NilAssertionError` if called before making a request. def response : HTTP::Client::Response + # ameba:disable Lint/NotNil Global.response.not_nil! + # ameba:enable Lint/NotNil end From aefbacfbf24f68f3038470c6b9911db1b005c716 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:03:15 +0300 Subject: [PATCH 08/11] Update README to clarify the necessity of calling `Kemal.config.setup` in tests --- README.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index fadecb1..b52f1f6 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ require "../src/your-kemal-app" Spec.before_each do Kemal.config.env = "test" + Kemal.config.setup end Spec.after_each do @@ -386,17 +387,6 @@ end Kemal::Session.config.secret = "test-secret" ``` -### Handlers Not Being Called - -Make sure `Kemal.config.setup` is called: - -```crystal -Spec.before_each do - Kemal.config.env = "test" - Kemal.config.setup -end -``` - ## Contributing We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. From 0013212eaee6416effb544fce2663fdf758c0a73 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:08:41 +0300 Subject: [PATCH 09/11] Add session cookie injection support instead of monkey patching in session --- src/spec-kemal.cr | 29 +++++++++++++++++------------ src/spec-kemal/session.cr | 12 ++++++++++++ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/spec-kemal.cr b/src/spec-kemal.cr index 9ced605..bbc135e 100644 --- a/src/spec-kemal.cr +++ b/src/spec-kemal.cr @@ -31,6 +31,21 @@ require "kemal" # Disable logging by default for cleaner test output Kemal.config.logging = false +# Internal hook for session cookie injection. When spec-kemal/session is required, +# it registers a callback here. This avoids referencing Global.session? in the +# base library, which would fail to compile when the session extension is not used. +class SessionInjector + @@callback : (HTTP::Request -> Nil)? = nil + + def self.register(&block : HTTP::Request -> Nil) + @@callback = block + end + + def self.run(request : HTTP::Request) : Nil + @@callback.try(&.call(request)) + end +end + # Internal class for storing the response between requests. # This allows the `response` helper method to access the last response. # @@ -110,18 +125,8 @@ private def process_request(request : HTTP::Request) : HTTP::Client::Response io = IO::Memory.new response = HTTP::Server::Response.new(io) - # Inject session cookie if session support is loaded and a session exists. - # This allows testing of session-based features. - if Global.responds_to?(:session?) - session = Global.session? - if session - session_cookie = HTTP::Cookie.new( - Kemal::Session.config.cookie_name, - Kemal::Session.encode(session.id) - ) - request.cookies << session_cookie - end - end + # Inject session cookie if session support is loaded (callback registered by spec-kemal/session). + SessionInjector.run(request) # Create the server context and process through handlers context = HTTP::Server::Context.new(request, response) diff --git a/src/spec-kemal/session.cr b/src/spec-kemal/session.cr index ebb4699..702e665 100644 --- a/src/spec-kemal/session.cr +++ b/src/spec-kemal/session.cr @@ -56,6 +56,18 @@ class Global class_property? session : Kemal::Session? end +# Register session cookie injection with the base library. +# This runs on every request when a session is set (e.g. inside with_session). +SessionInjector.register do |request| + if session = Global.session? + session_cookie = HTTP::Cookie.new( + Kemal::Session.config.cookie_name, + Kemal::Session.encode(session.id) + ) + request.cookies << session_cookie + end +end + # Creates a new Kemal session for testing. # # This method: From 9bac723782bad06a84ca781affce67fee4f83624 Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:13:47 +0300 Subject: [PATCH 10/11] Update CHANGELOG for version 1.1.1 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b546b..128c570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 1.1.1 (23-02-2026) + +- Fix `undefined method 'session?' for Global.class` when using spec-kemal without the session extension. Thanks @sdogruyol :pray: + # 1.1.0 (02-02-2026) - Session testing support via `with_session` helper [#23](https://github.com/kemalcr/spec-kemal/pull/23) Thanks @hugopl :pray: From 83cbb10c703b59aac924afdbe15250183b97265a Mon Sep 17 00:00:00 2001 From: Serdar Dogruyol <990485+sdogruyol@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:14:08 +0300 Subject: [PATCH 11/11] Bump version to 1.1.1 --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index 4b7c652..321493a 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spec-kemal -version: 1.1.0 +version: 1.1.1 crystal: '< 2.0.0' development_dependencies: