package hook import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "github.com/longbin/agent-notify/internal/config" "github.com/longbin/agent-notify/internal/inbox" "github.com/longbin/agent-notify/internal/remote" ) func TestCursorStopHookDisabled(t *testing.T) { stubCursorSend(t) cfg := config.Default() cfg.Events.Stop = false err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}) if err != nil { t.Fatal(err) } } func TestClaudeStopOutputsTerminalSequence(t *testing.T) { cfg := config.Default() var out bytes.Buffer err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out) if err != nil { t.Fatal(err) } var resp map[string]string if err := json.Unmarshal(out.Bytes(), &resp); err != nil { t.Fatal(err) } if !strings.Contains(resp["terminalSequence"], "777;notify") { t.Fatalf("bad sequence: %v", resp) } } func TestClaudeStopHookActiveSkips(t *testing.T) { cfg := config.Default() var out bytes.Buffer err := RunClaude(strings.NewReader(`{"stop_hook_active":true}`), cfg, "stop", &out) if err != nil { t.Fatal(err) } if strings.TrimSpace(out.String()) != "{}" { t.Fatalf("expected {}, got %q", out.String()) } } func TestCursorHookUploadsInboxRecord(t *testing.T) { stubCursorSend(t) var uploaded inbox.Record stubInbox(t, func(rec inbox.Record, cfg config.Config) error { uploaded = rec return nil }, func(rec inbox.Record) error { t.Fatalf("unexpected fallback append: %+v", rec) return nil }, ) cfg := config.Default() cfg.Inbox.Enabled = true err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}) if err != nil { t.Fatal(err) } if uploaded.Agent != "Cursor" || uploaded.Event != "stop" || uploaded.CWD != "/tmp/proj" { t.Fatalf("unexpected upload record: %+v", uploaded) } } func TestCursorHookFallbacksWhenInboxUploadFails(t *testing.T) { stubCursorSend(t) var fallback inbox.Record stubInbox(t, func(rec inbox.Record, cfg config.Config) error { return errors.New("offline") }, func(rec inbox.Record) error { fallback = rec return nil }, ) cfg := config.Default() cfg.Inbox.Enabled = true err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}) if err != nil { t.Fatal(err) } if fallback.Source != inbox.SourceFallback || fallback.Title == "" { t.Fatalf("unexpected fallback record: %+v", fallback) } } func TestDisabledHookDoesNotRecordInbox(t *testing.T) { stubCursorSend(t) stubInbox(t, func(rec inbox.Record, cfg config.Config) error { t.Fatalf("unexpected upload: %+v", rec) return nil }, func(rec inbox.Record) error { t.Fatalf("unexpected fallback: %+v", rec) return nil }, ) cfg := config.Default() cfg.Events.Stop = false if err := RunCursor(bytes.NewReader([]byte(`{}`)), cfg, "stop", &bytes.Buffer{}); err != nil { t.Fatal(err) } } func TestClaudeHookUploadsInboxRecord(t *testing.T) { var uploaded inbox.Record stubInbox(t, func(rec inbox.Record, cfg config.Config) error { uploaded = rec return nil }, func(rec inbox.Record) error { t.Fatalf("unexpected fallback append: %+v", rec) return nil }, ) cfg := config.Default() cfg.Inbox.Enabled = true var out bytes.Buffer if err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out); err != nil { t.Fatal(err) } if uploaded.Agent != "Claude" || uploaded.Event != "stop" || uploaded.Title == "" { t.Fatalf("unexpected upload record: %+v", uploaded) } } func stubInbox(t *testing.T, upload func(inbox.Record, config.Config) error, appendLocal func(inbox.Record) error) { t.Helper() prevUpload := uploadInboxRecord prevAppend := appendInboxRecord uploadInboxRecord = upload appendInboxRecord = appendLocal t.Cleanup(func() { uploadInboxRecord = prevUpload appendInboxRecord = prevAppend }) } func stubReportRemote(t *testing.T, fn func(config.Config, string, string, string, string, string)) { t.Helper() prev := reportRemoteHook reportRemoteHook = fn t.Cleanup(func() { reportRemoteHook = prev }) } func TestCursorHookReportsRemoteOnStop(t *testing.T) { stubCursorSend(t) var got remote.StatusReport srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/status" { http.NotFound(w, r) return } if err := json.NewDecoder(r.Body).Decode(&got); err != nil { http.Error(w, "decode", http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) })) defer srv.Close() cfg := config.Default() cfg.Remote.Enabled = true cfg.Remote.URL = srv.URL err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"],"conversation_id":"conv-1","transcript_path":"/tmp/t.jsonl"}`)), cfg, "stop", &bytes.Buffer{}) if err != nil { t.Fatal(err) } if got.Agent != "Cursor" || got.Event != "stop" || got.Status != "waiting" || got.CWD != "/tmp/proj" { t.Fatalf("unexpected report: %+v", got) } if got.ConversationID != "conv-1" { t.Fatalf("conversation_id: got %q", got.ConversationID) } } func TestCursorHookSkipsRemoteWhenDisabled(t *testing.T) { stubCursorSend(t) stubReportRemote(t, func(config.Config, string, string, string, string, string) { t.Fatal("reportRemote should not run when remote disabled") }) cfg := config.Default() cfg.Remote.Enabled = false if err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}); err != nil { t.Fatal(err) } } func TestCursorHookRemoteReportFailureStillOK(t *testing.T) { stubCursorSend(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "fail", http.StatusInternalServerError) })) defer srv.Close() cfg := config.Default() cfg.Remote.Enabled = true cfg.Remote.URL = srv.URL if err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}); err != nil { t.Fatalf("hook should return nil on remote failure, got %v", err) } }