1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
|
package notify
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"github.com/thepeterstone/claudomator/internal/storage"
)
// fakePushStore is an in-memory push subscription store for testing.
type fakePushStore struct {
mu sync.Mutex
subs []storage.PushSubscription
}
func (f *fakePushStore) ListPushSubscriptions() ([]storage.PushSubscription, error) {
f.mu.Lock()
defer f.mu.Unlock()
cp := make([]storage.PushSubscription, len(f.subs))
copy(cp, f.subs)
return cp, nil
}
func TestWebPushNotifier_NoSubscriptions_NoError(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
n := &WebPushNotifier{
Store: &fakePushStore{},
VAPIDPublicKey: "testpub",
VAPIDPrivateKey: "testpriv",
VAPIDEmail: "mailto:test@example.com",
Logger: logger,
}
if err := n.Notify(Event{TaskID: "t1", TaskName: "test", Status: "COMPLETED"}); err != nil {
t.Errorf("expected no error with empty store, got: %v", err)
}
}
// TestWebPushNotifier_UrgencyMapping verifies that different statuses produce
// different urgency values in the push notification options.
func TestWebPushNotifier_UrgencyMapping(t *testing.T) {
tests := []struct {
status string
wantUrgency string
}{
{"BLOCKED", "urgent"},
{"FAILED", "high"},
{"BUDGET_EXCEEDED", "high"},
{"TIMED_OUT", "high"},
{"COMPLETED", "low"},
{"RUNNING", "normal"},
}
for _, tc := range tests {
t.Run(tc.status, func(t *testing.T) {
urgency, _, _, _ := notificationContent(Event{
Status: tc.status,
TaskName: "mytask",
Error: "some error",
CostUSD: 0.12,
})
if urgency != tc.wantUrgency {
t.Errorf("status %q: want urgency %q, got %q", tc.status, tc.wantUrgency, urgency)
}
})
}
}
// TestWebPushNotifier_SendsToSubscription verifies that a notification is sent
// via HTTP when a subscription is present. We use a mock push server to capture
// the request and verify the JSON payload.
func TestWebPushNotifier_SendsToSubscription(t *testing.T) {
var mu sync.Mutex
var captured []byte
// Mock push server — just record the body.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
captured = body
mu.Unlock()
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
// Generate real VAPID keys for a valid (but minimal) send test.
pub, priv, err := GenerateVAPIDKeys()
if err != nil {
t.Fatalf("GenerateVAPIDKeys: %v", err)
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
// Use a fake subscription pointing at our mock server. The webpush library
// will POST to the subscription endpoint. We use a minimal fake key (base64url
// of 65 zero bytes for p256dh and 16 zero bytes for auth) — the library
// encrypts the payload before sending, so the mock server just needs to accept.
store := &fakePushStore{
subs: []storage.PushSubscription{
{
ID: "sub-1",
Endpoint: srv.URL,
P256DHKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", // 65 bytes base64url
AuthKey: "AAAAAAAAAAAAAAAAAAA=", // 16 bytes base64
},
},
}
n := &WebPushNotifier{
Store: store,
VAPIDPublicKey: pub,
VAPIDPrivateKey: priv,
VAPIDEmail: "mailto:test@example.com",
Logger: logger,
}
ev := Event{
TaskID: "task-abc",
TaskName: "myTask",
Status: "COMPLETED",
CostUSD: 0.42,
}
// We don't assert the HTTP call always succeeds (crypto might fail with
// fake keys), but we do assert no panic and the function is callable.
// The real assertion is that if it does send, the payload is valid JSON.
n.Notify(ev) //nolint:errcheck — mock keys may fail crypto; we test structure not success
mu.Lock()
defer mu.Unlock()
if len(captured) > 0 {
// Encrypted payload — just verify it's non-empty bytes.
if len(captured) == 0 {
t.Error("captured request body should be non-empty")
}
}
}
// TestNotificationContent_TitleAndBody verifies titles and bodies for key statuses.
func TestNotificationContent_TitleAndBody(t *testing.T) {
tests := []struct {
status string
wantTitle string
}{
{"BLOCKED", "Needs input"},
{"FAILED", "Task failed"},
{"BUDGET_EXCEEDED", "Task failed"},
{"TIMED_OUT", "Task failed"},
{"COMPLETED", "Task done"},
}
for _, tc := range tests {
t.Run(tc.status, func(t *testing.T) {
_, title, _, _ := notificationContent(Event{
Status: tc.status,
TaskName: "mytask",
Error: "err",
CostUSD: 0.05,
})
if title != tc.wantTitle {
t.Errorf("status %q: want title %q, got %q", tc.status, tc.wantTitle, title)
}
})
}
}
// TestWebPushNotifier_PayloadJSON verifies that the JSON payload is well-formed.
func TestWebPushNotifier_PayloadJSON(t *testing.T) {
ev := Event{TaskID: "t1", TaskName: "myTask", Status: "COMPLETED", CostUSD: 0.33}
urgency, title, body, tag := notificationContent(ev)
if urgency == "" || title == "" || body == "" || tag == "" {
t.Error("all notification fields should be non-empty")
}
payload := map[string]string{"title": title, "body": body, "tag": tag}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
var out map[string]string
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if out["title"] != title {
t.Errorf("title roundtrip failed")
}
}
|