Skip to content

Commit

Permalink
Add new watermill handlers that get or refresh entities by properties…
Browse files Browse the repository at this point in the history
… and call another handler

These will be used in webhook handlers instead of calling the property
service directly. The handlers follow the same base logic, just with
"pluggable" ways of retrieving the entity, converting the entity to
properties and which handler to call next.

Related: stacklok#4327
  • Loading branch information
jhrozek committed Sep 19, 2024
1 parent d869bce commit 6252c1f
Show file tree
Hide file tree
Showing 9 changed files with 1,072 additions and 12 deletions.
652 changes: 652 additions & 0 deletions internal/entities/handlers/handler.go

Large diffs are not rendered by default.

183 changes: 183 additions & 0 deletions internal/entities/handlers/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//
// Copyright 2024 Stacklok, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package handlers

import (
"database/sql"
"testing"

"github.com/ThreeDotsLabs/watermill/message"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

mockdb "github.com/stacklok/minder/database/mock"
"github.com/stacklok/minder/internal/engine/entities"
"github.com/stacklok/minder/internal/entities/models"
"github.com/stacklok/minder/internal/entities/properties"
"github.com/stacklok/minder/internal/entities/properties/service"
mock_service "github.com/stacklok/minder/internal/entities/properties/service/mock"
stubeventer "github.com/stacklok/minder/internal/events/stubs"
ghprops "github.com/stacklok/minder/internal/providers/github/properties"
"github.com/stacklok/minder/internal/providers/manager"
mock_manager "github.com/stacklok/minder/internal/providers/manager/mock"
minderv1 "github.com/stacklok/minder/pkg/api/protobuf/go/minder/v1"
)

