// Pass 4 follow-up — event pruner unit tests. // // Covers runPrune correctness (cutoff calculation + error path) and the // StartEventPruner goroutine lifecycle (nil store short-circuit, ctx // cancellation, startup-delay-bounded-by-interval behaviour introduced // in the Copilot review fix). package ws import ( "context" "errors" "sync" "sync/atomic" "testing" "time" "github.com/owncord/server/db" ) // fakeEventStore is a minimal EventStore stub that records every prune // call and optionally returns a canned error. Only the methods actually // exercised by the pruner are implemented; the rest panic so an accidental // code path change is noisy. type fakeEventStore struct { mu sync.Mutex pruneCalls int lastCutoff time.Time pruneReturn int64 pruneErr error pruneSignal chan struct{} // closed (via atomic swap) once a prune happens pruneDone atomic.Bool } func (f *fakeEventStore) PruneEventsOlderThan(_ context.Context, cutoff time.Time) (int64, error) { f.mu.Lock() f.pruneCalls++ f.lastCutoff = cutoff ret := f.pruneReturn err := f.pruneErr f.mu.Unlock() if !f.pruneDone.Swap(true) && f.pruneSignal != nil { close(f.pruneSignal) } return ret, err } func (f *fakeEventStore) Calls() int { f.mu.Lock() defer f.mu.Unlock() return f.pruneCalls } func (f *fakeEventStore) LastCutoff() time.Time { f.mu.Lock() defer f.mu.Unlock() return f.lastCutoff } // Stubs for the rest of the EventStore interface — not exercised here. func (*fakeEventStore) PersistEvents(context.Context, []db.PersistedEvent) (int, error) { return 0, nil } func (*fakeEventStore) PersistEvent(context.Context, int64, string, int64, []byte) error { panic("unused") } func (*fakeEventStore) GetEventsSince(context.Context, int64, int) ([]db.PersistedEvent, error) { panic("unused") } func (*fakeEventStore) GetEventsSinceForChannels(context.Context, int64, []int64, int) ([]db.PersistedEvent, error) { panic("unused") } func (*fakeEventStore) CountEventsInRange(context.Context, int64, int64) (int64, error) { panic("unused") } func (*fakeEventStore) GetMaxEventSeq(context.Context) (int64, error) { panic("unused") } func TestRunPruneCutoffCalculation(t *testing.T) { s := &fakeEventStore{pruneReturn: 3} retention := 24 * time.Hour before := time.Now() runPrune(context.Background(), s, retention) after := time.Now() if s.Calls() != 1 { t.Fatalf("expected 1 prune call, got %d", s.Calls()) } cutoff := s.LastCutoff() // cutoff must be in the window [before - retention, after - retention]. minCutoff := before.Add(-retention) maxCutoff := after.Add(-retention) if cutoff.Before(minCutoff) || cutoff.After(maxCutoff) { t.Errorf("cutoff %v not in expected window [%v, %v]", cutoff, minCutoff, maxCutoff) } } func TestRunPruneErrorDoesNotPanic(t *testing.T) { s := &fakeEventStore{pruneErr: errors.New("boom")} // Must not panic, must not propagate — error is logged and swallowed // so the background goroutine keeps ticking. runPrune(context.Background(), s, time.Hour) if s.Calls() != 1 { t.Fatalf("expected 1 prune call even on error, got %d", s.Calls()) } } func TestStartEventPrunerNilStoreIsNoop(t *testing.T) { // Should not spawn a goroutine, should not panic. ctx := t.Context() StartEventPruner(ctx, nil, time.Hour, time.Hour) // If the nil check were missing, calling PruneEventsOlderThan on nil // would panic inside the goroutine — but since we don't spawn one, // there's nothing to assert beyond "we got here". } func TestStartEventPrunerContextCancellation(t *testing.T) { s := &fakeEventStore{pruneSignal: make(chan struct{})} ctx, cancel := context.WithCancel(context.Background()) // Short interval so startup delay is bounded to the interval (50ms). StartEventPruner(ctx, s, time.Hour, 50*time.Millisecond) // Wait for the first prune to happen so we know the goroutine started. select { case <-s.pruneSignal: case <-time.After(2 * time.Second): t.Fatal("pruner did not run within 2s") } // Cancel and give the goroutine a moment to exit. There's no direct // handle to join on, but we can verify no further prunes happen after // a grace period. cancel() time.Sleep(150 * time.Millisecond) callsAfterCancel := s.Calls() time.Sleep(200 * time.Millisecond) if s.Calls() != callsAfterCancel { t.Errorf("pruner kept running after ctx cancel: %d -> %d calls", callsAfterCancel, s.Calls()) } } func TestStartEventPrunerStartupDelayBoundedByInterval(t *testing.T) { // With interval=20ms and the uncapped startup delay of 1 minute, the // test would have to wait a full minute for the first prune. The // Copilot-review fix caps the startup delay at min(interval, 1min), // so with interval=20ms the first prune happens within ~20ms. s := &fakeEventStore{pruneSignal: make(chan struct{})} ctx := t.Context() start := time.Now() StartEventPruner(ctx, s, time.Hour, 20*time.Millisecond) select { case <-s.pruneSignal: elapsed := time.Since(start) if elapsed > 500*time.Millisecond { t.Errorf("startup delay not bounded by interval: first prune took %v", elapsed) } case <-time.After(2 * time.Second): t.Fatal("pruner did not run within 2s — startup delay likely not bounded") } }