Files
Warp/server/internal/storage/push_test.go
T
tajniak81andClaude Opus 5.5 b507753172 Phase 8: push and notifications
Implements PLAN.md Phase 8. internal/pushrules is the rule language: the
spec's default ruleset, all six condition kinds, glob and word-boundary
matching, intentional mentions switching off the legacy body rules, and
rule ordering as Synapse has it. internal/pushserver keeps the rules,
pushers and notifications and talks to push gateways.

Push rules are the m.push_rules account data, written only by the push
server; reads keep the user's own rules and the enabled/actions of each
default and take the rest from the current defaults, so new default rules
reach every user without a migration. PUT account_data/m.push_rules is
refused with 405.

The push server listens on the roomserver's emit stage and evaluates each
event on its own worker for every local member and an invite's target.
Notifications and per-room unread counts are stored in migration 0010;
counts are a seventh sync token stream, so /sync reports
unread_notifications and picks up count changes made after the event or by
a receipt from another device. m.read and m.read.private receipts clear
the counts; leaving a room clears it.

Pushers (http kind only) push through a bounded sender pool with retries,
honour event_id_only, drop push keys the gateway rejects, and go away with
their device. GET /notifications pages the history. New config section
push: enabled, include_content, gateway_timeout, notification_retention.

Account data writes now wake the user's blocked syncs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:43:36 +02:00

219 lines
7.7 KiB
Go

