Skip to content

Commit 6ef494c

Browse files
go sum revert
1 parent 7cf73ff commit 6ef494c

File tree

2 files changed

+457
-0
lines changed

2 files changed

+457
-0
lines changed
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
package hooks
2+
3+
import (
4+
"context"
5+
"sync"
6+
"testing"
7+
"time"
8+
9+
"github.com/google/uuid"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/speakeasy-api/gram/server/gen/hooks"
14+
"github.com/speakeasy-api/gram/server/internal/cache"
15+
)
16+
17+
// TestBufferHook_AtomicAppend tests that buffering hooks uses atomic RPUSH
18+
func TestBufferHook_AtomicAppend(t *testing.T) {
19+
ctx, ti := newTestHooksService(t)
20+
21+
sessionID := uuid.NewString()
22+
toolName := "test_tool"
23+
toolUseID := "toolu_123"
24+
25+
// Buffer a single hook
26+
payload := &hooks.ClaudePayload{
27+
HookEventName: "PreToolUse",
28+
SessionID: &sessionID,
29+
ToolName: &toolName,
30+
ToolUseID: &toolUseID,
31+
}
32+
33+
// Access the private bufferHook method via the service
34+
// Since it's private, we'll test it indirectly through the Claude endpoint
35+
result, err := ti.service.Claude(ctx, payload)
36+
require.NoError(t, err)
37+
require.NotNil(t, result)
38+
39+
// Verify the hook was buffered in Redis by checking the key exists
40+
redisKey := "hook:pending:" + sessionID
41+
exists, err := ti.redisClient.Exists(ctx, redisKey).Result()
42+
require.NoError(t, err)
43+
assert.Equal(t, int64(1), exists, "Hook should be buffered in Redis")
44+
45+
// Verify it's a list with one element
46+
length, err := ti.redisClient.LLen(ctx, redisKey).Result()
47+
require.NoError(t, err)
48+
assert.Equal(t, int64(1), length, "Should have exactly one buffered hook")
49+
}
50+
51+
// TestBufferHook_MultipleConcurrent tests that concurrent buffering works correctly
52+
func TestBufferHook_MultipleConcurrent(t *testing.T) {
53+
ctx, ti := newTestHooksService(t)
54+
55+
sessionID := uuid.NewString()
56+
numHooks := 50
57+
var wg sync.WaitGroup
58+
59+
// Buffer multiple hooks concurrently to test for race conditions
60+
for i := 0; i < numHooks; i++ {
61+
wg.Add(1)
62+
go func(idx int) {
63+
defer wg.Done()
64+
65+
toolName := "concurrent_tool"
66+
toolUseID := uuid.NewString()
67+
payload := &hooks.ClaudePayload{
68+
HookEventName: "PreToolUse",
69+
SessionID: &sessionID,
70+
ToolName: &toolName,
71+
ToolUseID: &toolUseID,
72+
}
73+
74+
_, err := ti.service.Claude(ctx, payload)
75+
assert.NoError(t, err)
76+
}(i)
77+
}
78+
79+
wg.Wait()
80+
81+
// Verify all hooks were buffered atomically
82+
redisKey := "hook:pending:" + sessionID
83+
length, err := ti.redisClient.LLen(ctx, redisKey).Result()
84+
require.NoError(t, err)
85+
assert.Equal(t, int64(numHooks), length, "All hooks should be buffered atomically without race conditions")
86+
}
87+
88+
// TestFlushPendingHooks_Success tests successful flushing of buffered hooks
89+
func TestFlushPendingHooks_Success(t *testing.T) {
90+
ctx, ti := newTestHooksService(t)
91+
92+
// Create session metadata
93+
sessionID := uuid.NewString()
94+
userEmail := "test@example.com"
95+
gramOrgID := uuid.NewString()
96+
projectID := uuid.NewString()
97+
98+
// Buffer multiple hooks first
99+
numHooks := 5
100+
for i := 0; i < numHooks; i++ {
101+
toolName := "test_tool"
102+
toolUseID := uuid.NewString()
103+
payload := &hooks.ClaudePayload{
104+
HookEventName: "PreToolUse",
105+
SessionID: &sessionID,
106+
ToolName: &toolName,
107+
ToolUseID: &toolUseID,
108+
}
109+
110+
_, err := ti.service.Claude(ctx, payload)
111+
require.NoError(t, err)
112+
}
113+
114+
// Verify hooks are buffered
115+
redisKey := "hook:pending:" + sessionID
116+
lengthBefore, err := ti.redisClient.LLen(ctx, redisKey).Result()
117+
require.NoError(t, err)
118+
assert.Equal(t, int64(numHooks), lengthBefore)
119+
120+
// Store session metadata to trigger flush
121+
metadata := SessionMetadata{
122+
SessionID: sessionID,
123+
UserEmail: userEmail,
124+
GramOrgID: gramOrgID,
125+
ProjectID: projectID,
126+
ServiceName: "test-service",
127+
ClaudeOrgID: "claude-org-123",
128+
}
129+
130+
cacheAdapter := cache.NewRedisCacheAdapter(ti.redisClient)
131+
metadataKey := "session:metadata:" + sessionID
132+
err = cacheAdapter.Set(ctx, metadataKey, metadata, 24*time.Hour)
133+
require.NoError(t, err)
134+
135+
// Send a new hook to trigger flush (since session is now validated)
136+
toolName := "trigger_tool"
137+
toolUseID := uuid.NewString()
138+
payload := &hooks.ClaudePayload{
139+
HookEventName: "PostToolUse",
140+
SessionID: &sessionID,
141+
ToolName: &toolName,
142+
ToolUseID: &toolUseID,
143+
}
144+
145+
_, err = ti.service.Claude(ctx, payload)
146+
require.NoError(t, err)
147+
148+
// Verify hooks were flushed (Redis list should be deleted)
149+
exists, err := ti.redisClient.Exists(ctx, redisKey).Result()
150+
require.NoError(t, err)
151+
assert.Equal(t, int64(0), exists, "Buffered hooks should be flushed and deleted from Redis")
152+
}
153+
154+
// TestFlushPendingHooks_EmptyList tests flushing when there are no pending hooks
155+
func TestFlushPendingHooks_EmptyList(t *testing.T) {
156+
ctx, ti := newTestHooksService(t)
157+
158+
sessionID := uuid.NewString()
159+
userEmail := "test@example.com"
160+
gramOrgID := uuid.NewString()
161+
projectID := uuid.NewString()
162+
163+
// Store session metadata without buffering any hooks
164+
metadata := SessionMetadata{
165+
SessionID: sessionID,
166+
UserEmail: userEmail,
167+
GramOrgID: gramOrgID,
168+
ProjectID: projectID,
169+
ServiceName: "test-service",
170+
ClaudeOrgID: "claude-org-123",
171+
}
172+
173+
cacheAdapter := cache.NewRedisCacheAdapter(ti.redisClient)
174+
metadataKey := "session:metadata:" + sessionID
175+
err := cacheAdapter.Set(ctx, metadataKey, metadata, 24*time.Hour)
176+
require.NoError(t, err)
177+
178+
// Send a hook (should not error even though there are no pending hooks to flush)
179+
toolName := "test_tool"
180+
toolUseID := uuid.NewString()
181+
payload := &hooks.ClaudePayload{
182+
HookEventName: "PostToolUse",
183+
SessionID: &sessionID,
184+
ToolName: &toolName,
185+
ToolUseID: &toolUseID,
186+
}
187+
188+
_, err = ti.service.Claude(ctx, payload)
189+
require.NoError(t, err)
190+
}
191+
192+
// TestBufferAndFlush_MultipleSessionsConcurrent tests buffering and flushing across multiple sessions
193+
func TestBufferAndFlush_MultipleSessionsConcurrent(t *testing.T) {
194+
ctx, ti := newTestHooksService(t)
195+
196+
numSessions := 10
197+
hooksPerSession := 5
198+
199+
var wg sync.WaitGroup
200+
201+
// Create multiple sessions and buffer hooks concurrently
202+
for sessionIdx := 0; sessionIdx < numSessions; sessionIdx++ {
203+
wg.Add(1)
204+
go func(idx int) {
205+
defer wg.Done()
206+
207+
sessionID := uuid.NewString()
208+
209+
// Buffer multiple hooks for this session
210+
for hookIdx := 0; hookIdx < hooksPerSession; hookIdx++ {
211+
toolName := "test_tool"
212+
toolUseID := uuid.NewString()
213+
payload := &hooks.ClaudePayload{
214+
HookEventName: "PreToolUse",
215+
SessionID: &sessionID,
216+
ToolName: &toolName,
217+
ToolUseID: &toolUseID,
218+
}
219+
220+
_, err := ti.service.Claude(ctx, payload)
221+
assert.NoError(t, err)
222+
}
223+
224+
// Verify hooks are buffered for this session
225+
redisKey := "hook:pending:" + sessionID
226+
length, err := ti.redisClient.LLen(ctx, redisKey).Result()
227+
assert.NoError(t, err)
228+
assert.Equal(t, int64(hooksPerSession), length)
229+
}(sessionIdx)
230+
}
231+
232+
wg.Wait()
233+
}
234+
235+
// TestSessionMetadata_CacheSetGet tests storing and retrieving session metadata
236+
func TestSessionMetadata_CacheSetGet(t *testing.T) {
237+
ctx, ti := newTestHooksService(t)
238+
239+
sessionID := uuid.NewString()
240+
metadata := SessionMetadata{
241+
SessionID: sessionID,
242+
UserEmail: "user@example.com",
243+
GramOrgID: uuid.NewString(),
244+
ProjectID: uuid.NewString(),
245+
ServiceName: "test-service",
246+
ClaudeOrgID: "claude-org-456",
247+
}
248+
249+
cacheAdapter := cache.NewRedisCacheAdapter(ti.redisClient)
250+
251+
// Store metadata
252+
key := "session:metadata:" + sessionID
253+
err := cacheAdapter.Set(ctx, key, metadata, 24*time.Hour)
254+
require.NoError(t, err)
255+
256+
// Retrieve metadata
257+
var retrieved SessionMetadata
258+
err = cacheAdapter.Get(ctx, key, &retrieved)
259+
require.NoError(t, err)
260+
261+
// Verify all fields match
262+
assert.Equal(t, metadata.SessionID, retrieved.SessionID)
263+
assert.Equal(t, metadata.UserEmail, retrieved.UserEmail)
264+
assert.Equal(t, metadata.GramOrgID, retrieved.GramOrgID)
265+
assert.Equal(t, metadata.ProjectID, retrieved.ProjectID)
266+
assert.Equal(t, metadata.ServiceName, retrieved.ServiceName)
267+
assert.Equal(t, metadata.ClaudeOrgID, retrieved.ClaudeOrgID)
268+
}
269+
270+
// TestListAppend_TTLBehavior tests that TTL is only set once for new keys
271+
func TestListAppend_TTLBehavior(t *testing.T) {
272+
ctx, ti := newTestHooksService(t)
273+
274+
cacheAdapter := cache.NewRedisCacheAdapter(ti.redisClient)
275+
key := "test:list:" + uuid.NewString()
276+
277+
// First append - should set TTL
278+
err := cacheAdapter.ListAppend(ctx, key, "item1", 10*time.Second)
279+
require.NoError(t, err)
280+
281+
// Check TTL exists
282+
ttl1, err := ti.redisClient.TTL(ctx, key).Result()
283+
require.NoError(t, err)
284+
assert.Greater(t, ttl1.Seconds(), 0.0, "TTL should be set")
285+
286+
// Wait a bit
287+
time.Sleep(1 * time.Second)
288+
289+
// Second append - should NOT reset TTL
290+
err = cacheAdapter.ListAppend(ctx, key, "item2", 10*time.Second)
291+
require.NoError(t, err)
292+
293+
// Check TTL is less than original (proving it wasn't reset)
294+
ttl2, err := ti.redisClient.TTL(ctx, key).Result()
295+
require.NoError(t, err)
296+
assert.Less(t, ttl2.Seconds(), ttl1.Seconds(), "TTL should not be reset on subsequent appends")
297+
}
298+
299+
// TestListRange_CorrectDeserialization tests that ListRange properly deserializes msgpack data
300+
func TestListRange_CorrectDeserialization(t *testing.T) {
301+
ctx, ti := newTestHooksService(t)
302+
303+
cacheAdapter := cache.NewRedisCacheAdapter(ti.redisClient)
304+
key := "test:payloads:" + uuid.NewString()
305+
306+
// Create test payloads
307+
sessionID := uuid.NewString()
308+
payloads := []hooks.ClaudePayload{
309+
{
310+
HookEventName: "PreToolUse",
311+
SessionID: &sessionID,
312+
ToolName: stringPtr("tool1"),
313+
ToolUseID: stringPtr("id1"),
314+
},
315+
{
316+
HookEventName: "PostToolUse",
317+
SessionID: &sessionID,
318+
ToolName: stringPtr("tool2"),
319+
ToolUseID: stringPtr("id2"),
320+
},
321+
}
322+
323+
// Append payloads
324+
for _, payload := range payloads {
325+
err := cacheAdapter.ListAppend(ctx, key, payload, 1*time.Minute)
326+
require.NoError(t, err)
327+
}
328+
329+
// Read back using ListRange
330+
var retrieved []hooks.ClaudePayload
331+
err := cacheAdapter.ListRange(ctx, key, 0, -1, &retrieved)
332+
require.NoError(t, err)
333+
334+
// Verify we got both payloads back
335+
require.Len(t, retrieved, 2)
336+
assert.Equal(t, "PreToolUse", retrieved[0].HookEventName)
337+
assert.Equal(t, "PostToolUse", retrieved[1].HookEventName)
338+
assert.Equal(t, "tool1", *retrieved[0].ToolName)
339+
assert.Equal(t, "tool2", *retrieved[1].ToolName)
340+
}
341+
342+
func stringPtr(s string) *string {
343+
return &s
344+
}

0 commit comments

Comments
 (0)