Skip to content

Commit 161677a

Browse files
committed
test(yaad): cover storage/server/hooks/telemetry; fix LastUpdated parse bug
Added unit tests reusing the existing harnesses (setupStore real temp DB, setupTestServer httptest, MockStorage/engine): - storage: embeddings, metadata, filewatch, node-query (GetNodeByKey/UpdateNode/ GetNeighbors/point-in-time edges), stats, replay (36->48%) - internal/server: GetNode/UpdateNode/Forget/Stats/Versions/Rollback + seeded Context/Recall/Impact handlers (30->35%) - hooks: ReadInput/New/SessionStart/PostToolUse/SessionEnd/CaptureMessages (35->75%) - internal/telemetry: exercise all 10 OTel instruments via a manual reader Bug fix surfaced by the tests: Store.LastUpdated scanned MAX(updated_at) into a sql.NullTime, which the sqlite driver cannot convert from its stored string form -> errored whenever any node existed. Now scans a string and parses it (driver String() layout + RFC3339 + legacy), matching the convention elsewhere in the file. yaad overall ~50->53%. Gate 49->52.
1 parent 6f77f21 commit 161677a

11 files changed

Lines changed: 1742 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ jobs:
114114
- name: Coverage threshold
115115
run: |
116116
COVERAGE=$(go tool cover -func=coverage.out | tail -1 | grep -oE '[0-9]+\.[0-9]+' || echo "0")
117-
THRESHOLD=49
117+
THRESHOLD=52
118118
if [ "$(echo "$COVERAGE < $THRESHOLD" | bc -l)" -eq 1 ]; then
119119
echo "::error::Coverage ${COVERAGE}% is below threshold ${THRESHOLD}%"
120120
exit 1

