Documentation
¶
Overview ¶
Package flowtest is the multi-step flow-test engine (FLOWTEST-S1) — the last piece of the productivity-confidence layer: persisted, re-runnable scenarios ("login as role X → create → attach → assert") that a deploy can re-run for a PASS/FAIL regression verdict anchored to the schema version it ran against.
It GENERALIZES the molds STATE-AUDIT-V1 §4 mapped — it is not a new idea:
- the FORMAT is api-cert.postman_collection.json formalized: steps as DATA (method/path/body/headers + assertions) with CAPTURED variables flowing between steps ({{token}} from the login, {{cita_id}} from the create);
- the RUNNER is the acceptance-test/DevHub pattern: execute in order against the live app, emit each step's PASS/FAIL live (SSE), final verdict;
- the ASSERTIONS are the e2e/httpexpect vocabulary: status, field exists, field equals/contains.
A flow authenticates as a TENANT USER (a real POST /auth/login step, or a flow-level role that pre-mints a tenant JWT) — never the super-admin — so it exercises the REAL RBAC the app enforces.
Index ¶
- Variables
- func DeleteFlow(ctx context.Context, pool *pgxpool.Pool, tenantID, id string) error
- func EnsureTables(ctx context.Context, pool *pgxpool.Pool) error
- func SaveRun(ctx context.Context, pool *pgxpool.Pool, run *Run) error
- type Assert
- type Emit
- type Expect
- type Flow
- type FlowResult
- type Run
- type Runner
- type Step
- type StepResult
- type StoredFlow
- type Upload
Constants ¶
This section is empty.
Variables ¶
var ErrDuplicateName = errors.New("a flow with that name already exists")
ErrDuplicateName marks a create/rename colliding with an existing flow name.
var ErrFlowNotFound = errors.New("flow not found")
ErrFlowNotFound marks a lookup of a flow the tenant does not have.
Functions ¶
func DeleteFlow ¶
DeleteFlow removes one flow (its runs stay — the regression trail survives).
func EnsureTables ¶
EnsureTables creates the flow tables idempotently (the outbox/schema_history pattern — existing databases predate the canonical DDL in migrations/001).
Types ¶
type Assert ¶
type Assert struct {
Path string `json:"path"`
Op string `json:"op"`
Value string `json:"value,omitempty"`
}
Assert checks one field of the response JSON, addressed by dot-path. Ops (FLOWTEST-POWER-S1 — the full vocabulary, mirrored by the Studio assertion controls):
- "exists" / "not_exists" — the field is (not) present. not_exists is how a GraphQL step asserts success ("errors" not_exists — GraphQL is always 200).
- "eq" / "ne" — the stringified value equals / differs from Value ({{var}} substitution applies to Value, so a response field can be compared against a captured variable).
- "contains" — substring on the stringified value.
- "gt" / "gte" / "lt" / "lte" — numeric comparison (both sides must parse as numbers; a non-numeric side is a clear failure, never a silent pass).
- "len" — the field is an array (or string) whose length equals Value.
type Emit ¶
Emit receives live events while a flow runs: ("step", StepResult) after each step, so a UI can stream the run (the DevHub SSE pattern). nil is fine.
type Flow ¶
type Flow struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
// Role, when set, pre-mints a tenant JWT for that RBAC role into {{token}}
// before step 1 (the convenience path). A flow that wants to exercise the
// REAL login instead makes step 1 a POST /auth/login and captures
// {{token}} from the response — a capture overwrites the pre-minted var.
Role string `json:"role,omitempty"`
Steps []Step `json:"steps"`
}
Flow is one named multi-step scenario — the persistable "flow as data".
type FlowResult ¶
type FlowResult struct {
Name string `json:"name"`
Pass bool `json:"pass"`
Steps []StepResult `json:"steps"`
StepsTotal int `json:"steps_total"`
StepsFail int `json:"steps_failed"`
DurationMS int64 `json:"duration_ms"`
}
FlowResult is one flow's verdict with every step's outcome.
type Run ¶
type Run struct {
ID int64 `json:"id"`
TenantID string `json:"tenant_id"`
SchemaVersion int `json:"schema_version"`
Scope string `json:"scope"` // "suite" or the flow name
Pass bool `json:"pass"`
FlowsTotal int `json:"flows_total"`
FlowsFailed int `json:"flows_failed"`
StepsTotal int `json:"steps_total"`
StepsFailed int `json:"steps_failed"`
Results json.RawMessage `json:"results,omitempty"` // []FlowResult
CreatedAt time.Time `json:"created_at"`
}
Run is one persisted execution (a single flow or the whole suite), anchored to the schema version it ran against — the regression trail.
type Runner ¶
type Runner struct {
// Handler is the live data-plane router (App.currentRouter).
Handler http.Handler
// JWTSecret mints the flow-level Role token — the SAME tenant-JWT contract
// the engine validates (never a super-admin credential).
JWTSecret string
// HostSuffix builds the request Host (<tenant><HostSuffix>) the tenant
// middleware resolves. Default ".flows.internal".
HostSuffix string
}
Runner executes flows against the app's REAL router (the same handler the listener serves — tenant middleware, rate limit, cache, JWT, RBAC, generated routes), in-process: no loopback socket, no parallel path, and after a hot-swap it runs against the CURRENT surface. The acceptance-test pattern, server-side.
type Step ¶
type Step struct {
Name string `json:"name"`
Method string `json:"method"`
Path string `json:"path"` // e.g. /api/citas or /auth/login
Body string `json:"body,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
// Upload, when set, sends a multipart file (POST /api/files) instead of Body.
Upload *Upload `json:"upload,omitempty"`
Expect Expect `json:"expect"`
// Capture maps {{variable}} names to dot-paths into the response JSON
// (e.g. "cita_id": "id", "token": "token", "first": "data.0.id").
Capture map[string]string `json:"capture,omitempty"`
}
Step is one request + its expectations + what it captures for later steps. {{var}} placeholders are substituted in Path, Body and header values.
type StepResult ¶
type StepResult struct {
Index int `json:"index"`
Name string `json:"name"`
Method string `json:"method"`
Path string `json:"path"` // after variable substitution
Skipped bool `json:"skipped,omitempty"`
Pass bool `json:"pass"`
Status int `json:"status"`
Expected int `json:"expected"`
Failures []string `json:"failures,omitempty"`
BodySample string `json:"body_sample,omitempty"` // the response body (capped ~2 KB) — every step, pass or fail
Captured map[string]string `json:"captured,omitempty"`
DurationMS int64 `json:"duration_ms"`
}
StepResult is one executed step's outcome — always with the exact detail (expected vs got) so a red step is actionable.
type StoredFlow ¶
type StoredFlow struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Steps int `json:"steps"`
Flow *Flow `json:"flow,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
StoredFlow is a persisted flow with its identity.