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") } }