func TestRefreshEntityAndDoHandler_HandleRefreshEntityAndEval(t *testing.T) {
t.Parallel()

tests := []struct {
name string
lookupPropMap map[string]any
entPropMap map[string]any
nextHandler string
providerHint string
ewp *models.EntityWithProperties
setupMocks func(*gomock.Controller, *models.EntityWithProperties) (service.PropertiesService, manager.ProviderManager)
expectedError string
expectedPublish bool
}{
{
name: "successful refresh and publish of a repo",
lookupPropMap: map[string]any{
properties.PropertyUpstreamID: "123",
},
ewp: &models.EntityWithProperties{
Entity: models.EntityInstance{
ID: uuid.New(),
Type: minderv1.Entity_ENTITY_REPOSITORIES,
Name: "testorg/testrepo",
ProviderID: uuid.New(),
ProjectID: uuid.New(),
},
},
nextHandler: "call.me.next",
providerHint: "github",
entPropMap: map[string]any{
properties.PropertyName: "testorg/testrepo",
ghprops.RepoPropertyName: "testrepo",
ghprops.RepoPropertyOwner: "testorg",
ghprops.RepoPropertyId: int64(123),
properties.RepoPropertyIsPrivate: false,
properties.RepoPropertyIsFork: false,
},
setupMocks: func(ctrl *gomock.Controller, ewp *models.EntityWithProperties) (service.PropertiesService, manager.ProviderManager) {
mockPropSvc := mock_service.NewMockPropertiesService(ctrl)
mockProvMgr := mock_manager.NewMockProviderManager(ctrl)

protoEnt, err := ghprops.RepoV1FromProperties(ewp.Properties)
require.NoError(t, err)

mockPropSvc.EXPECT().
EntityWithPropertiesByUpstreamHint(gomock.Any(), ewp.Entity.Type, gomock.Any(), gomock.Any(), gomock.Any()).
Return(ewp, nil)
mockPropSvc.EXPECT().
RetrieveAllPropertiesForEntity(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil)
mockPropSvc.EXPECT().
EntityWithPropertiesAsProto(gomock.Any(), ewp, mockProvMgr).
Return(protoEnt, nil)

return mockPropSvc, mockProvMgr
},
expectedPublish: true,
},
{
name: "error unpacking message",
setupMocks: func(ctrl *gomock.Controller, _ *models.EntityWithProperties) (service.PropertiesService, manager.ProviderManager) {
return mock_service.NewMockPropertiesService(ctrl), mock_manager.NewMockProviderManager(ctrl)

},
expectedError: "error unpacking message",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

getByProps, err := properties.NewProperties(tt.lookupPropMap)
require.NoError(t, err)

handlerMsg := message.NewMessage(uuid.New().String(), nil)
entityMsg := NewEntityRefreshAndDoMessage().
WithEntity(minderv1.Entity_ENTITY_REPOSITORIES, getByProps).
WithProviderImplementsHint(tt.providerHint)

err = entityMsg.ToMessage(handlerMsg)
require.NoError(t, err)

entProps, err := properties.NewProperties(tt.entPropMap)
require.NoError(t, err)
tt.ewp.Properties = entProps

mockPropSvc, mockProvMgr := tt.setupMocks(ctrl, tt.ewp)

mockStore := mockdb.NewMockStore(ctrl)
tx := sql.Tx{}
mockStore.EXPECT().
BeginTransaction().
Return(&tx, nil)
mockStore.EXPECT().
Commit(&tx).
Return(nil)
mockStore.EXPECT().
Rollback(&tx).
Return(nil)
mockStore.EXPECT().
GetQuerierWithTransaction(&tx).
Return(mockStore)

stubEventer := &stubeventer.StubEventer{}
handler := NewRefreshEntityAndEvaluateHandler(stubEventer, mockStore, mockPropSvc, mockProvMgr)

refreshHandlerStruct, ok := handler.(*handleEntityAndDoBase)
require.True(t, ok)
err = refreshHandlerStruct.handleRefreshEntityAndDo(handlerMsg)

if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
}

if !tt.expectedPublish {
assert.Equal(t, 0, len(stubEventer.Sent), "Expected no publish calls")
return
}

assert.Equal(t, 1, len(stubEventer.Sent), "Expected one publish call")
sentMsg := stubEventer.Sent[0]
eiw, err := entities.ParseEntityEvent(sentMsg)
require.NoError(t, err)
require.NotNil(t, eiw)

assert.Equal(t, tt.ewp.Entity.Type, eiw.Type)
assert.Equal(t, tt.ewp.Entity.ProjectID, eiw.ProjectID)
assert.Equal(t, tt.ewp.Entity.ProviderID, eiw.ProviderID)

pbrepo, ok := eiw.Entity.(*minderv1.Repository)
require.True(t, ok)
assert.Equal(t, tt.entPropMap[ghprops.RepoPropertyName].(string), pbrepo.Name)
})
}
}
96 changes: 96 additions & 0 deletions internal/entities/handlers/message.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//
// Copyright 2024 Stacklok, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package handlers

import (
"encoding/json"
"fmt"

"github.com/ThreeDotsLabs/watermill/message"

"github.com/stacklok/minder/internal/entities/properties"
v1 "github.com/stacklok/minder/pkg/api/protobuf/go/minder/v1"
)

// TypedProps is a struct that contains the type of entity and its properties.
// it is used for either the entity or the owner entity.
type TypedProps struct {
Type v1.Entity `json:"type"`
GetByProps map[string]any `json:"get_by_props"`
}

// EntityHint is a hint that is used to help the entity handler find the entity.
type EntityHint struct {
ProviderImplementsHint string `json:"provider_implements_hint"`
}

// HandleEntityAndDoMessage is a message that is sent to the entity handler to refresh an entity and perform an action.
type HandleEntityAndDoMessage struct {
Entity TypedProps `json:"entity"`
Owner TypedProps `json:"owner"`
Hint EntityHint `json:"hint"`
}

// NewEntityRefreshAndDoMessage creates a new HandleEntityAndDoMessage struct.
func NewEntityRefreshAndDoMessage() *HandleEntityAndDoMessage {
return &HandleEntityAndDoMessage{}
}

// WithEntity sets the entity and its properties.
func (e *HandleEntityAndDoMessage) WithEntity(entType v1.Entity, getByProps *properties.Properties) *HandleEntityAndDoMessage {
e.Entity = TypedProps{
Type: entType,
GetByProps: getByProps.ToProtoStruct().AsMap(),
}
return e
}

