535 lines
14 KiB
Go
535 lines
14 KiB
Go
package core
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func boolPtr(v bool) *bool { return &v }
|
|
|
|
func TestNewHookManager_ValidatesConfig(t *testing.T) {
|
|
hooks := []HookConfig{
|
|
{Event: "message.received", Type: "command", Command: "echo ok"},
|
|
{Event: "", Type: "command", Command: "echo bad"}, // missing event
|
|
{Event: "error", Type: "http", URL: ""}, // missing url
|
|
{Event: "error", Type: "http", URL: "ftp://bad"}, // bad url scheme
|
|
{Event: "error", Type: "unknown", Command: "echo"}, // bad type
|
|
{Event: "error", Type: "command", Command: ""}, // missing command
|
|
{Event: "message.sent", Type: "http", URL: "http://ok.com"},
|
|
}
|
|
hm := NewHookManager("test", hooks)
|
|
got := hm.Hooks()
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 valid hooks, got %d", len(got))
|
|
}
|
|
if got[0].Event != "message.received" {
|
|
t.Errorf("expected first hook event=message.received, got %s", got[0].Event)
|
|
}
|
|
if got[1].Event != "message.sent" {
|
|
t.Errorf("expected second hook event=message.sent, got %s", got[1].Event)
|
|
}
|
|
}
|
|
|
|
func TestHookConfig_IsAsync(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
async *bool
|
|
want bool
|
|
}{
|
|
{"nil defaults to true", nil, true},
|
|
{"explicit true", boolPtr(true), true},
|
|
{"explicit false", boolPtr(false), false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := HookConfig{Async: tt.async}
|
|
if h.isAsync() != tt.want {
|
|
t.Errorf("isAsync() = %v, want %v", h.isAsync(), tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHookConfig_TimeoutDuration(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
typ string
|
|
timeout int
|
|
want time.Duration
|
|
}{
|
|
{"command default", "command", 0, 10 * time.Second},
|
|
{"http default", "http", 0, 5 * time.Second},
|
|
{"custom", "command", 30, 30 * time.Second},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := HookConfig{Type: tt.typ, Timeout: tt.timeout}
|
|
if h.timeoutDuration() != tt.want {
|
|
t.Errorf("timeoutDuration() = %v, want %v", h.timeoutDuration(), tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatchEvent(t *testing.T) {
|
|
tests := []struct {
|
|
pattern, event string
|
|
want bool
|
|
}{
|
|
{"*", "message.received", true},
|
|
{"*", "error", true},
|
|
{"message.received", "message.received", true},
|
|
{"MESSAGE.RECEIVED", "message.received", true},
|
|
{"message.sent", "message.received", false},
|
|
{"error", "session.started", false},
|
|
}
|
|
for _, tt := range tests {
|
|
if matchEvent(tt.pattern, tt.event) != tt.want {
|
|
t.Errorf("matchEvent(%q, %q) = %v, want %v", tt.pattern, tt.event, !tt.want, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmit_NilManager(t *testing.T) {
|
|
var hm *HookManager
|
|
// Should not panic
|
|
hm.Emit(HookEvent{Event: HookEventError, Error: "boom"})
|
|
}
|
|
|
|
func TestEmit_CommandHook(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Unix shell commands (touch, env) not available on Windows")
|
|
}
|
|
dir := t.TempDir()
|
|
marker := filepath.Join(dir, "hook_fired")
|
|
|
|
hooks := []HookConfig{
|
|
{
|
|
Event: "message.received",
|
|
Type: "command",
|
|
Command: "touch " + marker,
|
|
Async: boolPtr(false),
|
|
},
|
|
}
|
|
hm := NewHookManager("test-project", hooks)
|
|
|
|
hm.Emit(HookEvent{
|
|
Event: HookEventMessageReceived,
|
|
SessionKey: "tg:1:1",
|
|
Platform: "telegram",
|
|
UserName: "alice",
|
|
Content: "hello",
|
|
})
|
|
|
|
if _, err := os.Stat(marker); os.IsNotExist(err) {
|
|
t.Fatal("expected marker file to be created by command hook")
|
|
}
|
|
}
|
|
|
|
func TestEmit_CommandHookEnvVars(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Unix shell commands (touch, env) not available on Windows")
|
|
}
|
|
dir := t.TempDir()
|
|
outFile := filepath.Join(dir, "env_out")
|
|
|
|
hooks := []HookConfig{
|
|
{
|
|
Event: "message.received",
|
|
Type: "command",
|
|
Command: "env > " + outFile,
|
|
Async: boolPtr(false),
|
|
},
|
|
}
|
|
hm := NewHookManager("my-proj", hooks)
|
|
|
|
hm.Emit(HookEvent{
|
|
Event: HookEventMessageReceived,
|
|
SessionKey: "slack:C1:U1",
|
|
Platform: "slack",
|
|
UserID: "U1",
|
|
UserName: "bob",
|
|
Content: "test msg",
|
|
})
|
|
|
|
data, err := os.ReadFile(outFile)
|
|
if err != nil {
|
|
t.Fatalf("failed to read env output: %v", err)
|
|
}
|
|
envStr := string(data)
|
|
|
|
expected := map[string]string{
|
|
"CC_HOOK_EVENT": "message.received",
|
|
"CC_HOOK_PROJECT": "my-proj",
|
|
"CC_HOOK_SESSION_KEY": "slack:C1:U1",
|
|
"CC_HOOK_PLATFORM": "slack",
|
|
"CC_HOOK_USER_ID": "U1",
|
|
"CC_HOOK_USER_NAME": "bob",
|
|
"CC_HOOK_CONTENT": "test msg",
|
|
}
|
|
for k, v := range expected {
|
|
line := k + "=" + v
|
|
if !strings.Contains(envStr, line) {
|
|
t.Errorf("expected env to contain %q", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmit_HTTPHook(t *testing.T) {
|
|
var received atomic.Int32
|
|
var mu sync.Mutex
|
|
var lastBody HookEvent
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
received.Add(1)
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("expected Content-Type application/json, got %s", ct)
|
|
}
|
|
if ev := r.Header.Get("X-Hook-Event"); ev != "error" {
|
|
t.Errorf("expected X-Hook-Event=error, got %s", ev)
|
|
}
|
|
|
|
body, _ := io.ReadAll(r.Body)
|
|
mu.Lock()
|
|
_ = json.Unmarshal(body, &lastBody)
|
|
mu.Unlock()
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hooks := []HookConfig{
|
|
{
|
|
Event: "error",
|
|
Type: "http",
|
|
URL: srv.URL,
|
|
Async: boolPtr(false),
|
|
},
|
|
}
|
|
hm := NewHookManager("proj-1", hooks)
|
|
|
|
hm.Emit(HookEvent{
|
|
Event: HookEventError,
|
|
SessionKey: "tg:1:1",
|
|
Platform: "telegram",
|
|
Error: "something failed",
|
|
})
|
|
|
|
if received.Load() != 1 {
|
|
t.Fatalf("expected 1 HTTP request, got %d", received.Load())
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if lastBody.Event != HookEventError {
|
|
t.Errorf("expected event=error, got %s", lastBody.Event)
|
|
}
|
|
if lastBody.Project != "proj-1" {
|
|
t.Errorf("expected project=proj-1, got %s", lastBody.Project)
|
|
}
|
|
if lastBody.Error != "something failed" {
|
|
t.Errorf("expected error=something failed, got %s", lastBody.Error)
|
|
}
|
|
}
|
|
|
|
func TestEmit_WildcardMatchesAll(t *testing.T) {
|
|
var count atomic.Int32
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
count.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hooks := []HookConfig{
|
|
{Event: "*", Type: "http", URL: srv.URL, Async: boolPtr(false)},
|
|
}
|
|
hm := NewHookManager("proj", hooks)
|
|
|
|
hm.Emit(HookEvent{Event: HookEventMessageReceived})
|
|
hm.Emit(HookEvent{Event: HookEventSessionStarted})
|
|
hm.Emit(HookEvent{Event: HookEventError})
|
|
|
|
if count.Load() != 3 {
|
|
t.Fatalf("expected 3 requests for wildcard, got %d", count.Load())
|
|
}
|
|
}
|
|
|
|
func TestEmit_OnlyMatchingHooksFire(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Unix shell commands (touch, env) not available on Windows")
|
|
}
|
|
dir := t.TempDir()
|
|
matchedFile := filepath.Join(dir, "matched")
|
|
unmatchedFile := filepath.Join(dir, "unmatched")
|
|
|
|
hooks := []HookConfig{
|
|
{Event: "session.ended", Type: "command", Command: "touch " + matchedFile, Async: boolPtr(false)},
|
|
{Event: "message.received", Type: "command", Command: "touch " + unmatchedFile, Async: boolPtr(false)},
|
|
}
|
|
hm := NewHookManager("proj", hooks)
|
|
|
|
hm.Emit(HookEvent{Event: HookEventSessionEnded})
|
|
|
|
if _, err := os.Stat(matchedFile); os.IsNotExist(err) {
|
|
t.Error("expected matched hook to fire")
|
|
}
|
|
if _, err := os.Stat(unmatchedFile); !os.IsNotExist(err) {
|
|
t.Error("expected unmatched hook NOT to fire")
|
|
}
|
|
}
|
|
|
|
func TestEmit_AsyncDoesNotBlock(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Unix shell commands (touch, env) not available on Windows")
|
|
}
|
|
dir := t.TempDir()
|
|
marker := filepath.Join(dir, "async_done")
|
|
|
|
hooks := []HookConfig{
|
|
{
|
|
Event: "message.received",
|
|
Type: "command",
|
|
Command: "sleep 0.1 && touch " + marker,
|
|
Async: boolPtr(true),
|
|
},
|
|
}
|
|
hm := NewHookManager("proj", hooks)
|
|
|
|
start := time.Now()
|
|
hm.Emit(HookEvent{Event: HookEventMessageReceived})
|
|
elapsed := time.Since(start)
|
|
|
|
if elapsed > 50*time.Millisecond {
|
|
t.Errorf("async emit took %v, expected near-instant return", elapsed)
|
|
}
|
|
|
|
// Wait for the async command to finish
|
|
time.Sleep(300 * time.Millisecond)
|
|
if _, err := os.Stat(marker); os.IsNotExist(err) {
|
|
t.Error("expected async command to eventually create marker file")
|
|
}
|
|
}
|
|
|
|
func TestEmit_SyncBlocks(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Unix shell commands (touch, env) not available on Windows")
|
|
}
|
|
dir := t.TempDir()
|
|
marker := filepath.Join(dir, "sync_done")
|
|
|
|
hooks := []HookConfig{
|
|
{
|
|
Event: "message.received",
|
|
Type: "command",
|
|
Command: "touch " + marker,
|
|
Async: boolPtr(false),
|
|
},
|
|
}
|
|
hm := NewHookManager("proj", hooks)
|
|
hm.Emit(HookEvent{Event: HookEventMessageReceived})
|
|
|
|
// File should exist immediately after synchronous emit
|
|
if _, err := os.Stat(marker); os.IsNotExist(err) {
|
|
t.Error("expected sync command to create marker file before Emit returns")
|
|
}
|
|
}
|
|
|
|
func TestEmit_HTTPError_DoesNotPanic(t *testing.T) {
|
|
hooks := []HookConfig{
|
|
{Event: "error", Type: "http", URL: "http://127.0.0.1:1", Async: boolPtr(false), Timeout: 1},
|
|
}
|
|
hm := NewHookManager("proj", hooks)
|
|
// Should not panic even with connection refused
|
|
hm.Emit(HookEvent{Event: HookEventError, Error: "test"})
|
|
}
|
|
|
|
func TestEmit_CommandTimeout(t *testing.T) {
|
|
hooks := []HookConfig{
|
|
{
|
|
Event: "message.received",
|
|
Type: "command",
|
|
Command: "sleep 10",
|
|
Async: boolPtr(false),
|
|
Timeout: 1,
|
|
},
|
|
}
|
|
hm := NewHookManager("proj", hooks)
|
|
|
|
start := time.Now()
|
|
hm.Emit(HookEvent{Event: HookEventMessageReceived})
|
|
elapsed := time.Since(start)
|
|
|
|
// 1s timeout + up to 2s WaitDelay for orphan child cleanup
|
|
if elapsed > 5*time.Second {
|
|
t.Errorf("expected command to timeout within ~3s, took %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestEventToEnv(t *testing.T) {
|
|
e := HookEvent{
|
|
Event: HookEventCronTriggered,
|
|
Timestamp: time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC),
|
|
Project: "myproj",
|
|
SessionKey: "tg:1:1",
|
|
Platform: "telegram",
|
|
UserID: "U123",
|
|
UserName: "alice",
|
|
Content: "hello world",
|
|
Error: "oops",
|
|
}
|
|
env := eventToEnv(e)
|
|
m := make(map[string]string)
|
|
for _, kv := range env {
|
|
parts := strings.SplitN(kv, "=", 2)
|
|
if len(parts) == 2 {
|
|
m[parts[0]] = parts[1]
|
|
}
|
|
}
|
|
|
|
checks := map[string]string{
|
|
"CC_HOOK_EVENT": "cron.triggered",
|
|
"CC_HOOK_PROJECT": "myproj",
|
|
"CC_HOOK_SESSION_KEY": "tg:1:1",
|
|
"CC_HOOK_PLATFORM": "telegram",
|
|
"CC_HOOK_USER_ID": "U123",
|
|
"CC_HOOK_USER_NAME": "alice",
|
|
"CC_HOOK_CONTENT": "hello world",
|
|
"CC_HOOK_ERROR": "oops",
|
|
}
|
|
for k, want := range checks {
|
|
if got := m[k]; got != want {
|
|
t.Errorf("%s = %q, want %q", k, got, want)
|
|
}
|
|
}
|
|
if _, ok := m["CC_HOOK_TIMESTAMP"]; !ok {
|
|
t.Error("expected CC_HOOK_TIMESTAMP in env")
|
|
}
|
|
}
|
|
|
|
func TestEventToEnv_EmptyFieldsOmitted(t *testing.T) {
|
|
e := HookEvent{
|
|
Event: HookEventError,
|
|
Project: "p",
|
|
}
|
|
env := eventToEnv(e)
|
|
for _, kv := range env {
|
|
if strings.HasPrefix(kv, "CC_HOOK_SESSION_KEY=") ||
|
|
strings.HasPrefix(kv, "CC_HOOK_PLATFORM=") ||
|
|
strings.HasPrefix(kv, "CC_HOOK_USER_ID=") ||
|
|
strings.HasPrefix(kv, "CC_HOOK_CONTENT=") {
|
|
t.Errorf("expected empty field to be omitted: %s", kv)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHookManager_Hooks_NilManager(t *testing.T) {
|
|
var hm *HookManager
|
|
if hooks := hm.Hooks(); hooks != nil {
|
|
t.Errorf("expected nil hooks from nil manager, got %v", hooks)
|
|
}
|
|
}
|
|
|
|
func TestHookManager_ProjectSet(t *testing.T) {
|
|
var receivedProject string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
var ev HookEvent
|
|
_ = json.Unmarshal(body, &ev)
|
|
receivedProject = ev.Project
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hm := NewHookManager("special-project", []HookConfig{
|
|
{Event: "*", Type: "http", URL: srv.URL, Async: boolPtr(false)},
|
|
})
|
|
|
|
hm.Emit(HookEvent{Event: HookEventMessageReceived})
|
|
|
|
if receivedProject != "special-project" {
|
|
t.Errorf("expected project=special-project, got %s", receivedProject)
|
|
}
|
|
}
|
|
|
|
func TestValidateHookConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config HookConfig
|
|
wantErr bool
|
|
}{
|
|
{"valid command", HookConfig{Event: "error", Type: "command", Command: "echo"}, false},
|
|
{"valid http", HookConfig{Event: "error", Type: "http", URL: "https://x.com"}, false},
|
|
{"empty event", HookConfig{Event: "", Type: "command", Command: "echo"}, true},
|
|
{"empty command", HookConfig{Event: "e", Type: "command", Command: ""}, true},
|
|
{"empty url", HookConfig{Event: "e", Type: "http", URL: ""}, true},
|
|
{"bad url scheme", HookConfig{Event: "e", Type: "http", URL: "ftp://x"}, true},
|
|
{"unknown type", HookConfig{Event: "e", Type: "grpc", Command: "x"}, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateHookConfig(tt.config)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("validateHookConfig() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmit_MultipleHooksSameEvent(t *testing.T) {
|
|
var count atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
count.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hooks := []HookConfig{
|
|
{Event: "error", Type: "http", URL: srv.URL, Async: boolPtr(false)},
|
|
{Event: "error", Type: "http", URL: srv.URL, Async: boolPtr(false)},
|
|
{Event: "error", Type: "http", URL: srv.URL, Async: boolPtr(false)},
|
|
}
|
|
hm := NewHookManager("proj", hooks)
|
|
hm.Emit(HookEvent{Event: HookEventError})
|
|
|
|
if count.Load() != 3 {
|
|
t.Fatalf("expected 3 hooks to fire, got %d", count.Load())
|
|
}
|
|
}
|
|
|
|
func TestEmit_TimestampAutoFilled(t *testing.T) {
|
|
var receivedTime time.Time
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
var ev HookEvent
|
|
_ = json.Unmarshal(body, &ev)
|
|
receivedTime = ev.Timestamp
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hm := NewHookManager("proj", []HookConfig{
|
|
{Event: "*", Type: "http", URL: srv.URL, Async: boolPtr(false)},
|
|
})
|
|
|
|
before := time.Now()
|
|
hm.Emit(HookEvent{Event: HookEventMessageReceived})
|
|
|
|
if receivedTime.Before(before) {
|
|
t.Errorf("expected auto-filled timestamp >= %v, got %v", before, receivedTime)
|
|
}
|
|
}
|