mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
327 lines
13 KiB
Go
327 lines
13 KiB
Go
package engine_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/storage/bun/paginate"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/workflow/temporal"
|
|
"github.com/hanzo-fi/payments/internal/connectors"
|
|
"github.com/hanzo-fi/payments/internal/connectors/engine"
|
|
"github.com/hanzo-fi/payments/internal/connectors/engine/activities"
|
|
"github.com/hanzo-fi/payments/internal/connectors/engine/workflow"
|
|
"github.com/hanzo-fi/payments/pkg/domain/models"
|
|
"github.com/hanzo-fi/payments/internal/storage"
|
|
"github.com/google/uuid"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"go.temporal.io/api/enums/v1"
|
|
"go.temporal.io/api/serviceerror"
|
|
"go.temporal.io/sdk/client"
|
|
sdktemporal "go.temporal.io/sdk/temporal"
|
|
"go.temporal.io/sdk/worker"
|
|
gomock "go.uber.org/mock/gomock"
|
|
)
|
|
|
|
// basicPlugin satisfies models.Plugin (via embedding) but does not implement
|
|
// PluginWithPayoutThrottle, so OnStart should not create a payout worker for it.
|
|
type basicPlugin struct{ models.Plugin }
|
|
|
|
// payoutPlugin satisfies both models.Plugin and models.PluginWithPayoutThrottle.
|
|
// OnStart should create a dedicated payout worker for connectors that return it.
|
|
type payoutPlugin struct{ models.Plugin }
|
|
|
|
func (p *payoutPlugin) PayoutsPerSecond() float64 { return 10.0 }
|
|
|
|
var _ = Describe("Worker Tests", func() {
|
|
Context("on start", func() {
|
|
var (
|
|
pool *engine.WorkerPool
|
|
store *storage.MockStorage
|
|
manager *connectors.MockManager
|
|
conns []models.Connector
|
|
)
|
|
BeforeEach(func() {
|
|
ctrl := gomock.NewController(GinkgoT())
|
|
logger := logging.NewDefaultLogger(GinkgoWriter, false, false, false)
|
|
// Use NewLazyClient as worker.New() requires a properly created client
|
|
cl, err := client.NewLazyClient(client.Options{})
|
|
Expect(err).To(BeNil())
|
|
store = storage.NewMockStorage(ctrl)
|
|
manager = connectors.NewMockManager(ctrl)
|
|
|
|
pool = engine.NewWorkerPool(
|
|
logger,
|
|
"stackname",
|
|
cl,
|
|
[]temporal.DefinitionSet{},
|
|
[]temporal.DefinitionSet{},
|
|
store,
|
|
manager,
|
|
worker.Options{},
|
|
time.Second,
|
|
time.Hour,
|
|
)
|
|
// Skip schedule creation in tests since we don't have a Temporal server
|
|
pool.SetSkipScheduleCreation(true)
|
|
|
|
connID1 := models.ConnectorID{Reference: uuid.New(), Provider: "provider1"}
|
|
connID2 := models.ConnectorID{Reference: uuid.New(), Provider: "provider2"}
|
|
|
|
conns = []models.Connector{
|
|
{ConnectorBase: models.ConnectorBase{ID: connID1, Name: "abc-connector", Provider: connID1.Provider, CreatedAt: time.Now().Add(-time.Minute)}, Config: json.RawMessage(`{}`)},
|
|
{ConnectorBase: models.ConnectorBase{ID: connID2, Name: "efg-connector", Provider: connID2.Provider, CreatedAt: time.Now()}, Config: json.RawMessage(`{}`)},
|
|
}
|
|
|
|
})
|
|
|
|
It("should fail when listener fails", func(ctx SpecContext) {
|
|
expectedErr := fmt.Errorf("listener err")
|
|
store.EXPECT().ListenConnectorsChanges(gomock.Any(), gomock.Any()).Return(expectedErr)
|
|
err := pool.OnStart(ctx)
|
|
Expect(err).NotTo(BeNil())
|
|
Expect(err).To(MatchError(expectedErr))
|
|
})
|
|
|
|
It("should fail when unable to fetch connectors from storage", func(ctx SpecContext) {
|
|
store.EXPECT().ListenConnectorsChanges(gomock.Any(), gomock.Any()).Return(nil)
|
|
|
|
expectedErr := fmt.Errorf("storage err")
|
|
store.EXPECT().ConnectorsList(gomock.Any(), gomock.Any()).Return(nil, expectedErr)
|
|
err := pool.OnStart(ctx)
|
|
Expect(err).NotTo(BeNil())
|
|
Expect(err).To(MatchError(expectedErr))
|
|
})
|
|
|
|
It("should call RegisterPlugin on all connectors found", func(ctx SpecContext) {
|
|
store.EXPECT().ListenConnectorsChanges(gomock.Any(), gomock.Any()).Return(nil)
|
|
|
|
store.EXPECT().ConnectorsList(gomock.Any(), gomock.Any()).Return(&paginate.Cursor[models.Connector]{
|
|
Data: conns,
|
|
}, nil)
|
|
manager.EXPECT().Load(conns[0], false, false).Return("name", json.RawMessage(`{}`), nil)
|
|
manager.EXPECT().Load(conns[1], false, false).Return("name", json.RawMessage(`{}`), nil)
|
|
// onStartPlugin checks each loaded plugin for PluginWithPayoutThrottle;
|
|
// basicPlugin does not implement it so no payout worker should be created.
|
|
manager.EXPECT().Get(conns[0].ID).Return(&basicPlugin{}, nil)
|
|
manager.EXPECT().Get(conns[1].ID).Return(&basicPlugin{}, nil)
|
|
err := pool.OnStart(ctx)
|
|
Expect(err).To(BeNil())
|
|
})
|
|
|
|
It("should start a payout worker when a plugin implements PluginWithPayoutThrottle", func(ctx SpecContext) {
|
|
store.EXPECT().ListenConnectorsChanges(gomock.Any(), gomock.Any()).Return(nil)
|
|
store.EXPECT().ConnectorsList(gomock.Any(), gomock.Any()).Return(&paginate.Cursor[models.Connector]{
|
|
Data: []models.Connector{conns[0]},
|
|
}, nil)
|
|
manager.EXPECT().Load(conns[0], false, false).Return("name", json.RawMessage(`{}`), nil)
|
|
manager.EXPECT().Get(conns[0].ID).Return(&payoutPlugin{}, nil)
|
|
|
|
err := pool.OnStart(ctx)
|
|
Expect(err).To(BeNil())
|
|
|
|
payoutQueue := engine.GetPayoutTaskQueue("stackname", conns[0].ID)
|
|
Expect(pool.HasWorker(payoutQueue)).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Context("createOutboxPublisherSchedule", func() {
|
|
var (
|
|
pool *engine.WorkerPool
|
|
mockClient *activities.MockClient
|
|
mockScheduleClient *activities.MockScheduleClient
|
|
mockHandle *activities.MockScheduleHandle
|
|
stackName string
|
|
|
|
pollingInterval = time.Second
|
|
cleanupInterval = time.Hour
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctrl := gomock.NewController(GinkgoT())
|
|
logger := logging.NewDefaultLogger(GinkgoWriter, false, false, false)
|
|
stackName = "test-stack"
|
|
mockClient = activities.NewMockClient(ctrl)
|
|
mockScheduleClient = activities.NewMockScheduleClient(ctrl)
|
|
mockHandle = activities.NewMockScheduleHandle(ctrl)
|
|
store := storage.NewMockStorage(ctrl)
|
|
manager := connectors.NewMockManager(ctrl)
|
|
pool = engine.NewWorkerPool(
|
|
logger,
|
|
stackName,
|
|
mockClient,
|
|
[]temporal.DefinitionSet{},
|
|
[]temporal.DefinitionSet{},
|
|
store,
|
|
manager,
|
|
worker.Options{},
|
|
pollingInterval,
|
|
cleanupInterval,
|
|
)
|
|
// Don't skip schedule creation for these tests
|
|
pool.SetSkipScheduleCreation(false)
|
|
})
|
|
|
|
It("should successfully create schedule when it does not exist", func(ctx SpecContext) {
|
|
scheduleID := fmt.Sprintf("%s-outbox-publisher", stackName)
|
|
mockClient.EXPECT().ScheduleClient().Return(mockScheduleClient).AnyTimes()
|
|
mockScheduleClient.EXPECT().Create(ctx, gomock.Any()).Do(func(_ context.Context, opts client.ScheduleOptions) {
|
|
Expect(opts.ID).To(Equal(scheduleID))
|
|
Expect(opts.TriggerImmediately).To(BeTrue())
|
|
Expect(opts.Overlap).To(Equal(enums.SCHEDULE_OVERLAP_POLICY_SKIP))
|
|
Expect(opts.Spec.Intervals).To(HaveLen(1))
|
|
Expect(opts.Spec.Intervals[0].Every).To(Equal(pollingInterval))
|
|
stackKey := sdktemporal.NewSearchAttributeKeyKeyword(workflow.SearchAttributeStack)
|
|
scheduleStack, hasScheduleStack := opts.TypedSearchAttributes.GetKeyword(stackKey)
|
|
Expect(hasScheduleStack).To(BeTrue())
|
|
Expect(scheduleStack).To(Equal(stackName))
|
|
action, ok := opts.Action.(*client.ScheduleWorkflowAction)
|
|
Expect(ok).To(BeTrue())
|
|
Expect(action.Workflow).To(Equal("OutboxPublisher"))
|
|
Expect(action.TaskQueue).To(Equal(fmt.Sprintf("%s-default", stackName)))
|
|
actionStack, hasActionStack := action.TypedSearchAttributes.GetKeyword(stackKey)
|
|
Expect(hasActionStack).To(BeTrue())
|
|
Expect(actionStack).To(Equal(stackName))
|
|
}).Return(mockHandle, nil)
|
|
|
|
err := pool.CreateOutboxPublisherSchedule(ctx)
|
|
Expect(err).To(BeNil())
|
|
})
|
|
|
|
It("should return nil when schedule already exists (AlreadyExists error)", func(ctx SpecContext) {
|
|
mockClient.EXPECT().ScheduleClient().Return(mockScheduleClient).AnyTimes()
|
|
mockScheduleClient.EXPECT().Create(ctx, gomock.Any()).Return(nil, serviceerror.NewAlreadyExists("already exists"))
|
|
|
|
err := pool.CreateOutboxPublisherSchedule(ctx)
|
|
Expect(err).To(BeNil())
|
|
})
|
|
|
|
It("should return nil when initial workflow already started (WorkflowExecutionAlreadyStarted)", func(ctx SpecContext) {
|
|
mockClient.EXPECT().ScheduleClient().Return(mockScheduleClient).AnyTimes()
|
|
mockScheduleClient.EXPECT().Create(ctx, gomock.Any()).Return(nil, serviceerror.NewWorkflowExecutionAlreadyStarted("wf already started", "", ""))
|
|
|
|
err := pool.CreateOutboxPublisherSchedule(ctx)
|
|
Expect(err).To(BeNil())
|
|
})
|
|
|
|
It("should return nil when SDK reports ErrScheduleAlreadyRunning", func(ctx SpecContext) {
|
|
mockClient.EXPECT().ScheduleClient().Return(mockScheduleClient).AnyTimes()
|
|
mockScheduleClient.EXPECT().Create(ctx, gomock.Any()).Return(nil, sdktemporal.ErrScheduleAlreadyRunning)
|
|
|
|
err := pool.CreateOutboxPublisherSchedule(ctx)
|
|
Expect(err).To(BeNil())
|
|
})
|
|
|
|
It("should return error when Create fails with non-AlreadyExists error", func(ctx SpecContext) {
|
|
expectedErr := fmt.Errorf("create error")
|
|
mockClient.EXPECT().ScheduleClient().Return(mockScheduleClient).AnyTimes()
|
|
mockScheduleClient.EXPECT().Create(ctx, gomock.Any()).Return(nil, expectedErr)
|
|
|
|
err := pool.CreateOutboxPublisherSchedule(ctx)
|
|
Expect(err).NotTo(BeNil())
|
|
Expect(err.Error()).To(ContainSubstring("failed to create outbox publisher schedule"))
|
|
})
|
|
})
|
|
|
|
Context("createOutboxCleanupSchedule", func() {
|
|
var (
|
|
pool *engine.WorkerPool
|
|
mockClient *activities.MockClient
|
|
mockScheduleClient *activities.MockScheduleClient
|
|
mockHandle *activities.MockScheduleHandle
|
|
stackName string
|
|
|
|
pollingInterval = time.Second
|
|
cleanupInterval = time.Hour
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctrl := gomock.NewController(GinkgoT())
|
|
logger := logging.NewDefaultLogger(GinkgoWriter, false, false, false)
|
|
stackName = "test-stack"
|
|
mockClient = activities.NewMockClient(ctrl)
|
|
mockScheduleClient = activities.NewMockScheduleClient(ctrl)
|
|
mockHandle = activities.NewMockScheduleHandle(ctrl)
|
|
store := storage.NewMockStorage(ctrl)
|
|
manager := connectors.NewMockManager(ctrl)
|
|
pool = engine.NewWorkerPool(
|
|
logger,
|
|
stackName,
|
|
mockClient,
|
|
[]temporal.DefinitionSet{},
|
|
[]temporal.DefinitionSet{},
|
|
store,
|
|
manager,
|
|
worker.Options{},
|
|
pollingInterval,
|
|
cleanupInterval,
|
|
)
|
|
// Don't skip schedule creation for these tests
|
|
pool.SetSkipScheduleCreation(false)
|
|
})
|
|
|
|
It("should successfully create schedule when it does not exist", func(ctx SpecContext) {
|
|
scheduleID := fmt.Sprintf("%s-outbox-cleanup", stackName)
|
|
mockClient.EXPECT().ScheduleClient().Return(mockScheduleClient).AnyTimes()
|
|
mockScheduleClient.EXPECT().Create(ctx, gomock.Any()).Do(func(_ context.Context, opts client.ScheduleOptions) {
|
|
Expect(opts.ID).To(Equal(scheduleID))
|
|
Expect(opts.TriggerImmediately).To(BeTrue())
|
|
Expect(opts.Overlap).To(Equal(enums.SCHEDULE_OVERLAP_POLICY_SKIP))
|
|
Expect(opts.Spec.Intervals).To(HaveLen(1))
|
|
Expect(opts.Spec.Intervals[0].Every).To(Equal(cleanupInterval))
|
|
stackKey := sdktemporal.NewSearchAttributeKeyKeyword(workflow.SearchAttributeStack)
|
|
scheduleStack, hasScheduleStack := opts.TypedSearchAttributes.GetKeyword(stackKey)
|
|
Expect(hasScheduleStack).To(BeTrue())
|
|
Expect(scheduleStack).To(Equal(stackName))
|
|
action, ok := opts.Action.(*client.ScheduleWorkflowAction)
|
|
Expect(ok).To(BeTrue())
|
|
Expect(action.Workflow).To(Equal("OutboxCleanup"))
|
|
Expect(action.TaskQueue).To(Equal(fmt.Sprintf("%s-default", stackName)))
|
|
actionStack, hasActionStack := action.TypedSearchAttributes.GetKeyword(stackKey)
|
|
Expect(hasActionStack).To(BeTrue())
|
|
Expect(actionStack).To(Equal(stackName))
|
|
}).Return(mockHandle, nil)
|
|
|
|
err := pool.CreateOutboxCleanupSchedule(ctx)
|
|
Expect(err).To(BeNil())
|
|
})
|
|
|
|
It("should return nil when schedule already exists (AlreadyExists error)", func(ctx SpecContext) {
|
|
mockClient.EXPECT().ScheduleClient().Return(mockScheduleClient).AnyTimes()
|
|
mockScheduleClient.EXPECT().Create(ctx, gomock.Any()).Return(nil, serviceerror.NewAlreadyExists("already exists"))
|
|
|
|
err := pool.CreateOutboxCleanupSchedule(ctx)
|
|
Expect(err).To(BeNil())
|
|
})
|
|
|
|
It("should return nil when initial workflow already started (WorkflowExecutionAlreadyStarted)", func(ctx SpecContext) {
|
|
mockClient.EXPECT().ScheduleClient().Return(mockScheduleClient).AnyTimes()
|
|
mockScheduleClient.EXPECT().Create(ctx, gomock.Any()).Return(nil, serviceerror.NewWorkflowExecutionAlreadyStarted("wf already started", "", ""))
|
|
|
|
err := pool.CreateOutboxCleanupSchedule(ctx)
|
|
Expect(err).To(BeNil())
|
|
})
|
|
|
|
It("should return nil when SDK reports ErrScheduleAlreadyRunning", func(ctx SpecContext) {
|
|
mockClient.EXPECT().ScheduleClient().Return(mockScheduleClient).AnyTimes()
|
|
mockScheduleClient.EXPECT().Create(ctx, gomock.Any()).Return(nil, sdktemporal.ErrScheduleAlreadyRunning)
|
|
|
|
err := pool.CreateOutboxCleanupSchedule(ctx)
|
|
Expect(err).To(BeNil())
|
|
})
|
|
|
|
It("should return error when Create fails with non-AlreadyExists error", func(ctx SpecContext) {
|
|
expectedErr := fmt.Errorf("create error")
|
|
mockClient.EXPECT().ScheduleClient().Return(mockScheduleClient).AnyTimes()
|
|
mockScheduleClient.EXPECT().Create(ctx, gomock.Any()).Return(nil, expectedErr)
|
|
|
|
err := pool.CreateOutboxCleanupSchedule(ctx)
|
|
Expect(err).NotTo(BeNil())
|
|
Expect(err.Error()).To(ContainSubstring("failed to create outbox cleanup schedule"))
|
|
})
|
|
})
|
|
})
|