going full courtside
This commit is contained in:
@@ -18,6 +18,7 @@ type EvaluatorService struct {
|
||||
alertEvents *models.AlertEventModel
|
||||
addresses *models.AddressModel
|
||||
notifConfigs *models.NotificationConfigModel
|
||||
smtpConfig notifications.SMTPConfig
|
||||
}
|
||||
|
||||
func NewEvaluatorService(
|
||||
@@ -26,6 +27,7 @@ func NewEvaluatorService(
|
||||
alertEvents *models.AlertEventModel,
|
||||
addresses *models.AddressModel,
|
||||
notifConfigs *models.NotificationConfigModel,
|
||||
smtpCfg notifications.SMTPConfig,
|
||||
) *EvaluatorService {
|
||||
return &EvaluatorService{
|
||||
eth: eth,
|
||||
@@ -33,6 +35,7 @@ func NewEvaluatorService(
|
||||
alertEvents: alertEvents,
|
||||
addresses: addresses,
|
||||
notifConfigs: notifConfigs,
|
||||
smtpConfig: smtpCfg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,40 +171,56 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
||||
|
||||
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
|
||||
|
||||
// Send Discord notification (non-fatal on failure)
|
||||
if addr != nil {
|
||||
go s.sendNotification(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address)
|
||||
go s.sendNotifications(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
|
||||
func (s *EvaluatorService) sendNotifications(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
|
||||
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get notification config: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if notifConfig == nil || !notifConfig.NotificationEnabled || notifConfig.DiscordWebhookURL == nil {
|
||||
if notifConfig == nil || !notifConfig.NotificationEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
sent, err := notifications.SendDiscordNotification(
|
||||
*notifConfig.DiscordWebhookURL,
|
||||
message,
|
||||
notifications.AlertMetadata{
|
||||
TxHash: obs.Hash,
|
||||
AddressLabel: addressLabel,
|
||||
AlertType: string(rule.Type),
|
||||
Address: address,
|
||||
},
|
||||
)
|
||||
meta := notifications.AlertMetadata{
|
||||
TxHash: obs.Hash,
|
||||
AddressLabel: addressLabel,
|
||||
AlertType: string(rule.Type),
|
||||
Address: address,
|
||||
}
|
||||
|
||||
if err != nil || !sent {
|
||||
log.Printf("Discord notification failed for user %s: %v", userID, err)
|
||||
} else {
|
||||
log.Printf("Discord notification sent to user %s", userID)
|
||||
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
|
||||
sent, err := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
|
||||
if err != nil || !sent {
|
||||
log.Printf("Discord notification failed for user %s: %v", userID, err)
|
||||
} else {
|
||||
log.Printf("Discord notification sent to user %s", userID)
|
||||
}
|
||||
}
|
||||
|
||||
if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" {
|
||||
sent, err := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
|
||||
if err != nil || !sent {
|
||||
log.Printf("Slack notification failed for user %s: %v", userID, err)
|
||||
} else {
|
||||
log.Printf("Slack notification sent to user %s", userID)
|
||||
}
|
||||
}
|
||||
|
||||
if notifConfig.Email != nil && *notifConfig.Email != "" && s.smtpConfig.IsConfigured() {
|
||||
sent, err := notifications.SendEmailNotification(s.smtpConfig, *notifConfig.Email, message, meta)
|
||||
if err != nil || !sent {
|
||||
log.Printf("Email notification failed for user %s: %v", userID, err)
|
||||
} else {
|
||||
log.Printf("Email notification sent to user %s (%s)", userID, *notifConfig.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
113
backend-go/internal/services/observer_test.go
Normal file
113
backend-go/internal/services/observer_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
|
||||
func TestFilterRelevantTransactions(t *testing.T) {
|
||||
to1 := "0xaaaa"
|
||||
to2 := "0xbbbb"
|
||||
to3 := "0xcccc"
|
||||
|
||||
txs := []domain.NormalizedTx{
|
||||
{Hash: "0x1", From: "0xaaaa", To: &to2, Value: "1000"},
|
||||
{Hash: "0x2", From: "0xbbbb", To: &to1, Value: "2000"},
|
||||
{Hash: "0x3", From: "0xcccc", To: &to3, Value: "3000"},
|
||||
{Hash: "0x4", From: "0xdddd", To: nil, Value: "0"},
|
||||
}
|
||||
|
||||
t.Run("finds outgoing", func(t *testing.T) {
|
||||
result := filterRelevantTransactions(txs, "0xAAAA")
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 relevant txs (1 from, 1 to), got %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("finds incoming", func(t *testing.T) {
|
||||
result := filterRelevantTransactions(txs, "0xBBBB")
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 relevant txs, got %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no match", func(t *testing.T) {
|
||||
result := filterRelevantTransactions(txs, "0x9999")
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected 0 relevant txs, got %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case insensitive", func(t *testing.T) {
|
||||
result := filterRelevantTransactions(txs, "0xAAAA")
|
||||
if len(result) == 0 {
|
||||
t.Fatal("should match case-insensitively")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateObservedTx(t *testing.T) {
|
||||
to := "0xaaaa"
|
||||
tx := domain.NormalizedTx{
|
||||
Hash: "0xhash",
|
||||
From: "0xbbbb",
|
||||
To: &to,
|
||||
Value: "1000",
|
||||
}
|
||||
|
||||
t.Run("incoming direction", func(t *testing.T) {
|
||||
addr := domain.Address{ID: 1, Address: "0xaaaa"}
|
||||
obs := createObservedTx(tx, addr)
|
||||
if obs.Direction != domain.DirectionIncoming {
|
||||
t.Errorf("expected incoming, got %s", obs.Direction)
|
||||
}
|
||||
if obs.AddressID != 1 {
|
||||
t.Errorf("expected address ID 1, got %d", obs.AddressID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("outgoing direction", func(t *testing.T) {
|
||||
addr := domain.Address{ID: 2, Address: "0xbbbb"}
|
||||
obs := createObservedTx(tx, addr)
|
||||
if obs.Direction != domain.DirectionOutgoing {
|
||||
t.Errorf("expected outgoing, got %s", obs.Direction)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetStartBlock(t *testing.T) {
|
||||
s := &ObserverService{}
|
||||
|
||||
t.Run("no checkpoint uses latest", func(t *testing.T) {
|
||||
got := s.getStartBlock(0, false, 1000)
|
||||
if got != 1000 {
|
||||
t.Errorf("expected 1000, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with checkpoint uses next block", func(t *testing.T) {
|
||||
got := s.getStartBlock(999, true, 1000)
|
||||
if got != 1000 {
|
||||
t.Errorf("expected 1000, got %d", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetEndBlock(t *testing.T) {
|
||||
s := &ObserverService{}
|
||||
|
||||
t.Run("caps at latest", func(t *testing.T) {
|
||||
got := s.getEndBlock(990, 1000)
|
||||
if got != 1000 {
|
||||
t.Errorf("expected 1000, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("caps at maxBlocksPerRun", func(t *testing.T) {
|
||||
got := s.getEndBlock(0, 200)
|
||||
if got != maxBlocksPerRun-1 {
|
||||
t.Errorf("expected %d, got %d", maxBlocksPerRun-1, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user