Files
felhom-controller/controller/internal/report/trigger_test.go
T
admin fe9266f53f feat(report): v0.139.0 — immediate out-of-cycle hub report on user actions (Direction 1)
New report.Trigger (buffered-1 chan + worker; quiet 2s, min spacing 15s,
trailing-edge coalescing) generalizes the v0.70.0 geo out-of-band push.
One canonical fire closure in main.go; wired: geo save/sync + app
deploy/remove/delete (api reportPushNow), escrow recovery-code claim,
notification-prefs save, app-email toggle, offsite config + per-app
toggle, customer claim (web SetReportTrigger seam, nil-safe, fired only
after a successful local commit). 15-min hub-report cycle untouched as
the reconciliation backbone; hub.enabled=false stays a strict no-op.
Tests: trigger engine (2 red-proofs), seam fires-after-commit-only,
nil-seam no-ops.
2026-07-16 19:31:19 +02:00

183 lines
5.8 KiB
Go

package report
import (
"context"
"errors"
"sync"
"testing"
"time"
)
// fireRecorder is the fake fire func: counts calls, records their times, and returns a
// settable error — the trigger tests never touch HTTP (felhom-testing doctrine).
type fireRecorder struct {
mu sync.Mutex
calls []time.Time
err error
}
func (f *fireRecorder) fire() error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, time.Now())
return f.err
}
func (f *fireRecorder) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
func (f *fireRecorder) lastCall() time.Time {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.calls) == 0 {
return time.Time{}
}
return f.calls[len(f.calls)-1]
}
func (f *fireRecorder) setErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.err = err
}
// startTrigger runs tr.Run under a test-scoped context and returns a done channel that
// closes when the worker exits.
func startTrigger(t *testing.T, tr *Trigger) chan struct{} {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
done := make(chan struct{})
go func() {
tr.Run(ctx)
close(done)
}()
return done
}
// waitForCount polls until the recorder reaches want calls or the deadline passes.
func waitForCount(t *testing.T, rec *fireRecorder, want int, within time.Duration) {
t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if rec.count() >= want {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("fire count = %d, want >= %d within %s", rec.count(), want, within)
}
// Group A (Scenario A): a single fire produces exactly ONE push, within quiet + ε.
func TestTrigger_SingleFireExactlyOnePush(t *testing.T) {
rec := &fireRecorder{}
tr := newTriggerWithPacing(rec.fire, 30*time.Millisecond, 400*time.Millisecond, nil)
startTrigger(t, tr)
tr.Fire()
waitForCount(t, rec, 1, 2*time.Second)
// Exactly one: no second push may appear (a duplicate would double-report for nothing).
time.Sleep(600 * time.Millisecond) // > quiet + minInterval
if got := rec.count(); got != 1 {
t.Fatalf("single Fire produced %d pushes, want exactly 1", got)
}
}
// Group A: Fire() is non-blocking even when nothing consumes the signal (the worker is
// mid-sleep in production) — the buffered-1 channel's default branch returns immediately.
func TestTrigger_FireNonBlocking(t *testing.T) {
rec := &fireRecorder{}
tr := newTriggerWithPacing(rec.fire, time.Hour, time.Hour, nil)
// Deliberately NOT started: the signal buffer fills after one Fire, so every
// subsequent call exercises the "worker not listening" path.
for i := 0; i < 100; i++ {
start := time.Now()
tr.Fire()
if elapsed := time.Since(start); elapsed > time.Millisecond {
t.Fatalf("Fire() call %d took %s, want < 1ms (must never block a handler)", i, elapsed)
}
}
if rec.count() != 0 {
t.Fatalf("Fire without a running worker pushed %d times, want 0", rec.count())
}
}
// Group B (Scenario B): a 10-fire burst coalesces under the hard ceiling
// (1 + ceil(burst/minInterval) = 2 for this pacing) and the LAST push happens after the
// last fire (trailing edge — the final action's state reaches the hub, never lost).
// RED-PROOF (recorded in REPORT.md): deliver every signal straight to fire (naive
// `for { <-signal; fire() }` loop) → this test fails with ~10 calls.
func TestTrigger_BurstCoalescesTrailingEdge(t *testing.T) {
rec := &fireRecorder{}
// quiet 30ms, minInterval 400ms; burst spans ~45ms → ceiling = 1 + ceil(45/400) = 2.
tr := newTriggerWithPacing(rec.fire, 30*time.Millisecond, 400*time.Millisecond, nil)
startTrigger(t, tr)
var lastFire time.Time
for i := 0; i < 10; i++ {
tr.Fire()
lastFire = time.Now()
time.Sleep(5 * time.Millisecond)
}
// Let the burst fully settle: quiet + minInterval + generous margin.
waitForCount(t, rec, 1, 2*time.Second)
time.Sleep(700 * time.Millisecond)
got := rec.count()
if got < 1 || got > 2 {
t.Fatalf("10-fire burst produced %d pushes, want 1..2 (ceiling = 1 + ceil(burst/minInterval))", got)
}
if last := rec.lastCall(); !last.After(lastFire) {
t.Fatalf("last push at %s is not after the last fire at %s — trailing edge lost (10th action's state would wait for the 15-min cycle)", last.Format(time.RFC3339Nano), lastFire.Format(time.RFC3339Nano))
}
}
// Group C (Scenario C): a fire error is isolated — the worker keeps running and a
// SUBSEQUENT fire still pushes (the failed state is reconciled by the next cycle, the
// trigger itself adds no retry).
// RED-PROOF (recorded in REPORT.md): make Run return on fire error → the second
// waitForCount here fails (no push ever comes).
func TestTrigger_FireErrorWorkerContinues(t *testing.T) {
rec := &fireRecorder{err: errors.New("hub unreachable")}
tr := newTriggerWithPacing(rec.fire, 20*time.Millisecond, 50*time.Millisecond, nil)
startTrigger(t, tr)
tr.Fire()
waitForCount(t, rec, 1, 2*time.Second) // the failing push was attempted
rec.setErr(nil) // "hub back up"
tr.Fire()
waitForCount(t, rec, 2, 2*time.Second) // the worker survived the error and pushed again
}
// §8: the worker exits promptly on context cancel, even while mid-wait (a pending fire
// may be dropped — the scheduled cycle covers it; shutdown never hangs on the trigger).
func TestTrigger_CancelDuringWaitExitsPromptly(t *testing.T) {
rec := &fireRecorder{}
tr := newTriggerWithPacing(rec.fire, time.Hour, time.Hour, nil) // waits would block ~forever
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
tr.Run(ctx)
close(done)
}()
tr.Fire() // worker enters the hour-long quiet sleep
time.Sleep(20 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Run did not exit within 1s of context cancel")
}
if rec.count() != 0 {
t.Fatalf("cancelled mid-quiet-window but fired %d times, want 0", rec.count())
}
}