Cybersecurity-Projects/PROJECTS/intermediate/security-news-scraper/internal/cluster/rebuild_test.go

134 lines
3.3 KiB
Go

// ©AngelaMos | 2026
// rebuild_test.go
package cluster
import (
"fmt"
"path/filepath"
"testing"
"github.com/CarterPerez-dev/nadezhda/internal/store"
)
func openStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "cluster.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func seedArticles(t *testing.T, st *store.Store) {
t.Helper()
titles := []string{
"Acme Corp data breach exposes millions",
"Acme Corp data breach exposed millions",
"Acme Corp data breach hits millions",
"Linux kernel 7 released with new scheduler",
}
for i, title := range titles {
sourceID, err := st.UpsertSource(store.SourceInput{
Name: fmt.Sprintf("src%d", i), URL: fmt.Sprintf("https://outlet%d.example/feed", i),
Type: "rss", Weight: 1, Enabled: true,
})
if err != nil {
t.Fatalf("upsert source %d: %v", i, err)
}
if _, err := st.InsertArticle(store.Article{
SourceID: sourceID,
CanonicalURL: fmt.Sprintf("https://example.com/a/%d", i),
ContentHash: fmt.Sprintf("chash-%d", i),
TitleHash: fmt.Sprintf("thash-%d", i),
Title: title,
PublishedAt: int64(100 * (i + 1)),
FetchedAt: int64(100 * (i + 1)),
}); err != nil {
t.Fatalf("insert article %d: %v", i, err)
}
}
}
func clusterSizes(t *testing.T, st *store.Store) []int {
t.Helper()
rows, err := st.DB().Query(`SELECT size FROM clusters ORDER BY size`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []int
for rows.Next() {
var n int
if err := rows.Scan(&n); err != nil {
t.Fatal(err)
}
out = append(out, n)
}
return out
}
func countMembers(t *testing.T, st *store.Store) int {
t.Helper()
var n int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM cluster_members`).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func TestRebuildClustersEndToEnd(t *testing.T) {
st := openStore(t)
seedArticles(t, st)
stats, err := Rebuild(st, 0.6, bigWindow, 0)
if err != nil {
t.Fatalf("Rebuild: %v", err)
}
if stats.Total != 2 || stats.MultiSource != 1 || stats.LargestSize != 3 {
t.Fatalf("stats = %+v, want {Total:2 MultiSource:1 LargestSize:3}", stats)
}
if got := clusterSizes(t, st); len(got) != 2 || got[0] != 1 || got[1] != 3 {
t.Errorf("cluster sizes = %v, want [1 3]", got)
}
if m := countMembers(t, st); m != 4 {
t.Errorf("cluster_members = %d, want 4 (every article assigned once)", m)
}
}
func TestRebuildIsIdempotent(t *testing.T) {
st := openStore(t)
seedArticles(t, st)
if _, err := Rebuild(st, 0.6, bigWindow, 0); err != nil {
t.Fatalf("first Rebuild: %v", err)
}
stats, err := Rebuild(st, 0.6, bigWindow, 0)
if err != nil {
t.Fatalf("second Rebuild: %v", err)
}
if stats.Total != 2 {
t.Errorf("second run clusters = %d, want 2 (rebuild replaces, no accumulation)", stats.Total)
}
if m := countMembers(t, st); m != 4 {
t.Errorf("cluster_members = %d, want 4 after re-run", m)
}
}
func TestRebuildLookbackExcludesOld(t *testing.T) {
st := openStore(t)
seedArticles(t, st)
stats, err := Rebuild(st, 0.6, bigWindow, 250)
if err != nil {
t.Fatalf("Rebuild: %v", err)
}
if stats.Total != 2 {
t.Errorf("with since=250 only articles at t>=250 cluster, total = %d, want 2", stats.Total)
}
if m := countMembers(t, st); m != 2 {
t.Errorf("members = %d, want 2 (two articles below lookback excluded)", m)
}
}