diff --git a/internal/apirouter/destination_handlers.go b/internal/apirouter/destination_handlers.go index fc045c638..c45207da7 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 ed42a3eec..4f3f3d1d4 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"))) diff --git a/sdks/outpost-go/.speakeasy/gen.yaml b/sdks/outpost-go/.speakeasy/gen.yaml index 9e282eba3..e22e77766 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 000000000..fa32a9003 --- /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 000000000..ae2c38bc9 --- /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 000000000..54fec46fd --- /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 000000000..06446237c --- /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 000000000..d6129e215 --- /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) + }) +} diff --git a/spec-sdk-tests/tests/events.test.ts b/spec-sdk-tests/tests/events.test.ts index 2d6382354..63fd4fd8c 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 cb04cef57..30953b0b6 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'); });