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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..128c570 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# 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: +- Migrated from Travis CI to GitHub Actions +- Improved documentation with comprehensive examples +- Added inline documentation to source code + +# (1.0.0) - 25-04-2021 + +- Support for all HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD +- Custom headers support for requests +- Request body support for POST/PUT/PATCH requests +- Updated for Kemal 1.x compatibility +- Improved handler chain building 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 e41c8ab..b52f1f6 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,409 @@ # spec-kemal -Kemal helpers to Crystal's `spec` for easy testing. +[![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) + +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" + Kemal.config.setup 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 ``` -#### 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. +### Form Data -Set `Kemal.config.always_rescue = false` to prevent this behaviour and raise errors instead. +```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" + + 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" +``` + +**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.status_code.should eq 401 + end +end +``` + +**Available session methods:** + +```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" + ``` ## 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/shard.yml b/shard.yml index fb5059f..092ca8d 100644 --- a/shard.yml +++ b/shard.yml @@ -1,10 +1,12 @@ name: spec-kemal -version: 1.0.0 +version: 1.1.1 crystal: "< 2.0.0" development_dependencies: kemal: git: https://gitdab.com/luna/kemal.git + kemal-session: + github: kemalcr/kemal-session authors: - Luna diff --git a/spec/spec-kemal_spec.cr b/spec/spec-kemal_spec.cr index 01fd05e..3bad809 100644 --- a/spec/spec-kemal_spec.cr +++ b/spec/spec-kemal_spec.cr @@ -1,20 +1,503 @@ 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} 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 password: secret" + 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| + 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 + end + + 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 + + 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 - 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 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..bbc135e 100644 --- a/src/spec-kemal.cr +++ b/src/spec-kemal.cr @@ -1,48 +1,192 @@ +# 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 +# +# ``` +# 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 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. +# +# 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 -{% 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 +# 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 +# +# ``` +# # 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] %} + # 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 %} -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 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) 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 -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_with_index do |handler, index| + + Kemal.config.handlers.each do |handler| current_handler.next = handler current_handler = handler end + main_handler end -def response +# 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 +# +# ``` +# 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 + # ameba:disable Lint/NotNil Global.response.not_nil! + # ameba:enable Lint/NotNil end diff --git a/src/spec-kemal/session.cr b/src/spec-kemal/session.cr new file mode 100644 index 0000000..702e665 --- /dev/null +++ b/src/spec-kemal/session.cr @@ -0,0 +1,159 @@ +# 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: +# +# ``` +# require "spec-kemal/session" +# ``` +# +# Configure the session secret in your spec helper: +# +# ``` +# Spec.before_each do +# Kemal::Session.config.secret = "test-secret" +# end +# ``` +# +# Use `with_session` to test session-based features: +# +# ``` +# 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 + +# 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: +# 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 + 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 cleanup. +# +# 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 +# +# ``` +# 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 +# +# ``` +# 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 + +# 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