feat(notify): web push as a notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s

- VAPID keys from VAPID_* or generated once (push_keys, migration 9);
  devices in push_subscriptions, forgotten on 404/410 from the push service
- push column for every event (also due tasks) and the daily summary,
  bundles as one notification; /push/key, /devices, /subscribe,
  /unsubscribe, /test
- settings: switch push on per device, test it, see the devices; the
  service worker shows notifications and focuses MindSift on click
- tests decrypt a sent push like a browser (RFC 8291) and check VAPID

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 21:21:55 +01:00
1 parent 1a49e6b304
commit 38b8c92899
27 files changed
+918 -27

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestPushSubscribeKeyAndChannel(t *testing.T) {
srv, store, _ := setupTestServer(t)
srv.cfg.NotifyDigestMinutes = 5
router := srv.Router()
me, token := newTestUser(t, store, "push@example.com")
// The key is generated once and stays the same
var k1, k2 struct {
Enabled bool
PublicKey string
}
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/push/key", token, "", "").Body.Bytes(), &k1)
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/push/key", token, "", "").Body.Bytes(), &k2)
if !k1.Enabled || len(k1.PublicKey) < 80 || k1.PublicKey != k2.PublicKey {
t.Fatalf("key: %+v / %+v", k1, k2)
}
if rec := sessionCall(router, "POST", "/api/v1/push/subscribe", token, `{"endpoint":"http://intern/x","keys":{"p256dh":"a","auth":"b"}}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("http endpoint must be refused, got %d", rec.Code)
}
body := `{"endpoint":"https://push.example.com/abc","keys":{"p256dh":"BAAA","auth":"CCCC"},"device":"Firefox auf Linux"}`
if rec := sessionCall(router, "POST", "/api/v1/push/subscribe", token, body, ""); rec.Code != http.StatusOK {
t.Fatalf("subscribe: %d %s", rec.Code, rec.Body.String())
}
rec := sessionCall(router, "GET", "/api/v1/push/devices", token, "", "")
if !strings.Contains(rec.Body.String(), "Firefox auf Linux") || strings.Contains(rec.Body.String(), "CCCC") {
t.Fatalf("devices must list the device without its keys: %s", rec.Body.String())
}
// Push as a channel for watched tasks
var pushed []pushMessage
srv.notifyPushHook = func(userID string, msg pushMessage) error {
if userID == me.ID {
pushed = append(pushed, msg)
}
return nil
}
task, _ := store.CreateThoughtWithDetails(me.ID, "Reifen wechseln", "normal", "manual", "", "", "todo", "")
_ = store.WatchTask(task.ID, me.ID, true)
set := store.NotificationSettingsOf(me.ID)
set.IncludeOwnWatched = true
set.Events.Watched = domain.ChannelChoice{Push: true}
_ = store.SetNotificationSettings(me.ID, set)
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, token, `{"priority":"high"}`, "")
srv.flushNotifications(time.Now().Add(10 * time.Minute))
if len(pushed) != 1 || !strings.Contains(pushed[0].Body, "Reifen wechseln") {
t.Fatalf("expected one push, got %+v", pushed)
}
if rec := sessionCall(router, "POST", "/api/v1/push/unsubscribe", token, `{"endpoint":"https://push.example.com/abc"}`, ""); rec.Code != http.StatusOK {
t.Fatalf("unsubscribe: %d", rec.Code)
}
if rec := sessionCall(router, "POST", "/api/v1/push/test", token, "", ""); rec.Code != http.StatusBadRequest {
t.Fatalf("test without devices: want 400, got %d", rec.Code)
}
}
func TestPushDigestText(t *testing.T) {
one := pushDigest("Neuigkeiten", []notifyItem{{text: "Anna hat „Einkauf“ erledigt"}}, "")
if one.Body != "Anna hat „Einkauf“ erledigt" || one.URL != "/" {
t.Fatalf("single: %+v", one)
}
many := pushDigest("Neuigkeiten", []notifyItem{{text: "a"}, {text: "b"}, {text: "c"}, {text: "d"}, {text: "e"}, {text: "f"}}, "https://x/")
if many.Title != "MindSift: Neuigkeiten" || !strings.HasSuffix(many.Body, "… und 2 weitere") {
t.Fatalf("many: %+v", many)
}
}