Ctrl K
ComunidadGuías…

internal/server/analytics_retention_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: Apache-2.0

package server

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	appdb "github.com/X4Applegate/caddyui/internal/db"
	"github.com/X4Applegate/caddyui/internal/models"
)

func newRetentionTestServer(t *testing.T) (*Server, string) {
	t.Helper()
	path := filepath.Join(t.TempDir(), "caddyui.db")
	conn, err := appdb.Open(path)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	return &Server{DB: conn, DBPath: path}, path
}

func seedAccessEvents(t *testing.T, s *Server, n int, age time.Duration) {
	t.Helper()
	ts := time.Now().Add(-age)
	for i := 0; i < n; i++ {
		if err := models.InsertAccessEvent(s.DB, models.AccessEvent{TS: ts.Add(time.Duration(i) * time.Second), ServerID: 1, Host: "app.example.test", Path: "/x", Method: "GET", Status: 200, ClientIP: "10.0.0.1", UserAgent: strings.Repeat("ua", 40), BytesOut: 1234}); err != nil {
			t.Fatal(err)
		}
	}
}

// v2.43.0: the prune respects the retention setting, deletes in batches,
// records what it did, and leaves newer rows alone; 0 keeps everything.
func TestRunAccessPruneHonoursRetention(t *testing.T) {
	s, _ := newRetentionTestServer(t)
	seedAccessEvents(t, s, 45, 40*24*time.Hour) // old
	seedAccessEvents(t, s, 5, time.Hour)        // recent
	if days := analyticsRetentionDays(s); days != defaultAnalyticsRetentionDays {
		t.Fatalf("default retention = %d", days)
	}

	st := s.runAccessPrune(true)
	if st.Error != "" || st.Deleted != 45 || st.Remaining != 5 || st.FinishedAt == nil || st.Cutoff == nil || !st.Manual {
		t.Fatalf("status = %+v", st)
	}
	if got := s.lastPruneStatus(); got == nil || got.Deleted != 45 {
		t.Fatalf("stored status = %+v", got)
	}
	oldest, newest, err := models.AccessEventBounds(s.DB)
	if err != nil || time.Since(oldest) > 2*time.Hour || newest.Before(oldest) {
		t.Fatalf("bounds = %v .. %v, %v", oldest, newest, err)
	}

	// Batches smaller than the backlog still remove everything old.
	seedAccessEvents(t, s, 30, 60*24*time.Hour)
	var seen []int64
	n, err := models.PruneAccessEventsBatched(s.DB, time.Now().Add(-30*24*time.Hour), 7, false, time.Millisecond, nil, func(t int64) { seen = append(seen, t) })
	if err != nil || n != 30 || len(seen) != 5 || seen[len(seen)-1] != 30 {
		t.Fatalf("batched prune = %d, %v (progress %v)", n, err, seen)
	}
	// stop is honoured between batches.
	seedAccessEvents(t, s, 20, 60*24*time.Hour)
	calls := 0
	n, err = models.PruneAccessEventsBatched(s.DB, time.Now().Add(-30*24*time.Hour), 5, false, 0, func() bool { calls++; return calls > 2 }, nil)
	if err != nil || n != 10 {
		t.Fatalf("stopped prune = %d, %v", n, err)
	}

	// Retention 0 = keep forever.
	if err := models.SetSetting(s.DB, settingAnalyticsRetentionDays, "0"); err != nil {
		t.Fatal(err)
	}
	if st := s.runAccessPrune(false); st.Deleted != 0 || st.Cutoff != nil {
		t.Fatalf("retention 0 must not delete: %+v", st)
	}
	if got, _ := models.CountAccessEvents(s.DB); got != 15 {
		t.Fatalf("count after keep-forever = %d, want 15", got)
	}
	if v, err := parseAnalyticsRetentionDays("", 30); err != nil || v != 30 {
		t.Errorf("blank keeps current: %d, %v", v, err)
	}
	if _, err := parseAnalyticsRetentionDays("4000", 30); err == nil {
		t.Error("out of range must be rejected")
	}
	if v, err := parseAnalyticsRetentionDays(" 7 ", 30); err != nil || v != 7 {
		t.Errorf("7 days: %d, %v", v, err)
	}
}

// Reclaim space rewrites the SQLite file without the deleted rows: the file
// shrinks, the status records before/after, and a second run is refused
// while one is in flight.
func TestStartVacuumShrinksTheDatabase(t *testing.T) {
	s, path := newRetentionTestServer(t)
	seedAccessEvents(t, s, 4000, 40*24*time.Hour)
	if _, err := s.DB.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
		t.Fatal(err)
	}
	before, _ := os.Stat(path)
	if st := s.runAccessPrune(true); st.Deleted != 4000 {
		t.Fatalf("prune = %+v", st)
	}
	view := s.analyticsStorageView()
	if !view.SQLite || view.DBBytes == 0 || view.FreePageBytes == 0 || !view.VacuumPossible {
		t.Fatalf("storage view before reclaim = %+v", view)
	}
	if err := s.startVacuum(); err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(20 * time.Second)
	for {
		if st := s.lastVacuumStatus(); st != nil && st.FinishedAt != nil {
			if st.Error != "" || st.AfterBytes >= st.BeforeBytes || st.BeforeBytes != before.Size() {
				t.Fatalf("vacuum status = %+v (before %d)", st, before.Size())
			}
			break
		}
		if time.Now().After(deadline) {
			t.Fatal("vacuum did not finish")
		}
		time.Sleep(50 * time.Millisecond)
	}
	after := s.analyticsStorageView()
	if after.FreePageBytes != 0 || after.DBBytes >= before.Size() {
		t.Fatalf("storage view after reclaim = %+v", after)
	}
	if os.Getenv("SQLITE_TMPDIR") == "" {
		t.Error("SQLITE_TMPDIR should have been pointed at the data directory")
	}
}