diff --git a/cmd/e2e/api_test.go b/cmd/e2e/api_test.go index cde6f100..c0adc093 100644 --- a/cmd/e2e/api_test.go +++ b/cmd/e2e/api_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "net/http" + "time" "github.com/hookdeck/outpost/cmd/e2e/httpclient" "github.com/hookdeck/outpost/internal/idgen" @@ -1039,6 +1040,140 @@ func (suite *basicSuite) TestDestinationsAPI() { suite.RunAPITests(suite.T(), tests) } +func (suite *basicSuite) TestEntityUpdatedAt() { + t := suite.T() + tenantID := idgen.String() + destinationID := idgen.Destination() + + // Create tenant + resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodPUT, + Path: "/" + tenantID, + })) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + // Get tenant and verify created_at and updated_at exist and are equal + resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodGET, + Path: "/" + tenantID, + })) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + body := resp.Body.(map[string]interface{}) + require.NotNil(t, body["created_at"], "created_at should be present") + require.NotNil(t, body["updated_at"], "updated_at should be present") + + tenantCreatedAt := body["created_at"].(string) + tenantUpdatedAt := body["updated_at"].(string) + // On creation, created_at and updated_at should be very close (within 1 second) + createdTime, err := time.Parse(time.RFC3339Nano, tenantCreatedAt) + require.NoError(t, err) + updatedTime, err := time.Parse(time.RFC3339Nano, tenantUpdatedAt) + require.NoError(t, err) + require.WithinDuration(t, createdTime, updatedTime, time.Second, "created_at and updated_at should be close on creation") + + // Wait a bit to ensure different timestamp + time.Sleep(10 * time.Millisecond) + + // Update tenant + resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodPUT, + Path: "/" + tenantID, + Body: map[string]interface{}{ + "metadata": map[string]interface{}{ + "env": "production", + }, + }, + })) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Get tenant again and verify updated_at changed but created_at didn't + resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodGET, + Path: "/" + tenantID, + })) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + body = resp.Body.(map[string]interface{}) + newTenantCreatedAt := body["created_at"].(string) + newTenantUpdatedAt := body["updated_at"].(string) + + require.Equal(t, tenantCreatedAt, newTenantCreatedAt, "created_at should not change") + require.NotEqual(t, tenantUpdatedAt, newTenantUpdatedAt, "updated_at should change") + require.True(t, newTenantUpdatedAt > tenantUpdatedAt, "updated_at should be newer") + + // Create destination + resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodPOST, + Path: "/" + tenantID + "/destinations", + Body: map[string]interface{}{ + "id": destinationID, + "type": "webhook", + "topics": []string{"*"}, + "config": map[string]interface{}{ + "url": "http://host.docker.internal:4444", + }, + }, + })) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + // Get destination and verify created_at and updated_at exist and are equal + resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodGET, + Path: "/" + tenantID + "/destinations/" + destinationID, + })) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + body = resp.Body.(map[string]interface{}) + require.NotNil(t, body["created_at"], "created_at should be present") + require.NotNil(t, body["updated_at"], "updated_at should be present") + + destCreatedAt := body["created_at"].(string) + destUpdatedAt := body["updated_at"].(string) + // On creation, created_at and updated_at should be very close (within 1 second) + createdTime, err = time.Parse(time.RFC3339Nano, destCreatedAt) + require.NoError(t, err) + updatedTime, err = time.Parse(time.RFC3339Nano, destUpdatedAt) + require.NoError(t, err) + require.WithinDuration(t, createdTime, updatedTime, time.Second, "created_at and updated_at should be close on creation") + + // Wait a bit to ensure different timestamp + time.Sleep(10 * time.Millisecond) + + // Update destination + resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodPATCH, + Path: "/" + tenantID + "/destinations/" + destinationID, + Body: map[string]interface{}{ + "topics": []string{"user.created"}, + }, + })) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Get destination again and verify updated_at changed but created_at didn't + resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ + Method: httpclient.MethodGET, + Path: "/" + tenantID + "/destinations/" + destinationID, + })) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + body = resp.Body.(map[string]interface{}) + newDestCreatedAt := body["created_at"].(string) + newDestUpdatedAt := body["updated_at"].(string) + + require.Equal(t, destCreatedAt, newDestCreatedAt, "created_at should not change") + require.NotEqual(t, destUpdatedAt, newDestUpdatedAt, "updated_at should change") + require.True(t, newDestUpdatedAt > destUpdatedAt, "updated_at should be newer") +} + func (suite *basicSuite) TestDestinationsListAPI() { tenantID := idgen.String() tests := []APITest{ diff --git a/docs/apis/openapi.yaml b/docs/apis/openapi.yaml index 5c448ced..bec4e773 100644 --- a/docs/apis/openapi.yaml +++ b/docs/apis/openapi.yaml @@ -54,6 +54,11 @@ components: format: date-time description: ISO Date when the tenant was created. example: "2024-01-01T00:00:00Z" + updated_at: + type: string + format: date-time + description: ISO Date when the tenant was last updated. + example: "2024-01-01T00:00:00Z" TenantUpsert: type: object properties: @@ -328,7 +333,7 @@ components: DestinationWebhook: type: object # Properties duplicated from DestinationBase - required: [id, type, topics, config, credentials, created_at, disabled_at] + required: [id, type, topics, config, credentials, created_at, updated_at, disabled_at] properties: id: type: string @@ -352,6 +357,11 @@ components: format: date-time description: ISO Date when the destination was created. example: "2024-01-01T00:00:00Z" + updated_at: + type: string + format: date-time + description: ISO Date when the destination was last updated. + example: "2024-01-01T00:00:00Z" config: $ref: "#/components/schemas/WebhookConfig" credentials: @@ -388,6 +398,7 @@ components: topics: ["user.created", "order.shipped"] disabled_at: null created_at: "2024-02-15T10:00:00Z" + updated_at: "2024-02-15T10:00:00Z" config: url: "https://my-service.com/webhook/handler" credentials: @@ -397,7 +408,7 @@ components: DestinationAWSSQS: type: object # Properties duplicated from DestinationBase - required: [id, type, topics, config, credentials, created_at, disabled_at] + required: [id, type, topics, config, credentials, created_at, updated_at, disabled_at] properties: id: type: string @@ -421,6 +432,11 @@ components: format: date-time description: ISO Date when the destination was created. example: "2024-01-01T00:00:00Z" + updated_at: + type: string + format: date-time + description: ISO Date when the destination was last updated. + example: "2024-01-01T00:00:00Z" config: $ref: "#/components/schemas/AWSSQSConfig" credentials: @@ -457,6 +473,7 @@ components: topics: ["*"] disabled_at: "2024-03-01T12:00:00Z" created_at: "2024-02-20T11:30:00Z" + updated_at: "2024-02-20T11:30:00Z" config: queue_url: "https://sqs.us-west-2.amazonaws.com/123456789012/my-app-queue" endpoint: "https://sqs.us-west-2.amazonaws.com" @@ -466,7 +483,7 @@ components: DestinationRabbitMQ: type: object # Properties duplicated from DestinationBase - required: [id, type, topics, config, credentials, created_at, disabled_at] + required: [id, type, topics, config, credentials, created_at, updated_at, disabled_at] properties: id: type: string @@ -490,6 +507,11 @@ components: format: date-time description: ISO Date when the destination was created. example: "2024-01-01T00:00:00Z" + updated_at: + type: string + format: date-time + description: ISO Date when the destination was last updated. + example: "2024-01-01T00:00:00Z" config: $ref: "#/components/schemas/RabbitMQConfig" credentials: @@ -526,6 +548,7 @@ components: topics: ["inventory.updated"] disabled_at: null created_at: "2024-01-10T09:00:00Z" + updated_at: "2024-01-10T09:00:00Z" config: server_url: "amqp.cloudamqp.com:5671" exchange: "events-exchange" @@ -560,6 +583,11 @@ components: format: date-time description: ISO Date when the destination was created. example: "2024-01-01T00:00:00Z" + updated_at: + type: string + format: date-time + description: ISO Date when the destination was last updated. + example: "2024-01-01T00:00:00Z" config: {} # Empty config credentials: $ref: "#/components/schemas/HookdeckCredentials" @@ -595,13 +623,14 @@ components: topics: ["*"] disabled_at: null created_at: "2024-04-01T10:00:00Z" + updated_at: "2024-04-01T10:00:00Z" config: {} credentials: token: "hd_token_..." DestinationAWSKinesis: type: object # Properties duplicated from DestinationBase - required: [id, type, topics, config, credentials, created_at, disabled_at] + required: [id, type, topics, config, credentials, created_at, updated_at, disabled_at] properties: id: type: string @@ -625,6 +654,11 @@ components: format: date-time description: ISO Date when the destination was created. example: "2024-01-01T00:00:00Z" + updated_at: + type: string + format: date-time + description: ISO Date when the destination was last updated. + example: "2024-01-01T00:00:00Z" config: $ref: "#/components/schemas/AWSKinesisConfig" credentials: @@ -661,6 +695,7 @@ components: topics: ["user.created", "user.updated"] disabled_at: null created_at: "2024-03-10T15:30:00Z" + updated_at: "2024-03-10T15:30:00Z" config: stream_name: "production-events" region: "eu-west-1" @@ -694,6 +729,11 @@ components: format: date-time description: ISO Date when the destination was created. example: "2024-01-01T00:00:00Z" + updated_at: + type: string + format: date-time + description: ISO Date when the destination was last updated. + example: "2024-01-01T00:00:00Z" config: $ref: "#/components/schemas/AzureServiceBusConfig" credentials: @@ -730,6 +770,7 @@ components: topics: ["*"] disabled_at: null created_at: "2024-05-01T10:00:00Z" + updated_at: "2024-05-01T10:00:00Z" config: name: "my-queue-or-topic" credentials: @@ -738,7 +779,7 @@ components: DestinationAWSS3: type: object # Properties duplicated from DestinationBase - required: [id, type, topics, config, credentials, created_at, disabled_at] + required: [id, type, topics, config, credentials, created_at, updated_at, disabled_at] properties: id: type: string @@ -762,6 +803,11 @@ components: format: date-time description: ISO Date when the destination was created. example: "2024-01-01T00:00:00Z" + updated_at: + type: string + format: date-time + description: ISO Date when the destination was last updated. + example: "2024-01-01T00:00:00Z" config: $ref: "#/components/schemas/AWSS3Config" credentials: @@ -798,6 +844,7 @@ components: topics: ["*"] disabled_at: null created_at: "2024-03-20T12:00:00Z" + updated_at: "2024-03-20T12:00:00Z" config: bucket: "my-bucket" region: "us-east-1" @@ -807,7 +854,7 @@ components: DestinationGCPPubSub: type: object # Properties duplicated from DestinationBase - required: [id, type, topics, config, credentials, created_at, disabled_at] + required: [id, type, topics, config, credentials, created_at, updated_at, disabled_at] properties: id: type: string @@ -831,6 +878,11 @@ components: format: date-time description: ISO Date when the destination was created. example: "2024-01-01T00:00:00Z" + updated_at: + type: string + format: date-time + description: ISO Date when the destination was last updated. + example: "2024-01-01T00:00:00Z" config: $ref: "#/components/schemas/GCPPubSubConfig" credentials: @@ -867,6 +919,7 @@ components: topics: ["order.created", "order.updated"] disabled_at: null created_at: "2024-03-10T14:30:00Z" + updated_at: "2024-03-10T14:30:00Z" config: project_id: "my-project-123" topic: "events-topic" @@ -1855,6 +1908,7 @@ paths: topics: ["user.created", "order.shipped"] disabled_at: null created_at: "2024-02-15T10:00:00Z" + updated_at: "2024-02-15T10:00:00Z" config: url: "https://my-service.com/webhook/handler" credentials: @@ -1866,6 +1920,7 @@ paths: topics: ["*"] disabled_at: "2024-03-01T12:00:00Z" created_at: "2024-02-20T11:30:00Z" + updated_at: "2024-02-20T11:30:00Z" config: queue_url: "https://sqs.us-west-2.amazonaws.com/123456789012/my-app-queue" endpoint: "https://sqs.us-west-2.amazonaws.com" @@ -1877,6 +1932,7 @@ paths: topics: ["*"] disabled_at: null created_at: "2024-03-20T12:00:00Z" + updated_at: "2024-03-20T12:00:00Z" config: bucket: "my-bucket" region: "us-east-1" @@ -1912,6 +1968,7 @@ paths: topics: ["user.created", "order.shipped"] disabled_at: null created_at: "2024-02-15T10:00:00Z" + updated_at: "2024-02-15T10:00:00Z" config: url: "https://my-service.com/webhook/handler" credentials: @@ -1957,6 +2014,7 @@ paths: topics: ["user.created", "order.shipped"] disabled_at: null created_at: "2024-02-15T10:00:00Z" + updated_at: "2024-02-15T10:00:00Z" config: url: "https://my-service.com/webhook/handler" credentials: @@ -1993,6 +2051,7 @@ paths: topics: ["user.created"] disabled_at: null created_at: "2024-02-15T10:00:00Z" + updated_at: "2024-02-15T10:00:00Z" config: url: "https://my-service.com/webhook/new-handler" # Updated URL credentials: @@ -2058,6 +2117,7 @@ paths: topics: ["user.created", "order.shipped"] disabled_at: null # Now enabled created_at: "2024-02-15T10:00:00Z" + updated_at: "2024-02-15T10:00:00Z" config: url: "https://my-service.com/webhook/handler" credentials: @@ -2102,6 +2162,7 @@ paths: topics: ["user.created", "order.shipped"] disabled_at: "2024-04-11T21:00:00Z" # Now disabled created_at: "2024-02-15T10:00:00Z" + updated_at: "2024-02-15T10:00:00Z" config: url: "https://my-service.com/webhook/handler" credentials: diff --git a/internal/models/destination.go b/internal/models/destination.go index 60ed9043..7dbf268e 100644 --- a/internal/models/destination.go +++ b/internal/models/destination.go @@ -28,6 +28,7 @@ type Destination struct { DeliveryMetadata DeliveryMetadata `json:"delivery_metadata,omitempty" redis:"-"` Metadata Metadata `json:"metadata,omitempty" redis:"-"` CreatedAt time.Time `json:"created_at" redis:"created_at"` + UpdatedAt time.Time `json:"updated_at" redis:"updated_at"` DisabledAt *time.Time `json:"disabled_at" redis:"disabled_at"` } @@ -46,6 +47,10 @@ func (d *Destination) parseRedisHash(cmd *redis.MapStringStringCmd, cipher Ciphe if err = cmd.Scan(d); err != nil { return err } + // Fallback updated_at to created_at if missing (for existing records) + if hash["updated_at"] == "" { + d.UpdatedAt = d.CreatedAt + } err = d.Topics.UnmarshalBinary([]byte(hash["topics"])) if err != nil { return fmt.Errorf("invalid topics: %w", err) diff --git a/internal/models/entity.go b/internal/models/entity.go index edbd5d34..30717c5b 100644 --- a/internal/models/entity.go +++ b/internal/models/entity.go @@ -150,6 +150,15 @@ func (s *entityStoreImpl) UpsertTenant(ctx context.Context, tenant Tenant) error return err } + // Auto-generate timestamps if not provided + now := time.Now() + if tenant.CreatedAt.IsZero() { + tenant.CreatedAt = now + } + if tenant.UpdatedAt.IsZero() { + tenant.UpdatedAt = now + } + // Set tenant data (basic fields) if err := s.redisClient.HSet(ctx, key, tenant).Err(); err != nil { return err @@ -335,6 +344,15 @@ func (s *entityStoreImpl) UpsertDestination(ctx context.Context, destination Des } } + // Auto-generate timestamps if not provided + now := time.Now() + if destination.CreatedAt.IsZero() { + destination.CreatedAt = now + } + if destination.UpdatedAt.IsZero() { + destination.UpdatedAt = now + } + // All keys use same tenant prefix - cluster compatible transaction summaryKey := s.redisTenantDestinationSummaryKey(destination.TenantID) @@ -350,6 +368,7 @@ func (s *entityStoreImpl) UpsertDestination(ctx context.Context, destination Des pipe.HSet(ctx, key, "config", &destination.Config) pipe.HSet(ctx, key, "credentials", encryptedCredentials) pipe.HSet(ctx, key, "created_at", destination.CreatedAt) + pipe.HSet(ctx, key, "updated_at", destination.UpdatedAt) if destination.DisabledAt != nil { pipe.HSet(ctx, key, "disabled_at", *destination.DisabledAt) diff --git a/internal/models/entitysuite_test.go b/internal/models/entitysuite_test.go index 5f034efb..a55518e4 100644 --- a/internal/models/entitysuite_test.go +++ b/internal/models/entitysuite_test.go @@ -25,6 +25,7 @@ func assertEqualDestination(t *testing.T, expected, actual models.Destination) { assert.Equal(t, expected.DeliveryMetadata, actual.DeliveryMetadata) assert.Equal(t, expected.Metadata, actual.Metadata) assert.True(t, cmp.Equal(expected.CreatedAt, actual.CreatedAt)) + assert.True(t, cmp.Equal(expected.UpdatedAt, actual.UpdatedAt)) assert.True(t, cmp.Equal(expected.DisabledAt, actual.DisabledAt)) } @@ -53,9 +54,11 @@ func (s *EntityTestSuite) SetupTest() { func (s *EntityTestSuite) TestTenantCRUD() { t := s.T() + now := time.Now() input := models.Tenant{ ID: idgen.String(), - CreatedAt: time.Now(), + CreatedAt: now, + UpdatedAt: now, } t.Run("gets empty", func(t *testing.T) { @@ -158,10 +161,64 @@ func (s *EntityTestSuite) TestTenantCRUD() { require.NoError(s.T(), err) assert.Nil(s.T(), retrieved.Metadata) }) + + // UpdatedAt tests + t.Run("sets updated_at on create", func(t *testing.T) { + newTenant := testutil.TenantFactory.Any() + + err := s.entityStore.UpsertTenant(s.ctx, newTenant) + require.NoError(s.T(), err) + + retrieved, err := s.entityStore.RetrieveTenant(s.ctx, newTenant.ID) + require.NoError(s.T(), err) + assert.True(s.T(), newTenant.UpdatedAt.Unix() == retrieved.UpdatedAt.Unix()) + }) + + t.Run("updates updated_at on upsert", func(t *testing.T) { + original := testutil.TenantFactory.Any() + + err := s.entityStore.UpsertTenant(s.ctx, original) + require.NoError(s.T(), err) + + // Wait a bit to ensure different timestamp + time.Sleep(10 * time.Millisecond) + + // Update the tenant + updated := original + updated.UpdatedAt = time.Now() + + err = s.entityStore.UpsertTenant(s.ctx, updated) + require.NoError(s.T(), err) + + retrieved, err := s.entityStore.RetrieveTenant(s.ctx, updated.ID) + require.NoError(s.T(), err) + + // updated_at should be newer than original + assert.True(s.T(), retrieved.UpdatedAt.After(original.UpdatedAt)) + assert.True(s.T(), updated.UpdatedAt.Unix() == retrieved.UpdatedAt.Unix()) + }) + + t.Run("fallback updated_at to created_at for existing records", func(t *testing.T) { + // Create a tenant normally first + oldTenant := testutil.TenantFactory.Any() + err := s.entityStore.UpsertTenant(s.ctx, oldTenant) + require.NoError(s.T(), err) + + // Now manually remove the updated_at field from Redis to simulate old record + key := "tenant:" + oldTenant.ID + err = s.redisClient.HDel(s.ctx, key, "updated_at").Err() + require.NoError(s.T(), err) + + // Retrieve should fallback updated_at to created_at + retrieved, err := s.entityStore.RetrieveTenant(s.ctx, oldTenant.ID) + require.NoError(s.T(), err) + assert.True(s.T(), retrieved.UpdatedAt.Equal(retrieved.CreatedAt)) + }) } func (s *EntityTestSuite) TestDestinationCRUD() { t := s.T() + now := time.Now() input := models.Destination{ ID: idgen.Destination(), Type: "rabbitmq", @@ -182,7 +239,8 @@ func (s *EntityTestSuite) TestDestinationCRUD() { "environment": "test", "team": "platform", }, - CreatedAt: time.Now(), + CreatedAt: now, + UpdatedAt: now, DisabledAt: nil, TenantID: idgen.String(), } @@ -262,6 +320,69 @@ func (s *EntityTestSuite) TestDestinationCRUD() { // cleanup require.NoError(s.T(), s.entityStore.DeleteDestination(s.ctx, inputWithNilFields.TenantID, inputWithNilFields.ID)) }) + + // UpdatedAt tests + t.Run("sets updated_at on create", func(t *testing.T) { + newDest := testutil.DestinationFactory.Any() + + err := s.entityStore.CreateDestination(s.ctx, newDest) + require.NoError(s.T(), err) + + retrieved, err := s.entityStore.RetrieveDestination(s.ctx, newDest.TenantID, newDest.ID) + require.NoError(s.T(), err) + assert.True(s.T(), newDest.UpdatedAt.Unix() == retrieved.UpdatedAt.Unix()) + + // cleanup + require.NoError(s.T(), s.entityStore.DeleteDestination(s.ctx, newDest.TenantID, newDest.ID)) + }) + + t.Run("updates updated_at on upsert", func(t *testing.T) { + original := testutil.DestinationFactory.Any() + + err := s.entityStore.CreateDestination(s.ctx, original) + require.NoError(s.T(), err) + + // Wait a bit to ensure different timestamp + time.Sleep(10 * time.Millisecond) + + // Update the destination + updated := original + updated.UpdatedAt = time.Now() + updated.Topics = []string{"updated.topic"} + + err = s.entityStore.UpsertDestination(s.ctx, updated) + require.NoError(s.T(), err) + + retrieved, err := s.entityStore.RetrieveDestination(s.ctx, updated.TenantID, updated.ID) + require.NoError(s.T(), err) + + // updated_at should be newer than original + assert.True(s.T(), retrieved.UpdatedAt.After(original.UpdatedAt)) + assert.True(s.T(), updated.UpdatedAt.Unix() == retrieved.UpdatedAt.Unix()) + + // cleanup + require.NoError(s.T(), s.entityStore.DeleteDestination(s.ctx, updated.TenantID, updated.ID)) + }) + + t.Run("fallback updated_at to created_at for existing records", func(t *testing.T) { + // Create a destination normally first + oldDest := testutil.DestinationFactory.Any() + err := s.entityStore.CreateDestination(s.ctx, oldDest) + require.NoError(s.T(), err) + + // Now manually remove the updated_at field from Redis to simulate old record + key := "destination:" + oldDest.TenantID + ":" + oldDest.ID + err = s.redisClient.HDel(s.ctx, key, "updated_at").Err() + require.NoError(s.T(), err) + + // Retrieve should fallback updated_at to created_at + retrieved, err := s.entityStore.RetrieveDestination(s.ctx, oldDest.TenantID, oldDest.ID) + require.NoError(s.T(), err) + assert.True(s.T(), retrieved.UpdatedAt.Equal(retrieved.CreatedAt)) + + // cleanup + require.NoError(s.T(), s.entityStore.DeleteDestination(s.ctx, oldDest.TenantID, oldDest.ID)) + }) } func (s *EntityTestSuite) TestListDestinationEmpty() { diff --git a/internal/models/tenant.go b/internal/models/tenant.go index dce9a2f8..a56d0d8c 100644 --- a/internal/models/tenant.go +++ b/internal/models/tenant.go @@ -11,6 +11,7 @@ type Tenant struct { Topics []string `json:"topics" redis:"-"` Metadata Metadata `json:"metadata,omitempty" redis:"-"` CreatedAt time.Time `json:"created_at" redis:"created_at"` + UpdatedAt time.Time `json:"updated_at" redis:"updated_at"` } func (t *Tenant) parseRedisHash(hash map[string]string) error { @@ -30,6 +31,17 @@ func (t *Tenant) parseRedisHash(hash map[string]string) error { } t.CreatedAt = createdAt + // Deserialize updated_at if present, otherwise fallback to created_at (for existing records) + if hash["updated_at"] != "" { + updatedAt, err := time.Parse(time.RFC3339Nano, hash["updated_at"]) + if err != nil { + return err + } + t.UpdatedAt = updatedAt + } else { + t.UpdatedAt = t.CreatedAt + } + // Deserialize metadata if present if metadataStr, exists := hash["metadata"]; exists && metadataStr != "" { err = t.Metadata.UnmarshalBinary([]byte(metadataStr)) diff --git a/internal/models/tenant_test.go b/internal/models/tenant_test.go index 926ebdee..26957d2c 100644 --- a/internal/models/tenant_test.go +++ b/internal/models/tenant_test.go @@ -79,3 +79,24 @@ func TestTenant_JSONUnmarshalEmptyMetadata(t *testing.T) { assert.NotNil(t, tenant.Metadata) assert.Empty(t, tenant.Metadata) } + +func TestTenant_JSONMarshalWithUpdatedAt(t *testing.T) { + t.Parallel() + + tenant := testutil.TenantFactory.Any( + testutil.TenantFactory.WithID("tenant_123"), + ) + + // Marshal to JSON + jsonBytes, err := json.Marshal(tenant) + assert.NoError(t, err) + + // Unmarshal back + var unmarshaled models.Tenant + err = json.Unmarshal(jsonBytes, &unmarshaled) + assert.NoError(t, err) + + // Verify updated_at is preserved + assert.Equal(t, tenant.UpdatedAt.Unix(), unmarshaled.UpdatedAt.Unix()) + assert.Equal(t, tenant.CreatedAt.Unix(), unmarshaled.CreatedAt.Unix()) +} diff --git a/internal/services/api/destination_handlers.go b/internal/services/api/destination_handlers.go index 90dfc3d4..fdb0eac1 100644 --- a/internal/services/api/destination_handlers.go +++ b/internal/services/api/destination_handlers.go @@ -192,6 +192,7 @@ func (h *DestinationHandlers) Update(c *gin.Context) { } // Update destination. + updatedDestination.UpdatedAt = time.Now() if err := h.entityStore.UpsertDestination(c.Request.Context(), updatedDestination); err != nil { h.handleUpsertDestinationError(c, err) return diff --git a/internal/services/api/tenant_handlers.go b/internal/services/api/tenant_handlers.go index 088b7aef..ca41de70 100644 --- a/internal/services/api/tenant_handlers.go +++ b/internal/services/api/tenant_handlers.go @@ -59,6 +59,7 @@ func (h *TenantHandlers) Upsert(c *gin.Context) { // If tenant already exists, update it (PUT replaces metadata) if existingTenant != nil { existingTenant.Metadata = input.Metadata + existingTenant.UpdatedAt = time.Now() if err := h.entityStore.UpsertTenant(c.Request.Context(), *existingTenant); err != nil { AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) return diff --git a/internal/util/testutil/destination.go b/internal/util/testutil/destination.go index 05631dc4..deae85d5 100644 --- a/internal/util/testutil/destination.go +++ b/internal/util/testutil/destination.go @@ -15,13 +15,15 @@ type mockDestinationFactory struct { } func (f *mockDestinationFactory) Any(opts ...func(*models.Destination)) models.Destination { + now := time.Now() destination := models.Destination{ ID: idgen.Destination(), Type: "webhook", Topics: []string{"*"}, Config: map[string]string{"url": "http://host.docker.internal:4444"}, Credentials: map[string]string{}, - CreatedAt: time.Now(), + CreatedAt: now, + UpdatedAt: now, TenantID: "test-tenant", DisabledAt: nil, } @@ -75,6 +77,12 @@ func (f *mockDestinationFactory) WithCreatedAt(createdAt time.Time) func(*models } } +func (f *mockDestinationFactory) WithUpdatedAt(updatedAt time.Time) func(*models.Destination) { + return func(destination *models.Destination) { + destination.UpdatedAt = updatedAt + } +} + func (f *mockDestinationFactory) WithDisabledAt(disabledAt time.Time) func(*models.Destination) { return func(destination *models.Destination) { destination.DisabledAt = &disabledAt diff --git a/internal/util/testutil/tenant.go b/internal/util/testutil/tenant.go index 76ca89d0..ca19abe6 100644 --- a/internal/util/testutil/tenant.go +++ b/internal/util/testutil/tenant.go @@ -15,12 +15,14 @@ type mockTenantFactory struct { } func (f *mockTenantFactory) Any(opts ...func(*models.Tenant)) models.Tenant { + now := time.Now() tenant := models.Tenant{ ID: idgen.String(), DestinationsCount: 0, Topics: []string{}, Metadata: nil, - CreatedAt: time.Now(), + CreatedAt: now, + UpdatedAt: now, } for _, opt := range opts { @@ -47,3 +49,9 @@ func (f *mockTenantFactory) WithCreatedAt(createdAt time.Time) func(*models.Tena tenant.CreatedAt = createdAt } } + +func (f *mockTenantFactory) WithUpdatedAt(updatedAt time.Time) func(*models.Tenant) { + return func(tenant *models.Tenant) { + tenant.UpdatedAt = updatedAt + } +}