- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
410 lines
13 KiB
Go
410 lines
13 KiB
Go
package compaction
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"testing"
|
||
"time"
|
||
|
||
"switchboard-core/database"
|
||
"switchboard-core/models"
|
||
postgres "switchboard-core/store/postgres"
|
||
)
|
||
|
||
// ── Helpers ─────────────────────────────────
|
||
|
||
func requireDB(t *testing.T) {
|
||
t.Helper()
|
||
database.RequireTestDB(t)
|
||
database.TruncateAll(t)
|
||
}
|
||
|
||
func seedChannel(t *testing.T, userID, title string, msgCount, contentSize int) (channelID string, msgIDs []string) {
|
||
t.Helper()
|
||
channelID = database.SeedTestChannel(t, userID, title)
|
||
msgIDs = database.SeedTestMessages(t, channelID, msgCount, contentSize)
|
||
if len(msgIDs) > 0 {
|
||
database.SeedTestCursor(t, channelID, userID, msgIDs[len(msgIDs)-1])
|
||
}
|
||
return
|
||
}
|
||
|
||
// setGlobalSetting writes a global_settings key for scanner config tests.
|
||
func setGlobalSetting(t *testing.T, key string, value interface{}) {
|
||
t.Helper()
|
||
valMap := models.JSONMap{"value": value}
|
||
valJSON, _ := json.Marshal(valMap)
|
||
_, err := database.DB.Exec(`
|
||
INSERT INTO global_settings (key, value) VALUES ($1, $2)
|
||
ON CONFLICT (key) DO UPDATE SET value = $2
|
||
`, key, string(valJSON))
|
||
if err != nil {
|
||
t.Fatalf("setGlobalSetting(%s): %v", key, err)
|
||
}
|
||
}
|
||
|
||
// seedChannelBackdated creates a channel with updated_at set to (now - age).
|
||
// Uses INSERT with an explicit timestamp so the BEFORE UPDATE trigger on
|
||
// channels (which overwrites updated_at = NOW()) never fires.
|
||
func seedChannelBackdated(t *testing.T, userID, title string, age time.Duration, msgCount, contentSize int) (channelID string, msgIDs []string) {
|
||
t.Helper()
|
||
target := time.Now().Add(-age)
|
||
err := database.DB.QueryRow(`
|
||
INSERT INTO channels (user_id, title, type, updated_at)
|
||
VALUES ($1, $2, 'direct', $3)
|
||
RETURNING id
|
||
`, userID, title, target).Scan(&channelID)
|
||
if err != nil {
|
||
t.Fatalf("seedChannelBackdated: %v", err)
|
||
}
|
||
msgIDs = database.SeedTestMessages(t, channelID, msgCount, contentSize)
|
||
if len(msgIDs) > 0 {
|
||
database.SeedTestCursor(t, channelID, userID, msgIDs[len(msgIDs)-1])
|
||
}
|
||
return
|
||
}
|
||
|
||
// setChannelSettings sets the channel.settings JSONB.
|
||
func setChannelSettings(t *testing.T, channelID string, settings models.JSONMap) {
|
||
t.Helper()
|
||
sJSON, _ := json.Marshal(settings)
|
||
_, err := database.DB.Exec(`UPDATE channels SET settings = $1 WHERE id = $2`, string(sJSON), channelID)
|
||
if err != nil {
|
||
t.Fatalf("setChannelSettings: %v", err)
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// Estimator Tests (no DB — duplicated here for package-level access)
|
||
// ═══════════════════════════════════════════
|
||
// Note: estimator_test.go covers the unit tests more thoroughly.
|
||
// These are here to verify the package compiles with DB tests.
|
||
|
||
func TestEstimateTokens_Basic(t *testing.T) {
|
||
if got := EstimateTokens("hello world!"); got != 3 { // 12 chars → 3
|
||
t.Errorf("EstimateTokens = %d, want 3", got)
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// Scanner: Candidate Query
|
||
// ═══════════════════════════════════════════
|
||
|
||
func TestScanner_FindCandidates_ReturnsQualifying(t *testing.T) {
|
||
requireDB(t)
|
||
stores := postgres.NewStores(database.TestDB)
|
||
svc := NewService(stores, nil) // no resolver needed for this test
|
||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||
|
||
userID := database.SeedTestUser(t, "scanuser1", "scan1@test.com")
|
||
|
||
// Create a channel with enough messages to qualify
|
||
// 20 messages × 2000 chars = 40K chars (> candidateMinChars=20K)
|
||
// Backdated 5min so it passes activity gap (>2min) and recency (<7 days)
|
||
channelID, _ := seedChannelBackdated(t, userID, "Big Chat", 5*time.Minute, 20, 2000)
|
||
|
||
ctx := context.Background()
|
||
candidates := sc.findCandidates(ctx)
|
||
|
||
if len(candidates) == 0 {
|
||
t.Fatal("expected at least 1 candidate, got 0")
|
||
}
|
||
|
||
found := false
|
||
for _, c := range candidates {
|
||
if c.ID == channelID {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("channel %s not in candidates (got %d candidates)", channelID, len(candidates))
|
||
}
|
||
}
|
||
|
||
func TestScanner_FindCandidates_ExcludesTooFewMessages(t *testing.T) {
|
||
requireDB(t)
|
||
stores := postgres.NewStores(database.TestDB)
|
||
svc := NewService(stores, nil)
|
||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||
|
||
userID := database.SeedTestUser(t, "scanuser2", "scan2@test.com")
|
||
|
||
// Only 4 messages — below candidateMinMessages=10
|
||
channelID, _ := seedChannelBackdated(t, userID, "Small Chat", 5*time.Minute, 4, 2000)
|
||
|
||
ctx := context.Background()
|
||
candidates := sc.findCandidates(ctx)
|
||
|
||
for _, c := range candidates {
|
||
if c.ID == channelID {
|
||
t.Fatal("channel with <10 messages should not be a candidate")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestScanner_FindCandidates_ExcludesTooRecent(t *testing.T) {
|
||
requireDB(t)
|
||
stores := postgres.NewStores(database.TestDB)
|
||
svc := NewService(stores, nil)
|
||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||
|
||
userID := database.SeedTestUser(t, "scanuser3", "scan3@test.com")
|
||
|
||
// Enough messages but updated just now (< candidateActivityGap=2min)
|
||
seedChannel(t, userID, "Fresh Chat", 20, 2000)
|
||
// Don't backdate — should be excluded
|
||
|
||
ctx := context.Background()
|
||
candidates := sc.findCandidates(ctx)
|
||
|
||
for _, c := range candidates {
|
||
if c.UserID == userID {
|
||
t.Fatal("channel updated just now should not be a candidate (activity gap)")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestScanner_FindCandidates_ExcludesArchived(t *testing.T) {
|
||
requireDB(t)
|
||
stores := postgres.NewStores(database.TestDB)
|
||
svc := NewService(stores, nil)
|
||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||
|
||
userID := database.SeedTestUser(t, "scanuser4", "scan4@test.com")
|
||
channelID, _ := seedChannelBackdated(t, userID, "Archived Chat", 5*time.Minute, 20, 2000)
|
||
|
||
// Archive the channel
|
||
database.DB.Exec(`UPDATE channels SET is_archived = true WHERE id = $1`, channelID)
|
||
|
||
ctx := context.Background()
|
||
candidates := sc.findCandidates(ctx)
|
||
|
||
for _, c := range candidates {
|
||
if c.ID == channelID {
|
||
t.Fatal("archived channel should not be a candidate")
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// Scanner: Cooldown + Dedup
|
||
// ═══════════════════════════════════════════
|
||
|
||
func TestScanner_Cooldown(t *testing.T) {
|
||
sc := &Scanner{
|
||
lastCompacted: make(map[string]time.Time),
|
||
}
|
||
|
||
channelID := "test-cooldown-channel"
|
||
|
||
// No cooldown initially
|
||
sc.mu.Lock()
|
||
_, inCooldown := sc.lastCompacted[channelID]
|
||
sc.mu.Unlock()
|
||
if inCooldown {
|
||
t.Fatal("should not be in cooldown initially")
|
||
}
|
||
|
||
// Record compaction
|
||
sc.mu.Lock()
|
||
sc.lastCompacted[channelID] = time.Now()
|
||
sc.mu.Unlock()
|
||
|
||
// Now it's in cooldown
|
||
sc.mu.Lock()
|
||
last, ok := sc.lastCompacted[channelID]
|
||
sc.mu.Unlock()
|
||
if !ok {
|
||
t.Fatal("should be in cooldown after recording")
|
||
}
|
||
if time.Since(last) > time.Second {
|
||
t.Fatal("cooldown timestamp should be recent")
|
||
}
|
||
}
|
||
|
||
func TestScanner_InFlightDedup(t *testing.T) {
|
||
sc := &Scanner{}
|
||
// sync.Map zero value is ready to use
|
||
|
||
channelID := "test-dedup-channel"
|
||
|
||
// First load — not in flight
|
||
_, loaded := sc.inFlight.LoadOrStore(channelID, struct{}{})
|
||
if loaded {
|
||
t.Fatal("should not be loaded on first attempt")
|
||
}
|
||
|
||
// Second load — already in flight
|
||
_, loaded = sc.inFlight.LoadOrStore(channelID, struct{}{})
|
||
if !loaded {
|
||
t.Fatal("should be loaded on second attempt (dedup)")
|
||
}
|
||
|
||
// Clean up
|
||
sc.inFlight.Delete(channelID)
|
||
_, loaded = sc.inFlight.LoadOrStore(channelID, struct{}{})
|
||
if loaded {
|
||
t.Fatal("should not be loaded after delete")
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// Scanner: Channel Opt-Out
|
||
// ═══════════════════════════════════════════
|
||
|
||
func TestScanner_ShouldCompact_ChannelOptOut(t *testing.T) {
|
||
requireDB(t)
|
||
stores := postgres.NewStores(database.TestDB)
|
||
svc := NewService(stores, nil) // no resolver → IsConfigured returns false
|
||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||
|
||
userID := database.SeedTestUser(t, "optout_user", "optout@test.com")
|
||
channelID, _ := seedChannel(t, userID, "Opt-Out Chat", 20, 2000)
|
||
|
||
// Opt out via channel settings
|
||
setChannelSettings(t, channelID, models.JSONMap{"auto_compaction": false})
|
||
|
||
ch := models.Channel{Settings: models.JSONMap{"auto_compaction": false}}
|
||
ch.ID = channelID
|
||
ch.UserID = userID
|
||
|
||
ctx := context.Background()
|
||
if sc.shouldCompact(ctx, &ch) {
|
||
t.Fatal("shouldCompact should return false for opted-out channel")
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// Scanner: Settings Helpers
|
||
// ═══════════════════════════════════════════
|
||
|
||
func TestScanner_IsEnabled(t *testing.T) {
|
||
requireDB(t)
|
||
stores := postgres.NewStores(database.TestDB)
|
||
svc := NewService(stores, nil)
|
||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||
ctx := context.Background()
|
||
|
||
// Default: disabled
|
||
if sc.isEnabled(ctx) {
|
||
t.Fatal("should be disabled by default")
|
||
}
|
||
|
||
// Enable
|
||
setGlobalSetting(t, "auto_compaction_enabled", true)
|
||
if !sc.isEnabled(ctx) {
|
||
t.Fatal("should be enabled after setting to true")
|
||
}
|
||
|
||
// Disable again
|
||
setGlobalSetting(t, "auto_compaction_enabled", false)
|
||
if sc.isEnabled(ctx) {
|
||
t.Fatal("should be disabled after setting to false")
|
||
}
|
||
}
|
||
|
||
func TestScanner_GetThreshold_Default(t *testing.T) {
|
||
requireDB(t)
|
||
stores := postgres.NewStores(database.TestDB)
|
||
svc := NewService(stores, nil)
|
||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||
|
||
ch := &models.Channel{}
|
||
got := sc.getThreshold(ch)
|
||
if got != DefaultThreshold {
|
||
t.Errorf("default threshold = %f, want %f", got, DefaultThreshold)
|
||
}
|
||
}
|
||
|
||
func TestScanner_GetThreshold_GlobalOverride(t *testing.T) {
|
||
requireDB(t)
|
||
stores := postgres.NewStores(database.TestDB)
|
||
svc := NewService(stores, nil)
|
||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||
|
||
setGlobalSetting(t, "auto_compaction_threshold", 0.85)
|
||
|
||
ch := &models.Channel{}
|
||
got := sc.getThreshold(ch)
|
||
if got != 0.85 {
|
||
t.Errorf("global threshold = %f, want 0.85", got)
|
||
}
|
||
}
|
||
|
||
func TestScanner_GetThreshold_ChannelOverride(t *testing.T) {
|
||
requireDB(t)
|
||
stores := postgres.NewStores(database.TestDB)
|
||
svc := NewService(stores, nil)
|
||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||
|
||
setGlobalSetting(t, "auto_compaction_threshold", 0.85)
|
||
|
||
ch := &models.Channel{
|
||
Settings: models.JSONMap{"compaction_threshold": 0.60},
|
||
}
|
||
got := sc.getThreshold(ch)
|
||
if got != 0.60 {
|
||
t.Errorf("channel threshold = %f, want 0.60", got)
|
||
}
|
||
}
|
||
|
||
func TestScanner_GetCooldownDuration_Default(t *testing.T) {
|
||
requireDB(t)
|
||
stores := postgres.NewStores(database.TestDB)
|
||
svc := NewService(stores, nil)
|
||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||
ctx := context.Background()
|
||
|
||
got := sc.getCooldownDuration(ctx)
|
||
if got != DefaultCooldown {
|
||
t.Errorf("default cooldown = %s, want %s", got, DefaultCooldown)
|
||
}
|
||
}
|
||
|
||
func TestScanner_GetCooldownDuration_Override(t *testing.T) {
|
||
requireDB(t)
|
||
stores := postgres.NewStores(database.TestDB)
|
||
svc := NewService(stores, nil)
|
||
sc := NewScanner(svc, stores, ScannerConfig{})
|
||
ctx := context.Background()
|
||
|
||
setGlobalSetting(t, "auto_compaction_cooldown_minutes", float64(15))
|
||
|
||
got := sc.getCooldownDuration(ctx)
|
||
want := 15 * time.Minute
|
||
if got != want {
|
||
t.Errorf("cooldown = %s, want %s", got, want)
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// Context Budget Guard Rail
|
||
// ═══════════════════════════════════════════
|
||
|
||
func TestEstimateTokens_GuardRailMath(t *testing.T) {
|
||
// Simulate a large conversation that exceeds a 32K utility model
|
||
// 120K chars ≈ 30K tokens of conversation + system prompt overhead
|
||
// inputCeiling = 32K * 0.80 = 25.6K → 30K exceeds it
|
||
largeContent := make([]byte, 120000)
|
||
contentTokens := EstimateTokens(string(largeContent))
|
||
systemTokens := EstimateTokens("You are a conversation summarizer...") + 8 // +overhead
|
||
|
||
totalPrompt := contentTokens + systemTokens
|
||
utilityBudget := 32000 // 32K model
|
||
inputCeiling := int(float64(utilityBudget) * 0.80)
|
||
|
||
if totalPrompt <= inputCeiling {
|
||
t.Errorf("120K chars (%d tokens) should exceed 32K model ceiling (%d tokens)",
|
||
totalPrompt, inputCeiling)
|
||
}
|
||
|
||
// Smaller conversation should fit
|
||
smallContent := make([]byte, 50000)
|
||
smallTokens := EstimateTokens(string(smallContent)) + systemTokens
|
||
if smallTokens > inputCeiling {
|
||
t.Errorf("50K chars (%d tokens) should fit in 32K model ceiling (%d tokens)",
|
||
smallTokens, inputCeiling)
|
||
}
|
||
}
|