summaryrefslogtreecommitdiff
path: root/internal/api/push_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
commit7f920ca63af5329c19a0e5a879c649c594beea35 (patch)
tree803150e7c895d3232bad35c729aad647aaa54348 /internal/api/push_test.go
parent072652f617653dce74368cedb42b88189e5014fb (diff)
feat: add web push notifications and file drop
Web Push: - WebPushNotifier with VAPID auth; urgency mapped to event type (BLOCKED=urgent, FAILED=high, COMPLETED=low) - Auto-generates VAPID keys on first serve, persists to config file - push_subscriptions table in SQLite (upsert by endpoint) - GET /api/push/vapid-key, POST/DELETE /api/push/subscribe endpoints - Service worker (sw.js) handles push events and notification clicks - Notification bell button in web UI; subscribes on click File Drop: - GET /api/drops, GET /api/drops/{filename}, POST /api/drops - Persistent ~/.claudomator/drops/ directory - CLAUDOMATOR_DROP_DIR env var passed to agent subprocesses - Drops tab (📁) in web UI with file listing and download links Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/push_test.go')
-rw-r--r--internal/api/push_test.go159
1 files changed, 159 insertions, 0 deletions
diff --git a/internal/api/push_test.go b/internal/api/push_test.go
new file mode 100644
index 0000000..dfd5a3a
--- /dev/null
+++ b/internal/api/push_test.go
@@ -0,0 +1,159 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/storage"
+)
+
+// mockPushStore implements pushSubscriptionStore for testing.
+type mockPushStore struct {
+ mu sync.Mutex
+ subs []storage.PushSubscription
+}
+
+func (m *mockPushStore) SavePushSubscription(sub storage.PushSubscription) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ // Upsert by endpoint.
+ for i, s := range m.subs {
+ if s.Endpoint == sub.Endpoint {
+ m.subs[i] = sub
+ return nil
+ }
+ }
+ m.subs = append(m.subs, sub)
+ return nil
+}
+
+func (m *mockPushStore) DeletePushSubscription(endpoint string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ filtered := m.subs[:0]
+ for _, s := range m.subs {
+ if s.Endpoint != endpoint {
+ filtered = append(filtered, s)
+ }
+ }
+ m.subs = filtered
+ return nil
+}
+
+func (m *mockPushStore) ListPushSubscriptions() ([]storage.PushSubscription, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := make([]storage.PushSubscription, len(m.subs))
+ copy(cp, m.subs)
+ return cp, nil
+}
+
+func testServerWithPush(t *testing.T) (*Server, *mockPushStore) {
+ t.Helper()
+ srv, _ := testServer(t)
+ ps := &mockPushStore{}
+ srv.SetVAPIDConfig("testpub", "testpriv", "mailto:test@example.com")
+ srv.SetPushStore(ps)
+ return srv, ps
+}
+
+func TestHandleGetVAPIDKey(t *testing.T) {
+ srv, _ := testServerWithPush(t)
+
+ req := httptest.NewRequest("GET", "/api/push/vapid-key", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", rec.Code)
+ }
+
+ var resp map[string]string
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp["public_key"] != "testpub" {
+ t.Errorf("want public_key %q, got %q", "testpub", resp["public_key"])
+ }
+}
+
+func TestHandlePushSubscribe_CreatesSub(t *testing.T) {
+ srv, ps := testServerWithPush(t)
+
+ body := `{"endpoint":"https://push.example.com/sub1","keys":{"p256dh":"key1","auth":"auth1"}}`
+ req := httptest.NewRequest("POST", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ subs, _ := ps.ListPushSubscriptions()
+ if len(subs) != 1 {
+ t.Fatalf("want 1 subscription, got %d", len(subs))
+ }
+ if subs[0].Endpoint != "https://push.example.com/sub1" {
+ t.Errorf("endpoint: want %q, got %q", "https://push.example.com/sub1", subs[0].Endpoint)
+ }
+}
+
+func TestHandlePushSubscribe_MissingEndpoint(t *testing.T) {
+ srv, _ := testServerWithPush(t)
+
+ body := `{"keys":{"p256dh":"key1","auth":"auth1"}}`
+ req := httptest.NewRequest("POST", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("want 400, got %d", rec.Code)
+ }
+}
+
+func TestHandlePushUnsubscribe_DeletesSub(t *testing.T) {
+ srv, ps := testServerWithPush(t)
+
+ // Add a subscription.
+ ps.SavePushSubscription(storage.PushSubscription{ //nolint:errcheck
+ ID: "sub-1",
+ Endpoint: "https://push.example.com/todelete",
+ P256DHKey: "key",
+ AuthKey: "auth",
+ })
+
+ body := `{"endpoint":"https://push.example.com/todelete"}`
+ req := httptest.NewRequest("DELETE", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ subs, _ := ps.ListPushSubscriptions()
+ if len(subs) != 0 {
+ t.Errorf("want 0 subscriptions after delete, got %d", len(subs))
+ }
+}
+
+func TestHandlePushUnsubscribe_MissingEndpoint(t *testing.T) {
+ srv, _ := testServerWithPush(t)
+
+ body := `{}`
+ req := httptest.NewRequest("DELETE", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("want 400, got %d", rec.Code)
+ }
+}