199 lines
4.5 KiB
Go
199 lines
4.5 KiB
Go
// ©AngelaMos | 2026
|
|
// watch_test.go
|
|
|
|
package watch
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type fakeTicker struct{ ch chan time.Time }
|
|
|
|
func (f *fakeTicker) C() <-chan time.Time { return f.ch }
|
|
func (f *fakeTicker) Stop() {}
|
|
|
|
type fakeNotifier struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
last Report
|
|
}
|
|
|
|
func (f *fakeNotifier) Notify(_ context.Context, r Report) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls++
|
|
f.last = r
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeNotifier) count() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.calls
|
|
}
|
|
|
|
func recvWithin(t *testing.T, ch <-chan int, d time.Duration) int {
|
|
t.Helper()
|
|
select {
|
|
case v := <-ch:
|
|
return v
|
|
case <-time.After(d):
|
|
t.Fatal("timed out waiting for a watch cycle")
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func TestRunAtStartThenTicksThenGracefulShutdown(t *testing.T) {
|
|
ticker := &fakeTicker{ch: make(chan time.Time)}
|
|
ran := make(chan int, 4)
|
|
count := 0
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
opts := Options{
|
|
Interval: time.Hour,
|
|
RunAtStart: true,
|
|
NewTicker: func(time.Duration) Ticker { return ticker },
|
|
Cycle: func(context.Context) (Report, error) {
|
|
count++
|
|
ran <- count
|
|
return Report{}, nil
|
|
},
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- Run(ctx, opts) }()
|
|
|
|
if n := recvWithin(t, ran, 2*time.Second); n != 1 {
|
|
t.Fatalf("RunAtStart cycle = %d, want 1", n)
|
|
}
|
|
ticker.ch <- time.Unix(0, 0)
|
|
if n := recvWithin(t, ran, 2*time.Second); n != 2 {
|
|
t.Fatalf("post-tick cycle = %d, want 2", n)
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("Run returned %v, want nil on graceful shutdown", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Run did not return after ctx cancel")
|
|
}
|
|
}
|
|
|
|
func TestRunReturnsNilAndSkipsCycleOnImmediateCancel(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
called := false
|
|
err := Run(ctx, Options{
|
|
Interval: time.Hour,
|
|
RunAtStart: true,
|
|
NewTicker: func(time.Duration) Ticker { return &fakeTicker{ch: make(chan time.Time)} },
|
|
Cycle: func(context.Context) (Report, error) {
|
|
called = true
|
|
return Report{}, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run = %v, want nil", err)
|
|
}
|
|
if called {
|
|
t.Error("cycle should not run when the context is already cancelled")
|
|
}
|
|
}
|
|
|
|
func TestRunContinuesAfterCycleError(t *testing.T) {
|
|
ticker := &fakeTicker{ch: make(chan time.Time)}
|
|
ran := make(chan int, 4)
|
|
count := 0
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
err := make(chan error, 1)
|
|
go func() {
|
|
err <- Run(ctx, Options{
|
|
Interval: time.Hour,
|
|
RunAtStart: true,
|
|
NewTicker: func(time.Duration) Ticker { return ticker },
|
|
Cycle: func(context.Context) (Report, error) {
|
|
count++
|
|
if count == 1 {
|
|
return Report{}, errors.New("boom")
|
|
}
|
|
ran <- count
|
|
return Report{}, nil
|
|
},
|
|
})
|
|
}()
|
|
|
|
ticker.ch <- time.Unix(0, 0)
|
|
if n := recvWithin(t, ran, 2*time.Second); n != 2 {
|
|
t.Fatalf("second cycle = %d, want 2 (daemon survived the first cycle error)", n)
|
|
}
|
|
cancel()
|
|
if e := <-err; e != nil {
|
|
t.Fatalf("Run = %v, want nil", e)
|
|
}
|
|
}
|
|
|
|
func TestOnceNotifiesWhenNotable(t *testing.T) {
|
|
fn := &fakeNotifier{}
|
|
err := Once(context.Background(), Options{
|
|
Interval: time.Hour,
|
|
Cycle: func(context.Context) (Report, error) {
|
|
return Report{Notable: []NotableItem{{Title: "x"}}}, nil
|
|
},
|
|
Notifier: fn,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fn.count() != 1 {
|
|
t.Fatalf("notify calls = %d, want 1", fn.count())
|
|
}
|
|
}
|
|
|
|
func TestOnceSkipsNotifyWhenNoNotable(t *testing.T) {
|
|
fn := &fakeNotifier{}
|
|
err := Once(context.Background(), Options{
|
|
Interval: time.Hour,
|
|
Cycle: func(context.Context) (Report, error) {
|
|
return Report{NewArticles: 3}, nil
|
|
},
|
|
Notifier: fn,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fn.count() != 0 {
|
|
t.Fatalf("notify calls = %d, want 0 (nothing notable)", fn.count())
|
|
}
|
|
}
|
|
|
|
func TestOnceReturnsCycleError(t *testing.T) {
|
|
want := errors.New("cycle failed")
|
|
err := Once(context.Background(), Options{
|
|
Interval: time.Hour,
|
|
Cycle: func(context.Context) (Report, error) {
|
|
return Report{}, want
|
|
},
|
|
})
|
|
if !errors.Is(err, want) {
|
|
t.Fatalf("Once error = %v, want %v", err, want)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsBadOptions(t *testing.T) {
|
|
if err := (Options{Interval: time.Hour}).validate(); err == nil {
|
|
t.Error("validate should reject a nil Cycle")
|
|
}
|
|
if err := (Options{Cycle: func(context.Context) (Report, error) { return Report{}, nil }}).validate(); err == nil {
|
|
t.Error("validate should reject a non-positive Interval")
|
|
}
|
|
}
|