This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
)
|
||||
|
||||
func TestWebhookDeliveryPostgreSQLRetryAndAudit(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository, err := NewRepository(pool)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
calls := 0
|
||||
keys := map[string]struct{}{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
calls++
|
||||
keys[request.Header.Get("Idempotency-Key")] = struct{}{}
|
||||
if request.Header.Get("Authorization") != "Bearer integration-runtime-secret" {
|
||||
t.Errorf("authorization = %q", request.Header.Get("Authorization"))
|
||||
}
|
||||
if calls == 1 {
|
||||
writer.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = writer.Write([]byte("authorization=integration-runtime-secret"))
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
sender, err := NewWebhookSender(WebhookConfig{Endpoint: server.URL, BearerToken: "integration-runtime-secret", Timeout: time.Second, AllowHTTP: true}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
channelID := newID()
|
||||
channel, err := repository.CreateChannel(ctx, Channel{ID: channelID, Name: "integration-webhook-" + channelID[:8], Type: "webhook", Enabled: true, SecretRef: SecretRef{ID: WebhookSecretReference}, Configuration: map[string]any{"url": server.URL, "timeoutSeconds": 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM notification_deliveries WHERE outbox_id IN (SELECT id FROM notification_outbox WHERE channel_id=$1); DELETE FROM notification_outbox WHERE channel_id=$1; DELETE FROM notification_channels WHERE id=$1`, channel.ID)
|
||||
}()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
item, duplicate, err := repository.Enqueue(ctx, Outbox{IdempotencyKey: "webhook-integration-" + channelID, ChannelID: channel.ID, EventType: EventFiring, Subject: "Firing", Body: "real receiver delivery", NextAttemptAt: now, CreatedAt: now, UpdatedAt: now})
|
||||
if err != nil || duplicate {
|
||||
t.Fatalf("enqueue duplicate=%v err=%v", duplicate, err)
|
||||
}
|
||||
dispatcher := Dispatcher{Store: repository, Channels: map[string]ChannelSender{channel.ID: sender}}
|
||||
if delivered, err := dispatcher.DispatchDue(ctx, now, 10); err != nil || delivered != 0 {
|
||||
t.Fatalf("first dispatch delivered=%d err=%v", delivered, err)
|
||||
}
|
||||
retried, err := repository.GetOutbox(ctx, item.ID)
|
||||
if err != nil || retried.Status != StatusRetry || retried.Attempts != 1 || strings.Contains(retried.LastError, "integration-runtime-secret") {
|
||||
t.Fatalf("retry=%+v err=%v", retried, err)
|
||||
}
|
||||
if delivered, err := dispatcher.DispatchDue(ctx, now.Add(2*time.Second), 10); err != nil || delivered != 1 {
|
||||
t.Fatalf("second dispatch delivered=%d err=%v", delivered, err)
|
||||
}
|
||||
completed, err := repository.GetOutbox(ctx, item.ID)
|
||||
if err != nil || completed.Status != StatusDelivered || completed.Attempts != 2 || completed.DeliveredAt == nil {
|
||||
t.Fatalf("completed=%+v err=%v", completed, err)
|
||||
}
|
||||
var auditRows int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM notification_deliveries WHERE outbox_id=$1`, item.ID).Scan(&auditRows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 2 || len(keys) != 1 || auditRows != 2 {
|
||||
t.Fatalf("receiver calls=%d unique keys=%d audit rows=%d", calls, len(keys), auditRows)
|
||||
}
|
||||
var storedSecret bool
|
||||
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM notification_channels WHERE id=$1 AND configuration::text LIKE '%' || $2 || '%')`, channel.ID, "integration-runtime-secret").Scan(&storedSecret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storedSecret {
|
||||
t.Fatal("runtime webhook credential was persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationRepositoryPostgreSQL(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 12, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Ping(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository, err := NewRepository(pool)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelID := newID()
|
||||
channel, err := repository.CreateChannel(ctx, Channel{ID: channelID, Name: "integration-memory-" + channelID[:8], Type: "memory", Enabled: true, SecretRef: SecretRef{ID: "test-secret-ref"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM notification_deliveries WHERE outbox_id IN (SELECT id FROM notification_outbox WHERE channel_id=$1); DELETE FROM notification_outbox WHERE channel_id=$1; DELETE FROM notification_channels WHERE id=$1`, channel.ID)
|
||||
}()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
item := Outbox{IdempotencyKey: "integration-" + channelID, ChannelID: channel.ID, EventType: EventRecovery, Subject: "Recovered", Body: "entity recovered", NextAttemptAt: now, CreatedAt: now, UpdatedAt: now}
|
||||
first, duplicate, err := repository.Enqueue(ctx, item)
|
||||
if err != nil || duplicate {
|
||||
t.Fatalf("first enqueue item=%+v duplicate=%v err=%v", first, duplicate, err)
|
||||
}
|
||||
second, duplicate, err := repository.Enqueue(ctx, item)
|
||||
if err != nil || !duplicate || second.ID != first.ID {
|
||||
t.Fatalf("duplicate enqueue item=%+v duplicate=%v err=%v", second, duplicate, err)
|
||||
}
|
||||
claimed, err := repository.ClaimDue(ctx, now, 10)
|
||||
if err != nil || len(claimed) != 1 || claimed[0].Attempts != 1 {
|
||||
t.Fatalf("claimed=%+v err=%v", claimed, err)
|
||||
}
|
||||
retried, err := repository.Complete(ctx, first.ID, 1, false, errors.New("webhook token=should-not-leak"), now)
|
||||
if err != nil || retried.Status != StatusRetry || retried.LastError == "" {
|
||||
t.Fatalf("retry item=%+v err=%v", retried, err)
|
||||
}
|
||||
if retried.LastError == "webhook token=should-not-leak" {
|
||||
t.Fatal("secret appeared in persisted delivery error")
|
||||
}
|
||||
claimed, err = repository.ClaimDue(ctx, now.Add(2*time.Second), 10)
|
||||
if err != nil || len(claimed) != 1 || claimed[0].Attempts != 2 {
|
||||
t.Fatalf("second claim=%+v err=%v", claimed, err)
|
||||
}
|
||||
completed, err := repository.Complete(ctx, first.ID, 2, true, nil, now.Add(2*time.Second))
|
||||
if err != nil || completed.Status != StatusDelivered || completed.DeliveredAt == nil {
|
||||
t.Fatalf("completed item=%+v err=%v", completed, err)
|
||||
}
|
||||
var deliveryCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM notification_deliveries WHERE outbox_id=$1`, first.ID).Scan(&deliveryCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deliveryCount != 2 {
|
||||
t.Fatalf("delivery audit rows=%d", deliveryCount)
|
||||
}
|
||||
if _, err := repository.Complete(ctx, first.ID, 2, true, nil, now.Add(3*time.Second)); err != nil {
|
||||
t.Fatalf("idempotent completion error=%v", err)
|
||||
}
|
||||
if _, err := repository.UpdateChannel(ctx, channel, channel.Revision+1); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale channel update error=%v", err)
|
||||
}
|
||||
if _, err := repository.UpdateChannel(ctx, channel, channel.Revision); err != nil {
|
||||
t.Fatalf("channel update error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationRepositoryConcurrentClaims(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 16, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository, err := NewRepository(pool)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelID := newID()
|
||||
channel, err := repository.CreateChannel(ctx, Channel{ID: channelID, Name: "claim-memory-" + channelID[:8], Type: "memory", Enabled: true, SecretRef: SecretRef{ID: "test-secret-ref"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM notification_deliveries WHERE outbox_id IN (SELECT id FROM notification_outbox WHERE channel_id=$1); DELETE FROM notification_outbox WHERE channel_id=$1; DELETE FROM notification_channels WHERE id=$1`, channel.ID)
|
||||
}()
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
for i := 0; i < 8; i++ {
|
||||
if _, _, err := repository.Enqueue(ctx, Outbox{IdempotencyKey: fmt.Sprintf("claim-%s-%d", channelID, i), ChannelID: channel.ID, EventType: EventFiring, Subject: "Firing", Body: "test", NextAttemptAt: now, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
claimedIDs := map[string]bool{}
|
||||
errs := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
items, err := repository.ClaimDue(ctx, now, 8)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, item := range items {
|
||||
if claimedIDs[item.ID] {
|
||||
errs <- fmt.Errorf("item %s claimed twice", item.ID)
|
||||
}
|
||||
claimedIDs[item.ID] = true
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(claimedIDs) != 8 {
|
||||
t.Fatalf("claimed unique items=%d", len(claimedIDs))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user