testhelper

package
v1.0.1 Latest Latest
Warning

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

Go to latest
Published: Jul 26, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package testhelper provides the fakes and assertions a consumer of github.com/AtomiCloud/diene.go-api-engine would otherwise rebuild in every test suite.

Two kinds of pain motivate it. Testing a multi-backend client tree means standing up several backends that each answer differently, which is a fixture nobody wants to hand-roll twice: FakeBackend and NewFakeTree do it in a line. And testing the 3-case classification means producing RFC 9457 envelopes with the right `data` extension: ProblemResponse and Canned mint them, so a test can assert how a problem travels without first becoming an expert on the envelope's wire shape.

The assertions (AssertOutcome, AssertProblem, CheckProblem) exist for the same reason: every consumer would otherwise write the same errors.As dance followed by field-by-field comparison.

This package is for tests. It is a normal (non-test) package so consumers can import it, but nothing in the engine depends on it.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func AssertCount

func AssertCount(t TestingT, backend *FakeBackend, want int)

AssertCount fails t unless the backend received exactly want requests.

func AssertOutcome

func AssertOutcome(t TestingT, actual apiengine.Outcome, want apiengine.Outcome)

AssertOutcome fails t unless the response carries the expected 3-case classification.

func AssertProblem

func AssertProblem(t TestingT, err error, want ProblemOptions) problem.Problem

AssertProblem fails t unless err carries the expected problem envelope, and returns the envelope it found.

Only the fields set on want are compared, so a test asserts the `data` extension it cares about without restating the whole envelope.

func CheckCount

func CheckCount(backend *FakeBackend, want int) error

CheckCount reports whether a fake backend received the expected number of requests.

It is how the retry-once profile is asserted: a call that survived one transport failure leaves exactly two.

func CheckOutcome

func CheckOutcome(actual apiengine.Outcome, want apiengine.Outcome) error

CheckOutcome reports whether outcome is the expected one.

func CheckProblem

func CheckProblem(err error, want ProblemOptions) (problem.Problem, error)

CheckProblem reports whether err carries the expected problem envelope, returning the envelope it found.

It is the check half of AssertProblem, separated so a consumer can compose it into its own assertions without a *testing.T in hand.

func NewProblems

func NewProblems(extra ...problem.Type) (*apiengine.Problems, error)

NewProblems returns an api-engine problem factory on SampleErrorPortal, so a test that only wants to exercise the client need not build one.

Extra types are registered alongside the engine's, exactly as they would be in a consumer, so a test can raise its own problems through the same factory. A type whose id collides with an engine problem is rejected.

func ProblemResponse

func ProblemResponse(options ProblemOptions) problem.Problem

ProblemResponse builds the canned envelope a fake backend returns.

It is the fixture the 3-case classification is proven against: a client gets a problem case only because a body like this came back, so producing one has to be a single call rather than a hand-written JSON literal per test.

func SampleErrorPortal

func SampleErrorPortal() problem.ErrorPortal

SampleErrorPortal returns a deterministic error portal for tests.

Types

type FakeBackend

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

FakeBackend is an in-process HTTP backend for client-tree tests.

It answers by path, records every request it received, and can be told to fail the transport a fixed number of times — which is what makes the retry-once profile testable without a real network to break.

func NewFakeBackend

func NewFakeBackend(options FakeBackendOptions) *FakeBackend

NewFakeBackend starts a fake backend. Close it with FakeBackend.Close.

func (*FakeBackend) Backend

func (b *FakeBackend) Backend() apiengine.BackendConfig

Backend returns the engine config block entry pointing at this fake.

func (*FakeBackend) Close

func (b *FakeBackend) Close()

Close shuts the backend down.

func (*FakeBackend) Count

func (b *FakeBackend) Count() int

Count returns how many requests the backend received.

It is the assertion the retry profile is proven with: one call that succeeds after a single transport failure must leave a count of exactly two.

Example

A backend can be told to fail the transport, which is how the retry-once-on-network-error profile is proven without a real network to break.

package main

import (
	"context"
	"fmt"
	"net/http"

	"github.com/AtomiCloud/diene.go-api-engine/lib/apiengine"
	"github.com/AtomiCloud/diene.go-api-engine/testhelper"
)

// Invoice is the payload the examples decode.
type Invoice struct {
	ID    string `json:"id"`
	Total int    `json:"total"`
}

