Files
remark42/backend/app/notify/notify_test.go
T
Dmitry VerkhoturovandUmputun 95f59213e5 Make notify drop tests deterministic
TestService_WithDrops and TestService_SubmitVerificationWithDrops
submitted three items into a size-1 queue and asserted at least one was
dropped, relying on the single consumer not draining the queue between
submits. synctest does not fully pin this because the MockDest send path
does real logging I/O outside the bubble, so under CI's -race scheduling
the consumer occasionally drained all three, delivering everything and
failing the "<= 2" assertion (~0.2% of CI runs).

Add an optional gate channel to MockDest so a destination blocks in
Send/SendVerification until released. The tests now submit one item (the
consumer picks it up and blocks on the gate), fill the size-1 queue,
submit an overflow item that is dropped, then release the gate — the
drop is deterministic regardless of scheduling. Assert exactly two
delivered instead of "at most two".
2026-07-11 01:56:10 -05:00

332 lines
12 KiB
Go

package notify
import (
"fmt"
"sync/atomic"
"testing"
"testing/synctest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/remark42/backend/app/store"
)
func TestService_NoDestinations(t *testing.T) {
s := NewService(nil, 0)
assert.Equal(t, defaultQueueSize, cap(s.queue))
assert.NotNil(t, s)
s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Close()
// second call should not result in panic
s.Close()
}
func TestService_WithDestinations(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s)
s.Submit(Request{Comment: store.Comment{ID: "100"}})
synctest.Wait()
s.Submit(Request{Comment: store.Comment{ID: "101"}})
synctest.Wait()
s.Submit(Request{Comment: store.Comment{ID: "102"}})
synctest.Wait()
s.Close()
require.Equal(t, 3, len(d1.Get()), "got all comments to d1")
require.Equal(t, 3, len(d2.Get()), "got all comments to d2")
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
assert.Equal(t, "102", d1.Get()[2].Comment.ID)
})
}
func TestService_WithDrops(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// gated destinations pin the consumer on the first item so the size-1 queue
// fills deterministically and the overflow is dropped regardless of scheduling
gate := make(chan struct{})
d1, d2 := &MockDest{id: 1, block: gate}, &MockDest{id: 2, block: gate}
s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s)
s.Submit(Request{Comment: store.Comment{ID: "100"}}) // consumed, consumer blocks in Send on the gate
synctest.Wait()
s.Submit(Request{Comment: store.Comment{ID: "101"}}) // fills the size-1 queue
s.Submit(Request{Comment: store.Comment{ID: "102"}}) // queue full, dropped
synctest.Wait()
close(gate) // release the consumer: it finishes 100 then processes 101
synctest.Wait()
s.Close()
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close
require.Len(t, d1.Get(), 2, "one comment of three dropped from d1, got: %v", d1.Get())
require.Len(t, d2.Get(), 2, "one comment of three dropped from d2, got: %v", d2.Get())
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
})
}
func TestService_SubmitVerificationWithDrops(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// gated destinations pin the consumer on the first item so the size-1 queue
// fills deterministically and the overflow is dropped regardless of scheduling
gate := make(chan struct{})
d1, d2 := &MockDest{id: 1, block: gate}, &MockDest{id: 2, block: gate}
s := NewService(nil, 1, d1, d2)
assert.NotNil(t, s)
s.SubmitVerification(VerificationRequest{
SiteID: "remark",
User: "testUser",
Email: "test@example.org",
Token: "testToken",
}) // consumed, consumer blocks in SendVerification on the gate
synctest.Wait()
s.SubmitVerification(VerificationRequest{User: "second"}) // fills the size-1 queue
s.SubmitVerification(VerificationRequest{User: "dropped"}) // queue full, dropped
synctest.Wait()
close(gate) // release the consumer: it finishes testUser then processes second
synctest.Wait()
s.Close()
s.SubmitVerification(VerificationRequest{}) // safe to send after close
require.Len(t, d2.GetVerify(), 2, "one request of three dropped from d2, got: %v", d2.GetVerify())
verifyDest := d1.GetVerify()
require.Len(t, verifyDest, 2, "one request of three dropped from d1, got: %v", verifyDest)
assert.Equal(t, "remark", verifyDest[0].SiteID)
assert.Equal(t, "testUser", verifyDest[0].User)
assert.Equal(t, "test@example.org", verifyDest[0].Email)
assert.Equal(t, "testToken", verifyDest[0].Token)
assert.Equal(t, "second", verifyDest[1].User)
})
}
func TestService_Many(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
s := NewService(nil, 5, d1, d2)
assert.NotNil(t, s)
for i := range 10 {
s.Submit(Request{Comment: store.Comment{ID: fmt.Sprintf("%d", 100+i)}})
s.SubmitVerification(VerificationRequest{User: fmt.Sprintf("%d", 100+i)})
}
s.Close()
assert.NotEqual(t, 10, len(d1.Get()), "some comments dropped from d1")
assert.NotEqual(t, 10, len(d1.GetVerify()), "some verifications dropped from d1")
assert.NotEqual(t, 10, len(d2.Get()), "some comments dropped from d2")
assert.NotEqual(t, 10, len(d2.GetVerify()), "some verifications dropped from d2")
})
}
func TestService_WithParent(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}}
dataStore.data["p1"] = store.Comment{ID: "p1"}
dataStore.data["p2"] = store.Comment{ID: "p2"}
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
s.Submit(Request{Comment: store.Comment{ID: "c1", ParentID: "p1"}})
synctest.Wait()
s.Submit(Request{Comment: store.Comment{ID: "c11", ParentID: "p11"}})
synctest.Wait()
s.Close()
destRes := dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ParentID)
assert.Equal(t, "p1", destRes[0].parent.ID)
assert.Equal(t, "p11", destRes[1].Comment.ParentID)
assert.Equal(t, "", destRes[1].parent.ID)
})
}
func TestService_EmailRetrieval(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p1", User: store.User{ID: "u2"}}
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
dataStore.userDetails["u1"] = "u1@example.com"
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
// one comment, one notification
s.Submit(Request{Comment: dataStore.data["p1"]})
synctest.Wait()
destRes := dest.Get()
require.Equal(t, 1, len(destRes), "one comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ID)
assert.Empty(t, destRes[0].parent)
assert.Empty(t, destRes[0].Emails)
// reply to the first comment, same comment as one in original comment
s.Submit(Request{Comment: dataStore.data["p2"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p2", destRes[1].Comment.ID)
assert.Equal(t, "p1", destRes[1].parent.ID)
assert.Equal(t, "u1", destRes[1].parent.User.ID)
assert.Empty(t, destRes[1].Emails, "u1 is not notified they are the one who left the comment")
// another reply to the first comment, another user
s.Submit(Request{Comment: dataStore.data["p3"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 3, len(destRes), "three comment notified")
assert.Equal(t, "p3", destRes[2].Comment.ID)
assert.Equal(t, "p1", destRes[2].parent.ID)
assert.Equal(t, "u1", destRes[2].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
// reply to the last comment by another user, should trigger email retrieval error
s.Submit(Request{Comment: dataStore.data["p4"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 4, len(destRes), "four comment notified")
assert.Equal(t, "p4", destRes[3].Comment.ID)
assert.Equal(t, "p3", destRes[3].parent.ID)
assert.Equal(t, "u2", destRes[3].parent.User.ID)
assert.Empty(t, destRes[3].Emails, "no email can be retrieved for u2")
s.Close()
})
}
func TestService_Recursive(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
dest := &MockDest{id: 1}
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u2"}}
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p2", User: store.User{ID: "u3"}}
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
dataStore.data["p5"] = store.Comment{ID: "p5", ParentID: "p4", User: store.User{ID: "u4"}}
dataStore.userDetails["u1"] = "u1@example.com"
// second comment goes without email address for notification
dataStore.userDetails["u3"] = "u3@example.com"
s := NewService(dataStore, 1, dest)
assert.NotNil(t, s)
// one comment from u1 with email set
s.Submit(Request{Comment: dataStore.data["p1"]})
synctest.Wait()
destRes := dest.Get()
require.Equal(t, 1, len(destRes), "one comment notified")
assert.Equal(t, "p1", destRes[0].Comment.ID)
assert.Empty(t, destRes[0].parent)
assert.Empty(t, destRes[0].Emails)
// reply to the first comment from u2 without email set
s.Submit(Request{Comment: dataStore.data["p2"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 2, len(destRes), "two comment notified")
assert.Equal(t, "p2", destRes[1].Comment.ID)
assert.Equal(t, "p1", destRes[1].parent.ID)
assert.Equal(t, "u1", destRes[1].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[1].Emails)
// reply to the second comment from u3 with email set
s.Submit(Request{Comment: dataStore.data["p3"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 3, len(destRes), "three comment notified")
assert.Equal(t, "p3", destRes[2].Comment.ID)
assert.Equal(t, "p2", destRes[2].parent.ID)
assert.Equal(t, "u2", destRes[2].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
// reply to the third comment from u1 (author of the first comment), only u3 should be notified
s.Submit(Request{Comment: dataStore.data["p4"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 4, len(destRes), "four comment notified once each")
assert.Equal(t, "p4", destRes[3].Comment.ID)
assert.Equal(t, "p3", destRes[3].parent.ID)
assert.Equal(t, "u3", destRes[3].parent.User.ID)
assert.ElementsMatch(t, []string{"u3@example.com"}, destRes[3].Emails, "u1 is not notified they are the one who left the comment")
// reply to the fourth comment from u4, u1 and u3 should be notified once as a result
s.Submit(Request{Comment: dataStore.data["p5"]})
synctest.Wait()
destRes = dest.Get()
require.Equal(t, 5, len(destRes), "four comment notified once each")
assert.Equal(t, "p5", destRes[4].Comment.ID)
assert.Equal(t, "p4", destRes[4].parent.ID)
assert.Equal(t, "u1", destRes[4].parent.User.ID)
assert.ElementsMatch(t, []string{"u1@example.com", "u3@example.com"}, destRes[4].Emails, "u3 and u1 notified once")
s.Close()
})
}
func TestService_Nop(t *testing.T) {
s := NopService
s.Submit(Request{Comment: store.Comment{}})
s.Close()
assert.Equal(t, uint32(1), atomic.LoadUint32(&s.closed))
}
type mockStore struct {
data map[string]store.Comment
userDetails map[string]string
}
func (m mockStore) getUserDetail(userID string) (string, error) {
detail, ok := m.userDetails[userID]
if !ok {
return "", fmt.Errorf("no such user")
}
return detail, nil
}
func (m mockStore) Get(_ store.Locator, id string, _ store.User) (store.Comment, error) {
res, ok := m.data[id]
if !ok {
return store.Comment{}, fmt.Errorf("no such id")
}
return res, nil
}
func (m mockStore) GetUserEmail(_, userID string) (string, error) {
return m.getUserDetail(userID)
}
func (m mockStore) GetUserTelegram(_, userID string) (string, error) {
return m.getUserDetail(userID)
}