forgetest

package
v0.3.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 17, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

README

forgetest

Fluent testing API for Forge HTTP handlers with request builders, response assertions, HTML parsing, session management, and RBAC testing.

Installation

go get github.com/dmitrymomot/forge/forgetest

Usage

Create a test app with NewApp, build requests with Get/Post/etc., and assert responses:

package example_test

import (
	"net/http"
	"testing"

	"github.com/dmitrymomot/forge"
	"github.com/dmitrymomot/forge/forgetest"
)

type Handler struct{}

func (h *Handler) Routes(r forge.Router) {
	r.GET("/", h.index)
}

func (h *Handler) index(c forge.Context) error {
	return c.String(http.StatusOK, "Hello, World!")
}

func TestHandler(t *testing.T) {
	t.Parallel()

	app := forgetest.NewApp(t, &Handler{})
	resp := forgetest.Get(t, app, "/").Do()
	resp.RequireStatus(t, http.StatusOK)

	if resp.Body() != "Hello, World!" {
		t.Errorf("unexpected body: %s", resp.Body())
	}
}

Common Operations

Form Submission
func TestLogin(t *testing.T) {
	t.Parallel()

	app := forgetest.NewApp(t, &LoginHandler{})
	resp := forgetest.Post(t, app, "/login").
		WithForm("username", "alice").
		WithForm("password", "secret").
		Do()
	resp.RequireRedirect(t, http.StatusSeeOther, "/dashboard")
}
JSON API
func TestAPI(t *testing.T) {
	t.Parallel()

	app := forgetest.NewApp(t, &APIHandler{})
	resp := forgetest.Post(t, app, "/api/greet").
		WithJSON(map[string]string{"name": "Alice"}).
		Do()
	resp.RequireStatus(t, http.StatusOK)
	resp.RequireHeader(t, "Content-Type", "application/json")
}
Authenticated Requests
func TestProtected(t *testing.T) {
	t.Parallel()

	app := forgetest.NewApp(t, &ProtectedHandler{})
	resp := forgetest.Get(t, app, "/dashboard").
		AsUser("user-123").
		Do()
	resp.RequireStatus(t, http.StatusOK)
}
RBAC Testing
func TestPermissions(t *testing.T) {
	t.Parallel()

	roles := forge.RolePermissions{
		"admin":  {"posts:write"},
		"viewer": {},
	}

	app := forgetest.NewApp(t, &PostHandler{}, forgetest.WithRoles(roles))

	// Admin succeeds
	resp := forgetest.Post(t, app, "/posts").
		AsUser("user-123").
		WithRole("admin").
		Do()
	resp.RequireStatus(t, http.StatusOK)

	// Viewer fails
	resp = forgetest.Post(t, app, "/posts").
		AsUser("user-456").
		WithRole("viewer").
		Do()
	resp.RequireStatus(t, http.StatusForbidden)
}
HTML Assertions
func TestHTML(t *testing.T) {
	t.Parallel()

	app := forgetest.NewApp(t, &DashboardHandler{})
	doc := forgetest.Get(t, app, "/").Do().HTML()

	doc.RequireText(t, "h1", "Dashboard")
	doc.RequireExactText(t, "title", "Dashboard")
	doc.RequireAttr(t, "form", "action", "/submit")
	doc.RequireValue(t, "input[name=username]", "alice")
	doc.RequireCount(t, "ul.items li", 3)
	doc.RequireExists(t, "button[type=submit]")
	doc.RequireNotExists(t, ".error-message")
}
HTMX Testing
func TestHTMX(t *testing.T) {
	t.Parallel()

	app := forgetest.NewApp(t, &HTMXHandler{})
	resp := forgetest.Get(t, app, "/partial").WithHTMX().Do()
	resp.RequireStatus(t, http.StatusOK)
	resp.RequireHTMXTrigger(t, "notification")
	resp.RequireHTMXRetarget(t, "#content")
	resp.RequireHTMXReswap(t, "innerHTML")
}

API Documentation

Run go doc -all ./forgetest for complete API documentation.

Documentation

Overview

Package forgetest provides a fluent testing API for forge HTTP handlers.

