Files
felhom-controller/controller/internal/report/waiter_test.go
T
admin 2dd05670ae feat(report): Direction-2 immediate-sync wait channel client (v0.140.0)
report.Waiter holds a hanging GET against hub /api/v1/wait?gen=N (same hub
URL+key as the pusher). On a generation change it fires the v0.139.0
report.Trigger — nothing else; the report ACK delivers everything through the
unchanged machinery. No overall client timeout (held GET); per-request ctx
bounds a dead connection. First-observation records-not-fires; same-gen
timeout fires nothing; errors (incl. 404 from a pre-v0.58.0 hub) back off
5s->5min while the 15-min cycle reconciles. Wired beside the trigger under the
same hubPusher!=nil && Hub.Enabled gate.

Red-proof: disable the baseline branch -> first observation fires
(TestWaiter_FirstObservationRecordsNoFire), run-fail-reverted.

Copy soften: backups_remote/escrow "néhány másodperc, legfeljebb 15 perc".
Pairs with hub v0.58.0. Grounding:
felhom.eu/documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md
2026-07-16 20:55:30 +02:00

159 lines
5.3 KiB
Go

package report
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func testWaiter(t *testing.T, handler http.HandlerFunc) (*Waiter, *int32, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
var fires int32
w := NewWaiter(srv.URL, "KEY", func() { atomic.AddInt32(&fires, 1) }, log.New(io.Discard, "", 0))
// Shrink tunables so the loop spins in milliseconds.
w.holdCeiling = 2 * time.Second
w.backoffMin = 10 * time.Millisecond
w.backoffMax = 40 * time.Millisecond
w.jitterMin = 3 * time.Millisecond
w.jitterMax = 6 * time.Millisecond
return w, &fires, srv
}
// runFor runs the waiter for d, then cancels and waits for Run to return.
func runFor(w *Waiter, d time.Duration) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { w.Run(ctx); close(done) }()
time.Sleep(d)
cancel()
<-done
}
// genSequence returns a handler that answers the Nth request with seq[min(N-1,len-1)] (clamped),
// optionally prefixed with heartbeat newlines.
func genSequence(heartbeats int, seq ...uint64) http.HandlerFunc {
var n int32
return func(w http.ResponseWriter, r *http.Request) {
i := int(atomic.AddInt32(&n, 1)) - 1
if i >= len(seq) {
i = len(seq) - 1
}
fl, _ := w.(http.Flusher)
for h := 0; h < heartbeats; h++ {
io.WriteString(w, "\n")
if fl != nil {
fl.Flush()
}
}
fmt.Fprintf(w, "{\"gen\":%d}\n", seq[i])
}
}
func TestWaiter_FiresOnceOnGenChange(t *testing.T) {
// baseline gen 0, then 1, then steady 1 → exactly one fire.
w, fires, _ := testWaiter(t, genSequence(0, 0, 1, 1, 1, 1))
runFor(w, 200*time.Millisecond)
if got := atomic.LoadInt32(fires); got != 1 {
t.Fatalf("expected exactly 1 fire on a single gen change, got %d", got)
}
}
func TestWaiter_TimeoutSameGenNoFire(t *testing.T) {
// The hub keeps returning the same generation (its hold timed out) → never fires.
w, fires, _ := testWaiter(t, genSequence(0, 7, 7, 7, 7, 7))
runFor(w, 150*time.Millisecond)
if got := atomic.LoadInt32(fires); got != 0 {
t.Fatalf("same-gen timeouts must not fire, got %d", got)
}
}
// TestWaiter_FirstObservationRecordsNoFire is the load-bearing property with its RED-PROOF anchor:
// a fresh process whose FIRST wait returns an already-advanced generation records it WITHOUT firing
// (the startup report already covered current state; firing would echo one extra report on every
// restart / config-refresh).
//
// RED-PROOF: drop the `!w.seen` guard in Run (fire whenever gen != lastSeen, with lastSeen starting
// at 0) — the first poll here returns gen 5 != 0 and fires once; this assertion (0 fires) then fails.
// Revert to restore green.
func TestWaiter_FirstObservationRecordsNoFire(t *testing.T) {
w, fires, _ := testWaiter(t, genSequence(0, 5, 5, 5, 5)) // existing non-zero gen, never changes
runFor(w, 150*time.Millisecond)
if got := atomic.LoadInt32(fires); got != 0 {
t.Fatalf("first observation must record-not-fire; got %d fires", got)
}
}
func TestWaiter_HeartbeatsToleratedThenFire(t *testing.T) {
// 3 heartbeat newlines precede each completion; baseline 0 then 1 → one fire, no panic.
w, fires, _ := testWaiter(t, genSequence(3, 0, 1, 1, 1))
runFor(w, 200*time.Millisecond)
if got := atomic.LoadInt32(fires); got != 1 {
t.Fatalf("heartbeats must be tolerated and the change fire once; got %d", got)
}
}
func TestWaiter_MalformedCompletionNoFireNoPanic(t *testing.T) {
w, fires, _ := testWaiter(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "\n\nnot-json\n")
})
runFor(w, 120*time.Millisecond)
if got := atomic.LoadInt32(fires); got != 0 {
t.Fatalf("a malformed completion must not fire; got %d", got)
}
}
func TestWaiter_404TreatedAsErrorNoFire(t *testing.T) {
// A hub that predates the endpoint returns 404 — the Waiter backs off, never fires, keeps looping.
w, fires, _ := testWaiter(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
})
runFor(w, 120*time.Millisecond)
if got := atomic.LoadInt32(fires); got != 0 {
t.Fatalf("404 must be an ordinary (no-fire) error; got %d", got)
}
}
func TestWaiter_SendsBearerAndGenParam(t *testing.T) {
var sawAuth, sawGen atomic.Value
sawAuth.Store("")
sawGen.Store("")
w, _, _ := testWaiter(t, func(w http.ResponseWriter, r *http.Request) {
sawAuth.Store(r.Header.Get("Authorization"))
sawGen.Store(r.URL.Query().Get("gen"))
fmt.Fprint(w, "{\"gen\":0}\n")
})
runFor(w, 80*time.Millisecond)
if sawAuth.Load().(string) != "Bearer KEY" {
t.Fatalf("expected Bearer KEY, got %q", sawAuth.Load())
}
if sawGen.Load().(string) != "0" {
t.Fatalf("expected gen=0 on the first poll, got %q", sawGen.Load())
}
}
func TestWaiter_CtxCancelMidHoldReturnsPromptly(t *testing.T) {
// The server holds until the client disconnects; cancelling the ctx must return Run promptly.
w, _, _ := testWaiter(t, func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done() // hold until the client goes away
})
w.holdCeiling = 10 * time.Second // must NOT be what unblocks us
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { w.Run(ctx); close(done) }()
time.Sleep(40 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not return promptly after ctx cancel mid-hold")
}
}