Help us shape the future of Go! Take the 2026 Go Developer Survey

livewiretest

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Nov 2, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

Documentation

Overview

Package livewiretest holds the contract gates an application's test suite runs (D23, D70): GeneratedIsCurrent fails when a committed generated file differs from what the registry renders now; Parity fails when a read's REST body differs from its socket payload; Stale fails when a committed write leaves a live read's subscription behind its REST read; and TenantIsolation fails when one tenant reads a row of another (D42).

The gates other than GeneratedIsCurrent drive the application as its clients do: they serve Application.Handler, the application's own router with its authentication, its REST routes and its socket, on a local test server, and authenticate every request through the caller's Authenticate function (an API key header or a session cookie), so what they check is what a browser, the generated clients and the CLI get. They fail the test through the testing.TB they are given and log nothing else.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func GeneratedIsCurrent

func GeneratedIsCurrent(tb testing.TB, registry *livewire.Registry, options *generate.Options)

GeneratedIsCurrent renders every artifact of the registry in memory, as generate.Write would write it with options (TypeScript, OpenAPI, the Go client and the CLI, whichever the options name), and fails the test for every committed file that is missing or differs, with the lines that differ (D23). Pass the options the application's generator command passes, with paths relative to the test's working directory, the package directory.

Example
application, err := startNotes(brokenBy{})
if err != nil {
	panic(err)
}
defer application.close()
directory, err := os.MkdirTemp("", "livewiretest-example-")
if err != nil {
	panic(err)
}
defer func() { _ = os.RemoveAll(directory) }()
// The options the application's generator command passes, relative to the test's package.
options := &generate.Options{TypeScriptDirectory: filepath.Join(directory, "web", "src", "api")}
if err := generate.Write(application.Engine.Registry(), options); err != nil {
	panic(err)
}

fmt.Println(verdict(gateFailures(func(t testing.TB) {
	livewiretest.GeneratedIsCurrent(t, application.Engine.Registry(), options)
})))
Output:
passed

func GeneratedIsCurrentStrict

func GeneratedIsCurrentStrict(tb testing.TB, registry *livewire.Registry, options *generate.Options)

GeneratedIsCurrentStrict is GeneratedIsCurrent for an application whose output directories belong to the generator: it also fails on every file below the TypeScript, Go client and CLI directories that the registry no longer generates, such as the client of a removed declaration's leftover file. An application that keeps its own files there uses GeneratedIsCurrent.

func Parity

func Parity(tb testing.TB, options *ParityOptions)

Parity fails the test for every read of the registry whose REST body differs from its socket payload byte for byte (D23): the answer of a socket query, and for a live read the first push of a subscribe too, each against the REST body of the same read as the same identity, through the application's Handler. It covers every read kind of every declaration (a resource's list, item, distinct and history reads, a list's and an object's read, and the presence reads when the engine serves them); a read that needs parameters and has no Read fails the gate, as does a read the application answers with anything but 200.

Example
application, err := startNotes(brokenBy{})
if err != nil {
	panic(err)
}
defer application.close()

fmt.Println(verdict(gateFailures(func(t testing.TB) {
	livewiretest.Parity(t, &livewiretest.ParityOptions{
		Application:  application.Application,
		Authenticate: as("alice"),
		Reads: []livewiretest.Read{
			{Target: "notes.item", Parameters: map[string]string{"id": application.firstNote("north")}},
			{Target: "notes.history", Parameters: map[string]string{"id": application.firstNote("north")}},
			{Target: "notes.distinct", Parameters: map[string]string{"field": "title"}},
		},
	})
})))
Output:
passed

func Stale

func Stale(tb testing.TB, options *StaleOptions)