It offers request builders, response assertions, HTML parsing, session management, and RBAC testing utilities backed by an in-memory session store that is safe for parallel tests.

Basic Usage

Create a test app with NewApp, build requests with Get/Post/etc., and assert responses with RequireStatus, RequireRedirect, or HTML assertions:

import (
	"testing"
	"net/http"
	"github.com/dmitrymomot/forge"
	"github.com/dmitrymomot/forge/forgetest"
)

func TestHandler(t *testing.T) {
	t.Parallel()

	handler := func(c forge.Context) error {
		return c.String(http.StatusOK, "Hello, World!")
	}

	app := forgetest.NewApp(t, handler)
	resp := forgetest.Get(t, app, "/").Do()
	resp.RequireStatus(t, http.StatusOK)

	if resp.Body() != "Hello, World!" {
		t.Errorf("unexpected body: %s", resp.Body())
	}
}

Sessions and RBAC

Use AsUser to create a session with a user ID, WithRole to set the role, and WithSessionData to store arbitrary session data. WithRoles configures the app with RBAC permissions:

func TestProtectedEndpoint(t *testing.T) {
	t.Parallel()

	handler := func(c forge.Context) error {
		if !c.Can("posts:write") {
			return c.NoContent(http.StatusForbidden)
		}
		return c.String(http.StatusOK, "Post created")
	}

	app := forgetest.NewApp(t, handler,
		forgetest.WithRoles(forge.RolePermissions{
			"admin":  {"posts:write"},
			"viewer": {},
		}),
	)

	// Admin user with permission.
	resp := forgetest.Post(t, app, "/posts").
		AsUser("user-123").
		WithRole("admin").
		Do()
	resp.RequireStatus(t, http.StatusOK)

	// Viewer without permission.
	resp = forgetest.Post(t, app, "/posts").
		AsUser("user-456").
		WithRole("viewer").
		Do()
	resp.RequireStatus(t, http.StatusForbidden)
}

HTMX Support

Use WithHTMX to mark a request as an HTMX request, and assert HTMX response headers with RequireHTMXTrigger, RequireHTMXRetarget, RequireHTMXReswap, etc.:

import (
	"github.com/dmitrymomot/forge/pkg/htmx"
)

func TestHTMXPartial(t *testing.T) {
	t.Parallel()

	handler := func(c forge.Context) error {
		partial := &forgetest.MockComponent{HTML: "<div>Partial content</div>"}
		fullPage := &forgetest.MockComponent{HTML: "<html><body>Full page</body></html>"}

		if c.IsHTMX() {
			return c.Render(http.StatusOK, partial, htmx.WithTrigger("notification"))
		}
		return c.Render(http.StatusOK, fullPage)
	}

	app := forgetest.NewApp(t, handler)

	// HTMX request gets partial content.
	resp := forgetest.Get(t, app, "/partial").WithHTMX().Do()
	resp.RequireStatus(t, http.StatusOK)
	resp.RequireHTMXTrigger(t, "notification")
	resp.HTML().RequireText(t, "div", "Partial content")
}

HTML Assertions

Parse response HTML with resp.HTML() and assert element existence, text content, attributes, and counts using CSS selectors:

func TestHTMLRendering(t *testing.T) {
	t.Parallel()

	comp := &forgetest.MockComponent{
		HTML: `<html>
			<head><title>Dashboard</title></head>
			<body>
				<h1>Dashboard</h1>
				<form action="/submit" method="post">
					<input type="text" name="username" value="alice" />
					<button type="submit">Submit</button>
				</form>
				<ul class="items">
					<li>Item 1</li>
					<li>Item 2</li>
					<li>Item 3</li>
				</ul>
			</body>
		</html>`,
	}

	handler := func(c forge.Context) error {
		return c.Render(http.StatusOK, comp)
	}

	app := forgetest.NewApp(t, handler)
	doc := forgetest.Get(t, app, "/").Do().HTML()

	doc.RequireText(t, "h1", "Dashboard")
	doc.RequireExactText(t, "title", "Dashboard")
	doc.RequireAttr(t, "form", "action", "/submit")
	doc.RequireValue(t, "input[name=username]", "alice")
	doc.RequireCount(t, "ul.items li", 3)
	doc.RequireExists(t, "button[type=submit]")
	doc.RequireNotExists(t, ".error-message")
}

