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