package watch import ( "net/http/httptest" "path/filepath" "testing" "github.com/david/knox/internal/db" ) func tmpKnoxDB(t *testing.T) *db.KnoxDB { t.Helper() kdb, err := db.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { kdb.Close() }) return kdb } func seedObs(k *db.KnoxDB, prefix string) { // Insert distinct observations only via RecordObservation so they get this // node's node_id and HCL. for i := 0; i < 3; i++ { _, _, err := k.RecordObservation(db.ObservationRecord{ Fingerprint: prefix + ":" + string(rune('a'+i)), SourceID: "test", SourcePath: prefix, Project: "itest", ContentType: "test", Title: prefix + string(rune('a'+i)), Summary: "summary " + prefix + string(rune('a'+i)), CreatedAt: "2026-08-29T00:00:00Z", LineEnd: 0, Confidence: 0.9, IngesterVersion: "itest/v1", }) if err != nil { panic(err) } } } // TestGossipConvergence starts two nodes, seeds disjoint observations, and runs // a bidirectional handshake. Both must end with the concatenation of both logs. func TestGossipConvergence(t *testing.T) { a := tmpKnoxDB(t) b := tmpKnoxDB(t) seedObs(a, "AAA") seedObs(b, "BBB") // Wire a and b as peer HTTP servers. nodeA := &Node{Kdb: a, Name: "A"} sa := httptest.NewServer(nodeA.Handler()) defer sa.Close() nodeB := &Node{Kdb: b, Name: "B"} sb := httptest.NewServer(nodeB.Handler()) defer sb.Close() // A pulls from B, then B pulls from A (bidirectional sweep). Run(a, []string{sb.URL}) Run(b, []string{sa.URL}) av, err := a.KnowledgeVector() if err != nil { t.Fatalf("a vector: %v", err) } bv, err := b.KnowledgeVector() if err != nil { t.Fatalf("b vector: %v", err) } if len(av) != 2 || len(bv) != 2 { t.Fatalf("expected both nodes to hold 2 source logs, got a=%v b=%v", av, bv) } // Same maximum HCL per origin node on both sides. for nid, hcl := range av { if bv[nid] != hcl { t.Errorf("node %s max hcl mismatch: a=%d b=%d", nid, hcl, bv[nid]) } } } // TestGossipDiff ensures /v1/diff reports per-node observation fingerprints and // tombstoned thread divergence. func TestGossipDiff(t *testing.T) { a := tmpKnoxDB(t) b := tmpKnoxDB(t) seedObs(a, "AAA") seedObs(b, "BBB") nodeA := &Node{Kdb: a, Name: "A"} sa := httptest.NewServer(nodeA.Handler()) defer sa.Close() c := &Client{Addr: sa.URL} d, err := c.Diff() if err != nil { t.Fatalf("diff: %v", err) } if d.NodeID != a.NodeID() { t.Errorf("diff node mismatch: %s != %s", d.NodeID, a.NodeID()) } if len(d.Fingerprints) != 3 { t.Errorf("expected 3 fingerprints in diff, got %d", len(d.Fingerprints)) } } // TestGossipDiffTombstones confirms resolved auto-threads surface in diff. func TestGossipDiffTombstones(t *testing.T) { a := tmpKnoxDB(t) id, _, err := a.CreateThreadCluster("Test thread", "motivation", "medium", "[]", `{"trigger":"auto_thread"}`, "cluster:testkey") if err != nil { t.Fatalf("create thread: %v", err) } if err := a.CloseThread(id); err != nil { t.Fatalf("close thread: %v", err) } nodeA := &Node{Kdb: a, Name: "A"} sa := httptest.NewServer(nodeA.Handler()) defer sa.Close() c := &Client{Addr: sa.URL} d, err := c.Diff() if err != nil { t.Fatalf("diff: %v", err) } if len(d.ThreadStatus) != 1 { t.Errorf("expected 1 thread in diff, got %d", len(d.ThreadStatus)) } for k, st := range d.ThreadStatus { if k != "cluster:testkey" { t.Errorf("unexpected thread key %s", k) } if st != "resolved" { t.Errorf("thread %s should be resolved, got %q", k, st) } } } // TestGossipIdempotent ensures a repeated sweep does not duplicate observations. func TestGossipIdempotent(t *testing.T) { a := tmpKnoxDB(t) b := tmpKnoxDB(t) seedObs(a, "AAA") nodeA := &Node{Kdb: a, Name: "A"} sa := httptest.NewServer(nodeA.Handler()) defer sa.Close() nodeB := &Node{Kdb: b, Name: "B"} sb := httptest.NewServer(nodeB.Handler()) defer sb.Close() Run(b, []string{sa.URL}) before, err := b.EntryCount() if err != nil { t.Fatal(err) } Run(b, []string{sa.URL}) after, err := b.EntryCount() if err != nil { t.Fatal(err) } if before != after { t.Errorf("second sweep changed entry count: %d -> %d", before, after) } }