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()) } }