From 66b55add1e91ba33297152310559a9f59dd83ce6 Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Mon, 7 Sep 2026 08:35:58 +0300 Subject: [PATCH] Remove deprecated management endpoints and x-send-events shim The one-release deprecation window has elapsed: drop the /_mock/ws/* aliases and 410 schedule stubs (legacy paths now return plain 404), stop interpreting the x-send-events key (silently ignored at load), prune api/openapi.yaml to the canonical surface, and clean up docs, tests and the CHANGELOG. Archive the change. --- CHANGELOG.md | 14 +- README.md | 2 - api/openapi.yaml | 168 ------------ docs/architecture.md | 5 +- docs/extensions.md | 11 - internal/asyncapi/parse_test.go | 17 +- internal/runtime/event_source_test.go | 4 +- internal/server/async_state_test.go | 60 ----- internal/server/control_api_spec_sync_test.go | 7 +- internal/server/event_delivery_test.go | 56 ++++ internal/server/event_integration_test.go | 12 +- internal/server/event_server.go | 159 +++-------- internal/server/events_endpoint_test.go | 26 +- internal/server/fire_event.go | 18 +- internal/server/fire_event_endpoint_test.go | 4 +- internal/server/legacy_route_teardown_test.go | 143 ++++++++++ internal/server/management_async.go | 13 +- .../server/management_async_aliases_test.go | 252 ------------------ .../server/management_async_lifecycle_test.go | 6 +- internal/server/send_events.go | 53 ---- internal/server/send_events_test.go | 98 ------- internal/server/server_routes.go | 12 - internal/server/x_send_events_shim_test.go | 59 ---- .../.openspec.yaml | 2 + .../design.md | 80 ++++++ .../proposal.md | 32 +++ .../specs/event-driver/spec.md | 32 +++ .../tasks.md | 36 +++ openspec/specs/asyncapi-templating/spec.md | 6 +- openspec/specs/event-driver/spec.md | 10 +- .../resources/asyncapi-management.yaml | 4 +- .../management-api/management_api_test.go | 92 +++---- 32 files changed, 501 insertions(+), 992 deletions(-) create mode 100644 internal/server/legacy_route_teardown_test.go delete mode 100644 internal/server/management_async_aliases_test.go delete mode 100644 internal/server/send_events.go delete mode 100644 internal/server/send_events_test.go delete mode 100644 internal/server/x_send_events_shim_test.go create mode 100644 openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/design.md create mode 100644 openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/proposal.md create mode 100644 openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/specs/event-driver/spec.md create mode 100644 openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fe6940d..a95fd82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,24 +8,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Protocol-neutral async management prefix `/_mock/async/{push,consumers,disconnect}`; legacy `/_mock/ws/*` kept as deprecated aliases +- Protocol-neutral async management prefix `/_mock/async/{push,consumers,disconnect}` - Unified example injection: `POST /_mock/examples` gains `match`/`interval`/`delay` for AsyncAPI targets (runtime mirror of `x-mock-match`/`x-mock-interval`/`x-mock-delay`), with strict context-aware validation, plus `DELETE /_mock/examples/{exampleId}` to remove and cancel recurrence -- Single event resource `POST /_mock/events` with a `type` discriminator (V1: `fire`), replacing `/_mock/events/fire` (deprecated alias kept) +- Single event resource `POST /_mock/events` with a `type` discriminator (V1: `fire`) - Management WebSocket stream `/_mock/stream` with connect-time `events`/`channels` filters; pushes `event`/`push`/`consumer`/`schedule` envelopes - Event-context matching: `{$event.name}` (identity), `{$event.data}` (whole payload) alongside `{$event.}`; `{$connection.*}` per-connection recipient partition (id/channel/query/header) with broadcast fast path - Timing extensions `x-mock-interval` (periodic emission) and `x-mock-delay` (delayed emission); `cron` is no longer an event - Actually-fired built-in triggers `connect` (on consumer connection) and `receive` (on inbound traffic), gated by a cheap `hasSubscribers` check - Consumers listable without a `channel` filter — flat union across all channels (raw ws + SignalR streams) -- `x-send-events` is deprecated: a loader mapping shim translates `{on, wait}` to the match/interval form with a verbose deprecation note; removal deferred one release ### Changed -- Recurring delivery moved off the schedule endpoint onto `interval` on `/_mock/examples`; `/_mock/ws/schedule{,/{pushId}}` now answer `410 Gone` pointing at the examples endpoint +- Recurring delivery moved off the schedule endpoint onto `interval` on `/_mock/examples` - `AddExampleRequest` is now a `oneOf` two-branch schema (sync `path` vs async `channel`) rejecting mixed targeting - Delivered/scheduled messages are templated at emission time so `{$event.*}`/`{$state.*}`/`{$env.*}` resolve against current state +### Removed +- Deprecated alias endpoints `POST /_mock/ws/push`, `GET /_mock/ws/consumers`, `POST /_mock/ws/disconnect` and `POST /_mock/events/fire`; the canonical `/_mock/async/*` and `POST /_mock/events` surface is the only way to reach those behaviors and any legacy `/_mock/ws/*` path answers a plain 404 +- The schedule 410 stubs `POST /_mock/ws/schedule` and `DELETE /_mock/ws/schedule/{pushId}` +- The `x-send-events` extension mapping shim — a message example is classified solely by its `x-mock-match`/`x-mock-interval`/reply trigger; a spec still carrying the key loads without error and the key is silently ignored + ### Fixed - `x-mock-delay` now actually delays an async emission (it was parsed but never applied); a `connect` welcome honors it too -- The deprecated `/_mock/events/fire` alias again accepts the legacy type-less body shape (defaulting to `fire`) instead of requiring the new `type` discriminator - A runtime async `match` without an `{$event.*}` reference is rejected with 400 instead of silently registering nothing - `DELETE /_mock/examples/{exampleId}` now also removes sync (OpenAPI) dynamic examples, not only async-driven ones - The `/_mock/stream` ping keepalive goroutine no longer leaks past the connection's lifetime @@ -38,7 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Periodic deliveries now emit `push` envelopes and built-in `connect` fires emit `event` envelopes to `/_mock/stream` subscribers; `schedule` `started`/`stopped` envelopes carry the same example identity, channel and interval so clients can correlate them - SignalR upgrades now capture query/headers so `{$connection.query.*}`/`{$connection.header.*}` resolve for hub connections too - `{$event.*}`/`{$connection.*}` condition values pre-resolve at delivery; reply-path condition values stay literal (sync matching unchanged) -- Legacy `x-send-events {on: cron}` without a positive `wait` is a load error instead of a silent no-op - Docker image `/app/oasmock` is now marked executable — GitHub artifact downloads strip exec bits, breaking `ENTRYPOINT` in the published image - CI-built binaries are now statically linked (`CGO_ENABLED=0`) — previously `linux/amd64` was dynamically linked against glibc, causing `exec /app/oasmock: no such file or directory` in the `distroless/static` image - Release Docker image is now smoke-tested (starts and serves the control API) before it is pushed to Docker Hub, via a shared `smoke-test-image` action also used by the PR `docker-build` check diff --git a/README.md b/README.md index d70299a..c0f258a 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,6 @@ The server exposes a control HTTP API under the `/_mock` prefix. Full schema: [a - `POST /_mock/async/disconnect` — force-disconnect a consumer - `GET /_mock/stream` — management WebSocket stream of runtime notifications (event/push/consumer/schedule envelopes, filtered at connect time) -The legacy `/_mock/ws/*` aliases and `/_mock/events/fire` are deprecated but still work; the removed `/_mock/ws/schedule*` answers `410 Gone` pointing at the examples endpoint. - ## Command‑Line Interface See [cli.md](./cli.md) for the complete CLI specification. diff --git a/api/openapi.yaml b/api/openapi.yaml index 12d9054..e74caa1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -133,26 +133,6 @@ paths: $ref: '#/components/responses/InvalidRequest' '500': $ref: '#/components/responses/InternalError' - /events/fire: - post: - operationId: fireEventLegacy - summary: Fire a named event (deprecated alias) - description: | - Deprecated alias of POST /_mock/events. Accepts the legacy body without - the `type` discriminator (defaults to "fire"). Kept for backward - compatibility; use /_mock/events. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LegacyFireEventRequest' - deprecated: true - responses: - '200': - description: Event accepted - '400': - $ref: '#/components/responses/InvalidRequest' /async/push: post: operationId: pushToChannel @@ -253,101 +233,6 @@ paths: HTTP response body) '405': $ref: '#/components/responses/NotUpgraded' - /ws/push: - post: - operationId: pushToChannelLegacy - summary: Push a message (deprecated alias) - deprecated: true - description: Deprecated alias of POST /_mock/async/push. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PushRequest' - responses: - '200': - description: Push accepted - '400': - $ref: '#/components/responses/InvalidRequest' - '404': - $ref: '#/components/responses/NotFound' - /ws/consumers: - get: - operationId: listConsumersLegacy - summary: List connected consumers (deprecated alias) - deprecated: true - description: Deprecated alias of GET /_mock/async/consumers. - parameters: - - name: channel - in: query - required: false - description: Channel address (omit for all channels) - schema: - type: string - responses: - '200': - description: Consumers listed - content: - application/json: - schema: - $ref: '#/components/schemas/ConsumersResponse' - /ws/schedule: - post: - operationId: scheduleRecurringPush - summary: Schedule a recurring push (removed) - description: | - Removed. Recurring delivery is expressed via the interval field on - POST /_mock/examples for an AsyncAPI target. This path answers 410 Gone. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleRequest' - responses: - '410': - $ref: '#/components/responses/GoneExamples' - /ws/schedule/{pushId}: - delete: - operationId: stopRecurringPush - summary: Stop a recurring push (removed) - description: | - Removed. Recurring delivery is stopped with DELETE /_mock/examples/{id}. - This path answers 410 Gone. - parameters: - - name: pushId - in: path - required: true - description: Push ID returned by the former schedule endpoint - schema: - type: string - responses: - '410': - $ref: '#/components/responses/GoneDelete' - /ws/disconnect: - post: - operationId: disconnectConsumerLegacy - summary: Force-disconnect a consumer (deprecated alias) - deprecated: true - description: Deprecated alias of POST /_mock/async/disconnect. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DisconnectRequest' - responses: - '200': - description: Consumer disconnected - content: - application/json: - schema: - $ref: '#/components/schemas/AsyncActionResponse' - '400': - $ref: '#/components/responses/InvalidRequest' - '404': - $ref: '#/components/responses/NotFound' components: schemas: Error: @@ -538,31 +423,6 @@ components: type: boolean default: false description: When true, the event is broadcast over all loaded schemas - LegacyFireEventRequest: - type: object - required: - - event - properties: - type: - type: string - enum: [fire] - default: fire - description: Optional discriminator; the legacy alias defaults to "fire" - event: - type: string - description: The named event to fire - payload: - type: object - description: Event payload exposed to consuming templates via {$event.*} - delay: - type: integer - minimum: 0 - default: 0 - description: Delivery delay in milliseconds - global: - type: boolean - default: false - description: When true, the event is broadcast over all loaded schemas PushRequest: type: object required: @@ -582,22 +442,6 @@ components: minimum: 0 default: 0 description: Delivery delay in milliseconds - ScheduleRequest: - type: object - required: - - channel - - interval - properties: - channel: - type: string - description: AsyncAPI channel address to push to - interval: - type: integer - minimum: 1 - description: Delivery interval in milliseconds - payload: - type: object - description: Message payload pushed at each interval ManageEnvelope: type: object required: @@ -735,15 +579,3 @@ components: application/json: schema: $ref: '#/components/schemas/Error' - GoneExamples: - description: Removed — use POST /_mock/examples with interval - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - GoneDelete: - description: Removed — use DELETE /_mock/examples/{exampleId} - content: - application/json: - schema: - $ref: '#/components/schemas/Error' diff --git a/docs/architecture.md b/docs/architecture.md index ba68dac..a8b7d58 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -232,7 +232,6 @@ flowchart LR - `x-mock-headers` - Custom response headers - `x-mock-interval` / `x-mock-delay` - Periodically driven / delayed example timing - `x-event-trigger` - Fire a named event from an OpenAPI example - - `x-send-events` - Legacy AsyncAPI event subscription mapping (deprecated shim) - **Functions**: - `ExtractSetState()`, `ExtractParamsMatch()`, `ExtractHeaders()`, `ExtractEventTriggers()` - `EvaluateParamsMatch()` - Uses Runtime.Evaluator for expression evaluation @@ -347,9 +346,9 @@ OASMock autodetects AsyncAPI 3.0.0/3.1.0 files (root key `asyncapi`, version maj - **Neutral document view** (`internal/asyncapi/`): channels, operations, messages, bindings, examples with `x-mock-*` extensions; root `x-signalr` capture. The vendored `benelser/go-asyncapi` parser is isolated behind this package. - **Protocol adapters** (`internal/server/protocol.go`): `ProtocolAdapter` strategies registered keyed by protocol. `httpAdapter` reuses the HTTP pipeline; `wsAdapter` upgrades WebSockets and tracks a connection registry. - **SignalR overlay** (`internal/server/signalr_hub.go`): a document with root `x-signalr` is served as one SignalR hub — `POST {hubPath}/negotiate`, token-correlated ws upgrade, `\x1e` framing, handshake. Streams map to channels (`StreamInvocation` → held-open `StreamItem`); one-shot invocations map to operations (`Invocation` → `Completion`); event-driven items append into open streams via the open-stream registry. -- **Event broker** (`internal/server/event_broker.go`): OpenAPI examples fire named events via `x-event-trigger`; AsyncAPI message examples are classified at load (event-driven via `{$event.*}` `x-mock-match`, periodically driven via `x-mock-interval`, or reply) and registered keyed by match identity + schema scope. Classification is strict and atomic: mixed match contexts, an interval alongside any `x-mock-match`, a non-literal event identity, or fractional timing values are load errors, and a failed schema never partially registers. Legacy `x-send-events` entries map through a deprecation shim. Delivery runs the shared selection pipeline against the event context and narrows to per-connection recipients via a two-phase `{$connection.*}` partition (broadcast fast path otherwise). `POST /_mock/events` (with a `type` discriminator) fires events ad-hoc; built-ins `connect`/`receive` are actually fired from ws/SignalR lifecycle and inbound hooks gated by a cheap `hasSubscribers` check. +- **Event broker** (`internal/server/event_broker.go`): OpenAPI examples fire named events via `x-event-trigger`; AsyncAPI message examples are classified at load (event-driven via `{$event.*}` `x-mock-match`, periodically driven via `x-mock-interval`, or reply) and registered keyed by match identity + schema scope. Classification is strict and atomic: mixed match contexts, an interval alongside any `x-mock-match`, a non-literal event identity, or fractional timing values are load errors, and a failed schema never partially registers. Delivery runs the shared selection pipeline against the event context and narrows to per-connection recipients via a two-phase `{$connection.*}` partition (broadcast fast path otherwise). `POST /_mock/events` (with a `type` discriminator) fires events ad-hoc; built-ins `connect`/`receive` are actually fired from ws/SignalR lifecycle and inbound hooks gated by a cheap `hasSubscribers` check. - **Scheduler** (`internal/server/job_scheduler.go`): per-example `{id, interval, deliver func()}` interval jobs drive periodically driven examples, with per-delivery templating against current state/env; shutdown cancels all jobs, `DELETE /_mock/examples/{id}` cancels individual ones. -- **Async mocking management API**: `/_mock/async/{push,consumers,disconnect}` (canonical, protocol-neutral prefix; legacy `/_mock/ws/*` kept as deprecated aliases), `/_mock/events` (type-discriminated fire), `/_mock/examples` (unified sync/async injection with strict oneOf validation and runtime `match`/`interval`/`delay`, plus `DELETE /_mock/examples/{id}`), `/_mock/stream` (management WebSocket notifications). The removed `/_mock/ws/schedule*` surface answers `410 Gone` pointing at the examples endpoint. +- **Async mocking management API**: `/_mock/async/{push,consumers,disconnect}` (canonical, protocol-neutral prefix), `/_mock/events` (type-discriminated fire), `/_mock/examples` (unified sync/async injection with strict oneOf validation and runtime `match`/`interval`/`delay`, plus `DELETE /_mock/examples/{id}`), `/_mock/stream` (management WebSocket notifications). The removed `/_mock/ws/*` and `/_mock/events/fire` surface answers plain 404 like any unknown route. - **Management stream** (`internal/server/manage_ws.go`): `/_mock/stream` upgrade handler with connect-time `events`/`channels` filters; pushes `event`/`push`/`consumer`/`schedule` envelopes from the eventBus observer, consumer lifecycle hooks and scheduler start/stop. V1 is notifications-only (pings/pongs keep the socket alive). - **Templating parity**: `{$message.*}`, `{$channel.*}`, `{$event.*}` data sources; `ExampleValue` wrapper unifies selection across OpenAPI/AsyncAPI; state/history integration uses each schema's isolated namespace. - **Unsupported protocols** (`amqp`, `kafka`, ...) fail startup with exit code 3. diff --git a/docs/extensions.md b/docs/extensions.md index 60e9568..68fa1ee 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -103,17 +103,6 @@ examples: Timing values are integer milliseconds: a fractional value (e.g. `x-mock-interval: 2.5`) is rejected at load instead of being silently truncated. Periodically driven examples honor `x-mock-skip` like every other example and are not emitted while it is set. -### x-send-events (deprecated) - -**Location**: AsyncAPI message example object - -**Deprecated**: kept for one release with a loader mapping shim. Each entry is translated to the unified form during loading with a verbose-mode deprecation note: - -- `{on: }` → `x-mock-match: {'{$event.name}': }` -- `{on: connect, wait: N}` → `x-mock-match: {'{$event.name}': connect}` + `x-mock-delay: N` -- `{on: receive}` → `x-mock-match: {'{$event.name}': receive}` -- `{on: cron, wait: N}` → `x-mock-interval: N` - ### Runtime matches and timing (management API) `POST /_mock/examples` mirrors the extensions for AsyncAPI targets with `match`, `interval` and `delay` fields; the same classification and delivery rules apply. See `api/openapi.yaml`. diff --git a/internal/asyncapi/parse_test.go b/internal/asyncapi/parse_test.go index 81f8058..5730d02 100644 --- a/internal/asyncapi/parse_test.go +++ b/internal/asyncapi/parse_test.go @@ -266,14 +266,12 @@ func TestParse_NoRootSignalR(t *testing.T) { } /* -Scenario: Capturing x-send-events on a message example -Given an AsyncAPI message example with an x-send-events extension +Scenario: Capturing x-* extensions on a message example +Given an AsyncAPI message example with an arbitrary vendor extension When Parse is called -Then the neutral example view surfaces the extension - -Related spec scenarios: RS.EVT.7, RS.EVT.9, RS.EVT.10 +Then the neutral example view surfaces the extension generically under x-* */ -func TestParse_MessageExampleSendEvents(t *testing.T) { +func TestParse_MessageExampleVendorExtensions(t *testing.T) { t.Parallel() data := []byte(` @@ -293,10 +291,7 @@ channels: - name: ex1 payload: level: info - x-send-events: - - on: orderCreated - wait: 50 - - on: connect + x-mock-once: true operations: receiveAlerts: action: receive @@ -309,5 +304,5 @@ operations: require.Len(t, doc.Channels[0].Messages, 1) examples := doc.Channels[0].Messages[0].Examples require.Len(t, examples, 1) - assert.Contains(t, examples[0].Extensions, "x-send-events") + assert.Contains(t, examples[0].Extensions, "x-mock-once") } diff --git a/internal/runtime/event_source_test.go b/internal/runtime/event_source_test.go index 497dba8..27695ff 100644 --- a/internal/runtime/event_source_test.go +++ b/internal/runtime/event_source_test.go @@ -13,7 +13,7 @@ Given an event payload map and a nested value When the EventSource is queried Then the payload fields resolve via {$event.*} -Related spec scenarios: RS.EVT.8, RS.ATM.17 +Related spec scenarios: RS.EVT.23, RS.ATM.17 */ func TestEventSource_Get(t *testing.T) { t.Parallel() @@ -41,7 +41,7 @@ Given an evaluator with an event source registered as "event" When an expression is evaluated Then the event payload value is returned -Related spec scenarios: RS.EVT.8 +Related spec scenarios: RS.EVT.23 */ func TestEvaluator_EventExpression(t *testing.T) { t.Parallel() diff --git a/internal/server/async_state_test.go b/internal/server/async_state_test.go index 71e8094..3b6528e 100644 --- a/internal/server/async_state_test.go +++ b/internal/server/async_state_test.go @@ -4,42 +4,11 @@ import ( "testing" "github.com/golang/mock/gomock" - "github.com/mamonth/oasmock/internal/asyncapi" - "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/loader" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -const incrementCronDoc = `asyncapi: 3.0.0 -info: - title: Cron - version: 1.0.0 -channels: - feed: - address: /feed - bindings: - ws: - method: GET - messages: - tick: - examples: - - name: paced - payload: - seq: "{$state.counter}" - x-mock-set-state: - counter: - increment: 1 - x-send-events: - - on: cron - wait: 1000 -operations: - receiveFeed: - action: receive - channel: - $ref: '#/channels/feed' -` - /* Scenario: Incrementing state from a message example Given a message example whose x-mock-set-state increments a counter @@ -103,32 +72,3 @@ func TestRenderMessageSpecs_Delete(t *testing.T) { require.Equal(t, 1, count) assert.Contains(t, string(out), `"done":true`) } - -/* -Scenario: Cron subscriptions map to the periodic x-mock-interval shim -Given a message example subscribing to the cron built-in with a wait and a -state-backed sequence counter -When derivedExamples maps its x-send-events entry -Then the example becomes a periodically driven example with the wait interval, -keeping the pace-by-state-and-cron behavior of the templating spec - -Related spec scenarios: RS.ATM.18, RS.EVT.18, RS.EXT.22 -*/ -func TestDerivedExamples_CronToPeriodic(t *testing.T) { - t.Parallel() - - doc, err := asyncapi.Parse([]byte(incrementCronDoc)) - require.NoError(t, err) - - bus := newEventBus(nil, nil, false) - ex := doc.Channels[0].Messages[0].Examples[0] - derived, err := bus.derivedExamples(ex) - require.NoError(t, err) - require.Len(t, derived, 1) - - view := &MessageExampleView{spec: derived[0]} - trig, err := extensions.ClassifyTrigger(view) - require.NoError(t, err) - assert.Equal(t, extensions.TriggerPeriodic, trig.Kind) - assert.Equal(t, 1000, trig.Interval) -} diff --git a/internal/server/control_api_spec_sync_test.go b/internal/server/control_api_spec_sync_test.go index 1be5d5d..368c388 100644 --- a/internal/server/control_api_spec_sync_test.go +++ b/internal/server/control_api_spec_sync_test.go @@ -97,10 +97,7 @@ func specBasePath(doc *openapi3.T) string { /* Scenario: The OpenAPI control spec documents the registered management routes -Given the code registers the canonical /_mock/async|events|stream|examples routes, - - the deprecated /_mock/ws aliases and the removed /_mock/ws/schedule 410s - +Given the code registers the canonical /_mock/async|events|stream|examples routes When the OpenAPI control spec and the router are both enumerated Then every registered route is documented and every documented route is real @@ -329,7 +326,7 @@ func TestControlAPISpecSync_ErrorResponses(t *testing.T) { } } } - require.GreaterOrEqual(t, checked, 15, + require.GreaterOrEqual(t, checked, 9, "the control spec should document error bodies for every management operation") } diff --git a/internal/server/event_delivery_test.go b/internal/server/event_delivery_test.go index 1fa0b0b..9767e04 100644 --- a/internal/server/event_delivery_test.go +++ b/internal/server/event_delivery_test.go @@ -355,3 +355,59 @@ func TestEventDelivery_PeriodicSkipsSkippedExample(t *testing.T) { _, _, rerr := conn.ReadMessage() require.Error(t, rerr, "a skipped periodic example must not be emitted") } + +const legacyXSendEventsDoc = `asyncapi: 3.0.0 +info: + title: Legacy + version: 1.0.0 +channels: + alerts: + address: /alerts + bindings: + ws: + method: GET + messages: + alertMsg: + examples: + - name: named + payload: + level: "{$event.level}" + x-send-events: + - on: legacyAlert + - name: cron + payload: + seq: 1 + x-send-events: + - on: cron + wait: 1000 +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +` + +/* +Scenario: A legacy x-send-events key is silently ignored when classifying +Given message examples whose only extension is x-send-events (named and cron) +When registerSchema classifies them +Then no subscription, no interval job and no load error result (the key no longer +maps to the unified trigger form) + +Related spec scenarios: RS.EVT.5 +*/ +func TestSchemaRegistration_XSendEventsSilentlyIgnored(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(legacyXSendEventsDoc)) + require.NoError(t, err) + + bus := newEventBus(&stubMessageRenderer{}, &stubConsumerBus{}, false) + require.NoError(t, bus.registerSchema("", doc)) + + // Neither legacy key may register anything: the named entry would surface + // as a "legacyAlert" subscription, the cron entry as an interval job. + assert.Len(t, bus.broker.byEvent, 0, "x-send-events must not register any event subscription") + assert.False(t, bus.scheduler.started("interval---/alerts-cron"), + "x-send-events cron must not schedule an interval job") +} diff --git a/internal/server/event_integration_test.go b/internal/server/event_integration_test.go index 31c5479..51db34d 100644 --- a/internal/server/event_integration_test.go +++ b/internal/server/event_integration_test.go @@ -66,8 +66,8 @@ channels: payload: level: info account: "{$event.accountId}" - x-send-events: - - on: orderCreated + x-mock-match: + '{$event.name}': "orderCreated" operations: receiveAlerts: action: receive @@ -84,7 +84,7 @@ Given a REST example with x-event-trigger and a subscribed AsyncAPI ws channel When a REST request selects the trigger example with a connected consumer Then the consumer receives the templated message with the event payload -Related spec scenarios: RS.EVT.1, RS.EVT.7, RS.EVT.8, RS.EVT.12 +Related spec scenarios: RS.EVT.1, RS.EVT.12 */ func TestEventDriver_RESTToWS(t *testing.T) { t.Parallel() @@ -165,8 +165,8 @@ channels: payload: symbol: "{$event.symbol}" price: 3100 - x-send-events: - - on: orderCreated + x-mock-match: + '{$event.name}': "orderCreated" operations: receivePrice: action: receive @@ -180,7 +180,7 @@ Given a REST example with x-event-trigger and a subscribed SignalR hub channel When a REST request selects the trigger example with an open stream on the hub Then the consumer receives the templated message as a StreamItem -Related spec scenarios: RS.EVT.1, RS.EVT.7, RS.EVT.13, RS.SHR.18 +Related spec scenarios: RS.EVT.1, RS.EVT.13, RS.SHR.18 */ func TestEventDriver_RESTToSignalRStream(t *testing.T) { t.Parallel() diff --git a/internal/server/event_server.go b/internal/server/event_server.go index 649c163..d088b60 100644 --- a/internal/server/event_server.go +++ b/internal/server/event_server.go @@ -118,9 +118,9 @@ func (b *eventBus) shutdown() { // registerEventSubscriptions scans AsyncAPI schemas, classifies each message // example by trigger kind (event-driven via {$event.*} match, periodic via // x-mock-interval, or reply), and registers event-driven subscriptions keyed by -// identity + schema. Legacy x-send-events entries are mapped to the unified -// form with a verbose deprecation note (RS.EVT.18). Load errors (mixed match -// contexts, dual triggers) abort schema setup (RS.EXT.20, RS.EXT.28). +// identity + schema. A legacy x-send-events key, if present, is silently +// ignored. Load errors (mixed match contexts, dual triggers) abort schema setup +// (RS.EXT.20, RS.EXT.28). func (b *eventBus) registerEventSubscriptions(schemas []SchemaInfo) error { if b == nil || b.broker == nil { return nil @@ -166,48 +166,49 @@ func (b *eventBus) registerSchema(prefix string, doc *asyncapi.Document) error { return nil } -// classifyMessageExample classifies one spec example (and its legacy -// x-send-events derivations) into an event subscription, a periodic -// registration, or nothing (a plain reply), appending to the commit-stage -// accumulators. +// classifyMessageExample classifies one spec example into an event +// subscription, a periodic registration, or nothing (a plain reply), appending +// to the commit-stage accumulators. Classification is driven purely by the +// example's x-mock-match/x-mock-interval trigger extensions; a legacy +// x-send-events key, if present, is silently ignored (RS.EVT.18 removed). func (b *eventBus) classifyMessageExample(channelID, msgName, address, prefix string, ex *asyncapi.Example, subs *[]channelSubscription, periodic *[]periodicRegistration) error { if ex == nil { return nil } - derived, err := b.derivedExamples(ex) + spec := &loader.MessageExampleSpec{ + Name: ex.Name, + Headers: ex.Headers, + Payload: ex.Payload, + Extensions: ex.Extensions, + } + trig, err := extensions.ClassifyTrigger(&MessageExampleView{spec: spec}) if err != nil { - return err + return fmt.Errorf("channel %q example %q: %w", channelID, ex.Name, err) } - for _, spec := range derived { - trig, err := extensions.ClassifyTrigger(&MessageExampleView{spec: spec}) - if err != nil { - return fmt.Errorf("channel %q example %q: %w", channelID, ex.Name, err) - } - switch trig.Kind { - case extensions.TriggerEvent: - *subs = append(*subs, channelSubscription{ - address: address, - event: trig.Identity, - delay: trig.Delay, - messages: []*messageDeliverable{{ - spec: &loader.MessageSpec{Name: msgName, Examples: []*loader.MessageExampleSpec{spec}}, - prefix: prefix, - }}, - }) - case extensions.TriggerPeriodic: - *periodic = append(*periodic, periodicRegistration{ - address: address, prefix: prefix, exampleID: ex.Name, spec: spec, interval: trig.Interval, - }) - case extensions.TriggerReply: - // Reply examples are served by the channel's normal reply path; - // nothing to register here. A match that still references - // {$connection.*} can never evaluate (no connection context in the - // reply path), so point it out in verbose mode instead of failing - // silently. - if b.verbose && extensions.MatchReferencesConnection(trig.Match) { - slog.Warn("reply example references {$connection.*} which never matches without an event context; remove the connection condition or make the example event-driven", - "channel", channelID, "example", ex.Name) - } + switch trig.Kind { + case extensions.TriggerEvent: + *subs = append(*subs, channelSubscription{ + address: address, + event: trig.Identity, + delay: trig.Delay, + messages: []*messageDeliverable{{ + spec: &loader.MessageSpec{Name: msgName, Examples: []*loader.MessageExampleSpec{spec}}, + prefix: prefix, + }}, + }) + case extensions.TriggerPeriodic: + *periodic = append(*periodic, periodicRegistration{ + address: address, prefix: prefix, exampleID: ex.Name, spec: spec, interval: trig.Interval, + }) + case extensions.TriggerReply: + // Reply examples are served by the channel's normal reply path; + // nothing to register here. A match that still references + // {$connection.*} can never evaluate (no connection context in the + // reply path), so point it out in verbose mode instead of failing + // silently. + if b.verbose && extensions.MatchReferencesConnection(trig.Match) { + slog.Warn("reply example references {$connection.*} which never matches without an event context; remove the connection condition or make the example event-driven", + "channel", channelID, "example", ex.Name) } } return nil @@ -314,86 +315,6 @@ func (b *eventBus) removeIntervalJob(jobID string) { } } -// derivedExamples maps one spec example into the example specs to register. -// An example without x-send-events maps to itself. A legacy x-send-events -// example maps through the deprecation shim: each entry becomes the unified -// form ({on} → {$event.name} match, {on: cron, wait} → x-mock-interval) with a -// verbose-mode deprecation note (RS.EVT.18). -func (b *eventBus) derivedExamples(ex *asyncapi.Example) ([]*loader.MessageExampleSpec, error) { - events, err := parseSendEvents(ex.Extensions) - if err != nil { - return nil, fmt.Errorf("example %q: %w", ex.Name, err) - } - if len(events) == 0 { - return []*loader.MessageExampleSpec{{ - Name: ex.Name, - Headers: ex.Headers, - Payload: ex.Payload, - Extensions: ex.Extensions, - }}, nil - } - out := make([]*loader.MessageExampleSpec, 0, len(events)) - for _, ev := range events { - ext := cloneExtensions(ex.Extensions) - delete(ext, xSendEventsKey) - if ev.On == "cron" { - if ev.Wait <= 0 { - return nil, fmt.Errorf("example %q: x-send-events {on: cron} requires a positive wait interval (use x-mock-interval)", ex.Name) - } - if b.verbose { - slog.Warn("x-send-events is deprecated; use x-mock-interval", "example", ex.Name) - } - ext["x-mock-interval"] = ev.Wait - } else { - if b.verbose { - slog.Warn("x-send-events is deprecated; use x-mock-match: {'{$event.name}': }", "example", ex.Name) - } - match, ok := ext["x-mock-match"].(map[string]any) - if !ok { - // A pre-existing, non-object x-mock-match would be silently lost - // if overwritten; fail loud so the spec author fixes it. - if _, present := ext["x-mock-match"]; present { - return nil, fmt.Errorf("example %q: x-mock-match must be an object when combined with x-send-events", ex.Name) - } - match = make(map[string]any) - } - match["{$event.name}"] = ev.On - ext["x-mock-match"] = match - if ev.On == "connect" || ev.On == "receive" { - if ev.On == "connect" && ev.Wait > 0 { - ext["x-mock-delay"] = ev.Wait - } - } - } - out = append(out, &loader.MessageExampleSpec{ - Name: ex.Name, - Headers: ex.Headers, - Payload: ex.Payload, - Extensions: ext, - }) - } - return out, nil -} - -// cloneExtensions deep-copies an example's extension map. Nested maps (e.g. -// x-mock-match) are copied too, so a legacy example with several x-send-events -// entries never shares the match map across the derived clones. -func cloneExtensions(ext map[string]any) map[string]any { - out := make(map[string]any, len(ext)) - for k, v := range ext { - if m, ok := v.(map[string]any); ok { - inner := make(map[string]any, len(m)) - for ik, iv := range m { - inner[ik] = iv - } - out[k] = inner - continue - } - out[k] = v - } - return out -} - // deliver renders the subscribed message with the event payload and emits it // to the channel's consumers, narrowing to per-connection recipients when the // example's match references {$connection.*} (design D6, RS.EXT.24-25). An diff --git a/internal/server/events_endpoint_test.go b/internal/server/events_endpoint_test.go index 6849066..7ccb081 100644 --- a/internal/server/events_endpoint_test.go +++ b/internal/server/events_endpoint_test.go @@ -145,14 +145,14 @@ func TestEventsEndpoint_PayloadTemplating(t *testing.T) { } /* -Scenario: The legacy /events/fire alias defaults the type discriminator -Given a legacy management request to /_mock/events/fire without a type field -When the alias is invoked with a connected consumer -Then the event fires as type "fire" and the consumer receives the message +Scenario: The removed /events/fire alias answers 404 +Given a server with the canonical events endpoint +When a fire request is posted to the removed /_mock/events/fire path +Then the server responds with HTTP 404 (the legacy alias is gone) Related spec scenarios: RS.MAPI.22, RS.MAPI.32 */ -func TestEventsEndpoint_LegacyAliasDefaultsType(t *testing.T) { +func TestEventsEndpoint_LegacyAliasGone(t *testing.T) { t.Parallel() doc, err := asyncapi.Parse([]byte(fireEventWsDoc)) @@ -163,22 +163,10 @@ func TestEventsEndpoint_LegacyAliasDefaultsType(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - require.NoError(t, err) - defer conn.Close() //nolint:errcheck - body := `{"event":"levelUp","payload":{"level":"warn","message":"legacy"}}` + body := `{"type":"fire","event":"levelUp","payload":{"level":"warn"}}` resp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusOK, resp.StatusCode) - - _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - _, msg, err := conn.ReadMessage() - require.NoError(t, err) - var payload map[string]any - require.NoError(t, json.Unmarshal(msg, &payload)) - assert.Equal(t, "warn", payload["level"]) - assert.Equal(t, "legacy", payload["msg"]) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) } diff --git a/internal/server/fire_event.go b/internal/server/fire_event.go index fc26fee..f986718 100644 --- a/internal/server/fire_event.go +++ b/internal/server/fire_event.go @@ -16,20 +16,6 @@ type fireEventRequest struct { Global bool `json:"global"` } -// handleFireEventLegacy serves the deprecated /_mock/events/fire alias, -// accepting the pre-discriminator body (no "type" field). The alias keeps the -// old contract so pre-change clients keep working (design D1); the canonical -// /_mock/events endpoint still requires the type discriminator. -func (s *Server) handleFireEventLegacy(w http.ResponseWriter, r *http.Request) { - var req fireEventRequest - if err := decodeJSONBody(r, &req); err != nil { - writeJSONError(w, http.StatusBadRequest, "invalid JSON body") - return - } - req.Type = "fire" - s.dispatchFireEvent(w, req) -} - // handleEvents dispatches a discriminated event action through the event // broker. The type discriminator is required and only "fire" is accepted // (RS.MAPI.32); fire reuses the existing ad-hoc fire semantics. @@ -42,9 +28,7 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { s.dispatchFireEvent(w, req) } -// dispatchFireEvent validates and executes a fired event. Both the canonical -// and the legacy alias decode into the shared request, so the discriminator -// default lives next to the type checks instead of a body re-encode round trip. +// dispatchFireEvent validates and executes a fired event. func (s *Server) dispatchFireEvent(w http.ResponseWriter, req fireEventRequest) { if s.eventBus == nil { writeJSONError(w, http.StatusInternalServerError, "event broker not initialized") diff --git a/internal/server/fire_event_endpoint_test.go b/internal/server/fire_event_endpoint_test.go index a5c8da4..74fa8e8 100644 --- a/internal/server/fire_event_endpoint_test.go +++ b/internal/server/fire_event_endpoint_test.go @@ -32,8 +32,8 @@ channels: payload: level: "{$event.level}" msg: "{$event.message}" - x-send-events: - - on: levelUp + x-mock-match: + '{$event.name}': "levelUp" operations: receiveAlerts: action: receive diff --git a/internal/server/legacy_route_teardown_test.go b/internal/server/legacy_route_teardown_test.go new file mode 100644 index 0000000..9bf6fcd --- /dev/null +++ b/internal/server/legacy_route_teardown_test.go @@ -0,0 +1,143 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Deprecated push alias answers 404 after teardown +Given a server with the canonical async management routes +When a management push is sent to the removed /_mock/ws/push path +Then the server responds with HTTP 404 Not Found + +Related spec scenarios: RS.AMG.1, RS.AMG.6 +*/ +func TestLegacyRouteTeardown_PushAlias404(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"channel":"/alerts","payload":{"msg":"alias"}}` + resp, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +/* +Scenario: Deprecated consumers alias answers 404 after teardown +Given a server with the canonical async management routes +When consumers are listed via the removed /_mock/ws/consumers path +Then the server responds with HTTP 404 Not Found + +Related spec scenarios: RS.AMG.8 +*/ +func TestLegacyRouteTeardown_ConsumersAlias404(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + resp, err := http.Get(ts.URL + "/_mock/ws/consumers?channel=/alerts") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +/* +Scenario: Deprecated disconnect alias answers 404 after teardown +Given a server with the canonical async management routes +When a consumer is disconnected via the removed /_mock/ws/disconnect path +Then the server responds with HTTP 404 Not Found + +Related spec scenarios: RS.AMG.14 +*/ +func TestLegacyRouteTeardown_DisconnectAlias404(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"connectionId":"nope","reason":"alias"}` + resp, err := http.Post(ts.URL+"/_mock/ws/disconnect", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +/* +Scenario: Deprecated events/fire alias answers 404 after teardown +Given a server with the canonical events endpoint +When an event is fired via the removed /_mock/events/fire path +Then the server responds with HTTP 404 Not Found + +Related spec scenarios: RS.MAPI.22, RS.MAPI.32 +*/ +func TestLegacyRouteTeardown_EventsFireAlias404(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"type":"fire","event":"levelUp","payload":{"level":"warn"}}` + resp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +/* +Scenario: Removed schedule push answers 404 after teardown +Given a server with the canonical examples endpoint +When a recurring push is scheduled via the removed /_mock/ws/schedule path +Then the server responds with HTTP 404 Not Found + +Related spec scenarios: RS.AMG.12 +*/ +func TestLegacyRouteTeardown_SchedulePush404(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"channel":"/alerts","interval":50,"payload":{"tick":true}}` + resp, err := http.Post(ts.URL+"/_mock/ws/schedule", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +/* +Scenario: Removed schedule stop answers 404 after teardown +Given a server with the canonical examples endpoint +When a schedule is stopped via the removed /_mock/ws/schedule/{pushId} path +Then the server responds with HTTP 404 Not Found + +Related spec scenarios: RS.AMG.13 +*/ +func TestLegacyRouteTeardown_ScheduleStop404(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + req, err := http.NewRequest(http.MethodDelete, ts.URL+"/_mock/ws/schedule/push-123", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} diff --git a/internal/server/management_async.go b/internal/server/management_async.go index 28a5143..e351d2c 100644 --- a/internal/server/management_async.go +++ b/internal/server/management_async.go @@ -9,7 +9,7 @@ import ( "github.com/mamonth/oasmock/internal/runtime" ) -// asyncPushRequest is the payload of POST /_mock/ws/push (RS.AMG.1-7, RS.AMG.10-11). +// asyncPushRequest is the payload of POST /_mock/async/push (RS.AMG.1-7, RS.AMG.10-11). type asyncPushRequest struct { Channel string `json:"channel"` ConnectionID string `json:"connectionId"` @@ -202,16 +202,7 @@ func (s *Server) handleAsyncConsumers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"consumers": consumers}) } -// handleGoneSchedule answers the removed /_mock/ws/schedule surface with HTTP -// 410 Gone pointing at POST /_mock/examples (design D1). The recurring-delivery -// capability now lives on unified example injection with a runtime interval. -func (s *Server) handleGoneSchedule(w http.ResponseWriter, r *http.Request) { - writeJSONError(w, http.StatusGone, - "the async schedule endpoint is removed; use POST /_mock/examples with an AsyncAPI target, response.body and interval (and DELETE /_mock/examples/{exampleId} to stop)") -} - -// disconnectRequest is the payload of POST /_mock/async/disconnect -// (and the deprecated /_mock/ws/disconnect alias). +// disconnectRequest is the payload of POST /_mock/async/disconnect. type disconnectRequest struct { ConnectionID string `json:"connectionId"` Reason string `json:"reason"` diff --git a/internal/server/management_async_aliases_test.go b/internal/server/management_async_aliases_test.go deleted file mode 100644 index cb9a94c..0000000 --- a/internal/server/management_async_aliases_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package server - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/gorilla/websocket" - "github.com/mamonth/oasmock/internal/asyncapi" - "github.com/mamonth/oasmock/internal/loader" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -/* -Scenario: Deprecated ws alias for push still works -Given a server with the canonical async management routes -When a management push is sent to the deprecated /_mock/ws/push path -Then the request is accepted (200) and delivered to consumers - -Related spec scenarios: RS.AMG.1, RS.AMG.6 -*/ -func TestDeprecatedAlias_Push(t *testing.T) { - t.Parallel() - - srv := newPushServer(t) - ts := httptest.NewServer(srv.router) - defer ts.Close() //nolint:errcheck - - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - require.NoError(t, err) - defer conn.Close() //nolint:errcheck - _, _, _ = conn.ReadMessage() // consume snapshot - - body := `{"channel":"/alerts","payload":{"msg":"alias"}}` - resp, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", strings.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusOK, resp.StatusCode) - - _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - _, msg, err := conn.ReadMessage() - require.NoError(t, err) - assert.Contains(t, string(msg), `"alias"`) -} - -/* -Scenario: Deprecated ws alias for consumers still works -Given a server with the canonical async management routes and a connected consumer -When consumers are listed via the deprecated /_mock/ws/consumers path -Then the consumer list includes the connection id - -Related spec scenarios: RS.AMG.8 -*/ -func TestDeprecatedAlias_Consumers(t *testing.T) { - t.Parallel() - - srv := newPushServer(t) - ts := httptest.NewServer(srv.router) - defer ts.Close() //nolint:errcheck - - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - require.NoError(t, err) - defer conn.Close() //nolint:errcheck - - resp, err := http.Get(ts.URL + "/_mock/ws/consumers?channel=/alerts") - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var payload map[string]any - require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) - items, ok := payload["consumers"].([]any) - require.True(t, ok) - assert.NotEmpty(t, items) -} - -/* -Scenario: Deprecated ws alias for disconnect still works -Given a server with the canonical async management routes and an active consumer -When the consumer is disconnected via the deprecated /_mock/ws/disconnect path -Then the connection is closed and the request is accepted - -Related spec scenarios: RS.AMG.14 -*/ -func TestDeprecatedAlias_Disconnect(t *testing.T) { - t.Parallel() - - srv := newPushServer(t) - ts := httptest.NewServer(srv.router) - defer ts.Close() //nolint:errcheck - - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - require.NoError(t, err) - defer conn.Close() //nolint:errcheck - _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - _, _, _ = conn.ReadMessage() // consume snapshot - - resp, err := http.Get(ts.URL + "/_mock/ws/consumers?channel=/alerts") - require.NoError(t, err) - var payload map[string]any - require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) - _ = resp.Body.Close() - items, ok := payload["consumers"].([]any) - require.True(t, ok) - require.NotEmpty(t, items) - first, ok := items[0].(map[string]any) - require.True(t, ok) - connID, ok := first["connectionId"].(string) - require.True(t, ok) - - disc := `{"connectionId":"` + connID + `","reason":"alias"}` - discResp, err := http.Post(ts.URL+"/_mock/ws/disconnect", "application/json", strings.NewReader(disc)) - require.NoError(t, err) - defer discResp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusOK, discResp.StatusCode) - - // The disconnect must actually close the socket: a read on the consumer - // should observe the close (EOF) rather than keep waiting for frames. - _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - _, _, err = conn.ReadMessage() - require.Error(t, err, "expected the connection to close after the alias disconnect") -} - -/* -Scenario: Deprecated events/fire alias still works -Given a server with the canonical events endpoint and a fired-event subscription -When an event is fired via the deprecated /_mock/events/fire path -Then the templated message reaches a connected consumer - -Related spec scenarios: RS.MAPI.22, RS.AMG.20 -*/ -func TestDeprecatedAlias_EventsFire(t *testing.T) { - t.Parallel() - - doc, err := asyncapi.Parse([]byte(fireEventWsDoc)) - require.NoError(t, err) - schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} - srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) - require.NoError(t, err) - - ts := httptest.NewServer(srv.router) - defer ts.Close() //nolint:errcheck - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - require.NoError(t, err) - defer conn.Close() //nolint:errcheck - - body := `{"type":"fire","event":"levelUp","payload":{"level":"warn","message":"high load"}}` - resp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", strings.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusOK, resp.StatusCode) - - _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - _, msg, err := conn.ReadMessage() - require.NoError(t, err) - assert.Contains(t, string(msg), `"high load"`) -} - -/* -Scenario: The deprecated events/fire alias accepts a legacy type-less body -Given a server with the canonical events endpoint and a fired-event subscription -When an event is fired via the deprecated /_mock/events/fire path without a -'type' field -Then the legacy body is still honored and the message reaches a consumer - -Related spec scenarios: RS.MAPI.22, RS.AMG.20 -*/ -func TestDeprecatedAlias_EventsFireLegacyBody(t *testing.T) { - t.Parallel() - - doc, err := asyncapi.Parse([]byte(fireEventWsDoc)) - require.NoError(t, err) - schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} - srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) - require.NoError(t, err) - - ts := httptest.NewServer(srv.router) - defer ts.Close() //nolint:errcheck - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - require.NoError(t, err) - defer conn.Close() //nolint:errcheck - - body := `{"event":"levelUp","payload":{"level":"warn","message":"legacy high load"}}` - resp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", strings.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusOK, resp.StatusCode) - - _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - _, msg, err := conn.ReadMessage() - require.NoError(t, err) - assert.Contains(t, string(msg), `"legacy high load"`) -} - -/* -Scenario: Removed schedule push answers 410 Gone -Given a server with the canonical examples endpoint -When a recurring push is scheduled via the removed /_mock/ws/schedule path -Then the server responds with HTTP 410 Gone pointing at POST /_mock/examples - -Related spec scenarios: RS.AMG.12 -*/ -func TestRemovedScheduleRet_410(t *testing.T) { - t.Parallel() - - srv := newPushServer(t) - ts := httptest.NewServer(srv.router) - defer ts.Close() //nolint:errcheck - - body := `{"channel":"/alerts","interval":50,"payload":{"tick":true}}` - resp, err := http.Post(ts.URL+"/_mock/ws/schedule", "application/json", strings.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusGone, resp.StatusCode) - - bodyBytes, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.Contains(t, strings.ToLower(string(bodyBytes)), "/_mock/examples") -} - -/* -Scenario: Removed schedule stop answers 410 Gone -Given a server with the canonical examples endpoint -When a schedule is stopped via the removed /_mock/ws/schedule/{pushId} path -Then the server responds with HTTP 410 Gone pointing at POST /_mock/examples - -Related spec scenarios: RS.AMG.13 -*/ -func TestRemovedScheduleStop_410(t *testing.T) { - t.Parallel() - - srv := newPushServer(t) - ts := httptest.NewServer(srv.router) - defer ts.Close() //nolint:errcheck - - req, err := http.NewRequest(http.MethodDelete, ts.URL+"/_mock/ws/schedule/push-123", nil) - require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusGone, resp.StatusCode) -} diff --git a/internal/server/management_async_lifecycle_test.go b/internal/server/management_async_lifecycle_test.go index 8e9d08b..d1afe88 100644 --- a/internal/server/management_async_lifecycle_test.go +++ b/internal/server/management_async_lifecycle_test.go @@ -69,10 +69,10 @@ func TestPushEndpoint_UnresolvableExpression(t *testing.T) { } /* -Scenario: Removed schedule endpoint answers 410 Gone +Scenario: The removed schedule endpoint answers 404 Given a management schedule request against the removed /_mock/ws/schedule path When the schedule endpoint is invoked -Then the server responds 410 Gone pointing at POST /_mock/examples +Then the server responds 404 Not Found (the 410 stub is removed) Related spec scenarios: RS.AMG.12 */ @@ -92,7 +92,7 @@ func TestSchedulePush_Removed(t *testing.T) { resp, err := http.Post(ts.URL+"/_mock/ws/schedule", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusGone, resp.StatusCode) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) } /* diff --git a/internal/server/send_events.go b/internal/server/send_events.go deleted file mode 100644 index 37f8b38..0000000 --- a/internal/server/send_events.go +++ /dev/null @@ -1,53 +0,0 @@ -package server - -import ( - "fmt" - - "github.com/mamonth/oasmock/internal/extensions" -) - -// xSendEventsKey is the extension key carrying event subscriptions on an -// AsyncAPI message example. -const xSendEventsKey = "x-send-events" - -// SendEvent is a single x-send-events subscription entry. -type SendEvent struct { - // On is a named event or a built-in trigger (connect, receive, cron). - On string - // Wait is the optional delay/interval in milliseconds for built-ins. - Wait int -} - -// parseSendEvents parses the x-send-events extension on an AsyncAPI message -// example. Each entry is {on: , wait?: ms} or a bare built-in string -// (RS.EVT.7-11). -func parseSendEvents(ext map[string]any) ([]SendEvent, error) { - raw, ok := ext[xSendEventsKey] - if !ok { - return nil, nil - } - items, ok := raw.([]any) - if !ok { - return nil, fmt.Errorf("x-send-events must be a list") - } - var out []SendEvent - for _, item := range items { - switch v := item.(type) { - case string: - out = append(out, SendEvent{On: v}) - case map[string]any: - on, ok := v["on"].(string) - if !ok || on == "" { - return nil, fmt.Errorf("x-send-events entry must have an 'on' field") - } - ev := SendEvent{On: on} - if wait, ok := extensions.AsMilliseconds(v["wait"]); ok { - ev.Wait = wait - } - out = append(out, ev) - default: - return nil, fmt.Errorf("x-send-events entry must be a string or an object") - } - } - return out, nil -} diff --git a/internal/server/send_events_test.go b/internal/server/send_events_test.go deleted file mode 100644 index f63e012..0000000 --- a/internal/server/send_events_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package server - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -/* -Scenario: Parsing x-send-events named-event subscriptions -Given an AsyncAPI message example with x-send-events entries -When parseSendEvents is called -Then each named subscription is parsed with its wait - -Related spec scenarios: RS.EVT.7, RS.EVT.9, RS.EVT.10 -*/ -func TestParseSendEvents_Named(t *testing.T) { - t.Parallel() - - ext := map[string]any{ - "x-send-events": []any{ - map[string]any{"on": "orderCreated", "wait": 50}, - map[string]any{"on": "connect"}, - }, - } - - events, err := parseSendEvents(ext) - require.NoError(t, err) - require.Len(t, events, 2) - assert.Equal(t, "orderCreated", events[0].On) - assert.Equal(t, 50, events[0].Wait) - assert.Equal(t, "connect", events[1].On) - assert.Equal(t, 0, events[1].Wait) -} - -/* -Scenario: Parsing x-send-events built-in receive subscription -Given a message example with a flat receive entry -When parseSendEvents is called -Then a receive subscription is parsed - -Related spec scenarios: RS.EVT.11 -*/ -func TestParseSendEvents_FlatReceive(t *testing.T) { - t.Parallel() - - ext := map[string]any{ - "x-send-events": []any{"receive"}, - } - - events, err := parseSendEvents(ext) - require.NoError(t, err) - require.Len(t, events, 1) - assert.Equal(t, "receive", events[0].On) -} - -/* -Scenario: Parsing x-send-events cron built-in -Given a message example with an object cron entry -When parseSendEvents is called -Then the cron subscription carries its wait interval - -Related spec scenarios: RS.EVT.10 -*/ -func TestParseSendEvents_Cron(t *testing.T) { - t.Parallel() - - ext := map[string]any{ - "x-send-events": []any{ - map[string]any{"on": "cron", "wait": 1000}, - }, - } - - events, err := parseSendEvents(ext) - require.NoError(t, err) - require.Len(t, events, 1) - assert.Equal(t, "cron", events[0].On) - assert.Equal(t, 1000, events[0].Wait) -} - -/* -Scenario: Handling an invalid x-send-events entry -Given a message example with a malformed x-send-events entry -When parseSendEvents is called -Then it returns an error - -Related spec scenarios: RS.EVT.7 -*/ -func TestParseSendEvents_Invalid(t *testing.T) { - t.Parallel() - - ext := map[string]any{ - "x-send-events": []any{42}, - } - _, err := parseSendEvents(ext) - require.Error(t, err) -} diff --git a/internal/server/server_routes.go b/internal/server/server_routes.go index f0a35c8..ab86b97 100644 --- a/internal/server/server_routes.go +++ b/internal/server/server_routes.go @@ -131,18 +131,6 @@ func (s *Server) registerManagementRoutes(r chi.Router) { r.Get("/_mock/async/consumers", s.handleAsyncConsumers) r.Post("/_mock/async/disconnect", s.handleAsyncDisconnect) r.Get("/_mock/stream", s.handleManageStream) - - // Deprecated aliases kept for one release (design D1). The events/fire - // alias serves the legacy type-less contract (handleFireEventLegacy); the - // ws aliases share the canonical handlers. - r.Post("/_mock/events/fire", s.handleFireEventLegacy) - r.Post("/_mock/ws/push", s.handleAsyncPush) - r.Get("/_mock/ws/consumers", s.handleAsyncConsumers) - r.Post("/_mock/ws/disconnect", s.handleAsyncDisconnect) - - // Removed schedule surface answers 410 Gone pointing at /_mock/examples. - r.Post("/_mock/ws/schedule", s.handleGoneSchedule) - r.Delete("/_mock/ws/schedule/{pushId}", s.handleGoneSchedule) } // buildRequestSource constructs the runtime request data source from an diff --git a/internal/server/x_send_events_shim_test.go b/internal/server/x_send_events_shim_test.go deleted file mode 100644 index b556aad..0000000 --- a/internal/server/x_send_events_shim_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package server - -import ( - "testing" - - "github.com/mamonth/oasmock/internal/asyncapi" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -/* -Scenario: Rejecting a legacy cron entry without a wait interval -Given an example whose x-send-events contains {on: cron} without a wait -When derivedExamples maps the entry -Then loading fails loudly with an error naming the missing interval - -Related spec scenarios: RS.EVT.18 -*/ -func TestDerivedExamples_CronWithoutWaitRejected(t *testing.T) { - t.Parallel() - - bus := newEventBus(nil, nil, false) - ex := &asyncapi.Example{ - Name: "bad", - Payload: map[string]any{"x": 1}, - Extensions: map[string]any{ - "x-send-events": []any{map[string]any{"on": "cron"}}, - }, - } - _, err := bus.derivedExamples(ex) - require.Error(t, err) - assert.Contains(t, err.Error(), "wait") -} - -/* -Scenario: Mapping legacy {on: cron, wait: N} to the interval shim -Given an example whose x-send-events contains {on: cron, wait: 1000} -When derivedExamples maps the entry -Then the example becomes periodically driven at the given interval - -Related spec scenarios: RS.EVT.18, RS.EXT.22 -*/ -func TestDerivedExamples_CronWithWaitMapsToInterval(t *testing.T) { - t.Parallel() - - bus := newEventBus(nil, nil, false) - ex := &asyncapi.Example{ - Name: "tick", - Payload: map[string]any{"seq": 1}, - Extensions: map[string]any{ - "x-send-events": []any{map[string]any{"on": "cron", "wait": float64(1000)}}, - }, - } - derived, err := bus.derivedExamples(ex) - require.NoError(t, err) - require.Len(t, derived, 1) - require.NotNil(t, derived[0].Extensions["x-mock-interval"]) - assert.Equal(t, 1000, derived[0].Extensions["x-mock-interval"]) -} diff --git a/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/.openspec.yaml b/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/.openspec.yaml new file mode 100644 index 0000000..1a62d62 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-06 diff --git a/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/design.md b/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/design.md new file mode 100644 index 0000000..8f5c942 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/design.md @@ -0,0 +1,80 @@ +# Design: Cleanup deprecated management endpoints and `x-send-events` + +## Context + +The async-management-api-extensions change shipped the unified protocol-neutral management surface (`/_mock/async/*`, `/_mock/events`, `/_mock/examples`, `/_mock/stream`) alongside one-release compatibility: deprecated `/_mock/ws/{push,consumers,disconnect}` aliases, a deprecated `/_mock/events/fire` alias, `410 Gone` stubs for the removed `/_mock/ws/schedule{,/{pushId}}`, and a load-time `x-send-events` → `x-mock-match`/`x-mock-interval` mapping shim (see proposal.md — Why). The window has elapsed; those remnants are now the only dead weight in the surface. + +The canonical requirements live in `openspec/specs/` (`event-driver`, `management-api`, `asyncapi-management`, `extensions`). Notably the deprecated alias endpoints were never spec'd as requirements — they exist only in `api/openapi.yaml`, the router, and a handful of tests/docs. So the endpoint teardown is behavior-neutral at the spec level; only the `x-send-events` subscription vocabulary is a spec-surface change (delta in `specs/event-driver/`). + +## Goals / Non-Goals + +**Goals:** +- Canonical-only management routing: leave `registerManagementRoutes` with exactly the `/_mock/async/*`, `/_mock/events`, `/_mock/examples`, `/_mock/stream`, `/_mock/requests` surface. +- Legacy paths (`/_mock/ws/*`, `/_mock/events/fire`) return the same plain 404 as any unknown route. +- `x-send-events` is no longer interpreted: the loader classifies message examples purely by `x-mock-match`/`x-mock-interval`, while a key still present in a spec loads without error. +- OpenAPI spec, docs, and tests no longer advertise deprecated vocabulary. + +**Non-Goals:** +- Keep the `x-mock-params-match` alias handling (`extensions`, `mock-server-core` RS.MSC.8, `asyncapi-templating` RS.ATM.8) — a separate, still-supported deprecation. +- Change the canonical `/_mock/async/*`, `/_mock/events` (including its `type` discriminator), examples, or stream behavior. +- Add load-time validation/rejection for specs that still carry `x-send-events` (decision D2: silent ignore). +- Migrate or re-route any legacy path to a canonical one with a redirect — removal is final. + +## Decisions + +### D1: Remove the alias and 410-stub route registrations; legacy paths fall through to the default 404 + +Delete from `internal/server/server_routes.go::registerManagementRoutes`: +- `POST /_mock/events/fire` (→ `handleFireEventLegacy`) +- `POST /_mock/ws/push` (→ `handleAsyncPush`) +- `GET /_mock/ws/consumers` (→ `handleAsyncConsumers`) +- `POST /_mock/ws/disconnect` (→ `handleAsyncDisconnect`) +- `POST /_mock/ws/schedule` and `DELETE /_mock/ws/schedule/{pushId}` (→ `handleGoneSchedule`) + +chi's not-found handling already returns 404 for unregistered paths, so no `404` handler needs to be added — removing the routes *is* the new behavior. Delete `handleGoneSchedule` in `internal/server/management_async.go` and `handleFireEventLegacy` in `internal/server/fire_event.go` (keeping `handleEvents` + `dispatchFireEvent`, which now only ever serve the canonical `fireEventRequest` with a required `type`). + +**Alternatives considered:** keeping the 410 stubs so old schedule clients still get migration guidance. Rejected — the user chose full teardown; a documented one-release window was the migration vehicle, and `DELETE /_mock/examples/{id}` was already the pointer. Keeping stubs would leave half the dead surface alive. + +### D2: Remove the `x-send-events` mapping shim; ignore the key at load + +Delete `internal/server/send_events.go` (`SendEvent`, `parseSendEvents`, `xSendEventsKey`). In `internal/server/event_server.go`: +- `derivedExamples` currently expands one example into N derived message specs (one per `x-send-events` entry). Since the key is ignored, the derivation degenerates to a passthrough — delete `derivedExamples` and `cloneExtensions`, and have `classifyMessageExample` classify the example directly via `extensions.ClassifyTrigger`. +- Remove the two `slog.Warn("x-send-events is deprecated; ...")` branches and the `{on: cron}` without-wait error. +- The generic capture of `x-*` keys in `internal/asyncapi` remains untouched (the vendored parser still surfaces the key in `Example.Extensions`; nothing downstream reads it). This matches "silently ignore": the spec loads, the key rides along in the model, and classification ignores it. + +**Alternatives considered:** reject-at-load for specs that still use `x-send-events`. Rejected by the user — silent ignore keeps lingering (already-migrated) specs loadable, consistent with how other unknown vendor extensions are tolerated. This difference is the whole reason the spec delta REMOVES the old mapping requirement rather than replacing it with a stricter one. + +### D3: Prune `api/openapi.yaml` to the canonical surface + +Remove the paths `/events/fire`, `/ws/push`, `/ws/consumers`, `/ws/schedule`, `/ws/schedule/{pushId}`, `/ws/disconnect` and their `operationId`s. Remove the now-unused `components.schemas.LegacyFireEventRequest`, `components.schemas.ScheduleRequest`, `components.responses.GoneExamples`, `components.responses.GoneDelete`. Keep `PushRequest`, `DisconnectRequest`, `ConsumersResponse`, and the envelope schemas (still used by canonical endpoints). + +**Constraint:** `internal/server/control_api_spec_sync_test.go::TestControlAPISpecSync_ErrorResponses` asserts at least 15 error responses across all management operations. Removing the deprecated paths removes 7 (events/fire 400; ws/push 400+404; ws/schedule 410; ws/schedule/{pushId} 410; ws/disconnect 400+404) → lowers the count to 9. The test's `require.GreaterOrEqual(..., 15)` threshold must be lowered (e.g. to 9) in the same change that prunes the spec. `TestControlAPISpecSync_OpenAPI` auto-corrects since it requires both directions to match after the edit; its comment referencing the deprecated aliases must be updated. + +### D4: Update tests in the same change + +- Delete: `internal/server/send_events_test.go`, `internal/server/x_send_events_shim_test.go`, `internal/server/management_async_aliases_test.go`. +- Update: `async_state_test.go` (drops its `derivedExamples`/`x-send-events` case), `fire_event_endpoint_test.go` and `event_integration_test.go` (migrate fixtures to `x-mock-match`/`x-mock-interval`), `internal/asyncapi/parse_test.go` (drop the dedicated `x-send-events` capture assertion), `test/asyncapi/management-api/management_api_test.go` (remove the deprecated-alias and x-send-events-shim scenarios), `test/_shared/resources/asyncapi-management.yaml` (migrate its `x-send-events` example), `control_api_spec_sync_test.go`. +- Keep the deprecated-alias **runtime** coverage gone: the integration test that hits `/_mock/ws/push` etc. no longer expects 200/410 — it should assert plain 404 for those paths to pin D1. + +### D5: Docs + +`README.md` (remove the "legacy aliases still work / schedule answers 410" paragraph), `docs/architecture.md` (lines ~235, ~350, ~352: drop `x-send-events` and the alias/410 mentions), `docs/extensions.md` (delete the `x-send-events (deprecated)` section, keep the unified `x-mock-match`/`x-mock-interval`/`x-mock-delay` documentation, phrase the timing extensions as the sole recurrence mechanism), and a `CHANGELOG.md` entry announcing the removals. + +## Risks / Trade-offs + +- [Existing users still on legacy `/_mock/ws/*` or `/_mock/events/fire` break] → Mitigated: one-release deprecation already signposted the canonical paths; CHANGELOG documents the removal; error responses (when they hit a removed path) are now plain 404 — loud and unambiguous. +- [Specs still carrying `x-send-events` silently lose event-driven behavior] → By design (D2), consistent with the "silently ignored" decision and the spec-delta migration note; verbose users already saw the deprecation warning for a release. +- [Spec-sync test regresses when the OpenAPI surface shrinks] → D3 explicitly lowers the error-response threshold and relies on the bidirectional route-doc check, keeping docs and router in lockstep. +- [`derivedExamples` removal changes classification structure for `x-mock-match`-only examples] → Low: the passthrough path is exactly the current `len(events)==0` branch; the `classifyMessageExample` loop shape stays, just over one spec. + +## Migration Plan + +1. Code: D1 (routing + handlers), D2 (send_events deletion + event_server simplification). +2. Tests: D4 (delete/update) in the same commit so the suite stays green. +3. Contract: D3 (openapi.yaml prune + threshold). +4. Docs: D5. +5. Rollback: revert the change commit — the deprecated routes, shim and aliases are restored verbatim; no data migration is involved (no persistence). + +## Open Questions + +None — removal scope (whether to keep the 410 stubs) and the `x-send-events` post-removal behavior (silently ignore) were resolved with the user before planning. \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/proposal.md b/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/proposal.md new file mode 100644 index 0000000..4df417d --- /dev/null +++ b/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/proposal.md @@ -0,0 +1,32 @@ +## Why + +The deprecation window opened by the async-management-api-extensions change has elapsed: `/_mock/ws/{push,consumers,disconnect}` and `/_mock/events/fire` were kept as one-release aliases, `/_mock/ws/schedule{,/{pushId}}` answer `410 Gone` guidance stubs, and the `x-send-events` loader shim kept the legacy AsyncAPI subscription key alive. Keeping both the canonical surface and its deprecated aliases doubles the management API contract, slows the openapi.yaml and router together, and forces every new endpoint decision to account for legacy vocabulary that not even the skill docs use anymore. + +## What Changes + +- **BREAKING** Remove the deprecated alias endpoints `POST /_mock/ws/push`, `GET /_mock/ws/consumers`, `POST /_mock/ws/disconnect`, and `POST /_mock/events/fire`. The canonical paths (`/_mock/async/{push,consumers,disconnect}`, `POST /_mock/events`) remain the only way to reach those behaviors. +- **BREAKING** Remove the removed-surface stubs `POST /_mock/ws/schedule` and `DELETE /_mock/ws/schedule/{pushId}`. They currently answer `410 Gone` with migration guidance; after the change any legacy `/_mock/ws/*` path returns a plain 404 like any unknown route. +- **BREAKING** Remove the `x-send-events` extension property and its loader mapping shim. Specs that still carry `x-send-events` load without error but the key is silently ignored — a message example is classified solely by its `x-mock-match`/`x-mock-interval`/reply trigger, never by the legacy key. +- Remove the dead OpenAPI surface from `api/openapi.yaml`: the deprecated aliases, the schedule 410 paths, and the now-unused `LegacyFireEventRequest`/`ScheduleRequest` schemas and `GoneExamples`/`GoneDelete` responses. +- Update docs (`README.md`, `docs/architecture.md`, `docs/extensions.md`, `CHANGELOG.md`) to stop advertising the deprecated vocabulary and document the removal. +- Update tests that pinned the deprecated aliases, the `410` schedule answers, and the `x-send-events` shim; migrate the few remaining fixture usages of `x-send-events` to the unified `x-mock-match`/`x-mock-interval` form. + +## Capabilities + +### New Capabilities +- None. + +### Modified Capabilities +- `event-driver`: the `x-send-events` subscription-vocabulary requirement and its load-time mapping shim are removed from the spec surface. The remaining requirements are reworded to describe event-driven emission purely via `x-mock-match` against the event context and `x-mock-interval`. + +## Impact + +- `internal/server/server_routes.go` — drop the deprecated alias and 410-stub route registrations from `registerManagementRoutes`; keep only the canonical `/_mock/async/*`, `/_mock/events`, `/_mock/examples`, `/_mock/stream`, `/_mock/requests` surface. +- `internal/server/fire_event.go` — remove `handleFireEventLegacy` and its `fireEventRequest` legacy path; keep `handleEvents`/`dispatchFireEvent`. +- `internal/server/management_async.go` — remove `handleGoneSchedule`. +- `internal/server/event_server.go` — remove the `x-send-events` mapping shim (`derivedExamples` degenerates to a per-example passthrough) and its deprecation warnings; delete the `x-send-events` key from the classification path. +- `internal/server/send_events.go` and `send_events_test.go`, `x_send_events_shim_test.go`, `management_async_aliases_test.go` — deleted. +- Tests: `async_state_test.go`, `fire_event_endpoint_test.go`, `event_integration_test.go`, `internal/asyncapi/parse_test.go`, `test/asyncapi/management-api/management_api_test.go`, `test/_shared/resources/asyncapi-management.yaml`, `control_api_spec_sync_test.go` — updated to drop legacy references. +- `api/openapi.yaml` — delete the deprecated alias paths, schedule 410 paths and unused schemas/responses. +- Docs: `README.md`, `docs/architecture.md`, `docs/extensions.md`, `CHANGELOG.md`. +- Specs: canonical `openspec/specs/event-driver/spec.md` loses the `x-send-events` vocabulary; a delta spec records the change. \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/specs/event-driver/spec.md b/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/specs/event-driver/spec.md new file mode 100644 index 0000000..eb2a627 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/specs/event-driver/spec.md @@ -0,0 +1,32 @@ +# event-driver Delta + +## Purpose + +Event-driven emission on AsyncAPI message examples is expressed solely through `x-mock-match` against an event context and the `x-mock-interval` timing extension; the legacy `x-send-events` subscription key and its load-time mapping shim are removed after their one-release deprecation window. + +## MODIFIED Requirements + +### Requirement: Event name scoping +Event names SHALL be schema-local by default and server-wide only when declared `global: true`. + +#### Scenario RS.EVT.5: Schema-local event +- **WHEN** an OpenAPI schema fires an event without `global: true` +- **THEN** only matching event-driven message examples within the same schema receive it + +#### Scenario RS.EVT.6: Global event +- **WHEN** an `x-event-trigger` entry sets `global: true` +- **THEN** the event is broadcast over all loaded schemas and any matching subscription anywhere receives it + +## REMOVED Requirements + +### Requirement: x-send-events deprecation mapping +The mock server SHALL accept legacy `x-send-events` entries by mapping each `{on, wait}` to the unified form during loading, writing a deprecation note in verbose mode: `on` → `x-mock-match: {'{$event.name}': on}` for named/`connect`/`receive`, and `{on: cron, wait: N}` → `x-mock-interval: N`. + +#### Scenario RS.EVT.18: Mapping legacy x-send-events to match +- **WHEN** a spec still uses `x-send-events: [{on: orderCreated}]` or `[{on: cron, wait: 1000}]` +- **THEN** the server behaves as if the example declared `x-mock-match: {'{$event.name}': orderCreated}` (respectively `x-mock-interval: 1000`) and logs a deprecation note in verbose mode +- **AND** a `{on: cron}` entry without a positive `wait` SHALL be rejected at load with an error naming the missing interval (an interval of 0 would otherwise silently register a dead reply example) + +**Reason**: The one-release deprecation window has elapsed. The `x-send-events` subscription key duplicated `x-mock-match` (coarse `{$event.name}` equality over the unified matcher) and `{on: cron}` duplicated the `x-mock-interval` timing extension; the mapping shim kept a second subscription vocabulary alive inside the loader. Event-driven emission and recurrence are now fully expressed by `x-mock-match` and `x-mock-interval`. + +**Migration**: Replace `x-send-events: [{on: }]` with `x-mock-match: {'{$event.name}': }`; `{on: connect, wait: N}` with `x-mock-match: {'{$event.name}': connect}` + `x-mock-delay: N`; `{on: receive}` with `x-mock-match: {'{$event.name}': receive}`; `{on: cron, wait: N}` with `x-mock-interval: N`. After removal, an `x-send-events` key is ignored: the message example is classified solely by its `x-mock-match`/`x-mock-interval`/reply trigger and loads without error. \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/tasks.md b/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/tasks.md new file mode 100644 index 0000000..a059ca5 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-cleanup-deprecated-management-endpoints/tasks.md @@ -0,0 +1,36 @@ +# Tasks: Cleanup deprecated management endpoints and `x-send-events` + +TDD workflow for every item below (per AGENTS.md design-first/TDD rule): +1. **Red** — write or edit the test for the parent/consumer first (mocking the new/edited interface), prove it fails. +2. **Red** — add the interface-level unit test (mock its dependencies), prove it fails. +3. **Green** — implement the interface until all tests pass (`go test ./...`). +4. **Refactor** — keep cognitive complexity low, cohesion high; re-run tests + lint. + +Related spec scenarios: RS.EVT.5, RS.EVT.18 (removed), RS.MAPI.22, RS.MAPI.32. + +## 1. Route teardown (deprecated aliases + schedule 410 stubs) + +- [x] 1.1 **Red**: add failing unit/integration tests pinning that the legacy paths `POST /_mock/ws/push`, `GET /_mock/ws/consumers`, `POST /_mock/ws/disconnect`, `POST /_mock/ws/schedule`, `DELETE /_mock/ws/schedule/{pushId}` and `POST /_mock/events/fire` answer HTTP 404 (any existing alias/410 expectation already there is flipped); verify they fail (routes currently register) +- [x] 1.2 **Green**: in `internal/server/server_routes.go` `registerManagementRoutes`, remove the deprecated alias routes and the `/_mock/ws/schedule{,/{pushId}}` 410-stub registrations; drop `handleGoneSchedule` in `internal/server/management_async.go` and `handleFireEventLegacy` in `internal/server/fire_event.go` (keeping `handleEvents`/`dispatchFireEvent`); verify 1.1 passes and `POST /_mock/events` still requires the `type` discriminator (RS.MAPI.22, RS.MAPI.32) +- [x] 1.3 **Red**: write failing tests that the canonical surface is unchanged — `POST /_mock/events` (with/without `type`), `/_mock/async/{push,consumers,disconnect}`, `/_mock/examples`, `DELETE /_mock/examples/{id}`, `/_mock/stream` all still work as documented; verify the canonical-behavior subset passes after 1.2 (they already did — this pins no regression) +- [x] 1.4 **Refactor**: delete `internal/server/management_async_aliases_test.go` and remove any now-dead helpers/imports it isolated; verify `go test ./internal/server/` and `go vet ./...` pass + +## 2. Remove the `x-send-events` mapping shim (silently ignore the key) + +- [x] 2.1 **Red**: write failing unit tests that a message example carrying `x-send-events: [{on: }]` or `[{on: cron, wait: N}]` is classified by its trigger extensions alone (`x-mock-match`/`x-mock-interval`) — i.e. the key produces no subscription, no interval job and no load error when neither extension is present (RS.EVT.18 now removed); verify they fail against the current shim +- [x] 2.2 **Green**: delete `internal/server/send_events.go` (`SendEvent`, `parseSendEvents`, `xSendEventsKey`); in `internal/server/event_server.go` drop the `derivedExamples`/`cloneExtensions` derivation and the two deprecation `slog.Warn` branches plus the `{on: cron}` missing-wait error, letting `classifyMessageExample` classify the example directly via `extensions.ClassifyTrigger`; verify 2.1 passes and the mapping-shim-focused unit tests are removed +- [x] 2.3 **Red/Green**: update `internal/server/async_state_test.go` (drop the `derivedExamples`/`x-send-events` case) and `internal/asyncapi/parse_test.go` (drop the dedicated `x-send-events` capture assertion — generic `x-*` capture remains); delete `internal/server/send_events_test.go` and `internal/server/x_send_events_shim_test.go`; verify `go test ./internal/server/` and `go test ./internal/asyncapi/` pass +- [x] 2.4 **Integration**: update `test/asyncapi/management-api/management_api_test.go` (remove the legacy `x-send-events` shim scenario and the deprecated-alias scenario; migrate its fixture resources to `x-mock-match`/`x-mock-interval`) and `test/_shared/resources/asyncapi-management.yaml` (replace the `x-send-events` example with the unified form); verify `make test-integration` passes (skips when `-short`) + +## 3. Contract and docs cleanup + +- [x] 3.1 **Contract**: prune `api/openapi.yaml` — delete paths `/events/fire`, `/ws/push`, `/ws/consumers`, `/ws/schedule`, `/ws/schedule/{pushId}`, `/ws/disconnect` and remove unused `LegacyFireEventRequest`, `ScheduleRequest` schemas and `GoneExamples`/`GoneDelete` responses; lower the `TestControlAPISpecSync_ErrorResponses` threshold from 15 to 9 in `internal/server/control_api_spec_sync_test.go` and update its comment to drop the deprecated-alias/410 wording; verify `make test-unit` and the spec-sync tests pass +- [x] 3.2 **Docs**: update `README.md` (remove the "legacy aliases still work / schedule answers 410" paragraph), `docs/architecture.md` (drop `x-send-events` and the alias/410 mentions in the extension list, event-broker paragraph and management-API line), and `docs/extensions.md` (delete the `x-send-events (deprecated)` section, phrase timing recurrence solely via `x-mock-interval`); verify the docs contain no remaining `x-send-events`/deprecated-alias claims (`grep` clean) +- [x] 3.3 **Changelog**: add a `CHANGELOG.md` entry under the next release block noting the API removals (`/_mock/ws/*`, `/_mock/events/fire`, schedule stubs) and `x-send-events` handling (silently ignored); verify the entry matches the actual changes + +## 4. Final verification + +- [x] 4.1 Run `go test ./...` (unit + integration) and confirm the full suite passes +- [x] 4.2 Run `make lint` (golangci-lint) and verify no new findings +- [x] 4.3 Run `python3 scripts/check_test_headers.py` and confirm all remaining/updated tests carry Gherkin Scenario headers referencing the related spec codes; confirm no spec still references the removed `RS.EVT.18` behavior as implemented +- [x] 4.4 Cross-check the archived `2026-09-06-async-management-api-extensions` task notes (tasks 1.2, 7.1, 8.1) to confirm every deprecated alias, 410 stub, and `x-send-events` reference introduced there is now covered by this cleanup \ No newline at end of file diff --git a/openspec/specs/asyncapi-templating/spec.md b/openspec/specs/asyncapi-templating/spec.md index 6058879..0f67803 100644 --- a/openspec/specs/asyncapi-templating/spec.md +++ b/openspec/specs/asyncapi-templating/spec.md @@ -7,13 +7,13 @@ Runtime-expression evaluation, x-mock-* example selection, state mutation and hi The mock server SHALL expose event payload data to subscribed AsyncAPI message examples as the `{$event.*}` data source, evaluated at emission time. #### Scenario RS.ATM.17: Evaluating an event payload expression -- **WHEN** a message example subscribed to an event (per `event-driver` RS.EVT.7) references `{$event.orderId}` +- **WHEN** a message example subscribed to an event (per `event-driver` RS.EVT.22) references `{$event.orderId}` - **AND** the fired event's payload contains key `orderId` - **THEN** the expression evaluates to that event payload value at emission time -#### Scenario RS.ATM.18: Sequence/pacing via state and cron trigger +#### Scenario RS.ATM.18: Sequence/pacing via state and interval - **WHEN** a mock consumer needs monotonic sequence numbers or paced delivery for a stream -- **THEN** the server supports it with `x-mock-set-state` counters referenced via `{$state.*}` and the built-in `cron` send-event trigger (per `event-driver` RS.EVT.10); no dedicated sequence engine is required +- **THEN** the server supports it with `x-mock-set-state` counters referenced via `{$state.*}` and a periodically driven `x-mock-interval` example (per `event-driver` RS.EVT.26); no dedicated sequence engine is required ### Requirement: Runtime expression evaluation on message examples The mock server SHALL evaluate runtime expressions in AsyncAPI message examples using the same evaluator used for OpenAPI, exposing protocol-relevant data sources: the incoming message payload, headers, the channel address, server state, environment, and event payloads. diff --git a/openspec/specs/event-driver/spec.md b/openspec/specs/event-driver/spec.md index 89566ab..41fc712 100644 --- a/openspec/specs/event-driver/spec.md +++ b/openspec/specs/event-driver/spec.md @@ -27,7 +27,7 @@ Event names SHALL be schema-local by default and server-wide only when declared #### Scenario RS.EVT.5: Schema-local event - **WHEN** an OpenAPI schema fires an event without `global: true` -- **THEN** only `x-send-events` subscriptions within the same schema receive it +- **THEN** only matching event-driven message examples within the same schema receive it #### Scenario RS.EVT.6: Global event - **WHEN** an `x-event-trigger` entry sets `global: true` @@ -83,14 +83,6 @@ The mock server SHALL narrow event-driven delivery to consumers whose connection - **WHEN** an example has `x-mock-match: {'{$event.name}': orderCreated, '{$connection.id}': '{$event.connectionId}'}` and the event fires with a `connectionId` payload - **THEN** only the consumer whose connection id equals the payload value receives the message -### Requirement: x-send-events deprecation mapping -The mock server SHALL accept legacy `x-send-events` entries by mapping each `{on, wait}` to the unified form during loading, writing a deprecation note in verbose mode: `on` → `x-mock-match: {'{$event.name}': on}` for named/`connect`/`receive`, and `{on: cron, wait: N}` → `x-mock-interval: N`. - -#### Scenario RS.EVT.18: Mapping legacy x-send-events to match -- **WHEN** a spec still uses `x-send-events: [{on: orderCreated}]` or `[{on: cron, wait: 1000}]` -- **THEN** the server behaves as if the example declared `x-mock-match: {'{$event.name}': orderCreated}` (respectively `x-mock-interval: 1000`) and logs a deprecation note in verbose mode -- **AND** a `{on: cron}` entry without a positive `wait` SHALL be rejected at load with an error naming the missing interval (an interval of 0 would otherwise silently register a dead reply example) - ### Requirement: Management fire-event endpoint The mock server SHALL expose a management API endpoint to fire a named event ad-hoc, reusing the event broker and its delay semantics. diff --git a/test/_shared/resources/asyncapi-management.yaml b/test/_shared/resources/asyncapi-management.yaml index 4e80e3c..bc996ee 100644 --- a/test/_shared/resources/asyncapi-management.yaml +++ b/test/_shared/resources/asyncapi-management.yaml @@ -25,8 +25,8 @@ channels: - name: legacyAlert payload: legacy: "{$event.level}" - x-send-events: - - on: legacyAlert + x-mock-match: + '{$event.name}': "legacyAlert" operations: receiveAlerts: action: receive diff --git a/test/asyncapi/management-api/management_api_test.go b/test/asyncapi/management-api/management_api_test.go index efcd3e3..ad11855 100644 --- a/test/asyncapi/management-api/management_api_test.go +++ b/test/asyncapi/management-api/management_api_test.go @@ -321,54 +321,54 @@ func TestIntegration_ConnectionTargeting(t *testing.T) { } /* -Scenario: The deprecated /_mock/ws/push alias still works -Given a connected consumer and a push to the deprecated path -When the push is invoked -Then the message reaches the consumer +Scenario: The deprecated /_mock/ws/* aliases answer 404 after teardown +Given a connected consumer on a running server +When a push is sent to each removed /_mock/ws/* path +Then the server responds with HTTP 404 for every legacy path -Related spec scenarios: RS.AMG.1, RS.AMG.6 +Related spec scenarios: RS.AMG.1, RS.AMG.6, RS.AMG.14 */ -func TestIntegration_DeprecatedAliasStillWorks(t *testing.T) { +func TestIntegration_DeprecatedAliasesGone404(t *testing.T) { t.Parallel() port, stop := startManagementServer(t) defer stop() - conn := wsConnect(t, port, "/alerts") - defer conn.Close() //nolint:errcheck - _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - _, _, _ = conn.ReadMessage() // welcome - - body := `{"channel":"/alerts","payload":{"alias":true}}` - resp, err := http.Post(fmt.Sprintf("http://localhost:%d/_mock/ws/push", port), "application/json", strings.NewReader(body)) - require.NoError(t, err) - _ = resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - - _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - deadline := time.Now().Add(3 * time.Second) - got := "" - for time.Now().Before(deadline) { - _, raw, rerr := conn.ReadMessage() - if rerr != nil { - break - } - got = string(raw) - if strings.Contains(got, `"alias":true`) { - break + // The deprecated alias surface is fully removed; every legacy path must + // answer the same plain 404 as any unknown route. + for _, tc := range []struct { + method, path, body string + }{ + {http.MethodPost, "/_mock/ws/push", `{"channel":"/alerts","payload":{"alias":true}}`}, + {http.MethodGet, "/_mock/ws/consumers?channel=/alerts", ""}, + {http.MethodPost, "/_mock/ws/disconnect", `{"connectionId":"nope"}`}, + {http.MethodPost, "/_mock/ws/schedule", `{"channel":"/alerts","interval":50,"payload":{"tick":true}}`}, + {http.MethodDelete, "/_mock/ws/schedule/push-123", ""}, + {http.MethodPost, "/_mock/events/fire", `{"type":"fire","event":"levelUp","payload":{"level":"warn"}}`}, + } { + var req *http.Request + var err error + if tc.body == "" { + req, err = http.NewRequest(tc.method, fmt.Sprintf("http://localhost:%d%s", port, tc.path), nil) + } else { + req, err = http.NewRequest(tc.method, fmt.Sprintf("http://localhost:%d%s", port, tc.path), strings.NewReader(tc.body)) } + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, 404, resp.StatusCode, "legacy path %s %s must answer 404", tc.method, tc.path) } - assert.Contains(t, got, `"alias":true`) } /* -Scenario: The removed schedule endpoint answers 410 +Scenario: The removed schedule endpoint answers 404 Given a request to the removed /_mock/ws/schedule path When the schedule endpoint is invoked -Then the server responds with 410 Gone +Then the server responds with 404 Not Found Related spec scenarios: RS.AMG.12, RS.AMG.13 */ -func TestIntegration_ScheduleGone410(t *testing.T) { +func TestIntegration_ScheduleGone404(t *testing.T) { t.Parallel() port, stop := startManagementServer(t) defer stop() @@ -377,7 +377,7 @@ func TestIntegration_ScheduleGone410(t *testing.T) { resp, err := http.Post(fmt.Sprintf("http://localhost:%d/_mock/ws/schedule", port), "application/json", strings.NewReader(body)) require.NoError(t, err) _ = resp.Body.Close() - assert.Equal(t, 410, resp.StatusCode) + assert.Equal(t, 404, resp.StatusCode) } /* @@ -526,32 +526,6 @@ func TestIntegration_ReceiveBuiltIn_Runtime(t *testing.T) { assert.Contains(t, got, `"echoed":"hi"`) } -/* -Scenario: The legacy x-send-events shim still emits on a deprecated subscription -Given a spec example declared with x-send-events: [{on: legacyAlert}] -When the legacyAlert event fires via POST /_mock/events -Then the shim-mapped example is emitted with the event payload - -Related spec scenarios: RS.EVT.18 -*/ -func TestIntegration_XSendEventsShim_LegacyEmission(t *testing.T) { - t.Parallel() - port, stop := startManagementServer(t) - defer stop() - - conn := wsConnect(t, port, "/alerts") - defer conn.Close() //nolint:errcheck - _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - _, _, _ = conn.ReadMessage() // connect built-in welcome - - _, err := http.Post(fmt.Sprintf("http://localhost:%d/_mock/events", port), "application/json", - strings.NewReader(`{"type":"fire","event":"legacyAlert","payload":{"level":"warn"}}`)) - require.NoError(t, err) - - got := readUntilPayload(t, conn, `"legacy":"warn"`) - assert.Contains(t, got, `"legacy":"warn"`) -} - /* Scenario: A non-upgrade request to the management stream is rejected Given a plain HTTP GET to /_mock/stream