Files
kandev/apps/backend/internal/system/disk/usage_test.go
T

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")
}
}