Files
payments/internal/storage/connectors_test.go
fgueryandGitHub a63e3a20a8 feat(EN-214): create events via storage instead of manually calling event activity (#594)
* feat(EN-214): add outbox table migration

* feat(EN-214): add outbox models and storage layer

* feat(EN-214): implement outbox publisher activity

* feat(EN-214): implement outbox schedule activity

* feat(EN-214): add outbox publisher workflow

* feat(EN-214): migrate accounts to outbox pattern

* feat(EN-214): migrate balances to outbox pattern

* feat(EN-214): migrate payments to outbox pattern

* feat(EN-214): migrate bank accounts to outbox pattern

* feat(EN-214): migrate pools to outbox pattern

* feat(EN-214): migrate payment initiations to outbox pattern

* feat(EN-214): migrate PSU and connections to outbox pattern

* feat(EN-214): migrate connectors to outbox pattern

* feat(EN-214): remove legacy send_events workflow

* feat(EN-214): update workflows to use outbox pattern

* feat(EN-214): improve outbox idempotency and error handling

* feat(EN-214): update storage tasks and tests

* test(EN-214): update e2e tests for outbox pattern

* test(EN-214): clean up workflow tests

* chore(EN-214): update docker compose configuration

* feat(EN-214): add e2e run config in ide

* fix: add back docker-compose-dev run config

* chore: improve connector uninstall comments & remove connector id being passed

* fix: ensure events are not pushed if balances are not created

* fix: ensure err is not overwritten when using rollback

* fix: ensure we use v3 provider format

* chore: apply the nitpick comments from coderabbit

* chore: consistentely use e() error handling

* feat: optionally disable publisher schedule to make e2e test more consistent

* fix: improve error handling on uninstall connector

* avoid using custom payload and rely on existing interfaces; also avoid using different types for events

* fix: remove unecessary unmarshalling

* fix: ensure that rollbackOnTxError picks up errors on tx.Commit()

* fix: add extra validation on iban & account number in the test

* test: cover marshall/unmarshalling of event payload

* fix: increase test timeout

* try to make test more resiliant

* fix: wrong pointer use in loop

* fix: error formatting issue

* fix: move oubtox schedule creation out of an activity and do it in the worker itself

* feat: OutboxEventDeleteAndSend as batch

* change outbox_event to have id only instead of idempotency key

* make e2e test less brittle, save eventID as idempotency key in publication

* feat: make outbox_event keep an history

* feat(EN-214): added a schedule to cleanup outbox events

* test: make cleanup actually clean

* ignore linting rules for now

* feat: do not add psu metadata to event anymore as it's irrelevant

* fix: create local variable instead of relying on pointers, even if not 100% necessary

* fix: remove temporal schedule describe calls
2025-11-24 16:05:42 +00:00

540 lines
15 KiB
Go

package storage
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/formancehq/go-libs/v3/bun/bunpaginate"
"github.com/formancehq/go-libs/v3/logging"
"github.com/formancehq/go-libs/v3/query"
"github.com/formancehq/go-libs/v3/time"
"github.com/formancehq/payments/internal/models"
"github.com/formancehq/payments/pkg/events"
"github.com/gibson042/canonicaljson-go"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
now = time.Now()
defaultConnector = models.Connector{
ConnectorBase: models.ConnectorBase{
ID: models.ConnectorID{
Reference: uuid.New(),
Provider: "default",
},
Name: "default",
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
Provider: "default",
},
Config: []byte(`{}`),
}
defaultConnector2 = models.Connector{
ConnectorBase: models.ConnectorBase{
ID: models.ConnectorID{
Reference: uuid.New(),
Provider: "default2",
},
Name: "default2",
CreatedAt: now.Add(-45 * time.Minute).UTC().Time,
Provider: "default2",
},
Config: []byte(`{}`),
}
defaultConnector3 = models.Connector{
ConnectorBase: models.ConnectorBase{
ID: models.ConnectorID{
Reference: uuid.New(),
Provider: "default",
},
Name: "default3",
CreatedAt: now.Add(-30 * time.Minute).UTC().Time,
Provider: "default",
},
Config: []byte(`{}`),
}
)
func upsertConnector(t *testing.T, ctx context.Context, storage Storage, connector models.Connector) {
require.NoError(t, storage.ConnectorsInstall(ctx, connector, nil))
}
func TestConnectorsInstall(t *testing.T) {
t.Parallel()
ctx := logging.TestingContext()
store := newStore(t)
defer store.Close()
upsertConnector(t, ctx, store, defaultConnector)
upsertConnector(t, ctx, store, defaultConnector2)
t.Run("same id upsert", func(t *testing.T) {
c := models.Connector{
ConnectorBase: models.ConnectorBase{
ID: defaultConnector.ID,
Name: "test changed",
CreatedAt: time.Now().UTC().Time,
Provider: "test",
},
Config: []byte(`{}`),
}
require.NoError(t, store.ConnectorsInstall(ctx, c, nil))
connector, err := store.ConnectorsGet(ctx, c.ID)
require.NoError(t, err)
require.NotNil(t, connector)
require.Equal(t, defaultConnector, *connector)
})
t.Run("unique same upsert", func(t *testing.T) {
c := models.Connector{
ConnectorBase: models.ConnectorBase{
ID: models.ConnectorID{
Reference: uuid.New(),
Provider: "test",
},
Name: "default",
CreatedAt: now.Add(-23 * time.Minute).UTC().Time,
Provider: "test",
},
Config: []byte(`{}`),
}
require.Error(t, store.ConnectorsInstall(ctx, c, nil))
})
t.Run("outbox event created for connector reset", func(t *testing.T) {
// Create an old connector that will be "reset"
oldConnector := models.Connector{
ConnectorBase: models.ConnectorBase{
ID: models.ConnectorID{
Reference: uuid.New(),
Provider: "reset-test",
},
Name: "old connector",
CreatedAt: now.Add(-60 * time.Minute).UTC().Time,
Provider: "reset-test",
},
Config: []byte(`{}`),
}
upsertConnector(t, ctx, store, oldConnector)
// Create a new connector (simulating reset)
newConnector := models.Connector{
ConnectorBase: models.ConnectorBase{
ID: models.ConnectorID{
Reference: uuid.New(),
Provider: "reset-test",
},
Name: "new connector",
CreatedAt: now.Add(-1 * time.Minute).UTC().Time,
Provider: "reset-test",
},
Config: []byte(`{}`),
}
// Install with oldConnectorID (simulating reset)
require.NoError(t, store.ConnectorsInstall(ctx, newConnector, &oldConnector.ID))
// Verify outbox event was created
pendingEvents, err := store.OutboxEventsPollPending(ctx, 100)
require.NoError(t, err)
// Find our event
var ourEvent *models.OutboxEvent
for i := range pendingEvents {
if pendingEvents[i].EventType == events.EventTypeConnectorReset &&
pendingEvents[i].EntityID == oldConnector.ID.String() {
ourEvent = &pendingEvents[i]
break
}
}
require.NotNil(t, ourEvent, "expected outbox event for connector reset")
// Verify event details
assert.Equal(t, events.EventTypeConnectorReset, ourEvent.EventType)
assert.Equal(t, models.OUTBOX_STATUS_PENDING, ourEvent.Status)
assert.Equal(t, oldConnector.ID.String(), ourEvent.EntityID)
assert.Equal(t, newConnector.ID, *ourEvent.ConnectorID)
assert.Equal(t, 0, ourEvent.RetryCount)
// Verify payload
var payload map[string]interface{}
err = json.Unmarshal(ourEvent.Payload, &payload)
require.NoError(t, err)
assert.Equal(t, oldConnector.ID.String(), payload["connectorID"])
assert.NotNil(t, payload["createdAt"])
// Verify idempotency key format
expectedKeyPrefix := fmt.Sprintf("%s-", oldConnector.ID.String())
assert.Contains(t, ourEvent.ID.EventIdempotencyKey, expectedKeyPrefix)
})
t.Run("no outbox event for normal install", func(t *testing.T) {
// Count events before
eventsBefore, err := store.OutboxEventsPollPending(ctx, 1000)
require.NoError(t, err)
countBefore := len(eventsBefore)
// Create a new connector without oldConnectorID (normal install)
normalConnector := models.Connector{
ConnectorBase: models.ConnectorBase{
ID: models.ConnectorID{
Reference: uuid.New(),
Provider: "normal-test",
},
Name: "normal connector",
CreatedAt: now.Add(-1 * time.Minute).UTC().Time,
Provider: "normal-test",
},
Config: []byte(`{}`),
}
require.NoError(t, store.ConnectorsInstall(ctx, normalConnector, nil))
// Verify no outbox event was created
eventsAfter, err := store.OutboxEventsPollPending(ctx, 1000)
require.NoError(t, err)
countAfter := len(eventsAfter)
// Should have same number of events (no new reset event)
assert.Equal(t, countBefore, countAfter, "normal install should not create reset event")
})
}
func TestConnectorsUninstall(t *testing.T) {
t.Parallel()
ctx := logging.TestingContext()
store := newStore(t)
defer store.Close()
upsertConnector(t, ctx, store, defaultConnector)
upsertConnector(t, ctx, store, defaultConnector2)
t.Run("uninstall default connector", func(t *testing.T) {
require.NoError(t, store.ConnectorsUninstall(ctx, defaultConnector.ID))
connector, err := store.ConnectorsGet(ctx, defaultConnector.ID)
require.Error(t, err)
require.ErrorIs(t, err, ErrNotFound)
require.Nil(t, connector)
})
t.Run("uninstall unknown connector", func(t *testing.T) {
id := models.ConnectorID{
Reference: uuid.New(),
Provider: "unknown",
}
require.NoError(t, store.ConnectorsUninstall(ctx, id))
})
}
func TestConnectorsConfigUpdate(t *testing.T) {
t.Parallel()
ctx := logging.TestingContext()
store := newStore(t)
defer store.Close()
upsertConnector(t, ctx, store, defaultConnector)
upsertConnector(t, ctx, store, defaultConnector2)
t.Run("same id upsert", func(t *testing.T) {
config := json.RawMessage(`{"val":"new"}`)
c := models.Connector{
ConnectorBase: models.ConnectorBase{
ID: defaultConnector.ID,
Name: "new name",
},
Config: config,
}
require.NoError(t, store.ConnectorsConfigUpdate(ctx, c))
connector, err := store.ConnectorsGet(ctx, c.ID)
require.NoError(t, err)
require.NotNil(t, connector)
assert.Equal(t, c.Name, connector.Name)
assert.Equal(t, defaultConnector.CreatedAt, connector.CreatedAt)
assert.Equal(t, defaultConnector.Provider, connector.Provider)
assert.Equal(t, defaultConnector.ScheduledForDeletion, connector.ScheduledForDeletion)
expectedData, err := canonicaljson.Marshal(config)
require.NoError(t, err)
data, err := canonicaljson.Marshal(connector.Config)
require.NoError(t, err)
assert.Equal(t, string(expectedData), string(data))
})
t.Run("connector doesn't exist yet", func(t *testing.T) {
c := models.Connector{
ConnectorBase: models.ConnectorBase{
ID: models.ConnectorID{
Reference: uuid.New(),
Provider: "test",
},
},
Config: []byte(`{}`),
}
require.Error(t, store.ConnectorsConfigUpdate(ctx, c))
})
}
func TestConnectorsScheduleForDeletion(t *testing.T) {
t.Parallel()
ctx := logging.TestingContext()
store := newStore(t)
defer store.Close()
upsertConnector(t, ctx, store, defaultConnector)
t.Run("schedule for deletion of unknown connector", func(t *testing.T) {
id := models.ConnectorID{
Reference: uuid.New(),
Provider: "unknown",
}
require.NoError(t, store.ConnectorsScheduleForDeletion(ctx, id))
})
t.Run("schedule for deletion of default connector", func(t *testing.T) {
require.NoError(t, store.ConnectorsScheduleForDeletion(ctx, defaultConnector.ID))
connector, err := store.ConnectorsGet(ctx, defaultConnector.ID)
require.NoError(t, err)
require.NotNil(t, connector)
require.True(t, connector.ScheduledForDeletion)
})
}
func TestConnectorsGet(t *testing.T) {
t.Parallel()
ctx := logging.TestingContext()
store := newStore(t)
defer store.Close()
upsertConnector(t, ctx, store, defaultConnector)
upsertConnector(t, ctx, store, defaultConnector2)
t.Run("get connector", func(t *testing.T) {
connector, err := store.ConnectorsGet(ctx, defaultConnector.ID)
require.NoError(t, err)
require.NotNil(t, connector)
require.Equal(t, defaultConnector, *connector)
})
t.Run("get unknown connector", func(t *testing.T) {
id := models.ConnectorID{
Reference: uuid.New(),
Provider: "unknown",
}
connector, err := store.ConnectorsGet(ctx, id)
require.Error(t, err)
require.ErrorIs(t, err, ErrNotFound)
require.Nil(t, connector)
})
}
func TestConnectorsList(t *testing.T) {
t.Parallel()
ctx := logging.TestingContext()
store := newStore(t)
defer store.Close()
upsertConnector(t, ctx, store, defaultConnector)
upsertConnector(t, ctx, store, defaultConnector2)
upsertConnector(t, ctx, store, defaultConnector3)
t.Run("list connectors by name", func(t *testing.T) {
q := NewListConnectorsQuery(
bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}).
WithPageSize(15).
WithQueryBuilder(query.Match("name", "default")),
)
cursor, err := store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Len(t, cursor.Data, 1)
require.False(t, cursor.HasMore)
require.Empty(t, cursor.Next)
require.Empty(t, cursor.Previous)
require.Equal(t, defaultConnector, cursor.Data[0])
})
t.Run("list connectors by unknown name", func(t *testing.T) {
q := NewListConnectorsQuery(
bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}).
WithPageSize(15).
WithQueryBuilder(query.Match("name", "unknown")),
)
cursor, err := store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Empty(t, cursor.Data)
require.False(t, cursor.HasMore)
require.Empty(t, cursor.Next)
require.Empty(t, cursor.Previous)
})
t.Run("list connectors by provider", func(t *testing.T) {
q := NewListConnectorsQuery(
bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}).
WithPageSize(15).
WithQueryBuilder(query.Match("provider", "default")),
)
cursor, err := store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Len(t, cursor.Data, 2)
require.False(t, cursor.HasMore)
require.Empty(t, cursor.Next)
require.Empty(t, cursor.Previous)
require.Equal(t, defaultConnector3, cursor.Data[0])
require.Equal(t, defaultConnector, cursor.Data[1])
})
t.Run("list connectors by provider uppercase", func(t *testing.T) {
q := NewListConnectorsQuery(
bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}).
WithPageSize(15).
WithQueryBuilder(query.Match("provider", "DEFAULT")),
)
cursor, err := store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Len(t, cursor.Data, 2)
require.False(t, cursor.HasMore)
require.Empty(t, cursor.Next)
require.Empty(t, cursor.Previous)
require.Equal(t, defaultConnector3, cursor.Data[0])
require.Equal(t, defaultConnector, cursor.Data[1])
})
t.Run("list connectors by provider with wrong type", func(t *testing.T) {
q := NewListConnectorsQuery(
bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}).
WithPageSize(15).
WithQueryBuilder(query.Match("provider", 1)),
)
cursor, err := store.ConnectorsList(ctx, q)
require.Error(t, err)
require.Nil(t, cursor)
})
t.Run("list connectors by unknown provider", func(t *testing.T) {
q := NewListConnectorsQuery(
bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}).
WithPageSize(15).
WithQueryBuilder(query.Match("provider", "unknown")),
)
cursor, err := store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Empty(t, cursor.Data)
require.False(t, cursor.HasMore)
require.Empty(t, cursor.Next)
require.Empty(t, cursor.Previous)
})
t.Run("list connectors by id", func(t *testing.T) {
q := NewListConnectorsQuery(
bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}).
WithPageSize(15).
WithQueryBuilder(query.Match("id", defaultConnector3.ID.String())),
)
cursor, err := store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Len(t, cursor.Data, 1)
require.False(t, cursor.HasMore)
require.Empty(t, cursor.Next)
require.Empty(t, cursor.Previous)
require.Equal(t, defaultConnector3, cursor.Data[0])
})
t.Run("list connectors by unknown id", func(t *testing.T) {
q := NewListConnectorsQuery(
bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}).
WithPageSize(15).
WithQueryBuilder(query.Match("id", "unknown")),
)
cursor, err := store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Empty(t, cursor.Data)
require.False(t, cursor.HasMore)
require.Empty(t, cursor.Next)
require.Empty(t, cursor.Previous)
})
t.Run("list connectors test cursor", func(t *testing.T) {
q := NewListConnectorsQuery(
bunpaginate.NewPaginatedQueryOptions(ConnectorQuery{}).
WithPageSize(1),
)
cursor, err := store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Len(t, cursor.Data, 1)
require.True(t, cursor.HasMore)
require.NotEmpty(t, cursor.Next)
require.Empty(t, cursor.Previous)
require.Equal(t, defaultConnector3, cursor.Data[0])
err = bunpaginate.UnmarshalCursor(cursor.Next, &q)
require.NoError(t, err)
cursor, err = store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Len(t, cursor.Data, 1)
require.True(t, cursor.HasMore)
require.NotEmpty(t, cursor.Next)
require.NotEmpty(t, cursor.Previous)
require.Equal(t, defaultConnector2, cursor.Data[0])
err = bunpaginate.UnmarshalCursor(cursor.Next, &q)
require.NoError(t, err)
cursor, err = store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Len(t, cursor.Data, 1)
require.False(t, cursor.HasMore)
require.Empty(t, cursor.Next)
require.NotEmpty(t, cursor.Previous)
require.Equal(t, defaultConnector, cursor.Data[0])
err = bunpaginate.UnmarshalCursor(cursor.Previous, &q)
require.NoError(t, err)
cursor, err = store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Len(t, cursor.Data, 1)
require.True(t, cursor.HasMore)
require.NotEmpty(t, cursor.Next)
require.NotEmpty(t, cursor.Previous)
require.Equal(t, defaultConnector2, cursor.Data[0])
err = bunpaginate.UnmarshalCursor(cursor.Previous, &q)
require.NoError(t, err)
cursor, err = store.ConnectorsList(ctx, q)
require.NoError(t, err)
require.Len(t, cursor.Data, 1)
require.True(t, cursor.HasMore)
require.NotEmpty(t, cursor.Next)
require.Empty(t, cursor.Previous)
require.Equal(t, defaultConnector3, cursor.Data[0])
})
}