Compare commits
No commits in common. "20495b68746d6a8df731c858a71df03eaf0960e1" and "804220262843fcea737cd2689f77901d10523bab" have entirely different histories.
20495b6874
...
8042202628
10 changed files with 67 additions and 1564 deletions
88
.github/workflows/ci.yml
vendored
88
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
1
.travis.yml
Normal file
1
.travis.yml
Normal file
|
|
@ -0,0 +1 @@
|
|||
language: crystal
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -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
|
||||
273
CONTRIBUTING.md
273
CONTRIBUTING.md
|
|
@ -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! 🎉
|
||||
415
README.md
415
README.md
|
|
@ -1,409 +1,82 @@
|
|||
# spec-kemal
|
||||
|
||||
[](https://github.com/kemalcr/spec-kemal/actions/workflows/ci.yml)
|
||||
[](https://github.com/kemalcr/spec-kemal/releases)
|
||||
[](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 (<https://github.com/kemalcr/spec-kemal/fork>)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 <luna@l4.pm>
|
||||
|
|
|
|||
|
|
@ -1,81 +1,15 @@
|
|||
require "./spec_helper"
|
||||
|
||||
describe "spec-kemal" do
|
||||
describe "HTTP Methods" do
|
||||
describe "GET" do
|
||||
it "handles basic get request" do
|
||||
describe "SpecKemalApp" do
|
||||
it "handles get" 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
|
||||
it "handles post" do
|
||||
post "/user" do |env|
|
||||
env.params.json.to_json
|
||||
end
|
||||
|
|
@ -83,421 +17,4 @@ describe "spec-kemal" 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 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
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue