mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116) Route the tray Status submenu through saveUserStatus() (mapping the legacy "offline" to "invisible") so notifications, autoIdle, and reconnect presence restore all agree with the tray's choice; build the connected overlay from the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the TOTP overlay open across a rejected verify (totpPending latch) and retain the partial token for the retry instead of clearing it in finally. Hand-applied combined cluster preserved from the previous fix run's overlap-guard block (both clusters edit main.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 2 defect(s) (OC-0010, OC-0011) * fix(ws): 1 defect(s) (OC-0050) * fix(db): 1 defect(s) (OC-0052) * fix(client): 1 defect(s) (OC-0054) * fix(client): 1 defect(s) (OC-0059) * fix(auth): 1 defect(s) (OC-0061) * fix(ws): 1 defect(s) (OC-0062) * fix(client): 1 defect(s) (OC-0064) * fix(service): 1 defect(s) (OC-0070) * fix(ws): 1 defect(s) (OC-0073) * fix(service): 2 defect(s) (OC-0075, OC-0120) * fix(admin): 1 defect(s) (OC-0076) * fix(voice): 1 defect(s) (OC-0084) * fix(client): 2 defect(s) (OC-0085, OC-0094) Scope collapsed-category persistence to the connected host instead of the server display name, and stop the DM back button from jumping to the first text channel when DM mode was entered without recording channelBeforeDm. * fix(service): 1 defect(s) (OC-0087) * fix(client): 1 defect(s) (OC-0089) * fix(ws): 1 defect(s) (OC-0091) * fix(api): 1 defect(s) (OC-0093) * fix(identity): 1 defect(s) (OC-0118) * fix(dm): 1 defect(s) (OC-0119) * fix(voice): 1 defect(s) (OC-0135) * fix(api): 1 defect(s) (OC-0137) * fix(client): 1 defect(s) (OC-0142) * fix(client): 1 defect(s) (OC-0144) * fix(admin): 1 defect(s) (OC-0145) * fix(updater): 1 defect(s) (OC-0146) * fix(client): 1 defect(s) (OC-0150) * fix(mentions): 1 defect(s) (OC-0131) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
170 lines
5.1 KiB
Go
170 lines
5.1 KiB
Go
// 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")
|
|
}
|
|
}
|