228 lines
7.1 KiB
Go
228 lines
7.1 KiB
Go
// Tests for the lazy-cached disk-usage Service. Covers the four branches of
|
|
// Get described in the spec (cold-not-computing, cold-computing, fresh,
|
|
// stale) plus Refresh's force-walk semantics. Uses the in-process
|
|
// jobs.Tracker with a stub event bus.
|
|
package disk
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/system/jobs"
|
|
)
|
|
|
|
// stubBus is a minimal in-memory EventBus used by the jobs.Tracker. We only
|
|
// need Publish to succeed; the other methods are no-ops to satisfy the
|
|
// interface.
|
|
type stubBus struct {
|
|
mu sync.Mutex
|
|
events []*bus.Event
|
|
}
|
|
|
|
func (s *stubBus) Publish(_ context.Context, _ string, event *bus.Event) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.events = append(s.events, event)
|
|
return nil
|
|
}
|
|
|
|
func (s *stubBus) Subscribe(string, bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *stubBus) QueueSubscribe(string, string, bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *stubBus) Request(context.Context, string, *bus.Event, time.Duration) (*bus.Event, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *stubBus) Close() {}
|
|
func (s *stubBus) IsConnected() bool { return true }
|
|
|
|
func newTestLogger(t *testing.T) *logger.Logger {
|
|
t.Helper()
|
|
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stderr"})
|
|
if err != nil {
|
|
t.Fatalf("new logger: %v", err)
|
|
}
|
|
return log
|
|
}
|
|
|
|
// newServiceWithTmpHome builds a Service rooted at a fresh tmp dir with a
|
|
// known layout: data/ + worktrees/ + repos/ + data/backups/. The remaining
|
|
// subdirs (sessions/tasks/quick-chat) are intentionally absent to exercise
|
|
// the missing-root path.
|
|
func newServiceWithTmpHome(t *testing.T) (*Service, string) {
|
|
t.Helper()
|
|
home := t.TempDir()
|
|
writeFile(t, filepath.Join(home, "data", "kandev.db"), []byte("0123456789")) // 10
|
|
writeFile(t, filepath.Join(home, "data", "backups", "auto.db"), []byte("AAAA")) // 4
|
|
writeFile(t, filepath.Join(home, "worktrees", "ws1", "file"), []byte("BB")) // 2
|
|
writeFile(t, filepath.Join(home, "repos", "r", "x"), []byte("CCC")) // 3
|
|
tracker := jobs.NewTracker(&stubBus{}, newTestLogger(t))
|
|
return NewService(home, tracker, newTestLogger(t)), home
|
|
}
|
|
|
|
// waitForBreakdown polls until the Service caches a value or the deadline
|
|
// fires; mirrors the jobs_test.go waitForState helper.
|
|
func waitForBreakdown(t *testing.T, svc *Service) *Breakdown {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
svc.mu.Lock()
|
|
v := svc.value
|
|
svc.mu.Unlock()
|
|
if v != nil {
|
|
return v
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("breakdown not cached within 2s")
|
|
return nil
|
|
}
|
|
|
|
func TestGet_ColdReturnsComputingAndKicksWalk(t *testing.T) {
|
|
svc, _ := newServiceWithTmpHome(t)
|
|
|
|
first := svc.Get(context.Background())
|
|
if first.Data != nil {
|
|
t.Errorf("first Get.Data = %+v, want nil", first.Data)
|
|
}
|
|
if !first.Computing {
|
|
t.Error("first Get.Computing = false, want true")
|
|
}
|
|
|
|
got := waitForBreakdown(t, svc)
|
|
// data 10 + backups 4 + worktrees 2 + repos 3 = 19. Note that data
|
|
// includes backups (because backups lives under data/) so they are
|
|
// counted twice intentionally — backups is its own bucket but also
|
|
// physically nested in data_dir on disk.
|
|
const wantTotal = int64(10 + 4 + 2 + 3 + 4) // data=14 (10+4), worktrees=2, repos=3, backups=4
|
|
if got.Total != wantTotal {
|
|
t.Errorf("Total = %d, want %d (data 14 + worktrees 2 + repos 3 + backups 4)", got.Total, wantTotal)
|
|
}
|
|
if got.DataDir != 14 {
|
|
t.Errorf("DataDir = %d, want 14", got.DataDir)
|
|
}
|
|
if got.Backups != 4 {
|
|
t.Errorf("Backups = %d, want 4", got.Backups)
|
|
}
|
|
if got.Worktrees != 2 {
|
|
t.Errorf("Worktrees = %d, want 2", got.Worktrees)
|
|
}
|
|
if got.Repos != 3 {
|
|
t.Errorf("Repos = %d, want 3", got.Repos)
|
|
}
|
|
if got.Sessions != 0 || got.Tasks != 0 || got.QuickChat != 0 {
|
|
t.Errorf("expected missing subdirs to be zero, got sessions=%d tasks=%d quick=%d",
|
|
got.Sessions, got.Tasks, got.QuickChat)
|
|
}
|
|
if got.ComputedAt.IsZero() {
|
|
t.Error("ComputedAt is zero")
|
|
}
|
|
}
|
|
|
|
func TestGet_FreshReturnsCachedWithoutRecomputing(t *testing.T) {
|
|
svc, _ := newServiceWithTmpHome(t)
|
|
svc.Get(context.Background())
|
|
first := waitForBreakdown(t, svc)
|
|
|
|
// Capture the result-publishing event count before the second Get so
|
|
// we can prove no new walk was scheduled.
|
|
second := svc.Get(context.Background())
|
|
if second.Data == nil {
|
|
t.Fatal("second Get.Data = nil, want cached breakdown")
|
|
}
|
|
if second.Computing {
|
|
t.Error("second Get.Computing = true, want false (fresh cache)")
|
|
}
|
|
if !second.Data.ComputedAt.Equal(first.ComputedAt) {
|
|
t.Errorf("second ComputedAt = %s, want unchanged %s", second.Data.ComputedAt, first.ComputedAt)
|
|
}
|
|
|
|
// Give any rogue background walk a chance to overwrite the cache. If
|
|
// the implementation kicked one off, ComputedAt would shift.
|
|
time.Sleep(50 * time.Millisecond)
|
|
svc.mu.Lock()
|
|
final := svc.value
|
|
svc.mu.Unlock()
|
|
if !final.ComputedAt.Equal(first.ComputedAt) {
|
|
t.Errorf("cache was rewritten after fresh Get; ComputedAt drifted from %s to %s",
|
|
first.ComputedAt, final.ComputedAt)
|
|
}
|
|
}
|
|
|
|
func TestGet_StaleReturnsValueAndKicksRefresh(t *testing.T) {
|
|
svc, _ := newServiceWithTmpHome(t)
|
|
svc.Get(context.Background())
|
|
first := waitForBreakdown(t, svc)
|
|
|
|
// Rewind ComputedAt past the 2h TTL.
|
|
stale := time.Now().Add(-3 * time.Hour)
|
|
svc.setComputedAt(stale)
|
|
|
|
// Add a new file so the refreshed walk produces a different Total.
|
|
writeFile(t, filepath.Join(svc.homeDir, "tasks", "t1", "f"), []byte("DDDDD")) // 5
|
|
|
|
res := svc.Get(context.Background())
|
|
if res.Data == nil {
|
|
t.Fatal("stale Get.Data = nil, want stale value")
|
|
}
|
|
if !res.Computing {
|
|
t.Error("stale Get.Computing = false, want true (refresh kicked)")
|
|
}
|
|
|
|
// Wait for the refresh to land — Total should grow by the new file.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
svc.mu.Lock()
|
|
v := svc.value
|
|
svc.mu.Unlock()
|
|
if v != nil && v.Total > first.Total {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("refresh did not update cache; first Total=%d still current", first.Total)
|
|
}
|
|
|
|
func TestRefresh_AlwaysKicksWalkAndReturnsJobID(t *testing.T) {
|
|
svc, _ := newServiceWithTmpHome(t)
|
|
id := svc.Refresh(context.Background())
|
|
if id == "" {
|
|
t.Fatal("Refresh returned empty job ID")
|
|
}
|
|
if got := svc.jobs.Get(id); got == nil {
|
|
t.Errorf("tracker has no record of job %s", id)
|
|
}
|
|
waitForBreakdown(t, svc)
|
|
}
|
|
|
|
func TestGet_ColdComputingReturnsNilDataAndComputingTrue(t *testing.T) {
|
|
// Directly exercises the "cold-computing" branch of Get (value==nil,
|
|
// computing==true) by setting the flag under the mutex before calling Get.
|
|
// This avoids a timing dependency: the previous approach called Get twice
|
|
// and hoped the background walk hadn't finished yet, which is unreliable on
|
|
// fast hardware with a small tmp dir.
|
|
svc, _ := newServiceWithTmpHome(t)
|
|
|
|
svc.mu.Lock()
|
|
svc.computing = true
|
|
svc.mu.Unlock()
|
|
|
|
res := svc.Get(context.Background())
|
|
if res.Data != nil {
|
|
t.Errorf("Get.Data = %+v, want nil", res.Data)
|
|
}
|
|
if !res.Computing {
|
|
t.Error("Get.Computing = false, want true")
|
|
}
|
|
}
|