Form and JSON Requests

Build POST requests with form data using WithForm, or JSON bodies using WithJSON:

func TestFormSubmission(t *testing.T) {
	t.Parallel()

	handler := func(c forge.Context) error {
		username := c.Form("username")
		if username == "" {
			return c.NoContent(http.StatusBadRequest)
		}
		return c.Redirect(http.StatusSeeOther, "/dashboard")
	}

	app := forgetest.NewApp(t, handler)

	resp := forgetest.Post(t, app, "/login").
		WithForm("username", "alice").
		WithForm("password", "secret").
		Do()
	resp.RequireRedirect(t, http.StatusSeeOther, "/dashboard")
}

func TestJSONAPI(t *testing.T) {
	t.Parallel()

	handler := func(c forge.Context) error {
		var req struct {
			Name string `json:"name"`
		}
		if _, err := c.Bind(&req); err != nil {
			return c.NoContent(http.StatusBadRequest)
		}
		return c.JSON(http.StatusOK, map[string]string{"message": "Hello, " + req.Name})
	}

	app := forgetest.NewApp(t, handler)

	resp := forgetest.Post(t, app, "/api/greet").
		WithJSON(map[string]string{"name": "Alice"}).
		Do()
	resp.RequireStatus(t, http.StatusOK)
	resp.RequireHeader(t, "Content-Type", "application/json")
}

Session Store Access

Access the in-memory session store directly for advanced assertions:

import (
	"context"
)

func TestSessionManagement(t *testing.T) {
	t.Parallel()

	handler := func(c forge.Context) error {
		return c.String(http.StatusOK, "OK")
	}

	app := forgetest.NewApp(t, handler)

	// Make request as user.
	forgetest.Get(t, app, "/").AsUser("user-123").Do()

	// Verify session was created.
	count, _ := app.Store().CountByUserID(context.Background(), "user-123")
	if count != 1 {
		t.Errorf("expected 1 session, got %d", count)
	}
}

Mock Components

MockComponent renders static HTML or returns errors for testing component rendering:

func TestComponentRendering(t *testing.T) {
	t.Parallel()

	mock := &forgetest.MockComponent{
		HTML: "<div>Mocked content</div>",
	}

	handler := func(c forge.Context) error {
		return c.Render(http.StatusOK, mock)
	}

	app := forgetest.NewApp(t, handler)
	resp := forgetest.Get(t, app, "/").Do()
	resp.RequireStatus(t, http.StatusOK)
	resp.HTML().RequireText(t, "div", "Mocked content")
}

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type App

type App struct {
	// contains filtered or unexported fields
}

App wraps a forge.App with test helpers.

func NewApp

func NewApp(t testing.TB, handler forge.Handler, opts ...Option) *App

NewApp creates a forge.App wired for testing with an in-memory session store.

func (*App) Store

func (a *App) Store() *MemoryStore

Store returns the in-memory session store for direct inspection in tests.

type Document

type Document struct {
	// contains filtered or unexported fields
}

Document wraps a goquery.Document for HTML assertion helpers.

func (*Document) Find

func (d *Document) Find(selector string) *goquery.Selection

Find returns the goquery.Selection for the given CSS selector. Use this for custom assertions beyond what the helper methods provide.

func (*Document) RequireAttr

func (d *Document) RequireAttr(t testing.TB, selector, attr, val string)

RequireAttr asserts that the first element matching the selector has an attribute with the given value.

func (*Document) RequireCount

func (d *Document) RequireCount(t testing.TB, selector string, n int)

RequireCount asserts that exactly n elements match the CSS selector.

func (*Document) RequireExactText

func (d *Document) RequireExactText(t testing.TB, selector, text string)

RequireExactText asserts that the first element matching the selector has exactly the given text (trimmed of leading/trailing whitespace).

func (*Document) RequireExists

