Files
ITWorx-Pulse-Public/internal/notification/repository_integration_test.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

250 lines
9.4 KiB
Go

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))
}
}