From 1625a6277cb7513857071e2c62065fe31b21d8a0 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Mon, 20 Apr 2026 15:02:18 +0700 Subject: [PATCH 1/3] fix: use direct assignment for metadata/delivery_metadata in destination update Replace MergeStringMaps with direct assignment for metadata and delivery_metadata, matching existing filter behavior. User-defined key-value maps need full replacement semantics so keys can be removed and the map can be cleared with {}. Config and credentials still use merge since they have structured, known keys where partial updates make sense. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/apirouter/destination_handlers.go | 4 +- .../apirouter/destination_handlers_test.go | 185 ++++++++++++++++++ 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/internal/apirouter/destination_handlers.go b/internal/apirouter/destination_handlers.go index fc045c63..c45207da 100644 --- a/internal/apirouter/destination_handlers.go +++ b/internal/apirouter/destination_handlers.go @@ -176,10 +176,10 @@ func (h *DestinationHandlers) Update(c *gin.Context) { updatedDestination.Filter = input.Filter } if input.DeliveryMetadata != nil { - updatedDestination.DeliveryMetadata = maputil.MergeStringMaps(originalDestination.DeliveryMetadata, input.DeliveryMetadata) + updatedDestination.DeliveryMetadata = input.DeliveryMetadata } if input.Metadata != nil { - updatedDestination.Metadata = maputil.MergeStringMaps(originalDestination.Metadata, input.Metadata) + updatedDestination.Metadata = input.Metadata } // Always preprocess before updating diff --git a/internal/apirouter/destination_handlers_test.go b/internal/apirouter/destination_handlers_test.go index ed42a3ee..4f3f3d1d 100644 --- a/internal/apirouter/destination_handlers_test.go +++ b/internal/apirouter/destination_handlers_test.go @@ -312,6 +312,191 @@ func TestAPI_Destinations(t *testing.T) { require.Equal(t, http.StatusUnprocessableEntity, resp.Code) }) + t.Run("filter is replaced not merged", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), + df.WithFilter(models.Filter{"body": map[string]any{"user_id": "usr_123"}}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "filter": map[string]any{"body": map[string]any{"status": "active"}}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Nil(t, dest.Filter["body"].(map[string]any)["user_id"], "old filter key should not be present") + assert.Equal(t, "active", dest.Filter["body"].(map[string]any)["status"]) + }) + + t.Run("filter cleared with empty object", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), + df.WithFilter(models.Filter{"body": map[string]any{"user_id": "usr_123"}}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "filter": map[string]any{}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.True(t, dest.Filter == nil || len(dest.Filter) == 0, "filter should be cleared") + }) + + t.Run("filter unchanged when omitted", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), + df.WithFilter(models.Filter{"body": map[string]any{"user_id": "usr_123"}}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "topics": []string{"user.created"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Equal(t, "usr_123", dest.Filter["body"].(map[string]any)["user_id"]) + }) + + t.Run("metadata is replaced not merged", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), + df.WithMetadata(map[string]string{"env": "production", "team": "platform", "region": "us-east-1"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "metadata": map[string]string{"env": "production", "team": "platform"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Equal(t, models.Metadata{"env": "production", "team": "platform"}, dest.Metadata) + _, hasRegion := dest.Metadata["region"] + assert.False(t, hasRegion, "region key should have been removed") + }) + + t.Run("metadata cleared with empty object", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), + df.WithMetadata(map[string]string{"env": "production"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "metadata": map[string]string{}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.True(t, dest.Metadata == nil || len(dest.Metadata) == 0, "metadata should be cleared") + }) + + t.Run("metadata unchanged when omitted", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), + df.WithMetadata(map[string]string{"env": "production"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "topics": []string{"user.created"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Equal(t, models.Metadata{"env": "production"}, dest.Metadata) + }) + + t.Run("delivery_metadata is replaced not merged", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), + df.WithDeliveryMetadata(map[string]string{"source": "outpost", "version": "1.0"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "delivery_metadata": map[string]string{"source": "outpost"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Equal(t, models.DeliveryMetadata{"source": "outpost"}, dest.DeliveryMetadata) + _, hasVersion := dest.DeliveryMetadata["version"] + assert.False(t, hasVersion, "version key should have been removed") + }) + + t.Run("delivery_metadata cleared with empty object", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), + df.WithDeliveryMetadata(map[string]string{"source": "outpost"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "delivery_metadata": map[string]string{}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.True(t, dest.DeliveryMetadata == nil || len(dest.DeliveryMetadata) == 0, "delivery_metadata should be cleared") + }) + + t.Run("delivery_metadata unchanged when omitted", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), + df.WithDeliveryMetadata(map[string]string{"source": "outpost"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "topics": []string{"user.created"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Equal(t, models.DeliveryMetadata{"source": "outpost"}, dest.DeliveryMetadata) + }) + t.Run("sending same type is allowed", func(t *testing.T) { h := newAPITest(t) h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) From 46b9828bea98e0a92bb5f52006e13044d59c1778 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Mon, 20 Apr 2026 15:02:26 +0700 Subject: [PATCH 2/3] test: add SDK e2e tests for field removal + enable Go nullableOptionalWrapper SDK tests across TS, Go, and Python verifying that filter, metadata, and delivery_metadata can be set, cleared with {}, have keys removed via subset, and remain unchanged when omitted. Also enables nullableOptionalWrapper in Go SDK gen config so the generated SDK can distinguish "don't send" from "send empty" for nullable map fields. Co-Authored-By: Claude Opus 4.6 (1M context) --- sdks/outpost-go/.speakeasy/gen.yaml | 2 +- spec-sdk-tests/tests/destinations/go.mod | 17 ++ spec-sdk-tests/tests/destinations/go.sum | 12 ++ .../test_webhook_field_removal.py | 184 ++++++++++++++++ .../webhook-field-removal.test.ts | 196 ++++++++++++++++++ .../webhook-field-removal_test.go | 193 +++++++++++++++++ 6 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 spec-sdk-tests/tests/destinations/go.mod create mode 100644 spec-sdk-tests/tests/destinations/go.sum create mode 100644 spec-sdk-tests/tests/destinations/test_webhook_field_removal.py create mode 100644 spec-sdk-tests/tests/destinations/webhook-field-removal.test.ts create mode 100644 spec-sdk-tests/tests/destinations/webhook-field-removal_test.go diff --git a/sdks/outpost-go/.speakeasy/gen.yaml b/sdks/outpost-go/.speakeasy/gen.yaml index 9e282eba..e22e7776 100644 --- a/sdks/outpost-go/.speakeasy/gen.yaml +++ b/sdks/outpost-go/.speakeasy/gen.yaml @@ -56,7 +56,7 @@ go: methodArguments: require-security-and-request modulePath: "" multipartArrayFormat: legacy - nullableOptionalWrapper: false + nullableOptionalWrapper: true outputModelSuffix: output packageName: github.com/hookdeck/outpost/sdks/outpost-go respectRequiredFields: false diff --git a/spec-sdk-tests/tests/destinations/go.mod b/spec-sdk-tests/tests/destinations/go.mod new file mode 100644 index 00000000..fa32a900 --- /dev/null +++ b/spec-sdk-tests/tests/destinations/go.mod @@ -0,0 +1,17 @@ +module github.com/hookdeck/outpost/spec-sdk-tests/tests/destinations + +go 1.22 + +require ( + github.com/hookdeck/outpost/sdks/outpost-go v0.0.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spyzhov/ajson v0.8.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/hookdeck/outpost/sdks/outpost-go => ../../../sdks/outpost-go diff --git a/spec-sdk-tests/tests/destinations/go.sum b/spec-sdk-tests/tests/destinations/go.sum new file mode 100644 index 00000000..ae2c38bc --- /dev/null +++ b/spec-sdk-tests/tests/destinations/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spyzhov/ajson v0.8.0 h1:sFXyMbi4Y/BKjrsfkUZHSjA2JM1184enheSjjoT/zCc= +github.com/spyzhov/ajson v0.8.0/go.mod h1:63V+CGM6f1Bu/p4nLIN8885ojBdt88TbLoSFzyqMuVA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/spec-sdk-tests/tests/destinations/test_webhook_field_removal.py b/spec-sdk-tests/tests/destinations/test_webhook_field_removal.py new file mode 100644 index 00000000..54fec46f --- /dev/null +++ b/spec-sdk-tests/tests/destinations/test_webhook_field_removal.py @@ -0,0 +1,184 @@ +"""Tests for filter/metadata/delivery_metadata removal via Python SDK.""" + +import os +import pytest +from outpost_sdk import Outpost +from outpost_sdk.models import ( + DestinationCreateWebhook, + DestinationUpdateWebhook, + WebhookConfig, +) +from outpost_sdk.types.basemodel import Unset + +API_KEY = os.getenv("API_KEY", "apikey") +API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:3333/api/v1") +TENANT_ID = "python-sdk-test-tenant" + + +def is_empty_or_unset(val): + """Check if a field is empty, None, or Unset.""" + if val is None or isinstance(val, Unset): + return True + if isinstance(val, dict) and len(val) == 0: + return True + return False + + +@pytest.fixture(scope="module") +def client(): + return Outpost(api_key=API_KEY, server_url=API_BASE_URL) + + +@pytest.fixture(scope="module", autouse=True) +def setup_tenant(client): + client.tenants.upsert(tenant_id=TENANT_ID) + yield + try: + client.tenants.delete(tenant_id=TENANT_ID) + except Exception: + pass + + +def create_webhook(client, *, filter_=None, metadata=None, delivery_metadata=None): + kwargs = { + "type": "webhook", + "topics": ["user.created", "user.updated"], + "config": WebhookConfig(url="https://example.com/webhook"), + } + if filter_ is not None: + kwargs["filter_"] = filter_ + if metadata is not None: + kwargs["metadata"] = metadata + if delivery_metadata is not None: + kwargs["delivery_metadata"] = delivery_metadata + + return client.destinations.create( + tenant_id=TENANT_ID, + body=DestinationCreateWebhook(**kwargs), + ) + + +def get_webhook(client, dest_id): + return client.destinations.get(tenant_id=TENANT_ID, destination_id=dest_id) + + +def update_webhook(client, dest_id, **kwargs): + return client.destinations.update( + tenant_id=TENANT_ID, + destination_id=dest_id, + body=DestinationUpdateWebhook(**kwargs), + ) + + +def delete_webhook(client, dest_id): + try: + client.destinations.delete(tenant_id=TENANT_ID, destination_id=dest_id) + except Exception: + pass + + +class TestFilterRemoval: + @pytest.fixture(autouse=True) + def setup(self, client): + dest = create_webhook(client, filter_={"body": {"user_id": "usr_123"}}) + self.dest_id = dest.id + self.client = client + yield + delete_webhook(client, self.dest_id) + + def test_filter_set_after_creation(self): + dest = get_webhook(self.client, self.dest_id) + assert not isinstance(dest.filter_, Unset) + assert dest.filter_ is not None + assert dest.filter_["body"]["user_id"] == "usr_123" + + def test_clear_filter_with_empty_dict(self): + update_webhook(self.client, self.dest_id, filter_={}) + dest = get_webhook(self.client, self.dest_id) + assert is_empty_or_unset(dest.filter_) + + def test_no_change_when_field_omitted(self): + update_webhook(self.client, self.dest_id, filter_={"body": {"user_id": "usr_456"}}) + update_webhook(self.client, self.dest_id, topics=["user.created", "user.updated"]) + dest = get_webhook(self.client, self.dest_id) + assert dest.filter_["body"]["user_id"] == "usr_456" + + +class TestMetadataRemoval: + @pytest.fixture(autouse=True) + def setup(self, client): + dest = create_webhook( + client, + metadata={"env": "production", "team": "platform", "region": "us-east-1"}, + ) + self.dest_id = dest.id + self.client = client + yield + delete_webhook(client, self.dest_id) + + def test_metadata_set_after_creation(self): + dest = get_webhook(self.client, self.dest_id) + assert dest.metadata == { + "env": "production", + "team": "platform", + "region": "us-east-1", + } + + def test_remove_single_metadata_key(self): + update_webhook( + self.client, + self.dest_id, + metadata={"env": "production", "team": "platform"}, + ) + dest = get_webhook(self.client, self.dest_id) + assert dest.metadata == {"env": "production", "team": "platform"} + assert "region" not in dest.metadata + + def test_clear_metadata_with_empty_dict(self): + update_webhook(self.client, self.dest_id, metadata={}) + dest = get_webhook(self.client, self.dest_id) + assert is_empty_or_unset(dest.metadata) + + def test_no_change_when_field_omitted(self): + update_webhook(self.client, self.dest_id, metadata={"env": "staging"}) + update_webhook(self.client, self.dest_id, topics=["user.created", "user.updated"]) + dest = get_webhook(self.client, self.dest_id) + assert dest.metadata == {"env": "staging"} + + +class TestDeliveryMetadataRemoval: + @pytest.fixture(autouse=True) + def setup(self, client): + dest = create_webhook( + client, + delivery_metadata={"source": "outpost", "version": "1.0"}, + ) + self.dest_id = dest.id + self.client = client + yield + delete_webhook(client, self.dest_id) + + def test_delivery_metadata_set_after_creation(self): + dest = get_webhook(self.client, self.dest_id) + assert dest.delivery_metadata == {"source": "outpost", "version": "1.0"} + + def test_remove_single_delivery_metadata_key(self): + update_webhook( + self.client, + self.dest_id, + delivery_metadata={"source": "outpost"}, + ) + dest = get_webhook(self.client, self.dest_id) + assert dest.delivery_metadata == {"source": "outpost"} + assert "version" not in dest.delivery_metadata + + def test_clear_delivery_metadata_with_empty_dict(self): + update_webhook(self.client, self.dest_id, delivery_metadata={}) + dest = get_webhook(self.client, self.dest_id) + assert is_empty_or_unset(dest.delivery_metadata) + + def test_no_change_when_field_omitted(self): + update_webhook(self.client, self.dest_id, delivery_metadata={"source": "test"}) + update_webhook(self.client, self.dest_id, topics=["user.created", "user.updated"]) + dest = get_webhook(self.client, self.dest_id) + assert dest.delivery_metadata == {"source": "test"} diff --git a/spec-sdk-tests/tests/destinations/webhook-field-removal.test.ts b/spec-sdk-tests/tests/destinations/webhook-field-removal.test.ts new file mode 100644 index 00000000..06446237 --- /dev/null +++ b/spec-sdk-tests/tests/destinations/webhook-field-removal.test.ts @@ -0,0 +1,196 @@ +import { describe, it, before, after } from 'mocha'; +import { expect } from 'chai'; +import { SdkClient, createSdkClient } from '../../utils/sdk-client'; +import { createWebhookDestination } from '../../factories/destination.factory'; +/* eslint-disable no-console */ +/* eslint-disable no-undef */ + +describe('Webhook Destinations - Filter/Metadata/DeliveryMetadata Removal (SDK)', () => { + let client: SdkClient; + let destinationId: string; + + before(async () => { + client = createSdkClient(); + try { + await client.upsertTenant(); + } catch (error) { + console.warn('Failed to create tenant (may already exist):', error); + } + }); + + after(async () => { + try { + if (destinationId) { + await client.deleteDestination(destinationId); + } + } catch (error) { + console.warn('Failed to cleanup:', error); + } + try { + await client.deleteTenant(); + } catch (error) { + console.warn('Failed to delete tenant:', error); + } + }); + + describe('Filter removal', () => { + before(async () => { + const dest = await client.createDestination( + createWebhookDestination({ + filter: { + body: { user_id: 'usr_123' }, + }, + }) + ); + destinationId = dest.id; + }); + + it('should have filter set after creation', async () => { + const dest = await client.getDestination(destinationId); + expect(dest.filter).to.not.be.null; + expect(dest.filter).to.deep.include({ body: { user_id: 'usr_123' } }); + }); + + it('should clear filter when set to empty object {}', async () => { + const updated = await client.updateDestination(destinationId, { + filter: {}, + }); + const isEmpty = + updated.filter === null || + updated.filter === undefined || + (typeof updated.filter === 'object' && Object.keys(updated.filter).length === 0); + expect(isEmpty).to.be.true; + }); + + it('should not change filter when field is omitted', async () => { + await client.updateDestination(destinationId, { + filter: { body: { user_id: 'usr_456' } }, + }); + const updated = await client.updateDestination(destinationId, { + topics: ['user.created', 'user.updated'], + }); + expect(updated.filter).to.deep.include({ body: { user_id: 'usr_456' } }); + }); + }); + + describe('Metadata removal', () => { + before(async () => { + try { + if (destinationId) await client.deleteDestination(destinationId); + } catch {} + const dest = await client.createDestination( + createWebhookDestination({ + metadata: { + env: 'production', + team: 'platform', + region: 'us-east-1', + }, + }) + ); + destinationId = dest.id; + }); + + it('should have metadata set after creation', async () => { + const dest = await client.getDestination(destinationId); + expect(dest.metadata).to.deep.equal({ + env: 'production', + team: 'platform', + region: 'us-east-1', + }); + }); + + it('should remove a single metadata key when sending subset', async () => { + const updated = await client.updateDestination(destinationId, { + metadata: { + env: 'production', + team: 'platform', + }, + }); + expect(updated.metadata).to.deep.equal({ + env: 'production', + team: 'platform', + }); + expect(updated.metadata).to.not.have.property('region'); + }); + + it('should clear all metadata when set to empty object {}', async () => { + const updated = await client.updateDestination(destinationId, { + metadata: {}, + }); + const isEmpty = + updated.metadata === null || + updated.metadata === undefined || + (typeof updated.metadata === 'object' && Object.keys(updated.metadata).length === 0); + expect(isEmpty).to.be.true; + }); + + it('should not change metadata when field is omitted', async () => { + await client.updateDestination(destinationId, { + metadata: { env: 'staging' }, + }); + const updated = await client.updateDestination(destinationId, { + topics: ['user.created', 'user.updated'], + }); + expect(updated.metadata).to.deep.equal({ env: 'staging' }); + }); + }); + + describe('DeliveryMetadata removal', () => { + before(async () => { + try { + if (destinationId) await client.deleteDestination(destinationId); + } catch {} + const dest = await client.createDestination( + createWebhookDestination({ + deliveryMetadata: { + source: 'outpost', + version: '1.0', + }, + }) + ); + destinationId = dest.id; + }); + + it('should have delivery_metadata set after creation', async () => { + const dest = await client.getDestination(destinationId); + expect(dest.deliveryMetadata).to.deep.equal({ + source: 'outpost', + version: '1.0', + }); + }); + + it('should remove a single delivery_metadata key when sending subset', async () => { + const updated = await client.updateDestination(destinationId, { + deliveryMetadata: { + source: 'outpost', + }, + }); + expect(updated.deliveryMetadata).to.deep.equal({ + source: 'outpost', + }); + expect(updated.deliveryMetadata).to.not.have.property('version'); + }); + + it('should clear all delivery_metadata when set to empty object {}', async () => { + const updated = await client.updateDestination(destinationId, { + deliveryMetadata: {}, + }); + const isEmpty = + updated.deliveryMetadata === null || + updated.deliveryMetadata === undefined || + (typeof updated.deliveryMetadata === 'object' && + Object.keys(updated.deliveryMetadata).length === 0); + expect(isEmpty).to.be.true; + }); + + it('should not change delivery_metadata when field is omitted', async () => { + await client.updateDestination(destinationId, { + deliveryMetadata: { source: 'test' }, + }); + const updated = await client.updateDestination(destinationId, { + topics: ['user.created', 'user.updated'], + }); + expect(updated.deliveryMetadata).to.deep.equal({ source: 'test' }); + }); + }); +}); diff --git a/spec-sdk-tests/tests/destinations/webhook-field-removal_test.go b/spec-sdk-tests/tests/destinations/webhook-field-removal_test.go new file mode 100644 index 00000000..d6129e21 --- /dev/null +++ b/spec-sdk-tests/tests/destinations/webhook-field-removal_test.go @@ -0,0 +1,193 @@ +package destinations_test + +import ( + "context" + "os" + "testing" + + outpostgo "github.com/hookdeck/outpost/sdks/outpost-go" + "github.com/hookdeck/outpost/sdks/outpost-go/models/components" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getClient(t *testing.T) *outpostgo.Outpost { + t.Helper() + apiKey := os.Getenv("API_KEY") + if apiKey == "" { + apiKey = "apikey" + } + baseURL := os.Getenv("API_BASE_URL") + if baseURL == "" { + baseURL = "http://localhost:3333/api/v1" + } + return outpostgo.New( + outpostgo.WithSecurity(apiKey), + outpostgo.WithServerURL(baseURL), + ) +} + +const testTenantID = "go-sdk-test-tenant" + +func setupTenant(t *testing.T, client *outpostgo.Outpost) { + t.Helper() + ctx := context.Background() + _, err := client.Tenants.Upsert(ctx, testTenantID, nil) + require.NoError(t, err, "failed to upsert tenant") +} + +func cleanupTenant(t *testing.T, client *outpostgo.Outpost) { + t.Helper() + ctx := context.Background() + _, _ = client.Tenants.Delete(ctx, testTenantID) +} + +func createWebhookDest(t *testing.T, client *outpostgo.Outpost, filter map[string]any, metadata, deliveryMetadata map[string]string) string { + t.Helper() + ctx := context.Background() + topics := components.CreateTopicsArrayOfStr([]string{"user.created", "user.updated"}) + resp, err := client.Destinations.Create(ctx, testTenantID, components.CreateDestinationCreateWebhook(components.DestinationCreateWebhook{ + Topics: topics, + Config: components.WebhookConfig{ + URL: "https://example.com/webhook", + }, + Filter: filter, + Metadata: metadata, + DeliveryMetadata: deliveryMetadata, + })) + require.NoError(t, err, "failed to create destination") + wh := resp.GetDestinationWebhook() + require.NotNil(t, wh, "expected webhook destination in response") + return wh.ID +} + +func deleteDest(t *testing.T, client *outpostgo.Outpost, destID string) { + t.Helper() + ctx := context.Background() + _, _ = client.Destinations.Delete(ctx, testTenantID, destID) +} + +func getDest(t *testing.T, client *outpostgo.Outpost, destID string) *components.DestinationWebhook { + t.Helper() + ctx := context.Background() + resp, err := client.Destinations.Get(ctx, testTenantID, destID) + require.NoError(t, err, "failed to get destination") + wh := resp.Destination.DestinationWebhook + require.NotNil(t, wh, "expected webhook destination") + return wh +} + +func updateDest(t *testing.T, client *outpostgo.Outpost, destID string, update components.DestinationUpdateWebhook) *components.DestinationWebhook { + t.Helper() + ctx := context.Background() + resp, err := client.Destinations.Update(ctx, testTenantID, destID, + components.CreateDestinationUpdateDestinationUpdateWebhook(update)) + require.NoError(t, err, "failed to update destination") + wh := resp.OneOf.Destination.DestinationWebhook + require.NotNil(t, wh, "expected webhook destination in update response") + return wh +} + +func TestFilterRemoval(t *testing.T) { + client := getClient(t) + setupTenant(t, client) + defer cleanupTenant(t, client) + + filter := map[string]any{"body": map[string]any{"user_id": "usr_123"}} + destID := createWebhookDest(t, client, filter, nil, nil) + defer deleteDest(t, client, destID) + + t.Run("should have filter set after creation", func(t *testing.T) { + dest := getDest(t, client, destID) + assert.NotNil(t, dest.Filter) + }) + + t.Run("should clear filter when set to empty map", func(t *testing.T) { + dest := updateDest(t, client, destID, components.DestinationUpdateWebhook{ + Filter: map[string]any{}, + }) + assert.True(t, dest.Filter == nil || len(dest.Filter) == 0, + "filter should be nil or empty, got: %v", dest.Filter) + }) +} + +func TestMetadataRemoval(t *testing.T) { + client := getClient(t) + setupTenant(t, client) + defer cleanupTenant(t, client) + + metadata := map[string]string{ + "env": "production", + "team": "platform", + "region": "us-east-1", + } + destID := createWebhookDest(t, client, nil, metadata, nil) + defer deleteDest(t, client, destID) + + t.Run("should have metadata set after creation", func(t *testing.T) { + dest := getDest(t, client, destID) + assert.Equal(t, metadata, dest.Metadata) + }) + + t.Run("should remove a single metadata key when sending subset", func(t *testing.T) { + dest := updateDest(t, client, destID, components.DestinationUpdateWebhook{ + Metadata: map[string]string{ + "env": "production", + "team": "platform", + }, + }) + assert.Equal(t, map[string]string{ + "env": "production", + "team": "platform", + }, dest.Metadata) + _, hasRegion := dest.Metadata["region"] + assert.False(t, hasRegion, "region key should have been removed") + }) + + t.Run("should clear all metadata when set to empty map", func(t *testing.T) { + dest := updateDest(t, client, destID, components.DestinationUpdateWebhook{ + Metadata: map[string]string{}, + }) + assert.True(t, dest.Metadata == nil || len(dest.Metadata) == 0, + "metadata should be nil or empty, got: %v", dest.Metadata) + }) +} + +func TestDeliveryMetadataRemoval(t *testing.T) { + client := getClient(t) + setupTenant(t, client) + defer cleanupTenant(t, client) + + dm := map[string]string{ + "source": "outpost", + "version": "1.0", + } + destID := createWebhookDest(t, client, nil, nil, dm) + defer deleteDest(t, client, destID) + + t.Run("should have delivery_metadata set after creation", func(t *testing.T) { + dest := getDest(t, client, destID) + assert.Equal(t, dm, dest.DeliveryMetadata) + }) + + t.Run("should remove a single delivery_metadata key when sending subset", func(t *testing.T) { + dest := updateDest(t, client, destID, components.DestinationUpdateWebhook{ + DeliveryMetadata: map[string]string{ + "source": "outpost", + }, + }) + assert.Equal(t, map[string]string{ + "source": "outpost", + }, dest.DeliveryMetadata) + _, hasVersion := dest.DeliveryMetadata["version"] + assert.False(t, hasVersion, "version key should have been removed") + }) + + t.Run("should clear all delivery_metadata when set to empty map", func(t *testing.T) { + dest := updateDest(t, client, destID, components.DestinationUpdateWebhook{ + DeliveryMetadata: map[string]string{}, + }) + assert.True(t, dest.DeliveryMetadata == nil || len(dest.DeliveryMetadata) == 0, + "delivery_metadata should be nil or empty, got: %v", dest.DeliveryMetadata) + }) +} From 829da03bee59f0962c48e83476559360198c8a51 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Mon, 20 Apr 2026 19:57:33 +0700 Subject: [PATCH 3/3] fix: update spec-sdk-tests to use PageIterator response shape Pagination endpoints return PageIterator where the body is under .result. Update events.test.ts and tenants.test.ts to use page.result.models instead of response.models. Co-Authored-By: Claude Opus 4.6 (1M context) --- spec-sdk-tests/tests/events.test.ts | 18 +++++++++--------- spec-sdk-tests/tests/tenants.test.ts | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/spec-sdk-tests/tests/events.test.ts b/spec-sdk-tests/tests/events.test.ts index 2d638235..63fd4fd8 100644 --- a/spec-sdk-tests/tests/events.test.ts +++ b/spec-sdk-tests/tests/events.test.ts @@ -162,13 +162,13 @@ describe('Events (PR #491)', () => { const sdk: Outpost = client.getSDK(); const tid = client.getTenantId(); // SDK accepts string | string[] for tenantId and topic; verify array form is accepted - const response = await sdk.events.list({ + const page = await sdk.events.list({ tenantId: [tid], topic: [TEST_TOPICS[0]], limit: 5, }); - expect(response).to.not.be.undefined; - expect(response?.models).to.be.an('array'); + expect(page).to.not.be.undefined; + expect(page.result.models).to.be.an('array'); }); it('should list events by tenant', async function () { @@ -188,10 +188,10 @@ describe('Events (PR #491)', () => { const events = await pollForEvents( async () => { - const response = await sdk.events.list({ + const page = await sdk.events.list({ tenantId: client.getTenantId(), }); - return response?.models || []; + return page.result.models || []; }, 30000, 5000 @@ -206,10 +206,10 @@ describe('Events (PR #491)', () => { const sdk: Outpost = client.getSDK(); - const response = await sdk.events.list({ + const page = await sdk.events.list({ tenantId: client.getTenantId(), }); - const events = response?.models || []; + const events = page.result.models || []; if (events.length === 0) { console.warn('No events found - skipping single event test'); @@ -248,12 +248,12 @@ describe('Events (PR #491)', () => { const attempts = await pollForAttempts( async () => { - const response = await sdk.destinations.listAttempts({ + const page = await sdk.destinations.listAttempts({ tenantId: client.getTenantId(), destinationId: destinationId, eventId, }); - return response?.models ?? []; + return page.result.models ?? []; }, 45000, 5000 diff --git a/spec-sdk-tests/tests/tenants.test.ts b/spec-sdk-tests/tests/tenants.test.ts index cb04cef5..30953b0b 100644 --- a/spec-sdk-tests/tests/tenants.test.ts +++ b/spec-sdk-tests/tests/tenants.test.ts @@ -16,11 +16,11 @@ describe('Tenants - List with request object', () => { const client = createSdkClient(); const sdk = client.getSDK(); - const result = await sdk.tenants.list({ limit: 5 }); + const page = await sdk.tenants.list({ limit: 5 }); - expect(result).to.not.be.undefined; - expect(result?.models).to.be.an('array'); - (result?.models ?? []).forEach((t: { id?: string }, i: number) => { + expect(page).to.not.be.undefined; + expect(page.result.models).to.be.an('array'); + (page.result.models ?? []).forEach((t: { id?: string }, i: number) => { expect(t, `tenant[${i}]`).to.be.an('object'); if (t.id != null) expect(t.id, `tenant[${i}].id`).to.be.a('string'); });