func (d *Document) RequireExists(t testing.TB, selector string)

RequireExists asserts that at least one element matches the CSS selector.

func (*Document) RequireNotExists

func (d *Document) RequireNotExists(t testing.TB, selector string)

RequireNotExists asserts that no elements match the CSS selector.

func (*Document) RequireText

func (d *Document) RequireText(t testing.TB, selector, substr string)

RequireText asserts that the first element matching the selector contains substr in its text.

func (*Document) RequireValue

func (d *Document) RequireValue(t testing.TB, selector, val string)

RequireValue asserts that the first element matching the selector has the given value attribute.

type MemoryStore

type MemoryStore struct {
	// contains filtered or unexported fields
}

MemoryStore is an in-memory session store for testing. It implements forge.SessionStore backed by maps with a read-write mutex for safe concurrent access in parallel tests.

func (*MemoryStore) Count

func (m *MemoryStore) Count() int

Count returns the total number of sessions in the store.

func (*MemoryStore) CountByUserID

func (m *MemoryStore) CountByUserID(_ context.Context, userID string) (int, error)

CountByUserID returns the number of sessions for a user.

func (*MemoryStore) Create

func (m *MemoryStore) Create(_ context.Context, s *forge.Session) error

Create persists a new session.

func (*MemoryStore) Delete

func (m *MemoryStore) Delete(_ context.Context, id string) error

Delete removes a session by its ID.

func (*MemoryStore) DeleteByUserID

func (m *MemoryStore) DeleteByUserID(_ context.Context, userID string) error

DeleteByUserID removes all sessions for a user.

func (*MemoryStore) DeleteByUserIDExcept

func (m *MemoryStore) DeleteByUserIDExcept(_ context.Context, userID, exceptID string) error

DeleteByUserIDExcept removes all sessions for a user except the specified session ID.

func (*MemoryStore) DeleteOldestByUserID

func (m *MemoryStore) DeleteOldestByUserID(_ context.Context, userID string) error

DeleteOldestByUserID removes the oldest session for a user (by LastActiveAt).

func (*MemoryStore) GetByID

func (m *MemoryStore) GetByID(id string) (*forge.Session, bool)

GetByID retrieves a session by its ID. This is a test helper not part of the SessionStore interface, useful for inspecting session state in tests.

func (*MemoryStore) GetByTokenHash

func (m *MemoryStore) GetByTokenHash(_ context.Context, tokenHash string) (*forge.Session, error)

GetByTokenHash retrieves a session by its SHA-256 token hash.

func (*MemoryStore) ListByUserID

func (m *MemoryStore) ListByUserID(_ context.Context, userID string) ([]*forge.Session, error)

ListByUserID retrieves all sessions for a user.

func (*MemoryStore) Touch

func (m *MemoryStore) Touch(_ context.Context, id string, lastActiveAt time.Time) error

Touch updates the LastActiveAt timestamp.

func (*MemoryStore) Update

func (m *MemoryStore) Update(_ context.Context, s *forge.Session) error

Update saves changes to an existing session.

type MockComponent

type MockComponent struct {
	Err  error
	HTML string
}

MockComponent implements forge.Component for testing. It renders static HTML or returns a configured error.

func (*MockComponent) Render

func (m *MockComponent) Render(_ context.Context, w io.Writer) error

Render writes the HTML string to w or returns the configured error.

type Option

type Option func(*appConfig)

Option configures the test App.

func WithErrorHandler

func WithErrorHandler(h forge.ErrorHandler) Option

WithErrorHandler sets a custom error handler.

func WithMiddleware

func WithMiddleware(mw ...forge.Middleware) Option

WithMiddleware adds middleware to the test app.

func WithOption

func WithOption(opt forge.Option) Option

WithOption passes through an arbitrary forge.Option for extensibility.

func WithRoles

func WithRoles(perms forge.RolePermissions) Option

WithRoles enables RBAC with the given permissions. Roles are read from session data key "__forgetest_role", set via Request.WithRole().

type Request

type Request struct {
	// contains filtered or unexported fields
}

Request builds an HTTP request for testing forge handlers.

func Delete

