Appearance
internal/platform/testutil
internal/platform/testutil is the shared test harness consumed by every plexsphere bounded context. It ships as a dedicated Go module (its own go.mod) so test-only dependencies — testcontainers-go, gomega, envtest, the AWS SDK — never leak into the production module graph. The module is stitched into the repo via go.work and reachable from any test package under the workspace.
For the operational how-to (test pyramid, build tags, worked example, Feature-ID rule), see docs/contributing/testing.md.
This document is the authoritative reference for the exported surface of each sub-package:
containers— testcontainer fixturesenvtest— controller-runtime envtest bootstrapperbuilders— fluent domain aggregate builderssse— signed SSE stream capturematchers— gomega matchers
Failure messages, errors, and t.Fatalf strings produced by these sub-packages do not carry traceability identifiers — the rendered- surface convention forbids them in any non-comment surface. The originating planwerk-id of an assertion is recorded in the test's doc-comment, never in the surfaced string.
containers
Import path: github.com/plexsphere/plexsphere/internal/platform/testutil/containers Build tag: //go:build integration.
The package boots the platform dependencies bounded contexts need in an integration scenario — Postgres, SpiceDB, NATS, OpenBao, and SeaweedFS, plus a standalone Dex OIDC fixture. Each Start<Backend>(t) helper:
- pins the container image via an exported tag constant (verified by the workspace image-pin gate),
- registers a
t.Cleanupthat dumps container logs on failure before callingTerminate, - fails the test with a plain, identifier-free message on any startup error.
Image-pin constants
| Constant | Image |
|---|---|
PostgresImage | postgres:16.4 |
SpiceDBImage | authzed/spicedb:v1.39.0 |
NATSImage | nats:2.10.20 |
OpenBaoImage | openbao/openbao:2.0.0 |
SeaweedFSImage | chrislusf/seaweedfs:3.75 |
DexImage | ghcr.io/dexidp/dex:v2.41.1 |
Pins must stay in lockstep with tests/integration/testcontainers_helpers.go.
Fixtures
| Type | Fields | Starter |
|---|---|---|
PostgresFixture | Container *postgres.PostgresContainer, DSN string | StartPostgres(t *testing.T) PostgresFixture |
SpiceDBFixture | Container testcontainers.Container, Endpoint string, PresharedKey string | StartSpiceDB(t *testing.T, opts ...SpiceDBOption) SpiceDBFixture — in-memory datastore by default; pass WithPostgresDatastore(dsn) to back SpiceDB with a Postgres fixture (runs spicedb migrate head before serving) |
NATSFixture | Container testcontainers.Container, URL string (JetStream enabled by default) | StartNATS(t *testing.T) NATSFixture |
OpenBaoFixture | Container testcontainers.Container, Address string, RootToken string (dev-mode, fixed token) | StartOpenBao(t *testing.T) OpenBaoFixture |
SeaweedFSFixture | Container testcontainers.Container, Endpoint string, Region string, AccessKey string, SecretKey string (S3 gateway on :8333) | StartSeaweedFS(t *testing.T) SeaweedFSFixture |
DexFixture | Container testcontainers.Container, Issuer string, ClientID string, ClientSecret string, HostPort string (a Dex OIDC server with one static client and one static user, in-memory storage) | StartDex(t *testing.T) DexFixture |
StartDex boots an ephemeral Dex OIDC server with a single static OAuth2 client and a single static seed user, both exposed as exported constants (DexClientID, DexClientSecret, DexUserEmail, …) so tests reference them instead of duplicating literals. The issuer URL embeds a host port reserved up front so the discovery document and the JWTs Dex signs match the externally reachable address. It is an opt-in fixture: callers that do not need OIDC should not pay its startup cost.
Composite
Fixtures bundles the five backend fixtures — Postgres, SpiceDB, NATS, OpenBao, and SeaweedFS; StartAll(t *testing.T) Fixtures starts those five concurrently and logs per-fixture elapsed time via t.Logf. StartAll deliberately excludes Dex — use StartDex standalone when a test needs an OIDC server. Per-container startup budget is the package-internal startupTimeout (60 s); callers that care about end-to-end startup wrap the call in a test-level budget assertion.
envtest
Import path: github.com/plexsphere/plexsphere/internal/platform/testutil/envtest Build tag: //go:build integration.
go
func StartEnvtest(t *testing.T) (*rest.Config, client.Client, manager.Manager, func())Boots a controller-runtime envtest API server with the vendored Crossplane v2 and External Secrets Operator CRDs installed. Returns the REST config, a ready client.Client, a non-started manager.Manager, and a cleanup closure. The cleanup is registered both via t.Cleanup (for the normal exit path) and returned (for sequential-start tests that need explicit ordering).
Behaviour:
- Skips the test when
KUBEBUILDER_ASSETSis unset so CI jobs that do not install the envtest binaries report a skip rather than a hard failure. - Verifies the sha256 integrity of every vendored CRD against
testdata/crds/crds.sha256before starting the API server. A corrupted or tampered YAML aborts with a descriptive error instead of silently registering a mutated schema. - Loads every
*.yamlundertestdata/crds/directly — the manifest is used for integrity only, not path discovery, so a CRD file that slipped in without being hashed fails integrity rather than being silently skipped.
The failure modes are: missing envtest binaries (skip), CRD integrity mismatch (fatal), envtest boot failure (fatal), and client / manager construction failure (fatal). None of the surfaced messages carry a traceability identifier.
builders
Import path: github.com/plexsphere/plexsphere/internal/platform/testutil/builders Build tag: none (plain unit-tag code).
Generic primitive
go
type Builder[T any]
func New[T any](zero T) *Builder[T]
func (b *Builder[T]) WithDefaults(fns ...func(*T)) *Builder[T]
func (b *Builder[T]) WithInvariants(fns ...func(*T) error) *Builder[T]
func (b *Builder[T]) Mutate(fn func(*T)) *Builder[T]
func (b *Builder[T]) Build() (T, error)Build() runs defaults → mutators → invariants in that order. The first invariant failure wins. Every builder in the package returns errors via the package-local errInvariant helper so messages follow the stable format builder invariant: <field> <reason>, with no traceability identifier in the surfaced string.
Concrete aggregate builders
Each concrete builder exposes fluent With<Field> helpers, a pre-wired default + invariant pipeline, and a Build() (<Aggregate>, error). The test-local aggregate shapes are lean representations of the ubiquitous language — they will be replaced by imports from their respective bounded contexts once those production aggregates land (see the DECISION blocks inside each source file).
| Builder | Aggregate | Exported fields | Invariants |
|---|---|---|---|
NewDomainBuilder() *DomainBuilder | Domain{Name, Slug, Description, ProjectSlugs} | WithName, WithSlug, WithDescription, WithProjects(...*Project) | Name non-empty; Slug matches ^[a-z][a-z0-9-]*$ |
NewProjectBuilder() *ProjectBuilder | Project{Name, Slug, Domain} | WithName, WithSlug, WithDomain(*Domain) | Name non-empty; Slug kebab-case; Domain back-reference non-nil |
NewIdentityBuilder() *IdentityBuilder | Identity{Tenant, Subject, Email} | WithTenant, WithSubject, WithEmail | Tenant, Subject non-empty; Email matches ^[^@\s]+@[^@\s]+$ |
NewResourceBuilder() *ResourceBuilder | Resource{Kind, Namespace, Name, UID} | WithKind, WithNamespace, WithName, WithUID | All four fields non-empty; default UID is counter-backed (test-uid-0001, ...-0002,...) |
NewLabelBuilder() *LabelBuilder | Label{Key, Value} | WithKey, WithValue | Kubernetes label charset; optional DNS-subdomain prefix <=253 chars; name segment and value each <=63 chars, alphanumeric start/end |
NewPolicyBuilder() *PolicyBuilder | Policy{Name, Rules []Rule} with Rule{Subject, Action, Object} | WithName, WithRule(subject, action, object), WithRules(...Rule) | Name non-empty; at least one Rule; every Rule field non-empty |
NewCloudBuilder() *CloudBuilder | Cloud{Provider CloudProvider, Region, Account} | WithProvider, WithRegion, WithAccount | Provider ∈ {ProviderAWS, ProviderGCP, ProviderAzure, ProviderOCI}; Region, Account non-empty |
NewCredentialBuilder() *CredentialBuilder | Credential{Cloud *Cloud, Name, Kind, SecretRef} with String / GoString redacting SecretRef to [REDACTED] | WithCloud(*Cloud), WithName, WithKind, WithSecretRef | Name, Kind, SecretRef non-empty; Cloud may be nil |
The exported CloudProvider type and its enumeration constants (ProviderAWS, ProviderGCP, ProviderAzure, ProviderOCI) gate the Cloud builder's provider invariant; unknown values surface as a traced Provider must be one of ... error.
sse
Import path: github.com/plexsphere/plexsphere/internal/platform/testutil/sse Build tag: none (the NATS-bridge end-to-end test is integration-tagged, but the package itself compiles under the unit tag).
go
type Envelope struct {
ID string
Event string
Data []byte
Signature []byte
Timestamp time.Time
}
type Capture struct { /* unexported */ }
func NewCapture(ctx context.Context, url string, verifierKey ed25519.PublicKey) (*Capture, error)
func (c *Capture) Events() <-chan Envelope
func (c *Capture) Errors() <-chan error
func (c *Capture) ExpectEnvelope(t *testing.T, timeout time.Duration, matchFn func(Envelope) bool) Envelope
func (c *Capture) Close() errorNewCapture opens an SSE stream, validates every input synchronously (ctx non-nil, url non-empty, verifierKey exactly ed25519.PublicKeySize bytes), and hands ownership of the HTTP response body to a single background goroutine. Every event is parsed as a standard id: / event: / data: / signature: SSE frame and its ed25519 signature is verified against the concatenated data: payload before the envelope is surfaced on Events().
Signature failures, parse errors, and stream errors land on Errors() as plain, identifier-free errors. Close() is idempotent and deterministic: cancelling the derived context and closing the response body unblocks the reader goroutine; the event and error channels are closed exactly once, on exit, so for ev := range capture.Events() drains cleanly.
ExpectEnvelope is the synchronous helper for tests that want a single matching envelope. An error on Errors() is escalated via t.Fatalf so a signature failure cannot silently time the test out.
matchers
Import path: github.com/plexsphere/plexsphere/internal/platform/testutil/matchers Build tag: none.
All three matchers accept []metav1.Condition directly and fall back to reflection for any slice whose element type exposes string-typed Type and string- or bool-typed Status fields, which covers the plexsphere domain condition arrays. Failure messages render the observed conditions or envelope as a readable diff and carry no traceability identifier.
go
func BeReady() types.GomegaMatcher
func HaveCondition(conditionType, status string) types.GomegaMatcher
func EventuallyEmit(source EnvelopeSource, opts ...EmitOption) types.AsyncAssertionBeReadypasses when the conditions slice contains aType == "Ready"entry with a truthyStatus. Truthiness covers both the Kubernetesmetav1.ConditionTruevalue and a plainbool(true)from duck-typed arrays. Failure messages render every observed condition asType=Statuslines.HaveConditionpasses when the slice contains a(conditionType, status)pair — case-sensitive exactly as the aggregate emits it. Failure and negated-failure messages are rendered as a git-diff-style block (+ {Type, Status}/- {Type, Status}pairs).EventuallyEmitadapts an SSE-like source (anything satisfyingEnvelopeSource { Events <-chan sse.Envelope; Errors <-chan error }) as a gomegaAsyncAssertion. It is configured viaEmitOptionvalues:Option Default Effect WithTimeout(d time.Duration)5 * time.SecondOverride the deadline WithPolling(d time.Duration)50 * time.MillisecondOverride the polling interval Errors emitted on
source.Errors()are sticky: once observed, every subsequent poll re-surfaces the first error so the final failure message always includes it rather than silently timing out. The returnedAsyncAssertionsupports the full gomega chaining surface (Should,ShouldNot,To,ToNot,NotTo,WithOffset,WithTimeout,WithPolling,WithContext,WithArguments,Within,ProbeEvery,MustPassRepeatedly); user-supplied matchers are wrapped so their failure and negated-failure messages route through the package's rendering, with no traceability identifier in the surfaced text.
Callers must register a gomega fail handler (gomega.RegisterTestingT(t) or gomega.NewWithT(t)) before driving any of the matchers, per standard gomega practice.
Cross-references
- docs/contributing/testing.md — the operational how-to that links every primitive here back to the test pyramid and build tags.
- docs/contributing/traceability-conventions.md — the rendered-surface convention that forbids identifiers in any string literal, including the
t.Fatalfstrings produced by this module. tests/workspace/no_identifiers_in_rendered_surfaces_test.go— the CI gate that enforces the rendered-surface convention across every tracked file.tests/integration/testutil_sample_test.go— end-to-end sample exercising every primitive in one test.