func main() {
	backend := testhelper.NewFakeBackend(testhelper.FakeBackendOptions{
		Routes: map[string]testhelper.Route{
			"/v1/invoices/i-1": {Status: http.StatusOK, Body: Invoice{ID: "i-1", Total: 4200}},
		},
		TransportFailures: 1,
	})
	defer backend.Close()

	tree, err := testhelper.NewFakeTree(testhelper.FakeTreeOptions{
		Backends: map[string]*testhelper.FakeBackend{"billing": backend},
		Retry:    apiengine.RetryConfig{Network: true},
	})
	if err != nil {
		panic(err)
	}
	client, err := tree.Tree.Backend("billing")
	if err != nil {
		panic(err)
	}

	invoice, err := apiengine.Execute[Invoice](context.Background(), client,
		apiengine.Request{Path: "/v1/invoices/i-1"})
	if err != nil {
		panic(err)
	}

	fmt.Println(invoice.ID)
	// Exactly two: the original attempt and the one permitted retry.
	fmt.Println(backend.Count())
}
Output:
i-1
2

func (*FakeBackend) Requests

func (b *FakeBackend) Requests() []RecordedRequest

Requests returns the requests received so far, oldest first.

func (*FakeBackend) SetRoute

func (b *FakeBackend) SetRoute(path string, route Route)

SetRoute adds or replaces a route while the backend is running.

func (*FakeBackend) URL

func (b *FakeBackend) URL() string

URL returns the backend's base URL.

type FakeBackendOptions

type FakeBackendOptions struct {
	// Routes maps a request path to the answer for it.
	Routes map[string]Route
	// Fallback answers a path with no route. Defaults to 404 with an RFC 9457
	// envelope, because a real C0-conformant backend does not answer a miss
	// with a bare status.
	Fallback *Route
	// TransportFailures is how many leading requests die before a response is
	// produced. One failure exercises the single retry; two exhaust it.
	TransportFailures int
}

FakeBackendOptions configures a FakeBackend.

type FakeRetriever

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

FakeRetriever is an in-memory authengine.Retriever: it hands out a canned token per resource and records what was asked for.

A consumer testing the multi-backend tree needs to prove that backend A got A's token and backend B got B's — which needs a retriever that answers per resource and remembers, not a real IdP.

func NewFakeRetriever

func NewFakeRetriever(tokens map[string]string) *FakeRetriever

NewFakeRetriever creates a retriever serving the given resource-to-token map.

func (*FakeRetriever) Asked

func (r *FakeRetriever) Asked() []string

Asked returns the resources tokens were requested for, oldest first.

func (*FakeRetriever) FailResource

func (r *FakeRetriever) FailResource(resource string, cause error)

FailResource makes the named resource fail to resolve, which is how a test reaches the credentials-unavailable path without breaking an IdP.

func (*FakeRetriever) Token

func (r *FakeRetriever) Token(_ context.Context, resource string) (authengine.AccessToken, error)

Token implements authengine.Retriever.

type FakeTree

type FakeTree struct {
	// Tree is the client tree under test.
	Tree *apiengine.ClientTree
	// Backends maps each registered name to its fake backend.
	Backends map[string]*FakeBackend
	// Retriever is the per-backend token seam the tree was built with.
	Retriever *FakeRetriever
	// Problems is the factory the tree mints its errors through.
	Problems *apiengine.Problems
}

FakeTree is a running client tree over fake backends.

func NewFakeTree

func NewFakeTree(options FakeTreeOptions) (*FakeTree, error)

NewFakeTree assembles a client tree over the given fake backends.

The retry sleep is replaced with a no-op, so exercising the retry-once profile costs no wall-clock time.

Example

A whole multi-backend tree over fakes, in one call — with each backend's own token attached through the auth-engine retriever seam.

package main

import (
	"context"
	"fmt"
	"net/http"

	"github.com/AtomiCloud/diene.go-api-engine/lib/apiengine"
	"github.com/AtomiCloud/diene.go-api-engine/testhelper"
)

// Invoice is the payload the examples decode.
type Invoice struct {
	ID    string `json:"id"`
	Total int    `json:"total"`
}

func main() {
	route := map[string]testhelper.Route{
		"/v1/invoices/i-1": {Status: http.StatusOK, Body: Invoice{ID: "i-1", Total: 4200}},
	}
	billing := testhelper.NewFakeBackend(testhelper.FakeBackendOptions{Routes: route})
	catalog := testhelper.NewFakeBackend(testhelper.FakeBackendOptions{Routes: route})

	tree, err := testhelper.NewFakeTree(testhelper.FakeTreeOptions{
		Backends: map[string]*testhelper.FakeBackend{
			"billing": billing,
			"catalog": catalog,
		},
		Tokens: map[string]string{
			"billing": "token-billing",
			"catalog": "token-catalog",
		},
	})
	if err != nil {
		panic(err)
	}
	defer tree.Close()

	client, err := tree.Tree.Backend("billing")
	if err != nil {
		panic(err)
	}
	invoice, err := apiengine.Execute[Invoice](context.Background(), client,
		apiengine.Request{Path: "/v1/invoices/i-1"})
	if err != nil {
		panic(err)
	}

	fmt.Println(invoice.ID, invoice.Total)
	fmt.Println(billing.Requests()[0].Authorization())
}
Output:
i-1 4200
Bearer token-billing

