package rpc import ( "context" "errors" "testing" "time" "github.com/gotd/td/bin" "github.com/gotd/td/proto" "github.com/gotd/td/tg" "go.uber.org/zap/zaptest" "telesrv/internal/domain" "telesrv/internal/store" ) func TestOutboxDispatcherPushesNewMessageAndMarksDelivered(t *testing.T) { msg := domain.Message{ ID: 10, OwnerUserID: 1000000002, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, Date: 1700000300, Body: "hello", Pts: 7, } outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ ID: 55, TargetUserID: msg.OwnerUserID, Pts: msg.Pts, EventType: domain.UpdateEventNewMessage, ExcludeSessionID: 99, }}} events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ UserID: msg.OwnerUserID, Type: domain.UpdateEventNewMessage, Pts: msg.Pts, PtsCount: 1, Date: msg.Date, Message: msg, Users: []domain.User{{ ID: msg.From.ID, FirstName: "Sender", }}, }}} sessions := &captureSessions{} metrics := &captureOutboxMetrics{} dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxMetrics(metrics)) dispatcher.DispatchOnce(context.Background()) if !outbox.delivered || outbox.deliveredUserID != msg.OwnerUserID || outbox.deliveredID != 55 { t.Fatalf("delivered = %v user=%d id=%d, want outbox delivered", outbox.delivered, outbox.deliveredUserID, outbox.deliveredID) } if sessions.userID != msg.OwnerUserID || sessions.sessionID != 99 || sessions.messageType != proto.MessageFromServer { t.Fatalf("push target = user %d exclude %d type %v, want outbox target/exclude", sessions.userID, sessions.sessionID, sessions.messageType) } updates, ok := sessions.message.(*tg.Updates) if !ok { t.Fatalf("pushed message = %T, want *tg.Updates", sessions.message) } if len(updates.Updates) != 1 || len(updates.Users) != 1 { t.Fatalf("updates = %+v, want one update and sender user", updates) } update, ok := updates.Updates[0].(*tg.UpdateNewMessage) if !ok || update.Pts != msg.Pts { t.Fatalf("update = %#v, want UpdateNewMessage pts=%d", updates.Updates[0], msg.Pts) } if metrics.claimed != 1 || metrics.delivered != 1 || metrics.failed != 0 { t.Fatalf("metrics = claimed %d delivered %d failed %d, want 1/1/0", metrics.claimed, metrics.delivered, metrics.failed) } } func TestOutboxDispatcherUsesScopedAuthKeyExclusion(t *testing.T) { var excludeAuthKeyID [8]byte excludeAuthKeyID[0] = 7 peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ ID: 57, TargetUserID: 1000000002, Pts: 9, EventType: domain.UpdateEventPeerSettings, ExcludeAuthKeyID: excludeAuthKeyID, ExcludeSessionID: 99, }}} events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ UserID: 1000000002, Type: domain.UpdateEventPeerSettings, Pts: 9, PtsCount: 1, Date: 1700000302, Peer: peer, }}} sessions := &captureScopedSessions{captureSessions: &captureSessions{}} dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t)) dispatcher.DispatchOnce(context.Background()) if sessions.scopedAuthKeyID != excludeAuthKeyID || sessions.sessionID != 99 || sessions.userID != 1000000002 { t.Fatalf("scoped push = auth %x session %d user %d, want precise outbox exclusion", sessions.scopedAuthKeyID, sessions.sessionID, sessions.userID) } } // TestOutboxDispatcherBatchPath 覆盖生产批量路径:store 同时具备 BatchByCursor + MarkDeliveredBatch // 时,DispatchOnce 一次批量取事件、推送、再批量标记 delivered,而非逐条。 func TestOutboxDispatcherBatchPath(t *testing.T) { msg := domain.Message{ ID: 10, OwnerUserID: 1000000002, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, Date: 1700000300, Body: "hello", Pts: 7, } events := &batchEventStore{captureUpdateEventStore: &captureUpdateEventStore{events: []domain.UpdateEvent{{ UserID: msg.OwnerUserID, Type: domain.UpdateEventNewMessage, Pts: msg.Pts, PtsCount: 1, Date: msg.Date, Message: msg, Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}}, }}}} outbox := &batchDispatchOutbox{captureDispatchOutbox: &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ ID: 55, TargetUserID: msg.OwnerUserID, Pts: msg.Pts, EventType: domain.UpdateEventNewMessage, ExcludeSessionID: 99, }}}} sessions := &captureSessions{} metrics := &captureOutboxMetrics{} dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxMetrics(metrics)) dispatcher.DispatchOnce(context.Background()) if len(events.batchCursors) != 1 || events.batchCursors[0] != (store.EventCursor{UserID: msg.OwnerUserID, Pts: msg.Pts}) { t.Fatalf("batch cursors = %+v, want one cursor for (%d,%d)", events.batchCursors, msg.OwnerUserID, msg.Pts) } if sessions.userID != msg.OwnerUserID || sessions.sessionID != 99 { t.Fatalf("push target = user %d exclude %d, want batch push to outbox target", sessions.userID, sessions.sessionID) } if len(outbox.deliveredBatch) != 1 || outbox.deliveredBatch[0].ID != 55 { t.Fatalf("delivered batch = %+v, want one item id=55", outbox.deliveredBatch) } if outbox.delivered { t.Fatalf("batch path should not call per-item MarkDelivered") } if metrics.claimed != 1 || metrics.delivered != 1 || metrics.failed != 0 { t.Fatalf("metrics = claimed %d delivered %d failed %d, want 1/1/0", metrics.claimed, metrics.delivered, metrics.failed) } } func TestOutboxDispatcherUsesBestEffortPush(t *testing.T) { msg := domain.Message{ ID: 10, OwnerUserID: 1000000002, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, Date: 1700000300, Body: "hello", Pts: 7, } events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ UserID: msg.OwnerUserID, Type: domain.UpdateEventNewMessage, Pts: msg.Pts, PtsCount: 1, Date: msg.Date, Message: msg, Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}}, }}} outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ ID: 55, TargetUserID: msg.OwnerUserID, Pts: msg.Pts, EventType: domain.UpdateEventNewMessage, ExcludeSessionID: 99, }}} sessions := &captureBestEffortSessions{captureSessions: &captureSessions{}} dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxPushTimeout(50*time.Millisecond)) dispatcher.DispatchOnce(context.Background()) if !sessions.bestEffort || sessions.timeout != 50*time.Millisecond { t.Fatalf("best-effort push = %v timeout %v, want true/50ms", sessions.bestEffort, sessions.timeout) } if !outbox.delivered || outbox.failed { t.Fatalf("outbox delivered=%v failed=%v, want delivered after accepted best-effort push", outbox.delivered, outbox.failed) } } type captureBestEffortSessions struct { *captureSessions bestEffort bool timeout time.Duration } func (s *captureBestEffortSessions) PushToUserExceptSessionBestEffort(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) { s.bestEffort = true s.timeout = timeout return s.PushToUserExceptSession(ctx, userID, excludeSessionID, t, msg) } // batchEventStore 给 captureUpdateEventStore 加上 BatchByCursor 批量能力。 type batchEventStore struct { *captureUpdateEventStore batchCursors []store.EventCursor } func (s *batchEventStore) BatchByCursor(_ context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) { s.batchCursors = cursors out := make([]domain.UpdateEvent, 0, len(cursors)) for _, c := range cursors { for _, event := range s.events { if event.UserID == c.UserID && event.Pts == c.Pts { out = append(out, event) } } } return out, nil } // batchDispatchOutbox 给 captureDispatchOutbox 加上 MarkDeliveredBatch 批量能力。 type batchDispatchOutbox struct { *captureDispatchOutbox deliveredBatch []store.DispatchOutboxItem } func (s *batchDispatchOutbox) MarkDeliveredBatch(_ context.Context, items []store.DispatchOutboxItem) error { s.deliveredBatch = append(s.deliveredBatch, items...) return nil } type captureUpdateEventStore struct { events []domain.UpdateEvent } func (s *captureUpdateEventStore) Append(context.Context, int64, domain.UpdateEvent) error { return nil } func (s *captureUpdateEventStore) ListAfter(_ context.Context, _ int64, pts, limit int) ([]domain.UpdateEvent, error) { out := make([]domain.UpdateEvent, 0, len(s.events)) for _, event := range s.events { if event.Pts > pts { out = append(out, event) if len(out) == limit { break } } } return out, nil } func (s *captureUpdateEventStore) Current(context.Context, int64) (int, error) { maxPts := 0 for _, event := range s.events { if event.Pts > maxPts { maxPts = event.Pts } } return maxPts, nil } func (s *captureUpdateEventStore) MaxContiguousPts(context.Context, int64) (int, error) { present := make(map[int]struct{}, len(s.events)) for _, event := range s.events { present[event.Pts] = struct{}{} } contiguous := 0 for { if _, ok := present[contiguous+1]; !ok { break } contiguous++ } return contiguous, nil } func (s *captureUpdateEventStore) AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) { return s.MaxContiguousPts(ctx, userID) } type captureDispatchOutbox struct { items []store.DispatchOutboxItem delivered bool deliveredUserID int64 deliveredID int64 failed bool failedError string } type captureScopedSessions struct { *captureSessions scopedAuthKeyID [8]byte } func (s *captureScopedSessions) BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) { s.BindAuthKey(sessionID, authKeyID) s.scopedAuthKeyID = rawAuthKeyID } func (s *captureScopedSessions) AuthKeyIDForSession([8]byte, int64) ([8]byte, bool) { return s.AuthKeyID(0) } func (s *captureScopedSessions) BindUserForAuthKey(rawAuthKeyID [8]byte, sessionID, userID int64) { s.BindUser(sessionID, userID) s.scopedAuthKeyID = rawAuthKeyID } func (s *captureScopedSessions) UserIDForAuthKey([8]byte, int64) (int64, bool) { return s.UserID(0) } func (s *captureScopedSessions) UserIDResolvedForAuthKey([8]byte, int64) (int64, bool) { return s.UserIDResolved(0) } func (s *captureScopedSessions) SetReceivesUpdatesForAuthKey([8]byte, int64, bool) {} func (s *captureScopedSessions) PushToSessionForAuthKey(_ context.Context, rawAuthKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error { s.scopedAuthKeyID = rawAuthKeyID return s.PushToSession(context.Background(), sessionID, t, msg) } func (s *captureScopedSessions) PushToUserExceptAuthKeySession(_ context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { s.scopedAuthKeyID = excludeAuthKeyID return s.PushToUserExceptSession(context.Background(), userID, excludeSessionID, t, msg) } func (s *captureDispatchOutbox) ClaimPending(context.Context, int) ([]store.DispatchOutboxItem, error) { items := s.items s.items = nil return items, nil } func (s *captureDispatchOutbox) MarkDelivered(_ context.Context, targetUserID, id int64) error { s.delivered = true s.deliveredUserID = targetUserID s.deliveredID = id return nil } func (s *captureDispatchOutbox) MarkFailed(_ context.Context, _ int64, _ int64, lastError string) error { s.failed = true s.failedError = lastError return nil } func (s *captureDispatchOutbox) DeleteFailed(context.Context, time.Duration, int) (int, error) { return 0, nil } func TestOutboxDispatcherUsesNoopAsDelivered(t *testing.T) { outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ ID: 56, TargetUserID: 1000000002, Pts: 8, EventType: domain.UpdateEventNoop, }}} events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ UserID: 1000000002, Type: domain.UpdateEventNoop, Pts: 8, Date: 1700000301, }}} metrics := &captureOutboxMetrics{} dispatcher := NewOutboxDispatcher(events, outbox, &captureSessions{}, zaptest.NewLogger(t), WithOutboxMetrics(metrics)) dispatcher.DispatchOnce(context.Background()) if !outbox.delivered || outbox.failed { t.Fatalf("noop delivered=%v failed=%v, want delivered without push", outbox.delivered, outbox.failed) } if metrics.delivered != 1 { t.Fatalf("noop delivered metrics = %d, want 1", metrics.delivered) } } type captureOutboxMetrics struct { claimed int delivered int failed int } func (m *captureOutboxMetrics) MessageSend(time.Duration, bool, error) {} func (m *captureOutboxMetrics) MessageRateLimited(int) {} func (m *captureOutboxMetrics) OutboxClaimed(count int) { m.claimed += count } func (m *captureOutboxMetrics) OutboxDelivered(time.Duration) { m.delivered++ } func (m *captureOutboxMetrics) OutboxFailed(error) { m.failed++ } // queueFullBestEffortSessions 模拟出站队列拥塞:best-effort 推送总是失败(入队超时 / 队列满)。 type queueFullBestEffortSessions struct { *captureSessions attempts int } func (s *queueFullBestEffortSessions) PushToUserExceptSessionBestEffort(_ context.Context, _ int64, _ int64, _ proto.MessageType, _ bin.Encoder, _ time.Duration) (int, error) { s.attempts++ return 0, errors.New("mtproto outbound queue full") } // TestOutboxDispatcherDefersOnPushQueueFull 验证 best-effort 推送因出站队列拥塞失败时,dispatcher // 既不标记 delivered(任务保留,靠 dispatching 租约过期重投,满足至少一次投递语义),也不标记 // failed(拥塞不计入 attempts 升级,避免正常满 fan-out 负载把可靠 update 误打成 failed)。 func TestOutboxDispatcherDefersOnPushQueueFull(t *testing.T) { msg := domain.Message{ ID: 10, OwnerUserID: 1000000002, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, Date: 1700000300, Body: "hello", Pts: 7, } events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ UserID: msg.OwnerUserID, Type: domain.UpdateEventNewMessage, Pts: msg.Pts, PtsCount: 1, Date: msg.Date, Message: msg, Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}}, }}} outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ ID: 55, TargetUserID: msg.OwnerUserID, Pts: msg.Pts, EventType: domain.UpdateEventNewMessage, ExcludeSessionID: 99, }}} sessions := &queueFullBestEffortSessions{captureSessions: &captureSessions{}} metrics := &captureOutboxMetrics{} dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxPushTimeout(50*time.Millisecond), WithOutboxMetrics(metrics)) dispatcher.DispatchOnce(context.Background()) if sessions.attempts != 1 { t.Fatalf("best-effort push attempts = %d, want 1(应走 best-effort 推送路径)", sessions.attempts) } if outbox.delivered { t.Fatalf("outbox delivered=true, want 未投递(拥塞应保留 dispatching 行靠租约重投)") } if outbox.failed { t.Fatalf("outbox failed=true, want 未失败(拥塞不计入 attempts 升级)") } if metrics.failed != 0 { t.Fatalf("metrics.failed=%d, want 0(拥塞不算投递失败)", metrics.failed) } }