Files
sultan/pkg/stream/client_test.go
T
br c8df9d7651 erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
2026-10-01 22:22:13 +02:00

334 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package stream
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestBackoff(t *testing.T) {
const minD, maxD = time.Second, 60 * time.Second
tests := map[int]time.Duration{
0: time.Second,
1: 2 * time.Second,
5: 32 * time.Second,
6: 60 * time.Second,
100: 60 * time.Second,
}
for attempt, want := range tests {
if got := backoff(attempt, minD, maxD); got != want {
t.Errorf("backoff(%d) = %v, erwartet %v", attempt, got, want)
}
}
if got := backoff(3, 5*time.Second, 5*time.Second); got != 5*time.Second {
t.Errorf("min == max: %v", got)
}
}
func TestClient_Defaults(t *testing.T) {
var c Client
if got := c.idleTimeout(); got != 90*time.Second {
t.Errorf("IdleTimeout-Default = %v, erwartet 90s (2 × Keepalive)", got)
}
if got := c.minBackoff(); got != time.Second {
t.Errorf("MinBackoff-Default = %v", got)
}
if got := c.maxBackoff(); got != time.Minute {
t.Errorf("MaxBackoff-Default = %v", got)
}
if c.httpClient() != http.DefaultClient {
t.Error("HTTPClient-Default sollte http.DefaultClient sein")
}
}
// Ende-zu-Ende: Client verbindet sich über Run mit einem Hub, Prepare setzt
// Authentifizierung, ein vom Hub gesendetes Event kommt beim Handler an.
func TestClient_ReceivesEvents(t *testing.T) {
h := &Hub{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer geheim" {
http.Error(w, "nein", http.StatusUnauthorized)
return
}
if r.Header.Get("Accept") != "text/event-stream" {
http.Error(w, "falscher Accept", http.StatusNotAcceptable)
return
}
h.Serve(w, r, "node-1")
}))
t.Cleanup(srv.Close)
t.Cleanup(h.Close)
c := &Client{
URL: srv.URL,
Prepare: func(r *http.Request) error { r.Header.Set("Authorization", "Bearer geheim"); return nil },
}
got := make(chan Event, 1)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- c.Run(ctx, func(ev Event) error { got <- ev; return nil }) }()
eventually(t, "Client verbunden", func() bool { return h.Connected("node-1") })
if err := h.Send("node-1", Event{Type: EventNodeCommand}); err != nil {
t.Fatal(err)
}
select {
case ev := <-got:
if ev.Type != EventNodeCommand || ev.NodeID != "node-1" {
t.Errorf("unerwartetes Event: %+v", ev)
}
case <-time.After(2 * time.Second):
t.Fatal("kein Event empfangen")
}
cancel()
if err := waitErr(t, done); !errors.Is(err, context.Canceled) {
t.Errorf("Run: erwartet context.Canceled, bekommen %v", err)
}
}
func TestClient_UnexpectedResponse(t *testing.T) {
tests := map[string]http.HandlerFunc{
"Status 401": func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nein", http.StatusUnauthorized)
},
"falscher Content-Type": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("hallo"))
},
}
for name, handler := range tests {
t.Run(name, func(t *testing.T) {
srv := httptest.NewServer(handler)
defer srv.Close()
c := &Client{URL: srv.URL}
err := c.Connect(context.Background(), func(Event) error { return nil })
if !errors.Is(err, ErrUnexpectedResponse) {
t.Errorf("erwartet ErrUnexpectedResponse, bekommen %v", err)
}
})
}
}
// sseHandler sendet nur die Header und hält die Verbindung ohne Daten offen.
func silentSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
<-r.Context().Done()
}
func TestClient_IdleTimeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(silentSSE))
defer srv.Close()
c := &Client{URL: srv.URL, IdleTimeout: 50 * time.Millisecond}
start := time.Now()
err := c.Connect(context.Background(), func(Event) error { return nil })
if !errors.Is(err, ErrIdleTimeout) {
t.Fatalf("erwartet ErrIdleTimeout, bekommen %v", err)
}
if d := time.Since(start); d > time.Second {
t.Errorf("Timeout griff erst nach %v", d)
}
}
func TestClient_KeepAlivePreventsIdleTimeout(t *testing.T) {
h := &Hub{KeepAlive: 10 * time.Millisecond}
srv, _ := hubServer(t, h)
c := &Client{URL: srv.URL + "?node=n", IdleTimeout: 60 * time.Millisecond}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- c.Connect(ctx, func(Event) error { return nil }) }()
select {
case err := <-done:
t.Fatalf("Verbindung trotz Keepalives beendet: %v", err)
case <-time.After(300 * time.Millisecond):
}
cancel()
if err := waitErr(t, done); !errors.Is(err, context.Canceled) {
t.Errorf("erwartet context.Canceled, bekommen %v", err)
}
}
func TestClient_ServerClosesStream(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Write([]byte("data: " + evJSON + "\n\n"))
}))
defer srv.Close()
var got []Event
err := (&Client{URL: srv.URL}).Connect(context.Background(), func(ev Event) error {
got = append(got, ev)
return nil
})
if !errors.Is(err, ErrStreamClosed) {
t.Errorf("erwartet ErrStreamClosed, bekommen %v", err)
}
if !reflect.DeepEqual(got, []Event{evWant}) {
t.Errorf("Events: %+v", got)
}
}
func TestClient_PrepareError(t *testing.T) {
errSign := errors.New("Signatur fehlgeschlagen")
c := &Client{URL: "http://127.0.0.1:1", Prepare: func(*http.Request) error { return errSign }}
if err := c.Connect(context.Background(), nil); !errors.Is(err, errSign) {
t.Errorf("erwartet Prepare-Fehler, bekommen %v", err)
}
}
func TestClient_ConnectionRefused(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
url := srv.URL
srv.Close()
if err := (&Client{URL: url}).Connect(context.Background(), nil); err == nil {
t.Error("erwartet Verbindungsfehler")
}
}
func TestClient_HandlerErrorStopsRun(t *testing.T) {
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: " + evJSON + "\n\n"))
}))
defer srv.Close()
errStop := errors.New("Handler bricht ab")
err := (&Client{URL: srv.URL}).Run(context.Background(), func(Event) error { return errStop })
if !errors.Is(err, errStop) {
t.Errorf("erwartet Handler-Fehler, bekommen %v", err)
}
if n := requests.Load(); n != 1 {
t.Errorf("%d Verbindungsversuche, erwartet 1 (kein Reconnect nach Handler-Fehler)", n)
}
}
// Ablauf: 503, 503, erfolgreicher Stream (endet), danach nur noch 503.
// Erwartet: exponentielles Backoff, Reset nach erfolgreicher Verbindung,
// Deckelung bei MaxBackoff.
func TestClient_RunRetriesWithBackoff(t *testing.T) {
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requests.Add(1) == 3 {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: " + evJSON + "\n\n"))
return
}
http.Error(w, "später", http.StatusServiceUnavailable)
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var mu sync.Mutex
var delays []time.Duration
var reported int
var events atomic.Int32
c := &Client{
URL: srv.URL,
MinBackoff: 10 * time.Millisecond,
MaxBackoff: 40 * time.Millisecond,
OnError: func(err error, retryIn time.Duration) {
mu.Lock()
defer mu.Unlock()
if err == nil {
t.Error("OnError ohne Fehler aufgerufen")
}
reported++
},
sleep: func(ctx context.Context, d time.Duration) error {
mu.Lock()
defer mu.Unlock()
delays = append(delays, d)
if len(delays) == 6 {
cancel()
return ctx.Err()
}
return nil
},
}
err := c.Run(ctx, func(Event) error { events.Add(1); return nil })
if !errors.Is(err, context.Canceled) {
t.Fatalf("erwartet context.Canceled, bekommen %v", err)
}
ms := time.Millisecond
want := []time.Duration{10 * ms, 20 * ms, 10 * ms, 20 * ms, 40 * ms, 40 * ms}
mu.Lock()
defer mu.Unlock()
if !reflect.DeepEqual(delays, want) {
t.Errorf("Backoff-Folge = %v, erwartet %v", delays, want)
}
if reported != 6 {
t.Errorf("OnError %d-mal aufgerufen, erwartet 6", reported)
}
if events.Load() != 1 {
t.Errorf("%d Events empfangen, erwartet 1", events.Load())
}
}
func TestClient_ConnectReturnsHandlerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: " + evJSON + "\n\n"))
}))
defer srv.Close()
errStop := errors.New("stopp")
err := (&Client{URL: srv.URL}).Connect(context.Background(), func(Event) error { return errStop })
if err != errStop {
t.Errorf("erwartet den unverpackten Handler-Fehler, bekommen %v", err)
}
}
func TestClient_CustomHTTPClient(t *testing.T) {
var used atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
}))
defer srv.Close()
hc := srv.Client()
base := hc.Transport
hc.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
used.Store(true)
return base.RoundTrip(r)
})
err := (&Client{URL: srv.URL, HTTPClient: hc}).Connect(context.Background(), nil)
if !errors.Is(err, ErrStreamClosed) {
t.Errorf("erwartet ErrStreamClosed, bekommen %v", err)
}
if !used.Load() {
t.Error("eigener HTTPClient wurde nicht verwendet")
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestSleepCtx(t *testing.T) {
if err := sleepCtx(context.Background(), time.Millisecond); err != nil {
t.Errorf("abgelaufener Timer: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
if err := sleepCtx(ctx, time.Hour); !errors.Is(err, context.Canceled) {
t.Errorf("erwartet context.Canceled, bekommen %v", err)
}
if time.Since(start) > time.Second {
t.Error("sleepCtx hat trotz Abbruch gewartet")
}
}