feat(11-06): add the tide fake Centrifugo recorder, broadcast goldens and parity:broadcasts
- CentrifugoRecorder records publish/broadcast requests (method, path, whether the API key matched, JSON body) and binds loopback only - BroadcastGolden load/write, NormalizePublications (timestamps, actor, captured ids only) and DiffPublications (structural, key order ignored) - RecordBroadcasts runs a flow or one step against a loopback backend - summer parity:broadcasts wraps it; README documents format and rules
This commit is contained in:
@@ -6,7 +6,7 @@ HTTP parity toolkit that records request and response fixtures from a reference
|
||||
|
||||
## Overview
|
||||
|
||||
`tide` is the acceptance-test engine for porting an existing WinterCMS or other PHP backend to SummerCMS: the reference backend's real responses define the contract, and the Go port must reproduce them. It records YAML fixtures (flows of request and response steps) either by driving a spec against a target or by sitting as a loopback reverse proxy in front of the reference backend while a real client uses it, then replays those fixtures against the port and diffs the responses after masking values that legitimately differ, such as IDs and timestamps. The `summer` CLI's parity:proxy, parity:record and parity:replay commands are thin wrappers around this package. It has no WinterCMS counterpart.
|
||||
`tide` is the acceptance-test engine for porting an existing WinterCMS or other PHP backend to SummerCMS: the reference backend's real responses define the contract, and the Go port must reproduce them. It records YAML fixtures (flows of request and response steps) either by driving a spec against a target or by sitting as a loopback reverse proxy in front of the reference backend while a real client uses it, then replays those fixtures against the port and diffs the responses after masking values that legitimately differ, such as IDs and timestamps. It also checks realtime side effects: a fake Centrifugo recorder captures the publications a backend sends while a flow runs, and broadcast golden files hold them normalised for comparison. The `summer` CLI's parity:proxy, parity:record, parity:replay and parity:broadcasts commands are thin wrappers around this package. It has no WinterCMS counterpart.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -16,6 +16,9 @@ HTTP parity toolkit that records request and response fixtures from a reference
|
||||
- Capture rules: `tide.Rules` (loaded with `tide.LoadRules`) decide which request and response headers are kept per route and which values are captured into variables, from response JSON paths, headers, redirect query strings or form fields.
|
||||
- Variables: `tide.Store` holds captured values such as tokens and IDs in a mode-0600 file, `tide.Store.Expand` substitutes `{{name}}` placeholders before a request is sent, and `tide.ScrubStep` puts placeholders back into fixtures. Scrubbing fails when a step still holds an unclassified token- or password-shaped value, so credentials do not leak into committed fixtures.
|
||||
- Replay and diff: `tide.ReplayFlow` re-sends each step, compares status, a fixed set of contract headers and the body, and returns `tide.Result` with per-step `tide.Diff` entries. JSON bodies are compared structurally after masking `id`, `*_id` and `*_ids` values and `*_at` timestamps; other bodies are compared byte for byte.
|
||||
- Fake Centrifugo: `tide.NewCentrifugoRecorder` returns an `http.Handler` that records every POST to a path ending in `/publish` or `/broadcast` as a `tide.Publication` (method, path, whether `Authorization: apikey <key>` carried the configured key, JSON body) and answers `{"result":{}}`. Paths ending in `/presence` answer `{"result":{"presence":{}}}`, `/unsubscribe` and `/info` answer `{"result":{}}`, anything else is 404. Bodies are capped at `tide.MaxPublicationBody` (1 MiB). The API key is only compared, never stored. `tide.CentrifugoRecorder.ListenAndServe` binds loopback addresses only, like the recording proxy.
|
||||
- Broadcast goldens: `tide.RecordBroadcasts` runs a flow against a loopback reference backend whose Centrifugo API URL points at a recorder on `tide.DefaultCentrifugoListen` (`127.0.0.1:8424`). With `tide.BroadcastConfig` `Step` set, earlier steps run as setup and only that step's publications are kept. The result is a `tide.BroadcastGolden`, written with `tide.WriteBroadcastGolden` (which refuses token-shaped bodies) and read strictly with `tide.LoadBroadcastGolden`. A golden with `pending` set is recorded but not yet asserted.
|
||||
- Broadcast normalisation: `tide.NormalizePublications` masks only `$.data.timestamp` and `$.data.payload.timestamp` (ISO 8601 with an offset) as `"{{timestamp}}"`, `$.data.payload.actor` (an object of exactly `user_id` and `name`) as `"{{actor}}"`, and values equal to an `id:*` variable of a `tide.Store`: numbers or strings under `id`, `*_id` or `*_ids` keys, and the numeric last segment of a channel name such as `room:12`. A masked number is written as a bare `{{id:name}}`, so a number that becomes a string still differs. A value matching two id variables is an error. `tide.DiffPublications` compares the count, method, path, authorization flag and body (structurally, key order ignored) and reports paths such as `$[0].body.data.payload.id`.
|
||||
- Manifests: `tide.Manifest` lists routes with auth groups, a pending or ported status, cases and fixture paths; `tide.RecordManifest` records missing cases in batches of at most `tide.MaxBatch`, and `tide.ReplayManifest` replays every recorded case into a `tide.Coverage` table.
|
||||
|
||||
## Usage
|
||||
@@ -49,6 +52,33 @@ for _, step := range res.Steps {
|
||||
}
|
||||
```
|
||||
|
||||
Record the publications a reference backend sends for one flow step, then compare a new backend's publications with them:
|
||||
|
||||
```go
|
||||
golden, err := tide.RecordBroadcasts(ctx, spec, tide.BroadcastConfig{
|
||||
Target: "http://127.0.0.1:8000",
|
||||
APIKey: "test-only-key",
|
||||
Store: store,
|
||||
Step: "delete",
|
||||
IDs: []string{"id:room", "id:item"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tide.WriteBroadcastGolden("testdata/broadcasts/deleted.yaml", golden); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Later, with the new backend publishing to rec (a tide.CentrifugoRecorder):
|
||||
norm, err := tide.NormalizePublications(rec.Publications(), idsOfTheNewBackend)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range tide.DiffPublications(golden.Publications, norm) {
|
||||
fmt.Printf("%s: want %s, got %s\n", d.Path, d.Expected, d.Actual)
|
||||
}
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
@@ -71,13 +101,52 @@ for _, step := range res.Steps {
|
||||
| `tide.ValidateManifest` | Checks a manifest in `tide.ModeAllowIncomplete` or `tide.ModeRequireRecorded` mode. |
|
||||
| `tide.RecordManifest` | Records a manifest's seed flow and missing route cases in batches. |
|
||||
| `tide.ReplayManifest` | Replays every recorded route case and builds a coverage table. |
|
||||
| `tide.CentrifugoRecorder` | Fake Centrifugo HTTP API: `tide.CentrifugoRecorder.Publications`, `tide.CentrifugoRecorder.Reset`, `tide.CentrifugoRecorder.ListenAndServe`. |
|
||||
| `tide.NewCentrifugoRecorder` | Builds a recorder from `tide.CentrifugoRecorderOptions` (the expected API key). |
|
||||
| `tide.Publication` | One recorded publish or broadcast request: method, path, authorization flag, JSON body. |
|
||||
| `tide.RecordBroadcasts` | Runs a flow (or one step of it) against a loopback backend and returns its normalised publications as a golden. |
|
||||
| `tide.BroadcastConfig` | Target, recorder address, API key, vars store, step, id variables and settle time for `tide.RecordBroadcasts`. |
|
||||
| `tide.BroadcastGolden` | Versioned broadcast golden: name, flow, optional pending reason, publications. |
|
||||
| `tide.LoadBroadcastGolden` | Reads a golden strictly and checks every body parses. |
|
||||
| `tide.WriteBroadcastGolden` | Writes a golden atomically, refusing token-shaped bodies. |
|
||||
| `tide.NormalizePublications` | Masks timestamps, the actor and captured ids in publication bodies. |
|
||||
| `tide.DiffPublications` | Structural diff of two publication lists. |
|
||||
| `tide.DefaultCentrifugoListen` | Default recorder address, `127.0.0.1:8424`. |
|
||||
| `tide.Coverage` | Recorded, passing, failing and unrecorded counts, with table rows and a summary line. |
|
||||
|
||||
## CLI commands
|
||||
|
||||
`summer parity:broadcasts` wraps `tide.RecordBroadcasts` and `tide.WriteBroadcastGolden`:
|
||||
|
||||
```sh
|
||||
summer parity:broadcasts \
|
||||
--flow testdata/broadcasts/flows/item-lifecycle.yaml \
|
||||
--step delete --name deleted --ids id:room,id:item \
|
||||
--target http://127.0.0.1:8000 \
|
||||
--vars /tmp/parity/vars.yaml \
|
||||
--api-key test-only-key \
|
||||
--out testdata/broadcasts/deleted.yaml
|
||||
```
|
||||
|
||||
`--listen` defaults to `127.0.0.1:8424`, `--api-key` to `$PARITY_CENTRIFUGO_API_KEY` and `--settle` to `500ms`. `--ids` defaults to the `id:*` variables the flow mentions. `--pending` stores a reason the golden is not asserted yet. The target and the listen address must be loopback, and the vars file must be outside the golden's directory. The command writes:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
name: deleted
|
||||
flow: "items/lifecycle#delete"
|
||||
publications:
|
||||
- method: POST
|
||||
path: /api/publish
|
||||
authorization: true
|
||||
body: |-
|
||||
{"channel":"room:{{id:room}}","data":{"event":"deleted","payload":{"id":{{id:item}},"actor":"{{actor}}","timestamp":"{{timestamp}}"},"timestamp":"{{timestamp}}"}}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: none.
|
||||
- Third-party: `github.com/goccy/go-yaml` (fixture, rules and manifest parsing).
|
||||
- Standard library: `net/http`, `net/http/httputil`, `encoding/json`, `crypto/sha256`, among others.
|
||||
- Standard library: `net/http`, `net/http/httputil`, `encoding/json`, `crypto/sha256`, `crypto/subtle`, among others.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -85,4 +154,4 @@ for _, step := range res.Steps {
|
||||
go test ./modules/tide/...
|
||||
```
|
||||
|
||||
The tests run recording, the proxy and replay against local `net/http/httptest` servers and use the sample spec in `modules/tide/testdata/`; they need no external services.
|
||||
The tests run recording, the proxy, replay and the fake Centrifugo recorder against local `net/http/httptest` servers and use the sample spec in `modules/tide/testdata/`; they need no external services.
|
||||
|
||||
Reference in New Issue
Block a user