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 }