Conformance harness¶
Package gitlab.com/phpboyscout/go/chat-platform/test, conventionally imported
as chatplatformtest or cptest. It checks the behaviour a consumer relies on
that the compiler cannot see.
For how to wire it into a provider's test suite, see Run the conformance harness. This page is the list of what it actually asserts.
Every check runs without a network and sends no token. It reports through
t.Errorf, so a failing provider reports every breach in one run rather than
stopping at the first.
ConformanceConfig¶
type ConformanceConfig struct {
NewClient func(chatplatform.ClientConfig) (chatplatform.Client, error)
Needs []chatplatform.Need
Refuses []chatplatform.Need
Capabilities Capabilities
Space chatplatform.ID
AllowedChannel chatplatform.ID
SecondSpace chatplatform.ID
}
type Capabilities struct {
Moderator bool
MemberInspector bool
Interactive bool
Commands bool
Author bool
VoiceReceiver bool
VoiceSender bool
VoiceParticipants bool
Indicator bool
ReactionObserver bool
}
| Field | Required | Notes |
|---|---|---|
NewClient |
yes; t.Fatal if nil |
called once per check, so each gets an independent transport |
Needs |
no | declared to the client; leave empty and the harness declares all of them, less Refuses, since capability checks are about what a provider implements and discovery is gated on declared needs. A need in both Needs and Refuses stops the run |
Refuses |
no | the needs the transport can never serve, so a client asked for one must not be built; a transport that carries audio only names NeedMessages. A declaration the harness trusts, as Capabilities is |
Capabilities |
no | the zero value declares none, which is a legitimate provider |
Space |
in practice | the space every scope is minted on |
AllowedChannel |
in practice | becomes a one-element allowlist via WithAllowedChannels |
SecondSpace |
yes; reported as ConformanceConfig if empty or equal to Space |
a different space, never dialled; the sibling-scope checks need it, and Participants uses it as a channel outside the allowlist |
The harness mints several scopes from one client, because the failures it
checks for cannot happen with only one. A client that refuses a scope on a
second space, hands the same Provider back for two of them, lets a scope's
Close tear down the transport, or (the other way round) grants two live
scopes on one space, is broken for a multi-tenant consumer and
indistinguishable from a correct one otherwise. The last is checked
sequentially and then under fifty concurrent calls, because a guard that checks
and acts without holding its lock passes every sequential test and hands out
duplicates only under load.
Voice is two fields because it is two interfaces. A provider may implement the receiving half without the sending half, so declaring one does not declare the other.
If a provider's voice support depends on a build tag (the Discord provider's does, because end-to-end encryption needs a CGO dependency), declare these from the same build-tagged file that decides it. A declaration written once by hand drifts from reality in precisely the build nobody runs the harness in, and the symptom is a conformance failure on a machine that is configured differently from the one that last passed.
Space, SecondSpace and AllowedChannel must be syntactically valid
identifiers for your platform. They are never dialled; the harness only needs
values your constructor will accept. Discord's provider is exercised with
real-looking snowflakes for exactly this reason.
SecondSpace is required, and it is the one people expect to be expensive. It
is not: nothing connects, so it needs only to be an identifier your constructor
accepts, and fabricating one is fine. No second real space has to exist anywhere.
It has to be there because scopes on one space and scopes on different spaces are different rules, and only the second catches a guard keyed on the client rather than the space, the mistake an author implementing the singleton in a hurry makes, which passes every same-space check and breaks every multi-tenant consumer. Leaving it unset fails the run rather than skipping those checks, because a harness that quietly drops a check when it is under-configured tells you your provider is sound when nothing looked at it.
What the harness builds is:
// once per check, so each gets an independent transport; no token
chatplatform.ClientConfig{Needs: needs} // cfg.Needs, or every need less Refuses when empty
// then scopes on it
c.Provider(ctx, cfg.Space, chatplatform.WithAllowedChannels(cfg.AllowedChannel))
with WithReadOnly added only for the read-only check. Note that no token is
sent: ClientConfig.Token requires a constructor to accept an empty one and
leave its validity to Connect, so a provider that needs a real credential
to construct cannot be conformance-tested at all, and the same applies to one
that dials on NewClient rather than lazily. A transport that never
authenticates is built exactly as honestly as one that does.
Every check the harness runs¶
Construction is checked first and everything else is skipped if it fails, so a provider that cannot be built reports one clear error rather than a cascade of nil-pointer symptoms.
| Reported as | Fails when |
|---|---|
Refuses |
a need is in both Needs and Refuses; nothing else runs |
NewClient |
asked for a need in Refuses alone, the factory builds a client, errors with anything but ErrUnsupported, or errors without naming the need |
NewClient |
the factory errors on a valid client config |
NewClient |
the factory returns a nil client and a nil error |
NewClient |
a scope cannot be minted for a valid space |
NewClient |
minting returns a nil provider and a nil error |
Name |
Provider.Name is empty |
Reader |
Provider.Reader is nil |
Actor |
Provider.Actor is nil for a writable config |
Messages |
Reader.Messages() returns a nil channel |
ReadOnly |
the factory errors on a read-only config |
ReadOnly |
the factory returns a nil provider for a read-only config |
ConformanceConfig |
SecondSpace is empty, or the same as Space; the sibling checks did not run |
Provider |
a second scope on the same space is granted, refused with anything but ErrAlreadyScoped, refused without naming the space, or refused alongside a non-nil Provider |
Provider |
closing a scope does not release its space for a new one |
Provider |
under fifty concurrent calls on one space, more or fewer than one is granted, or a losing call returns anything but a nil Provider and ErrAlreadyScoped |
Provider |
a scope cannot be minted on a second space, or comes back nil with a nil error |
Provider |
the same Provider comes back for two spaces |
Provider |
closing one scope stops the client minting others |
Provider |
the declared needs are not carried onto the minted scope |
Provider |
a need is carried that the client was never asked for |
Participants |
a channel outside the allowlist is not refused with ErrChannelDenied |
Participants |
an empty channel id is not refused with ErrInvalidArgument |
Participants |
a call before Connect does not return ErrNotConnected |
Indicator |
IndicatorLimits().MaxLength is not positive |
Indicator |
Show accepts empty text, or text one rune over the limit |
Indicator |
Show refuses text exactly at the limit in two-byte runes, so the limit is counted in bytes |
Indicator |
Show or Clear before Connect does not return ErrNotConnected |
ReadOnly |
Actor is non-nil despite WithReadOnly |
ReadOnly |
Reader is nil for a read-only config |
ReadOnly |
VoiceParticipants or ReactionObserver is declared but not discoverable on a read-only scope; it was put on the Actor |
Reactions |
ReactionObserver.Reactions() returns a nil channel |
Close |
Reader.Close() panics unconnected, or on the second call |
VoiceReceiver.Capabilities |
Format is not Valid, Format.MaxPayloadBytes is negative, or the call panics on a provider that never connected |
Moderator etc. |
the capability is declared but the Actor does not implement it |
Moderator etc. |
the Actor implements it but Capabilities does not declare it |
A nil Reader stops the shape check there. A provider that cannot observe is
not usable, so the remaining checks would only restate it.
What the isolation checks do not cover. Except through
VoiceParticipants.Participants, which takes a bare channel id and must refuse
one outside the allowlist before touching the platform, so the harness does
exercise that refusal where the capability is declared. Otherwise the harness
cannot observe what a provider's allowlist admits. That is internal to the
provider, and inventing a second channel identifier to probe it would be rejected by anything that parses
them. So it checks the structural half a provider cannot get right by accident:
that a second scope can be minted, that it is a distinct Provider, and that
closing one does not stop the client minting others. The allowlist rule itself is
the contract's, and each provider tests it against its own internals.
Why capabilities are checked in both directions¶
Both mistakes are silent:
- Declared but missing makes the harness look for something impossible, and
makes a consumer's
AsModeratorreturn false where the author thought it would not. - Implemented but not declared is worse in a quiet way: the capability works, and nobody ever discovers it. A capability nobody declares is a capability nobody uses.
Why Close is called twice¶
Shutdown runs after failures, and it runs from defer blocks that may already
have run once. A Close that panics on the second call buries whatever error
caused the shutdown, so the harness calls it twice on a provider that never
connected, which is the state shutdown most often happens in.
What the harness does not check¶
Everything here is real contract behaviour that a passing provider may still get wrong. None of it can be checked offline.
- That the allowlist filters anything.
AllowedChannelis passed to the constructor and never exercised. A provider that ignores the scope's allowlist entirely passes conformance. - Anything a
Reactioncarries. Which event maps to which change, the own-reaction drop, the member on anAdded, the variant, the emoji round-trip againstReact: all need a platform event to produce, so they are provider conformance, with synthesised gateway events. - That
ErrNotConnectedis returned, except fromVoiceParticipants.ParticipantsandIndicator.ShowandClear, which the harness does call beforeConnectwhere the capability is declared. The only other method called isVoiceReceiver.Capabilities, which the contract promises is answerable before joining. Nothing else is called, so a provider that panics elsewhere instead of returning the sentinel passes. - Anything about a voice session.
Joinis never called, so the format a session reports, attribution, sequence numbers and interruptions are all provider conformance.Capabilitiesis checked for coherence, not against any platform's numbers: a harness expecting 48000 Hz would be a Discord harness. - That
Connectworks, or fails cleanly.Connectis never called. - That
Messages()orReactions()ever delivers anything, or that either closes onClose. Only that the channel is non-nil. - That capabilities behave. Only that the type assertion succeeds. A
ModeratorwhoseDeleteMessagealways fails passes. - That
ConnStateis ever populated, or thatLastReconnectLostEventscan fire. This is the single most valuable behaviour in the contract and the hardest to test, because it needs a real connection cut peer-side. See Testing it. - Concurrency, beyond the singleton. The scope-singleton check is the one
thing the harness runs concurrently, and it is a detector rather than a
proof: a guard with a very narrow window between its check and its write can
pass fifty contended calls by luck, and the race detector sees nothing
because every access is still under a lock. Nothing else runs in parallel,
and nothing runs under
-racefrom inside the harness. Run your own tests with-race.
Those belong in the provider's own integration tests, gated so they do not run on every merge. Passing conformance means the contract a consumer depends on before a single message arrives is honoured, not that the provider works.
Reading a failure¶
Failures are reported as method: message, where method is the column above:
--- FAIL: TestConformance
conformance_test.go:18: ReadOnly: exposed an Actor despite WithReadOnly; an
observe-only deployment must have nothing to post or delete through
Treat one as a finding about your provider before you treat it as one about the harness. On the Discord provider's first run the harness reported that the provider could not be constructed at all, and that an implemented capability was not declared. Both were real.