package observer import ( "os" "path/filepath" "strings" "testing" "time" ) func mkOp(success bool, source Source) ObservedOp { return ObservedOp{ Timestamp: time.Now().UTC().Format(time.RFC3339), Endpoint: "/v1/test", InputSummary: "test op", Success: success, DurationMs: 42, OutputSummary: "ok", Source: source, } } func TestRecord_RequiresEndpointAndTimestamp(t *testing.T) { s := NewStore(nil) bad := ObservedOp{Endpoint: ""} // EnsureTimestamp will fill, but Endpoint empty stays if err := s.Record(bad); err == nil { t.Error("expected error on empty endpoint") } good := mkOp(true, SourceMCP) if err := s.Record(good); err != nil { t.Errorf("good op: %v", err) } } func TestRecord_DefaultsTimestampAndSource(t *testing.T) { s := NewStore(nil) op := ObservedOp{ Endpoint: "/x", InputSummary: "no ts no source", Success: true, } if err := s.Record(op); err != nil { t.Fatal(err) } stored := s.Recent()[0] if stored.Timestamp == "" { t.Error("Timestamp should be defaulted") } if stored.Source != SourceMCP { t.Errorf("Source: want %q, got %q", SourceMCP, stored.Source) } } func TestStats_Aggregates(t *testing.T) { s := NewStore(nil) for i := 0; i < 5; i++ { _ = s.Record(mkOp(true, SourceMCP)) } for i := 0; i < 3; i++ { _ = s.Record(mkOp(false, SourceScenario)) } for i := 0; i < 2; i++ { _ = s.Record(mkOp(true, SourceLangfuse)) } st := s.Stats() if st.Total != 10 { t.Errorf("total: want 10, got %d", st.Total) } if st.Successes != 7 { t.Errorf("successes: want 7, got %d", st.Successes) } if st.Failures != 3 { t.Errorf("failures: want 3, got %d", st.Failures) } if st.BySource["mcp"] != 5 || st.BySource["scenario"] != 3 || st.BySource["langfuse"] != 2 { t.Errorf("by_source mismatch: %+v", st.BySource) } if len(st.RecentScenarios) != 3 { t.Errorf("recent scenarios: want 3, got %d", len(st.RecentScenarios)) } } func TestStats_RecentScenariosCappedAndOrdered(t *testing.T) { s := NewStore(nil) // Record 15 scenario ops; only the last 10 should appear. for i := 0; i < 15; i++ { op := mkOp(true, SourceScenario) op.StafferID = "staffer-" + string(rune('a'+i)) _ = s.Record(op) time.Sleep(time.Millisecond) // ensure timestamps order-distinguishable } st := s.Stats() if len(st.RecentScenarios) != DefaultRecentScenariosCap { t.Errorf("cap: want %d, got %d", DefaultRecentScenariosCap, len(st.RecentScenarios)) } // Last entry should be the most recently added (staffer-o, the 15th). last := st.RecentScenarios[len(st.RecentScenarios)-1] if last.Staffer != "staffer-o" { t.Errorf("most recent: want staffer-o, got %q", last.Staffer) } } func TestRingBuffer_BoundedByDefaultCap(t *testing.T) { s := NewStore(nil) s.cap = 5 // shrink for testability for i := 0; i < 12; i++ { op := mkOp(true, SourceMCP) op.InputSummary = string(rune('a' + i)) _ = s.Record(op) } r := s.Recent() if len(r) != 5 { t.Errorf("ring size: want 5, got %d", len(r)) } // Oldest 7 dropped; first remaining should have InputSummary "h" (8th). if r[0].InputSummary != "h" { t.Errorf("oldest after rollover: want 'h', got %q", r[0].InputSummary) } } func TestPersistor_RoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ops.jsonl") p, err := NewPersistor(path) if err != nil { t.Fatal(err) } s := NewStore(p) for i := 0; i < 4; i++ { op := mkOp(i%2 == 0, SourceMCP) op.InputSummary = string(rune('a' + i)) if err := s.Record(op); err != nil { t.Fatal(err) } } // Sanity: file has 4 lines. bs, err := os.ReadFile(path) if err != nil { t.Fatal(err) } lines := strings.Split(strings.TrimSuffix(string(bs), "\n"), "\n") if len(lines) != 4 { t.Errorf("file lines: want 4, got %d", len(lines)) } // Rehydrate into a fresh Store. s2 := NewStore(p) n, err := s2.Load() if err != nil { t.Fatal(err) } if n != 4 { t.Errorf("loaded: want 4, got %d", n) } r := s2.Recent() if len(r) != 4 { t.Errorf("rehydrated ring: want 4, got %d", len(r)) } // Order preserved. for i, want := range []string{"a", "b", "c", "d"} { if r[i].InputSummary != want { t.Errorf("op %d: want %q, got %q", i, want, r[i].InputSummary) } } } func TestPersistor_CorruptionTolerant(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ops.jsonl") // Pre-seed with one valid + one corrupt + one valid line. valid1 := `{"timestamp":"2026-04-29T12:00:00Z","endpoint":"/x","input_summary":"a","success":true,"duration_ms":1,"output_summary":"ok","source":"mcp"}` corrupt := `{this is not json` valid2 := `{"timestamp":"2026-04-29T12:00:01Z","endpoint":"/y","input_summary":"b","success":false,"duration_ms":2,"output_summary":"err","source":"scenario"}` if err := os.WriteFile(path, []byte(valid1+"\n"+corrupt+"\n"+valid2+"\n"), 0o644); err != nil { t.Fatal(err) } p, err := NewPersistor(path) if err != nil { t.Fatal(err) } s := NewStore(p) n, err := s.Load() if err != nil { t.Fatal(err) } if n != 2 { t.Errorf("applied: want 2 (valid pair), got %d (corrupt should skip)", n) } }