package watcher import ( "errors" "os" "path/filepath" "sync/atomic" "testing" "time" ) func write(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } // replace mimics the agent's atomic deploy: write a temp file next to the // target and rename it over the target. func replace(t *testing.T, path, content string) { t.Helper() tmp := path + ".tmp" write(t, tmp, content) if err := os.Rename(tmp, path); err != nil { t.Fatal(err) } } type counter struct { n atomic.Int32 done chan struct{} err error } func newCounter() *counter { return &counter{done: make(chan struct{}, 16)} } func (c *counter) reload() error { c.n.Add(1) select { case c.done <- struct{}{}: default: } return c.err } func (c *counter) wait(t *testing.T) { t.Helper() select { case <-c.done: case <-time.After(5 * time.Second): t.Fatal("timeout waiting for reload") } } // start creates and starts a watcher; debounce must be set before Start so // the watcher goroutine never races with the test. func start(t *testing.T, path string, c *counter, debounce time.Duration) *Watcher { t.Helper() w, err := New([]string{path}, c.reload) if err != nil { t.Fatal(err) } w.debounce = debounce w.Start() t.Cleanup(w.Stop) time.Sleep(50 * time.Millisecond) // let the watches settle return w } func TestWatcher_ReloadsOnInPlaceWrite(t *testing.T) { path := filepath.Join(t.TempDir(), "rspamd_domains_db") write(t, path, "v1") c := newCounter() start(t, path, c, 100*time.Millisecond) write(t, path, "v2") c.wait(t) if c.n.Load() < 1 { t.Errorf("reloads = %d, want >= 1", c.n.Load()) } } // The agent deploys the CDB with mv; after the first replacement the old // kqueue file watch is stale, so a second replacement must still be seen. func TestWatcher_ReloadsOnAtomicReplaceTwice(t *testing.T) { path := filepath.Join(t.TempDir(), "rspamd_domains_db") write(t, path, "v1") c := newCounter() start(t, path, c, 100*time.Millisecond) replace(t, path, "v2") c.wait(t) first := c.n.Load() time.Sleep(150 * time.Millisecond) replace(t, path, "v3") c.wait(t) if c.n.Load() <= first { t.Errorf("second replacement not detected: reloads %d -> %d", first, c.n.Load()) } } func TestWatcher_IgnoresOtherFilesInDir(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "rspamd_domains_db") write(t, path, "v1") c := newCounter() start(t, path, c, 100*time.Millisecond) write(t, filepath.Join(dir, "other_file"), "x") time.Sleep(400 * time.Millisecond) if c.n.Load() != 0 { t.Errorf("reloads = %d for an unrelated file, want 0", c.n.Load()) } } func TestWatcher_DebouncesBurst(t *testing.T) { path := filepath.Join(t.TempDir(), "rspamd_domains_db") write(t, path, "v1") c := newCounter() start(t, path, c, 200*time.Millisecond) for i := 0; i < 5; i++ { write(t, path, "burst") time.Sleep(20 * time.Millisecond) } c.wait(t) time.Sleep(500 * time.Millisecond) if n := c.n.Load(); n > 2 { t.Errorf("reloads = %d, want 1-2 (debounced)", n) } } func TestWatcher_ContinuesAfterReloadError(t *testing.T) { path := filepath.Join(t.TempDir(), "rspamd_domains_db") write(t, path, "v1") c := newCounter() c.err = errors.New("boom") start(t, path, c, 100*time.Millisecond) write(t, path, "v2") c.wait(t) time.Sleep(150 * time.Millisecond) write(t, path, "v3") c.wait(t) if c.n.Load() < 2 { t.Errorf("reloads = %d, want >= 2 (a failing reload must not stop the watcher)", c.n.Load()) } } func TestWatcher_StopPreventsReload(t *testing.T) { path := filepath.Join(t.TempDir(), "rspamd_domains_db") write(t, path, "v1") c := newCounter() w := start(t, path, c, 100*time.Millisecond) w.Stop() write(t, path, "v2") time.Sleep(400 * time.Millisecond) if c.n.Load() != 0 { t.Errorf("reloads = %d after Stop, want 0", c.n.Load()) } w.Stop() // idempotent } func TestNew_MissingDirectory(t *testing.T) { if _, err := New([]string{"/nonexistent-dir-for-test/rspamd_domains_db"}, func() error { return nil }); err == nil { t.Fatal("expected error for a missing directory") } } func TestNew_MissingFileInExistingDirIsFine(t *testing.T) { // The CDB may be deployed after the daemon starts; the directory watch // picks up its creation. path := filepath.Join(t.TempDir(), "rspamd_domains_db") c := newCounter() w, err := New([]string{path}, c.reload) if err != nil { t.Fatal(err) } w.debounce = 100 * time.Millisecond w.Start() defer w.Stop() time.Sleep(50 * time.Millisecond) write(t, path, "v1") c.wait(t) }