owpengram-server/internal/rpc/outbox_projection_strict_test.go
2026-09-01 12:06:31 +03:00

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)
}
})
}
}