mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-11-01 00:52:38 +00:00
723c0e84ba
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.
292 lines
9.5 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|