262 lines
9.7 KiB
Go
262 lines
9.7 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/iamxvbaba/td/clock"
|
|
"github.com/iamxvbaba/td/tg"
|
|
"go.uber.org/zap/zaptest"
|
|
|
|
appusers "telesrv/internal/app/users"
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
type capacitySplittingMainOutboxUsers struct {
|
|
*countingOutboxUsersService
|
|
maxEdges int
|
|
capacityErr error
|
|
sparseRequests []map[int64][]int64
|
|
}
|
|
|
|
func (s *capacitySplittingMainOutboxUsers) ByIDsForViewerUserIDs(ctx context.Context, requested map[int64][]int64) (map[int64][]domain.User, error) {
|
|
s.sparseRequests = append(s.sparseRequests, cloneOutboxSparseRequest(requested))
|
|
if s.maxEdges > 0 && sparseOutboxRequestedEdgeCount(requested, s.maxEdges+1) > s.maxEdges {
|
|
err := s.capacityErr
|
|
if err == nil {
|
|
err = store.ErrActiveChannelMemberPairsLimit
|
|
}
|
|
return nil, fmt.Errorf("%w: test capacity", err)
|
|
}
|
|
return s.countingOutboxUsersService.ByIDsForViewerUserIDs(ctx, requested)
|
|
}
|
|
|
|
type failingSparseMainOutboxResolver struct {
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (s *failingSparseMainOutboxResolver) ByIDsForViewerUserIDs(context.Context, map[int64][]int64) (map[int64][]domain.User, error) {
|
|
s.calls++
|
|
return nil, s.err
|
|
}
|
|
|
|
func TestResolveSparseOutboxUsersSplitsEveryProjectionCapacityError(t *testing.T) {
|
|
capacityErrors := map[string]error{
|
|
"privacy_memberships": store.ErrActiveChannelMemberPairsLimit,
|
|
"owner_union": appusers.ErrBatchUsersLimit,
|
|
"sparse_cells": appusers.ErrBatchViewerCells,
|
|
}
|
|
for name, capacityErr := range capacityErrors {
|
|
t.Run(name, func(t *testing.T) {
|
|
base := &countingOutboxUsersService{users: map[int64]domain.User{
|
|
2001: {ID: 2001, FirstName: "one"},
|
|
2002: {ID: 2002, FirstName: "two"},
|
|
}}
|
|
resolver := &capacitySplittingMainOutboxUsers{
|
|
countingOutboxUsersService: base,
|
|
maxEdges: 1,
|
|
capacityErr: capacityErr,
|
|
}
|
|
projected, err := resolveSparseOutboxUsers(context.Background(), resolver, map[int64][]int64{
|
|
1001: {2001},
|
|
1002: {2002},
|
|
})
|
|
if err != nil || len(projected[1001]) != 1 || len(projected[1002]) != 1 {
|
|
t.Fatalf("projected=%+v err=%v, want both split results", projected, err)
|
|
}
|
|
if len(resolver.sparseRequests) != 3 || len(base.calls) != 0 {
|
|
t.Fatalf("sparse calls=%d scalar=%+v, want one failed batch plus two sparse halves", len(resolver.sparseRequests), base.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveSparseOutboxUsersDoesNotSplitOtherErrors(t *testing.T) {
|
|
boom := errors.New("projection unavailable")
|
|
resolver := &failingSparseMainOutboxResolver{err: boom}
|
|
projected, err := resolveSparseOutboxUsers(context.Background(), resolver, map[int64][]int64{
|
|
1001: {2001},
|
|
1002: {2002},
|
|
})
|
|
if !errors.Is(err, boom) || projected != nil {
|
|
t.Fatalf("resolveSparseOutboxUsers = %+v, %v; want nil, boom", projected, err)
|
|
}
|
|
if resolver.calls != 1 {
|
|
t.Fatalf("resolver calls = %d, want no split for non-capacity error", resolver.calls)
|
|
}
|
|
}
|
|
|
|
func TestRouterBuildOutboxUpdatesFailsClosedAtSparseRecoveryCallBudget(t *testing.T) {
|
|
const viewerID = int64(1000000050)
|
|
base := &countingOutboxUsersService{users: make(map[int64]domain.User)}
|
|
peers := make([]domain.Peer, 100)
|
|
for i := range peers {
|
|
id := int64(1000001000 + i)
|
|
peers[i] = domain.Peer{Type: domain.PeerTypeUser, ID: id}
|
|
base.users[id] = domain.User{ID: id, FirstName: "projected"}
|
|
}
|
|
users := &capacitySplittingMainOutboxUsers{
|
|
countingOutboxUsersService: base,
|
|
maxEdges: 1,
|
|
capacityErr: store.ErrActiveChannelMemberPairsLimit,
|
|
}
|
|
router := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System)
|
|
|
|
updates, err := router.BuildOutboxUpdates(context.Background(), []OutboxUpdateRequest{{
|
|
TargetUserID: viewerID,
|
|
Event: domain.UpdateEvent{
|
|
UserID: viewerID,
|
|
Type: domain.UpdateEventPinnedDialogs,
|
|
Peers: peers,
|
|
},
|
|
}})
|
|
if updates != nil || !errors.Is(err, ErrUserProjectionCapacityRecoveryLimit) {
|
|
t.Fatalf("updates=%+v err=%v, want nil recovery-limit error", updates, err)
|
|
}
|
|
if errors.Is(err, store.ErrActiveChannelMemberPairsLimit) {
|
|
t.Fatalf("recovery-limit error must not retain capacity identity: %v", err)
|
|
}
|
|
if len(users.sparseRequests) != maxSparseOutboxRecoveryCalls {
|
|
t.Fatalf("sparse resolver calls=%d, want hard limit %d", len(users.sparseRequests), maxSparseOutboxRecoveryCalls)
|
|
}
|
|
if len(base.calls) != 0 {
|
|
t.Fatalf("scalar ByIDs calls=%+v, want zero fallback", base.calls)
|
|
}
|
|
}
|
|
|
|
func TestResolveSparseOutboxUsersRejectsAttemptedEdgeOverflowBeforeResolver(t *testing.T) {
|
|
makeIDs := func(count int) []int64 {
|
|
ids := make([]int64, count)
|
|
for i := range ids {
|
|
ids[i] = int64(i + 1)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
t.Run("initial request", func(t *testing.T) {
|
|
resolver := &failingSparseMainOutboxResolver{err: errors.New("must not be called")}
|
|
projected, err := resolveSparseOutboxUsers(context.Background(), resolver, map[int64][]int64{
|
|
1001: makeIDs(maxSparseOutboxAttemptedUserEdges + 1),
|
|
})
|
|
if projected != nil || !errors.Is(err, ErrUserProjectionCapacityRecoveryLimit) {
|
|
t.Fatalf("projected=%+v err=%v, want nil recovery-limit error", projected, err)
|
|
}
|
|
if resolver.calls != 0 {
|
|
t.Fatalf("resolver calls=%d, want rejection before first call", resolver.calls)
|
|
}
|
|
if isSparseProjectionCapacityError(err) {
|
|
t.Fatalf("recovery-limit error must be terminal, got capacity identity: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("cumulative recursive edges", func(t *testing.T) {
|
|
resolver := &failingSparseMainOutboxResolver{err: store.ErrActiveChannelMemberPairsLimit}
|
|
projected, err := resolveSparseOutboxUsers(context.Background(), resolver, map[int64][]int64{
|
|
1001: makeIDs(300000),
|
|
})
|
|
if projected != nil || !errors.Is(err, ErrUserProjectionCapacityRecoveryLimit) {
|
|
t.Fatalf("projected=%+v err=%v, want nil recovery-limit error", projected, err)
|
|
}
|
|
if resolver.calls != 2 {
|
|
t.Fatalf("resolver calls=%d, want root and first half before shared edge limit", resolver.calls)
|
|
}
|
|
if errors.Is(err, store.ErrActiveChannelMemberPairsLimit) {
|
|
t.Fatalf("recovery-limit error must not retain capacity identity: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRouterBuildOutboxUpdatesRejectsIncompleteSparseProjection(t *testing.T) {
|
|
const (
|
|
viewerUserID = int64(1000000010)
|
|
missingUserID = int64(1000000011)
|
|
)
|
|
users := &countingOutboxUsersService{users: map[int64]domain.User{}}
|
|
router := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System)
|
|
updates, err := router.BuildOutboxUpdates(context.Background(), []OutboxUpdateRequest{{
|
|
TargetUserID: viewerUserID,
|
|
Event: domain.UpdateEvent{UserID: viewerUserID, Type: domain.UpdateEventNewMessage, Pts: 3, PtsCount: 1,
|
|
Message: domain.Message{ID: 3, OwnerUserID: viewerUserID,
|
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: missingUserID},
|
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: missingUserID}}},
|
|
}})
|
|
if !errors.Is(err, ErrSparseOutboxUserProjectionIncomplete) || updates != nil {
|
|
t.Fatalf("BuildOutboxUpdates=%+v err=%v, want incomplete fail-closed", updates, err)
|
|
}
|
|
if users.sparseCalls != 1 || len(users.calls) != 0 {
|
|
t.Fatalf("projection calls = sparse %d scalar %+v, want one sparse call and no fallback", users.sparseCalls, users.calls)
|
|
}
|
|
}
|
|
|
|
func TestRouterBuildOutboxUpdatesAllowsOnlyExplicitNoopToStayEmpty(t *testing.T) {
|
|
router := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
|
|
updates, err := router.BuildOutboxUpdates(context.Background(), []OutboxUpdateRequest{{
|
|
TargetUserID: 1000000030,
|
|
Event: domain.UpdateEvent{UserID: 1000000030, Type: domain.UpdateEventNoop, Pts: 9, PtsCount: 1},
|
|
}})
|
|
if err != nil || len(updates) != 1 || updates[0] != nil {
|
|
t.Fatalf("explicit noop updates=%+v err=%v, want one nil entry without error", updates, err)
|
|
}
|
|
|
|
updates, err = router.BuildOutboxUpdates(context.Background(), []OutboxUpdateRequest{{
|
|
TargetUserID: 1000000030,
|
|
Event: domain.UpdateEvent{UserID: 1000000030, Type: domain.UpdateEventReadHistoryInbox, Pts: 10, PtsCount: 1},
|
|
}})
|
|
if !errors.Is(err, ErrOutboxUpdateProjectionEmpty) || updates != nil {
|
|
t.Fatalf("invalid non-noop updates=%+v err=%v, want ErrOutboxUpdateProjectionEmpty", updates, err)
|
|
}
|
|
}
|
|
|
|
func TestOutboxDispatcherFailsClosedOnBuilderErrorAndNilNonNoop(t *testing.T) {
|
|
const userID = int64(1000000040)
|
|
baseEvent := domain.UpdateEvent{
|
|
UserID: userID, Type: domain.UpdateEventReadHistoryInbox, Pts: 10, PtsCount: 1,
|
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000041}, MaxID: 9,
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
builder OutboxUpdateBuilder
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "builder error",
|
|
builder: func(context.Context, []OutboxUpdateRequest) ([]*tg.Updates, error) {
|
|
return nil, errors.New("projection unavailable")
|
|
},
|
|
},
|
|
{
|
|
name: "nil non-noop",
|
|
builder: func(_ context.Context, requests []OutboxUpdateRequest) ([]*tg.Updates, error) {
|
|
return make([]*tg.Updates, len(requests)), nil
|
|
},
|
|
wantErr: errOutboxUpdateBuilderEmpty,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{
|
|
ID: 71, TargetUserID: userID, Pts: baseEvent.Pts, EventType: baseEvent.Type,
|
|
}}}
|
|
sessions := &captureSessions{}
|
|
dispatcher := newTestOutboxDispatcher(
|
|
&captureUpdateEventStore{events: []domain.UpdateEvent{baseEvent}},
|
|
outbox,
|
|
sessions,
|
|
zaptest.NewLogger(t),
|
|
WithOutboxUpdateBuilder(tt.builder),
|
|
)
|
|
dispatcher.DispatchOnce(context.Background())
|
|
if !outbox.failed || outbox.delivered || sessions.message != nil {
|
|
t.Fatalf("failed=%v delivered=%v pushed=%T, want terminal failure without delivery", outbox.failed, outbox.delivered, sessions.message)
|
|
}
|
|
if tt.wantErr != nil && !strings.Contains(outbox.failedError, tt.wantErr.Error()) {
|
|
t.Fatalf("failed error=%q, want %v", outbox.failedError, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|