package compaction import ( "context" "encoding/json" "testing" "time" "chat-switchboard/database" "chat-switchboard/models" postgres "chat-switchboard/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) } }