Stale subscribes to every live read of the registry (presence reads aside, whose table is the library's own) and runs the writes one after another (D23). After each it reads every subscribed read over REST and fails the test, naming the declaration, for every subscription whose state does not reach that REST body within the timeout: a write the subscription should have re-run on and did not, because the declaration does not list the table the write changed, or the table sends no notification (TriggerSQL missing), or the change feed does not reach the engine. A write to a declared table that changes no live read of a declaration listing it fails too: it proves nothing about that table.

What it cannot catch: a table no Write writes. It requires a write for every declared table, but no gate can discover a table a loader reads without declaring it; give the writes of every table the loaders read, declared or not, and Stale catches the undeclared ones. It also checks only what the fixture identity reads, through the reads it is given.

Example
application, err := startNotes(brokenBy{})
if err != nil {
	panic(err)
}
defer application.close()

fmt.Println(verdict(gateFailures(func(t testing.TB) {
	livewiretest.Stale(t, &livewiretest.StaleOptions{
		Application:  application.Application,
		Authenticate: as("alice"),
		Reads: []livewiretest.Read{
			{Target: "notes.item", Parameters: map[string]string{"id": application.firstNote("north")}},
			{Target: "notes.history", Parameters: map[string]string{"id": application.firstNote("north")}},
			{Target: "notes.distinct", Parameters: map[string]string{"field": "title"}},
		},
		Writes: []livewiretest.Write{application.addNote("north", "second"), application.addTag("north", "later")},
	})
})))
Output:
passed

func TenantIsolation

func TenantIsolation(tb testing.TB, options *TenantIsolationOptions)

TenantIsolation reads every filterable resource of the registry as both tenants over REST (D42, DAT-017) and fails the test when anything of one tenant reaches the other. It lists every row of each tenant, paging until a page comes back short (past a CountCap), and keys each row by the resource's item key (its "id" without an item read); a row without a key fails, as nothing could be checked about it. Then, for each tenant, it fails on:

  • a listed row whose key is one of the other tenant's;
  • an item read of one of its own rows that answers another row, and an item read of any row of the other tenant that answers anything but 404 or 403;
  • a version in the history of one of its own rows that belongs to another row, and a history read of any row of the other tenant that answers anything but 404 or 403;
  • an export that writes no key column, or a row whose key is not one of its own;
  • a distinct read of a filterable field whose values or counts differ from its own rows'.

A resource of which a tenant reads no row fails too: the gate needs rows of both tenants.

Example
application, err := startNotes(brokenBy{})
if err != nil {
	panic(err)
}
defer application.close()

fmt.Println(verdict(gateFailures(func(t testing.TB) {
	livewiretest.TenantIsolation(t, &livewiretest.TenantIsolationOptions{
		Application: application.Application,
		Tenants:     [2]livewiretest.Tenant{{Name: "north", Authenticate: as("alice")}, {Name: "south", Authenticate: as("bob")}},
	})
})))
Output:
passed

Types

type Application

type Application struct {
	Engine     *livewire.Engine
	Handler    http.Handler
	SocketPath string
}

Application is the application under test as its clients reach it: Handler is its router, as its main builds it, with the authentication, the REST routes ginadapter.RegisterRoutes mounts below the registry's BasePath and the socket at SocketPath ("/api/v1/socket"), all in front of Engine, whose registry names the reads the gates run.

type ParityOptions

type ParityOptions struct {
	Application  Application
	Authenticate func(request *http.Request)
	Reads        []Read
}

ParityOptions configures Parity. Application and Authenticate are required: Authenticate makes a request the fixture identity's (an API key header, a session cookie) and runs for every REST request and the socket's upgrade. Reads gives the parameters of the reads that need them (an item's key, a distinct read's field, a presence read's key); a read that needs none runs without parameters when Reads has none for it, and several Reads for one target each run.

type Read

type Read struct {
	Target     string
	Parameters map[string]string
}

Read is one read a gate runs: the target as the socket names it (D46: "todos", "todos.item", "todos.distinct", "todos.history", "todos.presence") and its parameters as both transports carry them, one text per name; a path parameter fills the route's placeholder, every other one is a query parameter on REST.

type StaleOptions

type StaleOptions struct {
	Application  Application
	Authenticate func(request *http.Request)
	Reads        []Read
	Writes       []Write
	Timeout      time.Duration
}

StaleOptions configures Stale. Application, Authenticate (as for ParityOptions) and Writes are required: Writes needs a write for every table a live declaration lists in Tables, each of a row the fixture identity reads, so it changes a live read of a declaration that lists the table. A write to a table no live declaration lists may change nothing; what it changes must re-run like any other change, so it is how a test proves that a loader reading an undeclared table goes stale. Reads gives the parameters of the live reads that need them, as ParityOptions.Reads does. Timeout bounds the wait for a subscription to catch up with one write (default 5 s).

type Tenant

type Tenant struct {
	Name         string
	Authenticate func(request *http.Request)
}

Tenant is one tenant of TenantIsolation: a name for the failure messages and the function that makes a request an identity of the tenant's (an API key header, a session cookie).

type TenantIsolationOptions

type TenantIsolationOptions struct {
	Application Application
	Tenants     [2]Tenant
	Shared      func(resource string, row json.RawMessage) bool
}

TenantIsolationOptions configures TenantIsolation: the application and two tenants, each with rows of every filterable resource its identity reads. Shared, when set, names the rows both tenants may read on purpose (reference data, public rows): it is asked for every listed row with the resource's name and the row's JSON, and a row it answers true for is no other tenant's, so either tenant may list, read and export it. A shared resource is one whose every row it accepts; every other row of every resource is still checked.

type Write

type Write struct {
	Table  string
	Commit func(ctx context.Context) error
}

Write is one committed write of a fixture the application makes to a table, in its own committed transaction, the way its repositories or a job write (a mutation through the application's own code, a sqlc query on the pool): Commit returns once it committed.

Jump to

Keyboard shortcuts

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