hooks/runner_test.go

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
package hooks
2+
3+
import (
4+
"context"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/GrayCodeAI/yaad/engine"
10+
"github.com/GrayCodeAI/yaad/graph"
11+
"github.com/GrayCodeAI/yaad/storage"
12+
)
13+
14+
// setupRunner builds a Runner backed by a real temp SQLite store. The project
15+
// directory is a TempDir so the session file (<project>/.yaad/.session) is fully
16+
// isolated per-test and cleaned up automatically.
17+
func setupRunner(t *testing.T) (*Runner, *storage.Store, string) {
18+
t.Helper()
19+
dir := t.TempDir()
20+
store, err := storage.NewStore(filepath.Join(dir, "test.db"))
21+
if err != nil {
22+
t.Fatalf("NewStore: %v", err)
23+
}
24+
t.Cleanup(func() { _ = store.Close() })
25+
26+
eng := engine.New(store, graph.New(store, store.DB()))
27+
t.Cleanup(func() { eng.Close() })
28+
29+
project := t.TempDir()
30+
return New(eng, project), store, project
31+
}
32+
33+
func TestReadInput_DecodesJSON(t *testing.T) {
34+
r := strings.NewReader(`{"session_id":"s1","project":"p1","agent":"a1","tool_name":"Write","tool_input":"main.go"}`)
35+
in, err := ReadInput(r)
36+
if err != nil {
37+
t.Fatalf("ReadInput: %v", err)
38+
}
39+
if in.SessionID != "s1" {
40+
t.Errorf("SessionID = %q, want s1", in.SessionID)
41+
}
42+
if in.Project != "p1" {
43+
t.Errorf("Project = %q, want p1", in.Project)
44+
}
45+
if in.Agent != "a1" {
46+
t.Errorf("Agent = %q, want a1", in.Agent)
47+
}
48+
if in.ToolName != "Write" {
49+
t.Errorf("ToolName = %q, want Write", in.ToolName)
50+
}
51+
if in.ToolInput != "main.go" {
52+
t.Errorf("ToolInput = %q, want main.go", in.ToolInput)
53+
}
54+
}
55+
56+
func TestReadInput_Empty(t *testing.T) {
57+
in, err := ReadInput(strings.NewReader(""))
58+
if err != nil {
59+
t.Fatalf("ReadInput on empty reader: %v", err)
60+
}
61+
if in == nil {
62+
t.Fatal("expected non-nil HookInput")
63+
}
64+
if *in != (HookInput{}) {
65+
t.Errorf("expected zero-value HookInput, got %+v", *in)
66+
}
67+
}
68+
69+
func TestReadInput_InvalidJSON(t *testing.T) {
70+
in, err := ReadInput(strings.NewReader(`{"session_id": `))
71+
if err == nil {
72+
t.Fatal("expected error decoding malformed JSON")
73+
}
74+
if in == nil {
75+
t.Fatal("expected non-nil HookInput even on error")
76+
}
77+
if !strings.Contains(err.Error(), "decode hook input") {
78+
t.Errorf("error = %v, want it to mention 'decode hook input'", err)
79+
}
80+
}
81+
82+
func TestNew_SetsFields(t *testing.T) {
83+
r, _, project := setupRunner(t)
84+
if r.eng == nil {
85+
t.Error("expected non-nil engine")
86+
}
87+
if r.project != project {
88+
t.Errorf("project = %q, want %q", r.project, project)
89+
}
90+
}
91+
92+
func TestNew_EmptyProjectDefaultsToCwd(t *testing.T) {
93+
store, err := storage.NewStore(filepath.Join(t.TempDir(), "t.db"))
94+
if err != nil {
95+
t.Fatalf("NewStore: %v", err)
96+
}
97+
t.Cleanup(func() { _ = store.Close() })
98+
eng := engine.New(store, graph.New(store, store.DB()))
99+
t.Cleanup(func() { eng.Close() })
100+
101+
r := New(eng, "")
102+
if r.project == "" {
103+
t.Error("expected empty project to default to a non-empty working dir")
104+
}
105+
}
106+
107+
func TestSessionStart_CreatesSessionAndFile(t *testing.T) {
108+
r, store, project := setupRunner(t)
109+
ctx := context.Background()
110+
111+
if err := r.SessionStart(ctx, &HookInput{Agent: "agent-a"}); err != nil {
112+
t.Fatalf("SessionStart: %v", err)
113+
}
114+
115+
// A session record must exist for the project.
116+
sessions, err := store.ListSessions(ctx, project, 0)
117+
if err != nil {
118+
t.Fatalf("ListSessions: %v", err)
119+
}
120+
if len(sessions) != 1 {
121+
t.Fatalf("expected 1 session, got %d", len(sessions))
122+
}
123+
if sessions[0].Agent != "agent-a" {
124+
t.Errorf("session agent = %q, want agent-a", sessions[0].Agent)
125+
}
126+
127+
// The session id must have been written where readSessionID expects it.
128+
got := readSessionID(project)
129+
if got != sessions[0].ID {
130+
t.Errorf("session file id = %q, want %q", got, sessions[0].ID)
131+
}
132+
}
133+
134+
func TestPostToolUse_StoresObservation(t *testing.T) {
135+
r, store, project := setupRunner(t)
136+
ctx := context.Background()
137+
138+
if err := r.SessionStart(ctx, &HookInput{Agent: "agent-a"}); err != nil {
139+
t.Fatalf("SessionStart: %v", err)
140+
}
141+
142+
// A Write tool scores 0.7 (>= 0.3 threshold) so it is captured, and
143+
// classifyTool maps Write -> "convention".
144+
in := &HookInput{
145+
Agent: "agent-a",
146+
ToolName: "Write",
147+
ToolInput: "internal/config/config.go",
148+
}
149+
if err := r.PostToolUse(ctx, in); err != nil {
150+
t.Fatalf("PostToolUse: %v", err)
151+
}
152+
153+
nodes, err := store.ListNodes(ctx, storage.NodeFilter{Type: "convention", Project: project})
154+
if err != nil {
155+
t.Fatalf("ListNodes: %v", err)
156+
}
157+
if len(nodes) != 1 {
158+
t.Fatalf("expected 1 convention node, got %d", len(nodes))
159+
}
160+
if !strings.Contains(nodes[0].Content, "config.go") {
161+
t.Errorf("node content = %q, want it to reference the tool input", nodes[0].Content)
162+
}
163+
}
164+
165+
func TestPostToolUse_EmptyToolNameNoop(t *testing.T) {
166+
r, store, _ := setupRunner(t)
167+
ctx := context.Background()
168+
169+
before := storeNodeCount(t, store, ctx)
170+
if err := r.PostToolUse(ctx, &HookInput{Agent: "agent-a"}); err != nil {
171+
t.Fatalf("PostToolUse: %v", err)
172+
}
173+
if after := storeNodeCount(t, store, ctx); after != before {
174+
t.Errorf("node count changed from %d to %d on empty tool name", before, after)
175+
}
176+
}
177+
178+
func TestPostToolUse_LowSignalReadFiltered(t *testing.T) {
179+
r, store, _ := setupRunner(t)
180+
ctx := context.Background()
181+
182+
before := storeNodeCount(t, store, ctx)
183+
// Read scores 0.2 and the short input/output drops it further: below 0.3.
184+
in := &HookInput{
185+
Agent: "agent-a",
186+
ToolName: "Read",
187+
ToolInput: "x",
188+
ToolOutput: "y",
189+
}
190+
if err := r.PostToolUse(ctx, in); err != nil {
191+
t.Fatalf("PostToolUse: %v", err)
192+
}
193+
if after := storeNodeCount(t, store, ctx); after != before {
194+
t.Errorf("low-signal Read should be filtered: count %d -> %d", before, after)
195+
}
196+
}
197+
198+
func TestSessionEnd_StoresSummaryAndRemovesFile(t *testing.T) {
199+
r, store, project := setupRunner(t)
200+
ctx := context.Background()
201+
202+
if err := r.SessionStart(ctx, &HookInput{Agent: "agent-a"}); err != nil {
203+
t.Fatalf("SessionStart: %v", err)
204+
}
205+
206+
in := &HookInput{Agent: "agent-a", Summary: "Shipped the auth refactor and migrated to JWT tokens"}
207+
if err := r.SessionEnd(ctx, in); err != nil {
208+
t.Fatalf("SessionEnd: %v", err)
209+
}
210+
211+
nodes, err := store.ListNodes(ctx, storage.NodeFilter{Type: "session", Project: project})
212+
if err != nil {
213+
t.Fatalf("ListNodes: %v", err)
214+
}
215+
if len(nodes) != 1 {
216+
t.Fatalf("expected 1 session-summary node, got %d", len(nodes))
217+
}
218+
if !strings.Contains(nodes[0].Content, "auth refactor") {
219+
t.Errorf("summary node content = %q, want it to contain the summary", nodes[0].Content)
220+
}
221+
222+
// SessionEnd cleans up the session file.
223+
if got := readSessionID(project); got != "" {
224+
t.Errorf("expected session file removed, readSessionID returned %q", got)
225+
}
226+
}
227+
228+
func TestSessionEnd_NoSessionNoop(t *testing.T) {
229+
r, store, _ := setupRunner(t)
230+
ctx := context.Background()
231+
232+
// No SessionStart -> no session file -> readSessionID == "" -> early return.
233+
before := storeNodeCount(t, store, ctx)
234+
if err := r.SessionEnd(ctx, &HookInput{Summary: "ignored because no active session here"}); err != nil {
235+
t.Fatalf("SessionEnd: %v", err)
236+
}
237+
if after := storeNodeCount(t, store, ctx); after != before {
238+
t.Errorf("SessionEnd with no session should be a noop: %d -> %d", before, after)
239+
}
240+
}
241+
242+
func TestCaptureMessages_StoresMemories(t *testing.T) {
243+
r, store, project := setupRunner(t)
244+
ctx := context.Background()
245+
246+
cc := NewConversationCapture(r.eng, project)
247+
msgs := []ConversationMessage{
248+
{Role: "system", Content: "We decided to use PostgreSQL for the database and ignore this system line"},
249+
{Role: "user", Content: "We decided to use PostgreSQL instead of MySQL for the primary datastore"},
250+
{Role: "assistant", Content: "Always use context.Context as the first parameter; this is our convention"},
251+
{Role: "user", Content: "ok"}, // too short, no memory
252+
}
253+
254+
stored := cc.CaptureMessages(ctx, msgs, "sess-1", "agent-a")
255+
if stored != 2 {
256+
t.Fatalf("CaptureMessages stored = %d, want 2 (decision + convention; system & short skipped)", stored)
257+
}
258+
259+
decisions, err := store.ListNodes(ctx, storage.NodeFilter{Type: "decision", Project: project})
260+
if err != nil {
261+
t.Fatalf("ListNodes decision: %v", err)
262+
}
263+
if len(decisions) != 1 {
264+
t.Errorf("expected 1 decision node, got %d", len(decisions))
265+
}
266+
267+
conventions, err := store.ListNodes(ctx, storage.NodeFilter{Type: "convention", Project: project})
268+
if err != nil {
269+
t.Fatalf("ListNodes convention: %v", err)
270+
}
271+
if len(conventions) != 1 {
272+
t.Errorf("expected 1 convention node, got %d", len(conventions))
273+
}
274+
}
275+
276+
func TestCaptureMessages_Empty(t *testing.T) {
277+
r, _, project := setupRunner(t)
278+
cc := NewConversationCapture(r.eng, project)
279+
if n := cc.CaptureMessages(context.Background(), nil, "sess-1", "agent-a"); n != 0 {
280+
t.Errorf("CaptureMessages on empty slice = %d, want 0", n)
281+
}
282+
}
283+
284+
func storeNodeCount(t *testing.T, store *storage.Store, ctx context.Context) int {
285+
t.Helper()
286+
nodes, err := store.ListNodes(ctx, storage.NodeFilter{})
287+
if err != nil {
288+
t.Fatalf("ListNodes: %v", err)
289+
}
290+
return len(nodes)
291+
}

0 commit comments

Comments
 (0)