Skip to content

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 fixtures
  • envtest — controller-runtime envtest bootstrapper
  • builders — fluent domain aggregate builders
  • sse — signed SSE stream capture
  • matchers — 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.Cleanup that dumps container logs on failure before calling Terminate,
  • fails the test with a plain, identifier-free message on any startup error.

Image-pin constants

ConstantImage
PostgresImagepostgres:16.4
SpiceDBImageauthzed/spicedb:v1.39.0
NATSImagenats:2.10.20
OpenBaoImageopenbao/openbao:2.0.0
SeaweedFSImagechrislusf/seaweedfs:3.75
DexImageghcr.io/dexidp/dex:v2.41.1

Pins must stay in lockstep with tests/integration/testcontainers_helpers.go.

Fixtures

TypeFieldsStarter
PostgresFixtureContainer *postgres.PostgresContainer, DSN stringStartPostgres(t *testing.T) PostgresFixture
SpiceDBFixtureContainer testcontainers.Container, Endpoint string, PresharedKey stringStartSpiceDB(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)
NATSFixtureContainer testcontainers.Container, URL string (JetStream enabled by default)StartNATS(t *testing.T) NATSFixture
OpenBaoFixtureContainer testcontainers.Container, Address string, RootToken string (dev-mode, fixed token)StartOpenBao(t *testing.T) OpenBaoFixture
SeaweedFSFixtureContainer testcontainers.Container, Endpoint string, Region string, AccessKey string, SecretKey string (S3 gateway on :8333)StartSeaweedFS(t *testing.T) SeaweedFSFixture
DexFixtureContainer 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_ASSETS is 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.sha256 before starting the API server. A corrupted or tampered YAML aborts with a descriptive error instead of silently registering a mutated schema.
  • Loads every *.yaml under testdata/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).

BuilderAggregateExported fieldsInvariants
NewDomainBuilder() *DomainBuilderDomain{Name, Slug, Description, ProjectSlugs}WithName, WithSlug, WithDescription, WithProjects(...*Project)Name non-empty; Slug matches ^[a-z][a-z0-9-]*$
NewProjectBuilder() *ProjectBuilderProject{Name, Slug, Domain}WithName, WithSlug, WithDomain(*Domain)Name non-empty; Slug kebab-case; Domain back-reference non-nil
NewIdentityBuilder() *IdentityBuilderIdentity{Tenant, Subject, Email}WithTenant, WithSubject, WithEmailTenant, Subject non-empty; Email matches ^[^@\s]+@[^@\s]+$
NewResourceBuilder() *ResourceBuilderResource{Kind, Namespace, Name, UID}WithKind, WithNamespace, WithName, WithUIDAll four fields non-empty; default UID is counter-backed (test-uid-0001, ...-0002,...)
NewLabelBuilder() *LabelBuilderLabel{Key, Value}WithKey, WithValueKubernetes label charset; optional DNS-subdomain prefix <=253 chars; name segment and value each <=63 chars, alphanumeric start/end
NewPolicyBuilder() *PolicyBuilderPolicy{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() *CloudBuilderCloud{Provider CloudProvider, Region, Account}WithProvider, WithRegion, WithAccountProvider{ProviderAWS, ProviderGCP, ProviderAzure, ProviderOCI}; Region, Account non-empty
NewCredentialBuilder() *CredentialBuilderCredential{Cloud *Cloud, Name, Kind, SecretRef} with String / GoString redacting SecretRef to [REDACTED]WithCloud(*Cloud), WithName, WithKind, WithSecretRefName, 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() error

NewCapture 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.AsyncAssertion
  • BeReady passes when the conditions slice contains a Type == "Ready" entry with a truthy Status. Truthiness covers both the Kubernetes metav1.ConditionTrue value and a plain bool(true) from duck-typed arrays. Failure messages render every observed condition as Type=Status lines.

  • HaveCondition passes 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).

  • EventuallyEmit adapts an SSE-like source (anything satisfying EnvelopeSource { Events <-chan sse.Envelope; Errors <-chan error }) as a gomega AsyncAssertion. It is configured via EmitOption values:

    OptionDefaultEffect
    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 returned AsyncAssertion supports 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