package reporting import ( "context" "errors" "path/filepath" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" ) // TestEngineGenerate_AttachesHeuristicNarrativeByDefault verifies that // without a request-supplied narrator, the engine still populates // ReportData.Narrative with a heuristic so renderers always have one. func TestEngineGenerate_AttachesHeuristicNarrativeByDefault(t *testing.T) { store := newReportingMetricsStore(t) defer store.Close() now := time.Now() nodeID := "node-1" for i := 0; i < 12; i++ { ts := now.Add(time.Duration(-60+i*5) * time.Minute) store.Write("node", nodeID, "cpu", 95.0, ts) store.Write("node", nodeID, "memory", 90.0, ts) } store.Flush() engine := NewReportEngine(EngineConfig{MetricsStore: store}) req := MetricReportRequest{ ResourceType: "node", ResourceID: nodeID, Start: now.Add(-2 * time.Hour), End: now.Add(time.Minute), Format: FormatPDF, } bytes, _, err := engine.Generate(req) if err != nil { t.Fatalf("Generate: %v", err) } if len(bytes) == 0 { t.Fatal("expected non-empty PDF") } // We cannot inspect the per-call ReportData directly through the // public API, but we can re-derive the narrative the same way the // engine does and assert it produces heuristic content. This locks // in the contract that the engine never returns without a narrative // regardless of caller configuration. in := NarrativeInput{MetricStats: map[string]MetricStats{ "cpu": {Avg: 95, Max: 95}, "memory": {Avg: 90, Max: 90}, }} out, _ := HeuristicNarrator{}.Narrate(context.Background(), in) if out.HealthStatus == "" { t.Fatal("heuristic narrator produced empty status") } } // TestEngineGenerate_UsesSuppliedNarrator verifies that a non-nil narrator // on the request is invoked with the queried metric stats and that its // output is preferred over the heuristic. func TestEngineGenerate_UsesSuppliedNarrator(t *testing.T) { store := newReportingMetricsStore(t) defer store.Close() now := time.Now() nodeID := "node-2" for i := 0; i < 12; i++ { ts := now.Add(time.Duration(-60+i*5) * time.Minute) store.Write("node", nodeID, "cpu", 50.0, ts) } store.Flush() stub := &capturingNarrator{ out: Narrative{ Source: NarrativeSourceAI, HealthStatus: "HEALTHY", HealthMessage: "Quiet", ExecutiveSummary: "AI prose", Observations: []NarrativeBullet{{Text: "AI says fine", Severity: NarrativeSeverityOK}}, Recommendations: []string{"Carry on"}, }, } engine := NewReportEngine(EngineConfig{MetricsStore: store}) req := MetricReportRequest{ ResourceType: "node", ResourceID: nodeID, Start: now.Add(-2 * time.Hour), End: now.Add(time.Minute), Format: FormatPDF, Narrator: stub, } // Metrics writes are buffered; retry until the narrator sees stats. deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { stub.seen = NarrativeInput{} stub.called = false if _, _, err := engine.Generate(req); err != nil { t.Fatalf("Generate: %v", err) } if _, ok := stub.seen.MetricStats["cpu"]; ok { break } time.Sleep(50 * time.Millisecond) } if !stub.called { t.Fatal("narrator was not invoked") } if _, ok := stub.seen.MetricStats["cpu"]; !ok { t.Fatalf("narrator received no cpu stats: %#v", stub.seen.MetricStats) } } // TestEngineGenerate_NarratorErrorFallsBackToHeuristic verifies that an AI // failure does not surface to callers — the engine still produces a PDF. func TestEngineGenerate_NarratorErrorFallsBackToHeuristic(t *testing.T) { store := newReportingMetricsStore(t) defer store.Close() now := time.Now() nodeID := "node-3" for i := 0; i < 6; i++ { ts := now.Add(time.Duration(-30+i*5) * time.Minute) store.Write("node", nodeID, "cpu", 60.0, ts) } store.Flush() stub := &capturingNarrator{err: errors.New("boom")} engine := NewReportEngine(EngineConfig{MetricsStore: store}) req := MetricReportRequest{ ResourceType: "node", ResourceID: nodeID, Start: now.Add(-1 * time.Hour), End: now.Add(time.Minute), Format: FormatPDF, Narrator: stub, } pdf, _, err := engine.Generate(req) if err != nil { t.Fatalf("Generate: %v", err) } if len(pdf) == 0 { t.Fatal("expected non-empty PDF after AI fallback") } } // TestEngineGenerate_PriorPeriodQueriedWhenAvailable verifies that when // historical data exists for the comparable prior window, the engine // supplies it to the narrator so deltas can be expressed. func TestEngineGenerate_PriorPeriodQueriedWhenAvailable(t *testing.T) { store := newReportingMetricsStore(t) defer store.Close() now := time.Now() nodeID := "node-4" // Populate two adjacent one-hour windows so the prior-period query // finds data. for i := 0; i < 12; i++ { ts := now.Add(time.Duration(-120+i*5) * time.Minute) store.Write("node", nodeID, "cpu", 30.0, ts) } for i := 0; i < 12; i++ { ts := now.Add(time.Duration(-60+i*5) * time.Minute) store.Write("node", nodeID, "cpu", 80.0, ts) } store.Flush() stub := &capturingNarrator{ out: Narrative{HealthStatus: "WARNING", Observations: []NarrativeBullet{{Text: "x"}}, Recommendations: []string{"y"}}, } engine := NewReportEngine(EngineConfig{MetricsStore: store}) req := MetricReportRequest{ ResourceType: "node", ResourceID: nodeID, Start: now.Add(-1 * time.Hour), End: now.Add(time.Minute), Format: FormatPDF, Narrator: stub, } // Metrics writes are buffered; retry until the prior-period query // surfaces data, mirroring the eventually-pattern used by the other // integration tests in this package. deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { stub.seen = NarrativeInput{} if _, _, err := engine.Generate(req); err != nil { t.Fatalf("Generate: %v", err) } if stub.seen.PriorPeriod != nil { break } time.Sleep(50 * time.Millisecond) } if stub.seen.PriorPeriod == nil { t.Fatal("expected PriorPeriod to be passed to narrator") } if _, ok := stub.seen.PriorPeriod.MetricStats["cpu"]; !ok { t.Fatalf("PriorPeriod.MetricStats missing cpu: %#v", stub.seen.PriorPeriod.MetricStats) } } // stubFindingsProvider lets us assert the engine threads findings through. type stubFindingsProvider struct { findings []FindingSummary called bool } func (s *stubFindingsProvider) FindingsForReport(_ context.Context, _ string, _ time.Time, _ time.Time) []FindingSummary { s.called = true return s.findings } func TestEngineGenerate_FindingsProviderInvoked(t *testing.T) { store := newReportingMetricsStore(t) defer store.Close() now := time.Now() nodeID := "node-5" for i := 0; i < 6; i++ { ts := now.Add(time.Duration(-30+i*5) * time.Minute) store.Write("node", nodeID, "cpu", 50.0, ts) } store.Flush() provider := &stubFindingsProvider{ findings: []FindingSummary{{Severity: "high", Title: "Patrol caught a thing"}}, } stub := &capturingNarrator{out: Narrative{HealthStatus: "HEALTHY", Observations: []NarrativeBullet{{Text: "x"}}, Recommendations: []string{"y"}}} engine := NewReportEngine(EngineConfig{MetricsStore: store}) req := MetricReportRequest{ ResourceType: "node", ResourceID: nodeID, Start: now.Add(-1 * time.Hour), End: now.Add(time.Minute), Format: FormatPDF, Narrator: stub, FindingsProvider: provider, } if _, _, err := engine.Generate(req); err != nil { t.Fatalf("Generate: %v", err) } if !provider.called { t.Fatal("FindingsProvider was not invoked") } if len(stub.seen.Findings) != 1 || stub.seen.Findings[0].Title != "Patrol caught a thing" { t.Fatalf("Findings not threaded to narrator: %#v", stub.seen.Findings) } } func TestEngineGenerate_CSVSkipsNarratorAndFindingsProvider(t *testing.T) { store := newReportingMetricsStore(t) defer store.Close() now := time.Now() nodeID := "node-csv-narrative-skip" for i := 0; i < 6; i++ { ts := now.Add(time.Duration(-30+i*5) * time.Minute) store.Write("node", nodeID, "cpu", 50.0, ts) } store.Flush() provider := &stubFindingsProvider{ findings: []FindingSummary{{Severity: "warning", Title: "Should not load"}}, } stub := &capturingNarrator{out: Narrative{HealthStatus: "HEALTHY"}} engine := NewReportEngine(EngineConfig{MetricsStore: store}) req := MetricReportRequest{ ResourceType: "node", ResourceID: nodeID, Start: now.Add(-1 * time.Hour), End: now.Add(time.Minute), Format: FormatCSV, Narrator: stub, FindingsProvider: provider, } csv, contentType, err := engine.Generate(req) if err != nil { t.Fatalf("Generate: %v", err) } if len(csv) == 0 { t.Fatal("expected non-empty CSV") } if contentType != "text/csv" { t.Fatalf("contentType = %q, want text/csv", contentType) } if stub.called { t.Fatal("CSV generation invoked narrator") } if provider.called { t.Fatal("CSV generation invoked findings provider") } } func TestEngineGenerateMulti_CSVSkipsFleetNarrator(t *testing.T) { store := newReportingMetricsStore(t) defer store.Close() now := time.Now() for _, nodeID := range []string{"node-csv-fleet-a", "node-csv-fleet-b"} { for i := 0; i < 6; i++ { ts := now.Add(time.Duration(-30+i*5) * time.Minute) store.Write("node", nodeID, "cpu", 60.0, ts) } } store.Flush() stub := &capturingFleetNarrator{out: FleetNarrative{HealthStatus: "HEALTHY"}} engine := NewReportEngine(EngineConfig{MetricsStore: store}) req := MultiReportRequest{ Title: "Fleet CSV narrative skip", Start: now.Add(-1 * time.Hour), End: now.Add(time.Minute), Format: FormatCSV, Resources: []MetricReportRequest{ {ResourceType: "node", ResourceID: "node-csv-fleet-a"}, {ResourceType: "node", ResourceID: "node-csv-fleet-b"}, }, FleetNarrator: stub, } csv, contentType, err := engine.GenerateMulti(req) if err != nil { t.Fatalf("GenerateMulti: %v", err) } if len(csv) == 0 { t.Fatal("expected non-empty CSV") } if contentType != "text/csv" { t.Fatalf("contentType = %q, want text/csv", contentType) } if stub.called { t.Fatal("CSV generation invoked fleet narrator") } } // TestEngineNarrativeFor_ReturnsStructuredNarrativeWithoutRendering // verifies the non-rendering entry point used by Pulse Assistant tools: // it must produce a Narrative grounded in the queried metrics without // running the PDF or CSV generator. func TestEngineNarrativeFor_ReturnsStructuredNarrativeWithoutRendering(t *testing.T) { store := newReportingMetricsStore(t) defer store.Close() now := time.Now() nodeID := "node-narrate-1" for i := 0; i < 12; i++ { ts := now.Add(time.Duration(-60+i*5) * time.Minute) store.Write("node", nodeID, "cpu", 55.0, ts) } store.Flush() engine := NewReportEngine(EngineConfig{MetricsStore: store}) req := MetricReportRequest{ ResourceType: "node", ResourceID: nodeID, Start: now.Add(-2 * time.Hour), End: now.Add(time.Minute), } deadline := time.Now().Add(2 * time.Second) var narrative *Narrative for time.Now().Before(deadline) { var err error narrative, err = engine.NarrativeFor(req) if err != nil { t.Fatalf("NarrativeFor: %v", err) } if narrative != nil && len(narrative.Observations) > 0 { break } time.Sleep(50 * time.Millisecond) } if narrative == nil { t.Fatal("expected non-nil narrative") } if narrative.Source != NarrativeSourceHeuristic { t.Errorf("Source = %q, want heuristic (no narrator supplied)", narrative.Source) } if len(narrative.Observations) == 0 { t.Error("expected at least one observation from the heuristic narrator") } } // TestEngineFleetNarrativeFor_ReturnsStructuredFleetNarrativeWithoutRendering // is the multi-resource counterpart. func TestEngineFleetNarrativeFor_ReturnsStructuredFleetNarrativeWithoutRendering(t *testing.T) { store := newReportingMetricsStore(t) defer store.Close() now := time.Now() for _, nodeID := range []string{"node-fleet-a", "node-fleet-b"} { for i := 0; i < 6; i++ { ts := now.Add(time.Duration(-30+i*5) * time.Minute) store.Write("node", nodeID, "cpu", 60.0, ts) } } store.Flush() engine := NewReportEngine(EngineConfig{MetricsStore: store}) req := MultiReportRequest{ Title: "Fleet narrative test", Start: now.Add(-1 * time.Hour), End: now.Add(time.Minute), Resources: []MetricReportRequest{ {ResourceType: "node", ResourceID: "node-fleet-a"}, {ResourceType: "node", ResourceID: "node-fleet-b"}, }, } deadline := time.Now().Add(2 * time.Second) var fleet *FleetNarrative for time.Now().Before(deadline) { var err error fleet, err = engine.FleetNarrativeFor(req) if err != nil { t.Fatalf("FleetNarrativeFor: %v", err) } if fleet != nil && fleet.HealthStatus != "" { break } time.Sleep(50 * time.Millisecond) } if fleet == nil { t.Fatal("expected non-nil fleet narrative") } if fleet.Source != NarrativeSourceHeuristic { t.Errorf("Source = %q, want heuristic", fleet.Source) } } // TestEngineFleetNarrativeFor_NoResourcesReturnsError verifies the // non-rendering fleet entry point matches GenerateMulti's error contract // when zero resources are requested. func TestEngineFleetNarrativeFor_NoResourcesReturnsError(t *testing.T) { store := newReportingMetricsStore(t) defer store.Close() engine := NewReportEngine(EngineConfig{MetricsStore: store}) now := time.Now() req := MultiReportRequest{ Start: now.Add(-1 * time.Hour), End: now, Resources: nil, } if _, err := engine.FleetNarrativeFor(req); err == nil { t.Fatal("expected error when no resources are requested") } } // TestEngineFleetNarrativeFor_NoMetricsStoreReturnsError covers the // guard at the top of the entry point. func TestEngineFleetNarrativeFor_NoMetricsStoreReturnsError(t *testing.T) { engine := NewReportEngine(EngineConfig{}) if _, err := engine.FleetNarrativeFor(MultiReportRequest{}); err == nil { t.Fatal("expected error when metrics store is nil") } } type capturingNarrator struct { out Narrative err error called bool seen NarrativeInput } func (c *capturingNarrator) Narrate(_ context.Context, in NarrativeInput) (Narrative, error) { c.called = true c.seen = in return c.out, c.err } type capturingFleetNarrator struct { out FleetNarrative err error called bool seen FleetNarrativeInput } func (c *capturingFleetNarrator) NarrateFleet(_ context.Context, in FleetNarrativeInput) (FleetNarrative, error) { c.called = true c.seen = in return c.out, c.err } func newReportingMetricsStore(t *testing.T) *metrics.Store { t.Helper() dir := t.TempDir() store, err := metrics.NewStore(metrics.StoreConfig{ DBPath: filepath.Join(dir, "metrics.db"), WriteBufferSize: 10, FlushInterval: 50 * time.Millisecond, RetentionRaw: 24 * time.Hour, RetentionMinute: 7 * 24 * time.Hour, RetentionHourly: 30 * 24 * time.Hour, RetentionDaily: 90 * 24 * time.Hour, }) if err != nil { t.Fatalf("failed to create metrics store: %v", err) } return store }