package quality import ( "encoding/json" "os" "path/filepath" "testing" "time" ) func TestNewStateManager(t *testing.T) { dataDir := "/tmp/test_state" sm := NewStateManager(dataDir) if sm == nil { t.Error("NewStateManager() should not return nil") } if sm.dataDir != dataDir { t.Errorf("NewStateManager() dataDir = %v, want %v", sm.dataDir, dataDir) } expectedStateFile := filepath.Join(dataDir, "state.json") if sm.stateFile != expectedStateFile { t.Errorf("NewStateManager() stateFile = %v, want %v", sm.stateFile, expectedStateFile) } expectedHistoryDir := filepath.Join(dataDir, "history") if sm.historyDir != expectedHistoryDir { t.Errorf("NewStateManager() historyDir = %v, want %v", sm.historyDir, expectedHistoryDir) } } func TestStateManager_Load(t *testing.T) { tmpDir, err := os.MkdirTemp("", "state_test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) sm := NewStateManager(tmpDir) // Test loading non-existent file state, err := sm.Load() if err != nil { t.Errorf("Load() should not error for non-existent file: %v", err) } if state == nil { t.Error("Load() should return empty state for non-existent file") } if len(state.Findings) != 0 { t.Errorf("Load() should return empty findings for non-existent file, got %d", len(state.Findings)) } if state.Metadata == nil { t.Error("Load() should initialize metadata map") } // Test loading existing file testState := &State{ Findings: []Finding{ {ID: "test1", Type: "test", Title: "Test Finding 1", Status: StatusOpen}, {ID: "test2", Type: "test", Title: "Test Finding 2", Status: StatusFixed}, }, ScanCount: 5, Metadata: map[string]string{"env": "test"}, } data, _ := json.Marshal(testState) stateFile := sm.stateFile os.WriteFile(stateFile, data, 0644) loadedState, err := sm.Load() if err != nil { t.Errorf("Load() failed: %v", err) } if loadedState.ScanCount != 5 { t.Errorf("Load() ScanCount = %v, want 5", loadedState.ScanCount) } if len(loadedState.Findings) != 2 { t.Errorf("Load() findings count = %v, want 2", len(loadedState.Findings)) } if loadedState.Metadata["env"] != "test" { t.Errorf("Load() metadata = %v, want test", loadedState.Metadata["env"]) } } func TestStateManager_Load_InvalidJSON(t *testing.T) { tmpDir, err := os.MkdirTemp("", "state_test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) sm := NewStateManager(tmpDir) // Write invalid JSON stateFile := sm.stateFile os.WriteFile(stateFile, []byte("{ invalid json"), 0644) _, err = sm.Load() if err == nil { t.Error("Load() should error for invalid JSON") } } func TestStateManager_Save(t *testing.T) { tmpDir, err := os.MkdirTemp("", "state_test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) sm := NewStateManager(tmpDir) state := &State{ Findings: []Finding{ {ID: "test1", Type: "test", Title: "Test Finding", Status: StatusOpen}, }, Scorecard: &Scorecard{TotalScore: 100, StrictScore: 50}, ScanCount: 1, Metadata: map[string]string{"env": "test"}, } err = sm.Save(state) if err != nil { t.Errorf("Save() failed: %v", err) } // Verify file was created if _, err := os.Stat(sm.stateFile); err != nil { t.Errorf("Save() should create state file: %v", err) } // Verify content hash was calculated if state.ContentHash == "" { t.Error("Save() should calculate content hash") } // Load and verify loadedState, err := sm.Load() if err != nil { t.Errorf("Save() failed to load saved state: %v", err) } if len(loadedState.Findings) != 1 { t.Errorf("Save() should save findings, got %d", len(loadedState.Findings)) } if loadedState.ScanCount != 1 { t.Errorf("Save() should increment scan count, got %d", loadedState.ScanCount) } } func TestStateManager_Save_History(t *testing.T) { tmpDir, err := os.MkdirTemp("", "state_test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) sm := NewStateManager(tmpDir) state := &State{ Findings: []Finding{{ID: "test", Type: "test", Title: "Test", Status: StatusOpen}}, Scorecard: &Scorecard{TotalScore: 100, StrictScore: 50}, } err = sm.Save(state) if err != nil { t.Errorf("Save() failed: %v", err) } // Check history directory was created if _, err := os.Stat(sm.historyDir); err != nil { t.Errorf("Save() should create history directory: %v", err) } // Check history file was created historyFiles, err := filepath.Glob(filepath.Join(sm.historyDir, "*.json")) if err != nil { t.Errorf("Save() failed to list history files: %v", err) } if len(historyFiles) != 1 { t.Errorf("Save() should create 1 history file, got %d", len(historyFiles)) } } func TestStateManager_Merge(t *testing.T) { sm := NewStateManager("/tmp") existingState := &State{ Findings: []Finding{ {ID: "existing1", Type: "test", Title: "Existing 1", Status: StatusOpen, Score: 5}, {ID: "existing2", Type: "test", Title: "Existing 2", Status: StatusOpen, Score: 10}, {ID: "existing3", Type: "test", Title: "Existing 3", Status: StatusOpen, Score: 15}, }, } newFindings := []Finding{ {ID: "existing1", Type: "test", Title: "Existing 1 Changed", Status: StatusOpen, Score: 5}, // Changed {ID: "new1", Type: "test", Title: "New Finding", Status: StatusOpen, Score: 20}, // Added {ID: "new2", Type: "test", Title: "New Finding 2", Status: StatusOpen, Score: 25}, // Added } diff := sm.Merge(existingState, newFindings) if len(diff.Added) != 2 { t.Errorf("Merge() added count = %v, want 2", len(diff.Added)) } if len(diff.Changed) != 1 { t.Errorf("Merge() changed count = %v, want 1", len(diff.Changed)) } if len(diff.Resolved) != 2 { t.Errorf("Merge() resolved count = %v, want 2", len(diff.Resolved)) } if len(existingState.Findings) != 3 { t.Errorf("Merge() should update state findings count to %d, got %d", len(newFindings), len(existingState.Findings)) } if existingState.ScanCount != 1 { t.Errorf("Merge() should increment scan count to 1, got %d", existingState.ScanCount) } } func TestStateManager_Merge_Resolved(t *testing.T) { sm := NewStateManager("/tmp") existingState := &State{ Findings: []Finding{ {ID: "open1", Type: "test", Title: "Open Finding", Status: StatusOpen}, {ID: "open2", Type: "test", Title: "Open Finding 2", Status: StatusOpen}, }, } newFindings := []Finding{ {ID: "open1", Type: "test", Title: "Open Finding", Status: StatusOpen}, // Kept {ID: "new1", Type: "test", Title: "New Finding", Status: StatusOpen}, // Added } diff := sm.Merge(existingState, newFindings) if len(diff.Added) != 1 { t.Errorf("Merge() added count = %v, want 1", len(diff.Added)) } if len(diff.Resolved) != 1 { t.Errorf("Merge() resolved count = %v, want 1", len(diff.Resolved)) } if diff.Resolved[0].ID != "open2" { t.Errorf("Merge() resolved wrong finding: %s", diff.Resolved[0].ID) } } func TestStateManager_Diff(t *testing.T) { sm := NewStateManager("/tmp") oldState := &State{ Findings: []Finding{ {ID: "old1", Type: "test", Title: "Old Finding", Status: StatusOpen}, {ID: "old2", Type: "test", Title: "Old Finding 2", Status: StatusFixed}, }, } newState := &State{ Findings: []Finding{ {ID: "old1", Type: "test", Title: "Old Finding Changed", Status: StatusOpen}, // Changed {ID: "new1", Type: "test", Title: "New Finding", Status: StatusOpen}, // Added {ID: "old2", Type: "test", Title: "Old Finding 2", Status: StatusOpen}, // Regression }, } diff := sm.Diff(oldState, newState) if len(diff.Added) != 1 { t.Errorf("Diff() added count = %v, want 1", len(diff.Added)) } if len(diff.Changed) != 2 { t.Errorf("Diff() changed count = %v, want 2", len(diff.Changed)) } if len(diff.Removed) != 0 { t.Errorf("Diff() removed count = %v, want 0", len(diff.Removed)) } if len(diff.Regressions) != 1 { t.Errorf("Diff() regressions count = %v, want 1", len(diff.Regressions)) } } func TestStateManager_calculateHash(t *testing.T) { sm := NewStateManager("/tmp") findings := []Finding{ {ID: "test1", Type: "test", Title: "Test 1", Status: StatusOpen}, {ID: "test2", Type: "test", Title: "Test 2", Status: StatusOpen}, } hash1 := sm.calculateHash(findings) hash2 := sm.calculateHash(findings) if hash1 != hash2 { t.Errorf("calculateHash() should be deterministic, got %s and %s", hash1, hash2) } if len(hash1) != 16 { t.Errorf("calculateHash() should return 16 character hash, got %d", len(hash1)) } // Test with different order reversed := []Finding{findings[1], findings[0]} hash3 := sm.calculateHash(reversed) if hash1 != hash3 { t.Errorf("calculateHash() should be order-independent, got %s and %s", hash1, hash3) } } func TestStateManager_saveHistory(t *testing.T) { tmpDir, err := os.MkdirTemp("", "state_test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) sm := NewStateManager(tmpDir) state := &State{ Findings: []Finding{{ID: "test", Type: "test", Title: "Test", Status: StatusOpen}}, Scorecard: &Scorecard{TotalScore: 100, StrictScore: 50}, ContentHash: "testhash", } err = sm.saveHistory(state) if err != nil { t.Errorf("saveHistory() failed: %v", err) } // Check history file was created files, err := filepath.Glob(filepath.Join(sm.historyDir, "*.json")) if err != nil { t.Errorf("saveHistory() failed to list files: %v", err) } if len(files) != 1 { t.Errorf("saveHistory() should create 1 file, got %d", len(files)) } // Verify snapshot content snapshotFile := files[0] data, err := os.ReadFile(snapshotFile) if err != nil { t.Errorf("saveHistory() failed to read snapshot file: %v", err) } // The file contains the full state, not a snapshot var savedState State if err := json.Unmarshal(data, &savedState); err != nil { t.Errorf("saveHistory() failed to parse saved state: %v", err) } if len(savedState.Findings) != 1 { t.Errorf("saveHistory() saved state findings count = %v, want 1", len(savedState.Findings)) } if savedState.Scorecard.TotalScore != 100 { t.Errorf("saveHistory() saved state score = %v, want 100", savedState.Scorecard.TotalScore) } if savedState.ContentHash != "testhash" { t.Errorf("saveHistory() saved state hash = %v, want testhash", savedState.ContentHash) } } func TestStateManager_ResolveFinding(t *testing.T) { sm := NewStateManager("/tmp") state := &State{ Findings: []Finding{ {ID: "test1", Type: "test", Title: "Test Finding", Status: StatusOpen}, {ID: "test2", Type: "test", Title: "Test Finding 2", Status: StatusOpen}, }, } // Resolve existing finding err := sm.ResolveFinding(state, "test1", StatusFixed, "Fixed the issue") if err != nil { t.Errorf("ResolveFinding() failed: %v", err) } if state.Findings[0].Status != StatusFixed { t.Errorf("ResolveFinding() status = %v, want Fixed", state.Findings[0].Status) } if state.Findings[0].Metadata["resolution_note"] != "Fixed the issue" { t.Errorf("ResolveFinding() resolution_note = %v, want 'Fixed the issue'", state.Findings[0].Metadata["resolution_note"]) } // Try to resolve non-existent finding err = sm.ResolveFinding(state, "nonexistent", StatusFixed, "note") if err == nil { t.Error("ResolveFinding() should error for non-existent finding") } } func TestStateManager_GetFinding(t *testing.T) { sm := NewStateManager("/tmp") state := &State{ Findings: []Finding{ {ID: "test1", Type: "test", Title: "Test Finding", Status: StatusOpen}, {ID: "test2", Type: "test", Title: "Test Finding 2", Status: StatusOpen}, }, } // Get existing finding finding := sm.GetFinding(state, "test1") if finding == nil { t.Error("GetFinding() should return finding for existing ID") } if finding.ID != "test1" { t.Errorf("GetFinding() returned wrong finding ID: %s", finding.ID) } // Get non-existent finding finding = sm.GetFinding(state, "nonexistent") if finding != nil { t.Error("GetFinding() should return nil for non-existent ID") } } func TestStateManager_GetOpenFindings(t *testing.T) { sm := NewStateManager("/tmp") state := &State{ Findings: []Finding{ {ID: "open1", Type: "test", Title: "Open Finding", Status: StatusOpen}, {ID: "fixed1", Type: "test", Title: "Fixed Finding", Status: StatusFixed}, {ID: "open2", Type: "test", Title: "Open Finding 2", Status: StatusOpen}, }, } open := sm.GetOpenFindings(state) if len(open) != 2 { t.Errorf("GetOpenFindings() count = %v, want 2", len(open)) } for _, f := range open { if f.Status != StatusOpen { t.Errorf("GetOpenFindings() should only return open findings, got %v", f.Status) } } } func TestStateManager_GetFindingsByTier(t *testing.T) { sm := NewStateManager("/tmp") state := &State{ Findings: []Finding{ {ID: "t1", Type: "test", Title: "T1 Finding", Status: StatusOpen, Severity: SeverityT1}, {ID: "t2", Type: "test", Title: "T2 Finding", Status: StatusOpen, Severity: SeverityT2}, {ID: "t3", Type: "test", Title: "T3 Finding", Status: StatusOpen, Severity: SeverityT3}, {ID: "t4", Type: "test", Title: "T4 Finding", Status: StatusOpen, Severity: SeverityT4}, }, } byTier := sm.GetFindingsByTier(state) if len(byTier) != 4 { t.Errorf("GetFindingsByTier() should return 4 tiers, got %d", len(byTier)) } if len(byTier[SeverityT1]) != 1 { t.Errorf("GetFindingsByTier() T1 count = %v, want 1", len(byTier[SeverityT1])) } if len(byTier[SeverityT4]) != 1 { t.Errorf("GetFindingsByTier() T4 count = %v, want 1", len(byTier[SeverityT4])) } } func TestStateManager_GetTrend(t *testing.T) { sm := NewStateManager("/tmp") state := &State{ History: []StateSnapshot{ {Timestamp: time.Now().Add(-4 * time.Hour), Score: 100, Findings: 10}, {Timestamp: time.Now().Add(-3 * time.Hour), Score: 90, Findings: 12}, {Timestamp: time.Now().Add(-2 * time.Hour), Score: 80, Findings: 15}, {Timestamp: time.Now().Add(-1 * time.Hour), Score: 70, Findings: 18}, {Timestamp: time.Now(), Score: 60, Findings: 20}, }, } // Get last 3 snapshots trend := sm.GetTrend(state, 3) if len(trend) != 3 { t.Errorf("GetTrend() should return 3 snapshots, got %d", len(trend)) } // Verify order (should be chronological, oldest first) if trend[0].Score != 80 { t.Errorf("GetTrend() first snapshot should be oldest: %d", trend[0].Score) } if trend[2].Score != 60 { t.Errorf("GetTrend() last snapshot should be most recent: %d", trend[2].Score) } // Request more than available allTrend := sm.GetTrend(state, 10) if len(allTrend) != 5 { t.Errorf("GetTrend() should return all available snapshots when requesting more than available: %d", len(allTrend)) } } func TestFindingsEqual(t *testing.T) { finding1 := Finding{ ID: "test", Type: "test", Title: "Test", File: "test.go", Line: 10, Severity: SeverityT2, Score: 5, Status: StatusOpen, } finding2 := Finding{ ID: "test", Type: "test", Title: "Test", File: "test.go", Line: 10, Severity: SeverityT2, Score: 5, Status: StatusOpen, } finding3 := Finding{ ID: "different", Type: "test", Title: "Different", File: "test.go", Line: 10, Severity: SeverityT2, Score: 5, Status: StatusOpen, } if !findingsEqual(finding1, finding2) { t.Error("findingsEqual() should return true for equal findings") } if findingsEqual(finding1, finding3) { t.Error("findingsEqual() should return false for different findings") } } func TestFindingsEqual_DifferentStatus(t *testing.T) { finding1 := Finding{ID: "test", Type: "test", Status: StatusOpen} finding2 := Finding{ID: "test", Type: "test", Status: StatusFixed} if findingsEqual(finding1, finding2) { t.Error("findingsEqual() should return false for different status") } } func TestFormatDiff(t *testing.T) { diff := &StateDiff{ Added: []Finding{ {ID: "new1", Title: "New Finding 1"}, {ID: "new2", Title: "New Finding 2"}, }, Removed: []Finding{ {ID: "old1", Title: "Old Finding 1"}, }, Changed: []Finding{ {ID: "changed1", Title: "Changed Finding 1"}, }, Resolved: []Finding{ {ID: "resolved1", Title: "Resolved Finding 1"}, }, Regressions: []Finding{ {ID: "regression1", Title: "Regression Finding 1"}, }, } output := FormatDiff(diff) expected := "[+] Added: 2 findings\n - new1: New Finding 1\n - new2: New Finding 2\n[-] Removed: 1 findings\n - old1: Old Finding 1\n[~] Changed: 1 findings\n - changed1: Changed Finding 1\n[OK] Resolved: 1 findings\n - resolved1: Resolved Finding 1\n[!] Regressions: 1 findings\n - regression1: Regression Finding 1\n" if output != expected { t.Errorf("FormatDiff() output mismatch:\nGot:\n%s\nExpected:\n%s", output, expected) } } func TestFormatDiff_Empty(t *testing.T) { diff := &StateDiff{} output := FormatDiff(diff) expected := "No changes detected\n" if output != expected { t.Errorf("FormatDiff() empty diff output mismatch:\nGot:\n%s\nExpected:\n%s", output, expected) } }