Files
libredesk/internal/notification/push_test.go
Abhinav Raut 723c0e84ba fix notification and conversation read state
Opening a conversation used to mark it read even when the agent never scrolled to the new messages or the tab was in the background. Reply notifications in the bell also stayed unread after the agent read the reply.

Now the browser tells the server which message the agent saw. It only does this when the end of the thread is on screen and the tab has focus. The server moves the agent's last seen time forward to that message and never back.

A reply notification counts as read when the agent's last seen time is at or after the reply. "Mark as unread" marks the old reply notifications read first, so the bell stays the same.

Reply emails and pushes wait a little before sending. They are skipped if the agent already read the message or can no longer see the conversation.
2026-10-08 00:33:14 +05:30

292 lines
9.5 KiB
Go

package notifier
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"unicode/utf8"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/jmoiron/sqlx/types"
"github.com/zerodha/logf"
)
type replyDeliveryStub struct{ allowed atomic.Bool }
type fakePushSettings struct {
values map[string]string
updates int
}
func (s *replyDeliveryStub) ShouldDeliver(_ int, ref nmodels.NotificationReference, _ nmodels.NotificationChannel) (bool, error) {
return !nmodels.IsReply(ref.Type) || s.allowed.Load(), nil
}
func (s *fakePushSettings) Get(key string) (types.JSONText, error) {
b, _ := json.Marshal(s.values[key])
return b, nil
}
func (s *fakePushSettings) Update(value any) error {
s.updates++
for key, setting := range value.(map[string]string) {
s.values[key] = setting
}
return nil
}
type fakePushStore struct {
subscriptions []PushSubscription
deletedID int
}
func (s *fakePushStore) List(int) ([]PushSubscription, error) {
return s.subscriptions, nil
}
func (s *fakePushStore) Upsert(int, PushSubscriptionInput) error { return nil }
func (s *fakePushStore) Delete(int, string) error { return nil }
func (s *fakePushStore) DeleteByID(id int) error {
s.deletedID = id
return nil
}
func TestPushManagerDeliverSendsEverySubscription(t *testing.T) {
store := &fakePushStore{subscriptions: []PushSubscription{
{ID: 11, Endpoint: "https://push.example/one", P256DH: "key-one", Auth: "auth-one"},
{ID: 12, Endpoint: "https://push.example/two", P256DH: "key-two", Auth: "auth-two"},
}}
var endpoints []string
m := &PushManager{
store: store,
checker: &replyDeliveryStub{},
publicKey: "public",
privateKey: "private",
subject: "https://desk.example",
sender: func(_ context.Context, payload []byte, subscription PushSubscription, _, _, _ string) (*http.Response, error) {
if !strings.Contains(string(payload), `"url":"/inboxes/assigned/conversation/conv-1?scrollTo=msg-1"`) {
t.Fatalf("unexpected payload: %s", payload)
}
endpoints = append(endpoints, subscription.Endpoint)
return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(""))}, nil
},
}
if err := m.deliver(t.Context(), pushDelivery{UserID: 7, Payload: nmodels.PushPayload{
Title: "Assigned conversation",
Body: "A conversation was assigned to you",
Tag: "assignment_conv-1",
URL: "/inboxes/assigned/conversation/conv-1?scrollTo=msg-1",
}}); err != nil {
t.Fatal(err)
}
if len(endpoints) != 2 {
t.Fatalf("sent to %d endpoints, want 2", len(endpoints))
}
}
func TestPushManagerDeliverBoundsPreview(t *testing.T) {
for _, content := range []string{"", "Short note", strings.Repeat("a", 100), strings.Repeat("a", 101), strings.Repeat("a", 400), strings.Repeat("a", 401), strings.Repeat("a", 10000), strings.Repeat("世界🙂", 2000), strings.Repeat("<&>\x00\"\\", 2000), strings.Repeat("\x00", 2000)} {
t.Run(fmt.Sprintf("bytes_%d", len(content)), func(t *testing.T) {
original := nmodels.PushPayload{Title: content, Body: content, Tag: "mention_" + strings.Repeat("a", 36), URL: "/inboxes/mentioned/conversation/123?scrollTo=456"}
called := false
m := &PushManager{
store: &fakePushStore{subscriptions: []PushSubscription{{ID: 1}}},
checker: &replyDeliveryStub{},
sender: func(_ context.Context, payload []byte, _ PushSubscription, _, _, _ string) (*http.Response, error) {
called = true
if len(payload) > 3993 {
t.Fatalf("payload has %d bytes, exceeds Web Push capacity", len(payload))
}
var got nmodels.PushPayload
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatal(err)
}
if got.URL != original.URL || got.Tag != original.Tag {
t.Fatal("routing fields changed")
}
for _, preview := range []string{got.Title, got.Body} {
if !utf8.ValidString(preview) || !strings.HasPrefix(content, strings.TrimSuffix(preview, "…")) {
t.Fatal("invalid preview")
}
if len(content) < 100 && preview != content {
t.Fatal("short content changed")
}
}
return nil, nil
},
}
if err := m.deliver(t.Context(), pushDelivery{UserID: 1, Payload: original}); err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("push was not delivered")
}
})
}
}
func TestPushManagerDeliverDeletesExpiredSubscription(t *testing.T) {
store := &fakePushStore{subscriptions: []PushSubscription{{ID: 23, Endpoint: "https://push.example/expired"}}}
m := &PushManager{
store: store,
checker: &replyDeliveryStub{},
sender: func(context.Context, []byte, PushSubscription, string, string, string) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusGone, Body: io.NopCloser(strings.NewReader(""))}, nil
},
}
if err := m.deliver(t.Context(), pushDelivery{UserID: 7, Payload: nmodels.PushPayload{Title: "Mention"}}); err != nil {
t.Fatal(err)
}
if store.deletedID != 23 {
t.Fatalf("deleted subscription %d, want 23", store.deletedID)
}
}
func TestValidPushSubscription(t *testing.T) {
tests := []struct {
name string
input PushSubscriptionInput
want bool
}{
{"valid", PushSubscriptionInput{Endpoint: "https://push.example/id", P256DH: "key", Auth: "secret"}, true},
{"empty endpoint", PushSubscriptionInput{P256DH: "key", Auth: "secret"}, false},
{"non HTTPS endpoint", PushSubscriptionInput{Endpoint: "http://push.example/id", P256DH: "key", Auth: "secret"}, false},
{"missing public key", PushSubscriptionInput{Endpoint: "https://push.example/id", Auth: "secret"}, false},
{"missing auth", PushSubscriptionInput{Endpoint: "https://push.example/id", P256DH: "key"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validPushSubscription(tt.input); got != tt.want {
t.Fatalf("validPushSubscription(%#v) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestPushHTTPClientBlocksPrivateEndpoints(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("private endpoint received push request")
}))
defer server.Close()
lo := logf.New(logf.Opts{})
request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL, nil)
if err != nil {
t.Fatal(err)
}
if _, err := newPushHTTPClient(&lo).Do(request); err == nil {
t.Fatal("private endpoint request succeeded")
}
}
func TestPushHTTPClientRejectsRedirects(t *testing.T) {
lo := logf.New(logf.Opts{})
client := newPushHTTPClient(&lo)
if err := client.CheckRedirect(&http.Request{}, nil); !errors.Is(err, http.ErrUseLastResponse) {
t.Fatalf("redirect error = %v, want %v", err, http.ErrUseLastResponse)
}
}
func TestVAPIDSubject(t *testing.T) {
if got := vapidSubject("https://desk.example/"); got != "https://desk.example" {
t.Fatalf("HTTPS subject = %q", got)
}
if got := vapidSubject("http://localhost:9000"); got != "support@libredesk.io" {
t.Fatalf("local subject = %q", got)
}
}
func TestLoadVAPIDKeysReusesStoredPair(t *testing.T) {
settings := &fakePushSettings{values: map[string]string{
pushPrivateKeySetting: "stored-private",
pushPublicKeySetting: "stored-public",
}}
privateKey, publicKey, err := loadVAPIDKeys(settings)
if err != nil {
t.Fatal(err)
}
if privateKey != "stored-private" || publicKey != "stored-public" {
t.Fatalf("got %q, %q", privateKey, publicKey)
}
if settings.updates != 0 {
t.Fatalf("updated stored VAPID keys %d times", settings.updates)
}
}
func TestLoadVAPIDKeysCreatesMissingPair(t *testing.T) {
settings := &fakePushSettings{values: map[string]string{}}
privateKey, publicKey, err := loadVAPIDKeys(settings)
if err != nil {
t.Fatal(err)
}
if privateKey == "" || publicKey == "" {
t.Fatal("generated VAPID keys are empty")
}
if settings.updates != 1 {
t.Fatalf("updated VAPID keys %d times, want 1", settings.updates)
}
}
func TestReplyPushWaitsAndRechecksWithoutDelayingMentions(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
checker := &replyDeliveryStub{}
sent := make(chan string, 4)
lo := logf.New(logf.Opts{})
manager := &PushManager{
store: &fakePushStore{subscriptions: []PushSubscription{{ID: 1}}},
lo: &lo, publicKey: "public", privateKey: "private", concurrency: 1,
queue: make(chan pushDelivery, 2), checker: checker,
sender: func(_ context.Context, payload []byte, _ PushSubscription, _, _, _ string) (*http.Response, error) {
var p nmodels.PushPayload
if err := json.Unmarshal(payload, &p); err != nil {
return nil, err
}
sent <- p.Title
return nil, nil
},
}
go manager.Run(ctx)
if !manager.Send(7, nmodels.PushPayload{Title: "Reply"}, nmodels.NotificationReference{Type: nmodels.NotificationTypeNewReply}) {
t.Fatal("reply rejected")
}
if !manager.Send(7, nmodels.PushPayload{Title: "Mention"}, nmodels.NotificationReference{Type: nmodels.NotificationTypeMention}) {
t.Fatal("mention rejected")
}
synctest.Wait()
if len(sent) != 1 || <-sent != "Mention" {
t.Fatal("mention did not arrive immediately")
}
checker.allowed.Store(false)
time.Sleep(pushReplyDelay)
synctest.Wait()
if len(sent) != 0 {
t.Fatal("seen or ineligible reply was pushed")
}
checker.allowed.Store(true)
manager.Send(7, nmodels.PushPayload{Title: "Unread reply"}, nmodels.NotificationReference{Type: nmodels.NotificationTypeNewReply})
time.Sleep(pushReplyDelay)
synctest.Wait()
if len(sent) != 1 || <-sent != "Unread reply" {
t.Fatal("unread reply was not pushed after delay")
}
})
}