// WithOwner sets the owner entity and its properties.
func (e *HandleEntityAndDoMessage) WithOwner(ownerType v1.Entity, ownerProps *properties.Properties) *HandleEntityAndDoMessage {
e.Owner = TypedProps{
Type: ownerType,
GetByProps: ownerProps.ToProtoStruct().AsMap(),
}
return e
}

// WithProviderImplementsHint sets the provider hint for the entity that will be used when looking up the entity.
func (e *HandleEntityAndDoMessage) WithProviderImplementsHint(providerHint string) *HandleEntityAndDoMessage {
e.Hint.ProviderImplementsHint = providerHint
return e
}

func messageToEntityRefreshAndDo(msg *message.Message) (*HandleEntityAndDoMessage, error) {
entMsg := &HandleEntityAndDoMessage{}

err := json.Unmarshal(msg.Payload, entMsg)
if err != nil {
return nil, fmt.Errorf("error unmarshalling entity: %w", err)
}

return entMsg, nil
}

// ToMessage converts the HandleEntityAndDoMessage struct to a Watermill message.
func (e *HandleEntityAndDoMessage) ToMessage(msg *message.Message) error {
payloadBytes, err := json.Marshal(e)
if err != nil {
return fmt.Errorf("error marshalling entity: %w", err)
}

msg.Payload = payloadBytes
return nil
}
109 changes: 109 additions & 0 deletions internal/entities/handlers/message_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//
// Copyright 2024 Stacklok, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package handlers

import (
"testing"

"github.com/ThreeDotsLabs/watermill/message"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/stacklok/minder/internal/entities/properties"
v1 "github.com/stacklok/minder/pkg/api/protobuf/go/minder/v1"
)

func TestEntityRefreshAndDoMessageRoundTrip(t *testing.T) {
t.Parallel()

scenarios := []struct {
name string
props map[string]any
entType v1.Entity
ownerProps map[string]any
ownerType v1.Entity
providerHint string
}{
{
name: "Valid repository entity",
props: map[string]any{
"id": "123",
"name": "test-repo",
},
entType: v1.Entity_ENTITY_REPOSITORIES,
providerHint: "github",
},
{
name: "Valid artifact entity",
props: map[string]any{
"id": "456",
"version": "1.0.0",
},
entType: v1.Entity_ENTITY_ARTIFACTS,
ownerProps: map[string]any{
"id": "123",
},
ownerType: v1.Entity_ENTITY_REPOSITORIES,
providerHint: "docker",
},
{
name: "Valid pull request entity",
props: map[string]any{
"id": "789",
},
entType: v1.Entity_ENTITY_PULL_REQUESTS,
ownerProps: map[string]any{
"id": "123",
},
ownerType: v1.Entity_ENTITY_REPOSITORIES,
providerHint: "github",
},
}

for _, sc := range scenarios {
t.Run(sc.name, func(t *testing.T) {
t.Parallel()

props, err := properties.NewProperties(sc.props)
require.NoError(t, err)

original := NewEntityRefreshAndDoMessage().
WithEntity(sc.entType, props).
WithProviderImplementsHint(sc.providerHint)

if sc.ownerProps != nil {
ownerProps, err := properties.NewProperties(sc.ownerProps)
require.NoError(t, err)
original.WithOwner(sc.ownerType, ownerProps)
}

handlerMsg := message.NewMessage(uuid.New().String(), nil)
err = original.ToMessage(handlerMsg)
require.NoError(t, err)

roundTrip, err := messageToEntityRefreshAndDo(handlerMsg)
assert.NoError(t, err)
assert.Equal(t, original.Entity.GetByProps, roundTrip.Entity.GetByProps)
assert.Equal(t, original.Entity.Type, roundTrip.Entity.Type)
assert.Equal(t, original.Hint.ProviderImplementsHint, roundTrip.Hint.ProviderImplementsHint)
if original.Owner.Type != v1.Entity_ENTITY_UNSPECIFIED {
assert.Equal(t, original.Owner.GetByProps, roundTrip.Owner.GetByProps)
assert.Equal(t, original.Owner.Type, roundTrip.Owner.Type)
}
})
}
}
Loading

0 comments on commit 6252c1f

Please sign in to comment.