diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index d57eac1..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,88 +0,0 @@ -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 new file mode 100644 index 0000000..ffc7b6a --- /dev/null +++ b/.travis.yml @@ -0,0 +1 @@ +language: crystal diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 128c570..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index d841858..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,273 +0,0 @@ -# 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 b52f1f6..e41c8ab 100644 --- a/README.md +++ b/README.md @@ -1,409 +1,82 @@ # 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) - -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) +Kemal helpers to Crystal's `spec` for easy testing. ## Installation -Add spec-kemal to your `shard.yml` as a **development dependency**: +Add it to your `shard.yml`. ```yaml name: your-kemal-app version: 0.1.0 dependencies: - kemal: - github: kemalcr/kemal - -development_dependencies: spec-kemal: github: kemalcr/spec-kemal + branch: master + kemal: + github: kemalcr/kemal + branch: master ``` -Then run: +## Usage -```bash -shards install -``` - -## Quick Start - -### 1. Set up your spec helper - -Create or update `spec/spec_helper.cr`: +Just require it before your files in your `spec/spec_helper.cr` ```crystal -require "spec" require "spec-kemal" require "../src/your-kemal-app" - -Spec.before_each do - Kemal.config.env = "test" - Kemal.config.setup -end - -Spec.after_each do - Kemal.config.clear -end ``` -### 2. Write your tests +Your Kemal application ```crystal -# spec/your-kemal-app_spec.cr -require "./spec_helper" +# src/your-kemal-app.cr -describe "My Kemal App" do - it "renders the homepage" do +require "kemal" + +get "/" do + "Hello World!" +end + +Kemal.run +``` + +Now you can easily test your `Kemal` application in your `spec`s. + +``` +KEMAL_ENV=test crystal spec +``` + +```crystal +# spec/your-kemal-app-spec.cr + +describe "Your::Kemal::App" do + + # You can use get,post,put,patch,delete to call the corresponding route. + it "renders /" do get "/" - response.status_code.should eq 200 - response.body.should contain "Welcome" + response.body.should eq "Hello World!" 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 +#### 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. -```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 -get "/users" - -# Status -response.status_code # => 200 -response.status # => HTTP::Status::OK -response.success? # => true - -# 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 -``` - -### Form Data - -```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" - ``` +Set `Kemal.config.always_rescue = false` to prevent this behaviour and raise errors instead. ## Contributing -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. +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 ## Contributors -- [sdogruyol](https://github.com/sdogruyol) - Creator and maintainer +- [sdogruyol](https://github.com/sdogruyol) Sdogruyol - creator, maintainer diff --git a/shard.yml b/shard.yml index 092ca8d..fb5059f 100644 --- a/shard.yml +++ b/shard.yml @@ -1,12 +1,10 @@ name: spec-kemal -version: 1.1.1 +version: 1.0.0 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 3bad809..01fd05e 100644 --- a/spec/spec-kemal_spec.cr +++ b/spec/spec-kemal_spec.cr @@ -1,503 +1,20 @@ require "./spec_helper" -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 +describe "SpecKemalApp" do + it "handles get" do + get "/" do + "Hello world" end + get "/" + response.body.should eq "Hello world" end - 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 + it "handles post" 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 end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index d51745e..0f43d09 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,14 +1,10 @@ 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 bbc135e..bf6223e 100644 --- a/src/spec-kemal.cr +++ b/src/spec-kemal.cr @@ -1,192 +1,48 @@ -# 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 -# 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) +{% 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 %} -# 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 +def process_request(request) 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) - - # Close the response and parse it as a client response + main_handler.call context response.close io.rewind client_response = HTTP::Client::Response.from_io(io, decompress: false) Global.response = client_response end -# 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 +def build_main_handler main_handler = Kemal.config.handlers.first current_handler = main_handler - - Kemal.config.handlers.each do |handler| + Kemal.config.handlers.each_with_index do |handler, index| current_handler.next = handler current_handler = handler end - main_handler end -# 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 +def response Global.response.not_nil! - # ameba:enable Lint/NotNil end diff --git a/src/spec-kemal/session.cr b/src/spec-kemal/session.cr deleted file mode 100644 index 702e665..0000000 --- a/src/spec-kemal/session.cr +++ /dev/null @@ -1,159 +0,0 @@ -# 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