fe9266f53f
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.
183 lines
5.8 KiB
Go
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())
|
|
}
|
|
}
|