QA-09: Vertragstest fuer API-07-Wiederholung nach zeitweisem Ausfall

Ergaenzt internal/contracttest (Core QA-07) um den fuer DMS QA-09
Pruefung 3 noetigen Nachweis: ein Webhook-Ziel, das nach mehreren
Fehlversuchen wieder erreichbar ist, wird trotzdem noch zugestellt
(TestProcessDue_RetriesWithBackoffThenMarksFailed in
internal/webhook/webhook_test.go belegt bereits den dauerhaften
Fehlschlag, nicht aber die Wiederherstellung). Liegt im Core-Modul, da
DMS die Core-internen Pakete (Dispatcher/Store) nicht importieren kann
(getrenntes Go-Modul, siehe dms/internal/retentionclient-Muster).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5 committed 2026-10-15 21:41:52 +00:00
1 parent abc7e4b3ed
commit a76350a534
1 file changed
+102
+102
View File
@@ -0,0 +1,102 @@
package contracttest
import (
"context"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
"gitea.perlbach24.de/scripte/nexarch/internal/webhook"
)
// DMS QA-09 Pruefung 3: Wiederholungsverhalten bei Ausfall ist belegt —
// Retry-Logik aus Core API-07 greift nachweislich gegen einen absichtlich
// zeitweise nicht erreichbaren Webhook-Empfaenger. internal/webhook/
// webhook_test.go belegt bereits den PERMANENTEN Fehlschlag nach
// MaxAttempts (TestProcessDue_RetriesWithBackoffThenMarksFailed); dieser
// Test ergaenzt den fuer QA-09 noch fehlenden Fall, den ein konsumierendes
// Modul wie DMS tatsaechlich erwartet: ein Ziel, das nach wenigen
// Fehlversuchen wieder erreichbar ist, wird trotzdem noch zugestellt.
var webhookTestSecretKey = []byte("0123456789abcdef0123456789abcdef"[:32])
func TestContract_API07_RetryRecoversAfterTransientFailures(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Vertragstest uebersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, adminDSN)
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS webhook_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_type TEXT NOT NULL,
target_url TEXT NOT NULL, secret_ciphertext BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), subscription_id UUID NOT NULL REFERENCES webhook_subscriptions(id),
event_type TEXT NOT NULL, payload JSONB NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
attempt INT NOT NULL DEFAULT 0, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), delivered_at TIMESTAMPTZ
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
store, err := webhook.NewStore(pool, webhookTestSecretKey)
if err != nil {
t.Fatalf("newstore: %v", err)
}
const failuresBeforeSuccess = 2
var attempts int32
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&attempts, 1)
if n <= failuresBeforeSuccess {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
loopbackPolicy := webhook.TargetPolicy{AllowHTTP: true, Allow: []string{"127.0.0.1"}}
eventType := "dms.qa09.retry-" + time.Now().Format("150405.000000000")
sub, err := store.Subscribe(ctx, loopbackPolicy, eventType, target.URL, "qa09-secret")
if err != nil {
t.Fatalf("subscribe: %v", err)
}
if _, err := store.Enqueue(ctx, eventType, map[string]string{"document_id": "doc-qa09"}); err != nil {
t.Fatalf("enqueue: %v", err)
}
dispatcher, err := webhook.NewDispatcher(pool, webhookTestSecretKey, target.Client())
if err != nil {
t.Fatalf("newdispatcher: %v", err)
}
dispatcher.MaxAttempts = failuresBeforeSuccess + 3
dispatcher.BaseBackoff = 30 * time.Millisecond
deadline := time.Now().Add(5 * time.Second)
var status string
for time.Now().Before(deadline) {
if _, err := dispatcher.ProcessDue(ctx); err != nil {
t.Fatalf("processdue: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT status FROM webhook_deliveries WHERE subscription_id = $1`, sub.ID).Scan(&status); err != nil {
t.Fatalf("status lesen: %v", err)
}
if status == webhook.StatusDelivered || status == webhook.StatusFailed {
break
}
time.Sleep(20 * time.Millisecond)
}
if status != webhook.StatusDelivered {
t.Fatalf("status nach wiederholten versuchen = %q, want %q (Retry-Logik hat ein wieder erreichbares Ziel nicht zugestellt)", status, webhook.StatusDelivered)
}
if got := atomic.LoadInt32(&attempts); got <= failuresBeforeSuccess {
t.Fatalf("tatsaechliche zustellversuche = %d, erwartet mehr als %d (kein echter Retry stattgefunden)", got, failuresBeforeSuccess)
}
}