func Delete(t testing.TB, app *App, path string) *Request

Delete creates a DELETE request builder.

func Get

func Get(t testing.TB, app *App, path string) *Request

Get creates a GET request builder.

func Patch

func Patch(t testing.TB, app *App, path string) *Request

Patch creates a PATCH request builder.

func Post

func Post(t testing.TB, app *App, path string) *Request

Post creates a POST request builder.

func Put

func Put(t testing.TB, app *App, path string) *Request

Put creates a PUT request builder.

func (*Request) AsUser

func (r *Request) AsUser(userID string) *Request

AsUser creates a session for the given user ID. The session is created in the store and a cookie is attached to the request.

func (*Request) Do

func (r *Request) Do() *Response

Do executes the request and returns the response.

func (*Request) WithCookie

func (r *Request) WithCookie(name, value string) *Request

WithCookie adds a cookie to the request.

func (*Request) WithForm

func (r *Request) WithForm(key, value string) *Request

WithForm adds a form key-value pair. Sets Content-Type to application/x-www-form-urlencoded.

func (*Request) WithHTMX

func (r *Request) WithHTMX() *Request

WithHTMX sets the HX-Request header to mark this as an HTMX request.

func (*Request) WithHeader

func (r *Request) WithHeader(key, value string) *Request

WithHeader sets a custom request header.

func (*Request) WithJSON

func (r *Request) WithJSON(v any) *Request

WithJSON marshals the value as JSON body and sets Content-Type.

func (*Request) WithRole

func (r *Request) WithRole(role string) *Request

WithRole stores the given role in session data. Only effective when used with AsUser (no session = no role).

func (*Request) WithSessionData

func (r *Request) WithSessionData(key string, value any) *Request

WithSessionData sets arbitrary session data.

type Response

type Response struct {
	// contains filtered or unexported fields
}

Response wraps an httptest.ResponseRecorder with assertion helpers.

func (*Response) Body

func (r *Response) Body() string

Body returns the response body as a string.

func (*Response) HTML

func (r *Response) HTML() *Document

HTML parses the response body as HTML and returns a Document for assertions.

func (*Response) Header

func (r *Response) Header(key string) string

Header returns the value of the given response header.

func (*Response) Recorder

func (r *Response) Recorder() *httptest.ResponseRecorder

Recorder returns the underlying httptest.ResponseRecorder for advanced use.

func (*Response) RequireHTMXPushURL

func (r *Response) RequireHTMXPushURL(t testing.TB, url string)

RequireHTMXPushURL asserts that the HX-Push-Url header matches.

func (*Response) RequireHTMXRefresh

func (r *Response) RequireHTMXRefresh(t testing.TB)

RequireHTMXRefresh asserts that the HX-Refresh header is "true".

func (*Response) RequireHTMXReplaceURL

func (r *Response) RequireHTMXReplaceURL(t testing.TB, url string)

RequireHTMXReplaceURL asserts that the HX-Replace-Url header matches.

func (*Response) RequireHTMXReswap

func (r *Response) RequireHTMXReswap(t testing.TB, strategy string)

RequireHTMXReswap asserts that the HX-Reswap header matches.

func (*Response) RequireHTMXRetarget

func (r *Response) RequireHTMXRetarget(t testing.TB, sel string)

RequireHTMXRetarget asserts that the HX-Retarget header matches.

func (*Response) RequireHTMXTrigger

func (r *Response) RequireHTMXTrigger(t testing.TB, event string)

RequireHTMXTrigger asserts that the HX-Trigger header contains the given event.

func (*Response) RequireHeader

func (r *Response) RequireHeader(t testing.TB, key, value string)

RequireHeader asserts that the response has a header with the given value.

func (*Response) RequireRedirect

func (r *Response) RequireRedirect(t testing.TB, code int, url string)

RequireRedirect asserts that the response has the given status code and a Location header matching the given URL.

func (*Response) RequireStatus

func (r *Response) RequireStatus(t testing.TB, code int)

RequireStatus asserts that the response has the given status code.

func (*Response) StatusCode

func (r *Response) StatusCode() int

StatusCode returns the response status code.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL