/ internal / tools / notify_test.go
notify_test.go
  1  package tools
  2  
  3  import (
  4  	"context"
  5  	"testing"
  6  )
  7  
  8  func TestNotify_Info(t *testing.T) {
  9  	tool := &NotifyTool{}
 10  	info := tool.Info()
 11  	if info.Name != "notify" {
 12  		t.Errorf("expected name 'notify', got %q", info.Name)
 13  	}
 14  	if len(info.Required) != 1 || info.Required[0] != "title" {
 15  		t.Errorf("expected required [title], got %v", info.Required)
 16  	}
 17  }
 18  
 19  func TestNotify_InvalidArgs(t *testing.T) {
 20  	tool := &NotifyTool{}
 21  	result, err := tool.Run(context.Background(), `not valid json`)
 22  	if err != nil {
 23  		t.Fatalf("unexpected error: %v", err)
 24  	}
 25  	if !result.IsError {
 26  		t.Error("expected error result for invalid JSON")
 27  	}
 28  }
 29  
 30  func TestNotify_BuildScript(t *testing.T) {
 31  	tests := []struct {
 32  		title string
 33  		body  string
 34  		sound bool
 35  		want  string
 36  	}{
 37  		{
 38  			title: "Test",
 39  			body:  "Hello",
 40  			sound: false,
 41  			want:  `display notification "Hello" with title "Test"`,
 42  		},
 43  		{
 44  			title: "Test",
 45  			body:  "Hello",
 46  			sound: true,
 47  			want:  `display notification "Hello" with title "Test" sound name "default"`,
 48  		},
 49  		{
 50  			title: "Test",
 51  			body:  "",
 52  			sound: false,
 53  			want:  `display notification "" with title "Test"`,
 54  		},
 55  		{
 56  			title: `Say "hi"`,
 57  			body:  `It's "great"`,
 58  			sound: false,
 59  			want:  `display notification "It's \"great\"" with title "Say \"hi\""`,
 60  		},
 61  	}
 62  	for _, tt := range tests {
 63  		got := buildNotifyScript(tt.title, tt.body, tt.sound)
 64  		if got != tt.want {
 65  			t.Errorf("buildNotifyScript(%q, %q, %v) = %q, want %q", tt.title, tt.body, tt.sound, got, tt.want)
 66  		}
 67  	}
 68  }
 69  
 70  func TestNotify_RequiresApproval(t *testing.T) {
 71  	tool := &NotifyTool{}
 72  	if !tool.RequiresApproval() {
 73  		t.Error("expected RequiresApproval to return true")
 74  	}
 75  }
 76  
 77  func TestNotify_DesktopHandler_Delivered(t *testing.T) {
 78  	tool := &NotifyTool{}
 79  	var (
 80  		called    bool
 81  		gotTitle  string
 82  		gotBody   string
 83  		gotSound  bool
 84  	)
 85  	handler := NotifyHandler(func(title, body string, sound bool) bool {
 86  		called = true
 87  		gotTitle, gotBody, gotSound = title, body, sound
 88  		return true
 89  	})
 90  	ctx := WithNotifyHandler(context.Background(), handler)
 91  
 92  	result, err := tool.Run(ctx, `{"title":"T","body":"B","sound":true}`)
 93  	if err != nil {
 94  		t.Fatalf("Run error: %v", err)
 95  	}
 96  	if result.IsError {
 97  		t.Fatalf("unexpected tool error: %s", result.Content)
 98  	}
 99  	if !called {
100  		t.Fatal("expected NotifyHandler to be called")
101  	}
102  	if gotTitle != "T" || gotBody != "B" || gotSound != true {
103  		t.Errorf("handler args = (%q,%q,%v), want (T,B,true)", gotTitle, gotBody, gotSound)
104  	}
105  	if result.Content != "notification sent" {
106  		t.Errorf("unexpected content: %q", result.Content)
107  	}
108  }
109  
110  func TestNotify_DesktopHandler_BodyFromMessageAlias(t *testing.T) {
111  	tool := &NotifyTool{}
112  	var gotBody string
113  	handler := NotifyHandler(func(title, body string, sound bool) bool {
114  		gotBody = body
115  		return true
116  	})
117  	ctx := WithNotifyHandler(context.Background(), handler)
118  
119  	if _, err := tool.Run(ctx, `{"title":"T","message":"from-alias"}`); err != nil {
120  		t.Fatalf("Run error: %v", err)
121  	}
122  	if gotBody != "from-alias" {
123  		t.Errorf("expected body from message alias, got %q", gotBody)
124  	}
125  }
126  
127  func TestNotify_DesktopHandler_FallsBackWhenHeadless(t *testing.T) {
128  	// When the handler returns false (no Desktop attached), Run must fall
129  	// through to the osascript path. We cancel the context from inside the
130  	// handler so exec.CommandContext bails out immediately without actually
131  	// posting a real notification on the developer's machine.
132  	tool := &NotifyTool{}
133  	var called bool
134  	ctx, cancel := context.WithCancel(context.Background())
135  	handler := NotifyHandler(func(title, body string, sound bool) bool {
136  		called = true
137  		cancel()
138  		return false
139  	})
140  	ctx = WithNotifyHandler(ctx, handler)
141  
142  	_, err := tool.Run(ctx, `{"title":"T","body":"B"}`)
143  	if err != nil {
144  		t.Fatalf("Run error: %v", err)
145  	}
146  	if !called {
147  		t.Fatal("expected handler to be consulted before osascript fallback")
148  	}
149  }
150  
151  func TestNotify_NoHandler_UnchangedBehavior(t *testing.T) {
152  	// Backward-compat: when no NotifyHandler is in context, Run should reach
153  	// the osascript path. We cancel the parent context to prevent an actual
154  	// notification side effect, and verify that Run returns without panicking.
155  	tool := &NotifyTool{}
156  	ctx, cancel := context.WithCancel(context.Background())
157  	cancel()
158  	_, err := tool.Run(ctx, `{"title":"T","body":"B"}`)
159  	if err != nil {
160  		t.Fatalf("Run error: %v", err)
161  	}
162  }
163  
164  func TestNotifyHandlerFrom_NilWhenAbsent(t *testing.T) {
165  	if h := NotifyHandlerFrom(context.Background()); h != nil {
166  		t.Error("expected nil handler from bare context")
167  	}
168  }
169  
170  func TestWithNotifyHandler_NilIsNoop(t *testing.T) {
171  	ctx := WithNotifyHandler(context.Background(), nil)
172  	if h := NotifyHandlerFrom(ctx); h != nil {
173  		t.Error("expected nil handler after WithNotifyHandler(nil)")
174  	}
175  }