package storage_test
import (
"database/sql"
"errors"
"os"
"slices"
"testing"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// migratedPushDB opens a migrated database with empty push tables.
func migratedPushDB(t *testing.T, driver string) storage.Database {
t.Helper()
db := openTestDB(t, driver)
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("Migrate: %v", err)
}
if driver == storage.DriverPostgres {
pool, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN"))
if err != nil {
t.Fatalf("open postgres to reset the push tables: %v", err)
}
defer func() { _ = pool.Close() }()
if _, err := pool.ExecContext(t.Context(),
`TRUNCATE push_pushers, push_notifications, push_room_counts`); err != nil {
t.Fatalf("reset the push tables: %v", err)
}
if _, err := pool.ExecContext(t.Context(),
`UPDATE push_sequences SET value = 0`); err != nil {
t.Fatalf("reset the push counter: %v", err)
}
}
return db
}
func TestPushers(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
push := migratedPushDB(t, driver).Push()
pusher := func(localpart, device, key string) storage.Pusher {
return storage.Pusher{
Localpart: localpart, DeviceID: device, Kind: "http", AppID: "app", PushKey: key,
AppDisplayName: "App", DeviceDisplayName: "Phone", Lang: "en",
Data: []byte(`{"url":"https://push.example/_matrix/push/v1/notify"}`), PushKeyTSMS: 1,
}
}
if err := push.SetPusher(ctx, pusher("alice", "D1", "k1"), false); err != nil {
t.Fatal(err)
}
if err := push.SetPusher(ctx, pusher("alice", "D2", "k2"), false); err != nil {
t.Fatal(err)
}
// Replacing keeps one row and takes the new values.
updated := pusher("alice", "D1", "k1")
updated.Lang = "fr"
if err := push.SetPusher(ctx, updated, false); err != nil {
t.Fatal(err)
}
got, err := push.Pushers(ctx, "alice")
if err != nil || len(got) != 2 || got[0].Lang != "fr" || string(got[0].Data) != string(updated.Data) {
t.Fatalf("Pushers = %+v, %v", got, err)
}
// The same key for another user takes it over, unless appending.
if err := push.SetPusher(ctx, pusher("bob", "B1", "k1"), true); err != nil {
t.Fatal(err)
}
if got, _ := push.Pushers(ctx, "alice"); len(got) != 2 {
t.Errorf("appending removed alice's pusher: %+v", got)
}
if err := push.SetPusher(ctx, pusher("bob", "B1", "k1"), false); err != nil {
t.Fatal(err)
}
if got, _ := push.Pushers(ctx, "alice"); len(got) != 1 || got[0].PushKey != "k2" {
t.Errorf("taking the key over left alice with %+v", got)
}
if err := push.DeletePushersForDevices(ctx, "alice", []string{"D2"}); err != nil {
t.Fatal(err)
}
if got, _ := push.Pushers(ctx, "alice"); len(got) != 0 {
t.Errorf("after deleting the device: %+v", got)
}
if err := push.DeletePusher(ctx, "bob", "app", "k1"); err != nil {
t.Fatal(err)
}
if err := push.DeletePusher(ctx, "bob", "app", "k1"); err != nil {
t.Errorf("deleting a pusher twice: %v", err)
}
if got, _ := push.Pushers(ctx, "bob"); len(got) != 0 {
t.Errorf("bob's pushers after the delete: %+v", got)
}
})
}
}
func TestNotifications(t *testing.T) {
const alice, bob = "@alice:warp.example", "@bob:warp.example"
const room1, room2 = "!one:warp.example", "!two:warp.example"
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
db := migratedPushDB(t, driver)
push := db.Push()
start, err := db.Sync().StreamPositions(ctx)
if err != nil {
t.Fatal(err)
}
note := func(user, room, event string, pos int64, highlight bool) storage.NewNotification {
return storage.NewNotification{UserID: user, RoomID: room, EventID: event, EventPos: pos,
TimestampMS: 1000 + pos, Highlight: highlight, Actions: []byte(`["notify"]`)}
}
last, err := push.AddNotifications(ctx, []storage.NewNotification{
note(alice, room1, "$a", 10, false),
note(alice, room1, "$b", 11, true),
note(alice, room2, "$c", 12, false),
note(bob, room1, "$b", 11, false),
})
if err != nil || last != start.Notification+4 {
t.Fatalf("AddNotifications = %d, %v", last, err)
}
if positions, _ := db.Sync().StreamPositions(ctx); positions.Notification != last {
t.Errorf("the stream is at %d, want %d", positions.Notification, last)
}
counts, err := push.NotificationCounts(ctx, alice, []string{room1, room2, "!none:x"})
if err != nil {
t.Fatal(err)
}
if counts[room1] != (storage.NotificationCounts{Notifications: 2, Highlights: 1}) ||
counts[room2] != (storage.NotificationCounts{Notifications: 1}) ||
counts["!none:x"] != (storage.NotificationCounts{}) {
t.Errorf("counts = %+v", counts)
}
if total, _ := push.UnreadNotifications(ctx, alice); total != 3 {
t.Errorf("UnreadNotifications = %d", total)
}
changed, err := push.NotificationCountChanges(ctx, alice, start.Notification, last)
if err != nil || !slices.Equal(changed, []string{room1, room2}) {
t.Errorf("NotificationCountChanges = %v, %v", changed, err)
}
// Reading up to the first event clears it alone, and moves the
// stream for that room only.
pos, moved, err := push.MarkNotificationsRead(ctx, alice, room1, 10)
if err != nil || !moved || pos != last+1 {
t.Fatalf("MarkNotificationsRead = %d, %v, %v", pos, moved, err)
}
counts, _ = push.NotificationCounts(ctx, alice, []string{room1})
if counts[room1] != (storage.NotificationCounts{Notifications: 1, Highlights: 1}) {
t.Errorf("after reading one: %+v", counts)
}
if changed, _ := push.NotificationCountChanges(ctx, alice, last, pos); !slices.Equal(changed, []string{room1}) {
t.Errorf("changes after the read = %v", changed)
}
// Reading the same again moves nothing.
if again, moved, err := push.MarkNotificationsRead(ctx, alice, room1, 10); err != nil || moved || again != pos {
t.Errorf("reading again = %d, %v, %v", again, moved, err)
}
if _, _, err := push.MarkNotificationsRead(ctx, alice, room1, 100); err != nil {
t.Fatal(err)
}
counts, _ = push.NotificationCounts(ctx, alice, []string{room1})
if counts[room1] != (storage.NotificationCounts{}) {
t.Errorf("after reading everything: %+v", counts)
}
// Bob's are his own.
if counts, _ := push.NotificationCounts(ctx, bob, []string{room1}); counts[room1].Notifications != 1 {
t.Errorf("bob's counts = %+v", counts)
}
all, err := push.Notifications(ctx, alice, 0, 10, false)
if err != nil || len(all) != 3 || all[0].EventID != "$c" || all[0].Read || !all[2].Read ||
string(all[1].Actions) != `["notify"]` || !all[1].Highlight {
t.Fatalf("Notifications = %+v, %v", all, err)
}
page, _ := push.Notifications(ctx, alice, all[0].ID, 1, false)
if len(page) != 1 || page[0].EventID != "$b" {
t.Errorf("the second page = %+v", page)
}
highlights, _ := push.Notifications(ctx, alice, 0, 10, true)
if len(highlights) != 1 || highlights[0].EventID != "$b" {
t.Errorf("highlights = %+v", highlights)
}
// Pruning takes only read notifications older than the cutoff.
n, err := push.PruneNotifications(ctx, 1011)
if err != nil || n != 1 {
t.Errorf("PruneNotifications = %d, %v", n, err)
}
if rest, _ := push.Notifications(ctx, alice, 0, 10, false); len(rest) != 2 {
t.Errorf("after pruning: %+v", rest)
}
})
}
}
func TestPushNotImplementedOnPocketBase(t *testing.T) {
db, err := storage.Open(t.Context(), storage.Config{Driver: storage.DriverPocketBase}, nil)
if err != nil {
t.Skipf("pocketbase: %v", err)
}
defer func() { _ = db.Close() }()
if _, err := db.Push().Pushers(t.Context(), "alice"); !errors.Is(err, storage.ErrNotImplemented) {
t.Errorf("Pushers = %v", err)
}
}