package healthcheck import ( "context" "io" "log/slog" "sync" "testing" "time" ) type fakeStore struct { mu sync.Mutex known []string stale map[string]bool // deviceID -> whether MarkOfflineIfStale should report a change changes []string // devices actually marked changed, in call order } func (f *fakeStore) KnownDevices(context.Context) ([]string, error) { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.known...), nil } func (f *fakeStore) MarkOfflineIfStale(_ context.Context, deviceID string, _ time.Duration) (bool, error) { f.mu.Lock() defer f.mu.Unlock() if !f.stale[deviceID] { return false, nil } // Only report the transition once, like the real store would. f.stale[deviceID] = false f.changes = append(f.changes, deviceID) return true, nil } func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func TestChecker_FlipsStaleDevicesAndCallsOnOffline(t *testing.T) { store := &fakeStore{ known: []string{"d1", "d2"}, stale: map[string]bool{"d1": true, "d2": false}, } offline := make(chan string, 2) c := New(store, time.Minute, 10*time.Millisecond, func(deviceID string) { offline <- deviceID }, discardLogger()) c.Start() defer c.Stop() select { case id := <-offline: if id != "d1" { t.Fatalf("got offline callback for %q, want d1", id) } case <-time.After(time.Second): t.Fatal("timed out waiting for onOffline callback") } select { case id := <-offline: t.Fatalf("unexpected second onOffline callback for %q (d2 was never stale)", id) case <-time.After(50 * time.Millisecond): // expected: no further callbacks } } func TestChecker_StopWaitsForLoopExit(t *testing.T) { store := &fakeStore{known: nil} c := New(store, time.Minute, time.Hour, nil, discardLogger()) c.Start() c.Stop() // must return without hanging }