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 ¶
- func GeneratedIsCurrent(tb testing.TB, registry *livewire.Registry, options *generate.Options)
- func GeneratedIsCurrentStrict(tb testing.TB, registry *livewire.Registry, options *generate.Options)
- func Parity(tb testing.TB, options *ParityOptions)
- func Stale(tb testing.TB, options *StaleOptions)
- func TenantIsolation(tb testing.TB, options *TenantIsolationOptions)
- type Application
- type ParityOptions
- type Read
- type StaleOptions
- type Tenant
- type TenantIsolationOptions
- type Write
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func GeneratedIsCurrent ¶
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 ¶
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 ¶
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 ¶
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
}
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.