func (*FakeTree) Close

func (f *FakeTree) Close()

Close shuts down every fake backend in the tree.

type FakeTreeOptions

type FakeTreeOptions struct {
	// Backends maps a logical backend name to the fake serving it.
	Backends map[string]*FakeBackend
	// Tokens maps a resource name to the token the retriever hands out. Nil
	// means the tree is built without a retriever, i.e. unauthenticated.
	Tokens map[string]string
	// Retry is the resilience profile. Zero disables the retry, so a test that
	// wants it says so.
	Retry apiengine.RetryConfig
	// ExtraProblems are the consumer's own problem types, registered on the
	// tree's factory alongside the engine's.
	ExtraProblems []problem.Type
}

FakeTreeOptions configures NewFakeTree.

type ProblemOptions

type ProblemOptions struct {
	// Type is the problem type URI.
	Type string
	// Title is the short human-readable summary.
	Title string
	// Status is the origin-generated HTTP status.
	Status int
	// Detail is the occurrence-specific explanation, omitted when blank.
	Detail string
	// Instance is the occurrence URI, omitted when blank.
	Instance string
	// Recoverable is the C0 retry-vs-fatal flag.
	Recoverable bool
	// Data is the typed payload extension. This is the member that must survive
	// the whole journey to the caller, so tests set it and assert it.
	Data map[string]any
}

ProblemOptions describes a canned RFC 9457 envelope.

type RecordedRequest

type RecordedRequest struct {
	// Method is the HTTP method.
	Method string
	// Path is the request path.
	Path string
	// Query is the raw query string.
	Query string
	// Header is the request header, including any Authorization the engine
	// attached.
	Header http.Header
	// Body is the request body as received.
	Body []byte
}

RecordedRequest is one request a FakeBackend received.

func (RecordedRequest) Authorization

func (r RecordedRequest) Authorization() string

Authorization returns the request's Authorization header.

type Route

type Route struct {
	// Status is the HTTP status to return.
	Status int
	// Body is the response body. A []byte or string is sent verbatim; anything
	// else is JSON-encoded.
	Body any
	// Header carries additional response headers.
	Header http.Header
}

Route is one canned answer a FakeBackend gives.

func Canned

func Canned(options ProblemOptions) Route

Canned returns a Route serving the envelope with the problem's own status and the `application/problem+json` content type RFC 9457 requires.

Example

Canned mints the RFC 9457 envelope a 4xx test needs, so a test asserts how a problem travels without first becoming an expert on the wire shape.

package main

import (
	"context"
	"fmt"
	"net/http"

	"github.com/AtomiCloud/diene.go-api-engine/lib/apiengine"
	"github.com/AtomiCloud/diene.go-api-engine/testhelper"
)

// Invoice is the payload the examples decode.
type Invoice struct {
	ID    string `json:"id"`
	Total int    `json:"total"`
}

func main() {
	backend := testhelper.NewFakeBackend(testhelper.FakeBackendOptions{
		Routes: map[string]testhelper.Route{
			"/v1/invoices/i-9": testhelper.Canned(testhelper.ProblemOptions{
				Type:        "https://docs.example.com/docs/prod/api/billing/core/v1/quota-exceeded",
				Title:       "Quota exceeded",
				Status:      http.StatusTooManyRequests,
				Detail:      "the monthly quota is spent",
				Recoverable: true,
				Data:        map[string]any{"limit": 100},
			}),
		},
	})
	defer backend.Close()

	tree, err := testhelper.NewFakeTree(testhelper.FakeTreeOptions{
		Backends: map[string]*testhelper.FakeBackend{"billing": backend},
	})
	if err != nil {
		panic(err)
	}
	client, err := tree.Tree.Backend("billing")
	if err != nil {
		panic(err)
	}

	_, err = apiengine.Execute[Invoice](context.Background(), client,
		apiengine.Request{Path: "/v1/invoices/i-9"})

	// CheckProblem is the *testing.T-free half, so it works in an example.
	envelope, checkErr := testhelper.CheckProblem(err, testhelper.ProblemOptions{
		Title:  "Quota exceeded",
		Status: http.StatusTooManyRequests,
	})
	fmt.Println(checkErr == nil)
	fmt.Println(envelope.Data["limit"])
}
Output:
true
100

type TestingT

type TestingT interface {
	Helper()
	Errorf(format string, args ...any)
}

TestingT is the slice of *testing.T the assertions here use.

Taking an interface rather than *testing.T keeps this package importable from a consumer's own helper layer and lets the assertions be proven against a recording double in the meta tier — an assertion nobody has watched fail is an assertion nobody has tested.

Jump to

Keyboard shortcuts

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