173 lines
8.8 KiB
Go
173 lines
8.8 KiB
Go
package rpc
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/gotd/td/tg"
|
||
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
// channelUpdateWirePts 返回一条 channel update 的 channel pts(若该 TL update 携带 channel pts)。
|
||
// 只有携带 channel pts 的 update 会推进客户端 channel PtsWaiter,是设计 §5 classifier 第一类
|
||
// 「channel-payload-pts durable」的判据;其余(participant=qts、channel state=无 pts)不携带。
|
||
func channelUpdateWirePts(u tg.UpdateClass) (int, bool) {
|
||
switch v := u.(type) {
|
||
case *tg.UpdateNewChannelMessage:
|
||
return v.Pts, true
|
||
case *tg.UpdateEditChannelMessage:
|
||
return v.Pts, true
|
||
case *tg.UpdateDeleteChannelMessages:
|
||
return v.Pts, true
|
||
case *tg.UpdatePinnedChannelMessages:
|
||
return v.Pts, true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
// TestChannelUpdateEventTypePtsClassification 固化设计 §5 classifier 的基础轴:哪些
|
||
// domain.ChannelUpdateEventType 经 tgChannelUpdate 产出「带 channel pts、会推进客户端 channel
|
||
// PtsWaiter」的真实 payload(→可进 Phase 0 异步 durable fan-out worker),哪些不带 channel pts
|
||
// (participant=qts / noop → 必须排除出 ChannelFanoutJob,走专用同步路径)。
|
||
//
|
||
// 新增 ChannelUpdateEventType 时必须在此表显式分类:忘记会触发完整性断言失败,强制人为裁决,
|
||
// 防止把无 pts / transient 事件误塞进 (channel_id, pts) durable worker(设计 §2.1/§8-D6)。
|
||
func TestChannelUpdateEventTypePtsClassification(t *testing.T) {
|
||
const viewer = int64(9001)
|
||
msg := domain.ChannelMessage{ChannelID: 1, ID: 10, SenderUserID: 2, Date: 1, Body: "hi"}
|
||
|
||
cases := []struct {
|
||
name string
|
||
eventType domain.ChannelUpdateEventType
|
||
event domain.ChannelUpdateEvent
|
||
wantCarriesPts bool // 是否产出带 channel pts 的 wire update(= 可进 durable worker)
|
||
}{
|
||
{
|
||
name: "new_message",
|
||
eventType: domain.ChannelUpdateNewMessage,
|
||
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateNewMessage, ChannelID: 1, Pts: 5, PtsCount: 1, Message: msg},
|
||
wantCarriesPts: true,
|
||
},
|
||
{
|
||
name: "edit_message",
|
||
eventType: domain.ChannelUpdateEditMessage,
|
||
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateEditMessage, ChannelID: 1, Pts: 6, PtsCount: 1, Message: msg},
|
||
wantCarriesPts: true,
|
||
},
|
||
{
|
||
name: "delete_messages",
|
||
eventType: domain.ChannelUpdateDeleteMessages,
|
||
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateDeleteMessages, ChannelID: 1, Pts: 7, PtsCount: 1, MessageIDs: []int{10}},
|
||
wantCarriesPts: true,
|
||
},
|
||
{
|
||
name: "pinned_messages",
|
||
eventType: domain.ChannelUpdatePinnedMessages,
|
||
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdatePinnedMessages, ChannelID: 1, Pts: 8, PtsCount: 1, MessageIDs: []int{10}, Pinned: true},
|
||
wantCarriesPts: true,
|
||
},
|
||
{
|
||
// participant 是 qts/no-channel-pts:必须排除出 durable worker,走 participant 专用路径
|
||
// (账号级 channel state / getFullChannel / getParticipants 兜底)。设计 §8-D6。
|
||
name: "participant",
|
||
eventType: domain.ChannelUpdateParticipant,
|
||
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateParticipant, ChannelID: 1, Participant: domain.ChannelMember{UserID: 2}},
|
||
wantCarriesPts: false,
|
||
},
|
||
{
|
||
name: "noop",
|
||
eventType: domain.ChannelUpdateNoop,
|
||
event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateNoop, ChannelID: 1},
|
||
wantCarriesPts: false,
|
||
},
|
||
}
|
||
|
||
// 完整性:本表必须覆盖全部已知 ChannelUpdateEventType。新增类型未分类 → 失败。
|
||
allKnownTypes := map[domain.ChannelUpdateEventType]struct{}{
|
||
domain.ChannelUpdateNewMessage: {},
|
||
domain.ChannelUpdateEditMessage: {},
|
||
domain.ChannelUpdateDeleteMessages: {},
|
||
domain.ChannelUpdateParticipant: {},
|
||
domain.ChannelUpdatePinnedMessages: {},
|
||
domain.ChannelUpdateNoop: {},
|
||
}
|
||
covered := make(map[domain.ChannelUpdateEventType]struct{}, len(cases))
|
||
for _, tc := range cases {
|
||
covered[tc.eventType] = struct{}{}
|
||
}
|
||
for et := range allKnownTypes {
|
||
if _, ok := covered[et]; !ok {
|
||
t.Fatalf("ChannelUpdateEventType %q 未在 classifier 固化表分类——新增类型必须显式裁决 pts 归类", et)
|
||
}
|
||
}
|
||
if len(covered) != len(allKnownTypes) {
|
||
t.Fatalf("classifier 表覆盖 %d 类型,已知 %d 类型;新增类型须同步 allKnownTypes + 本表", len(covered), len(allKnownTypes))
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
update := tgChannelUpdate(viewer, tc.event)
|
||
if tc.eventType == domain.ChannelUpdateNoop {
|
||
if update != nil {
|
||
t.Fatalf("noop event produced update %#v, want nil", update)
|
||
}
|
||
return
|
||
}
|
||
if update == nil {
|
||
t.Fatalf("event %q produced nil update", tc.eventType)
|
||
}
|
||
pts, carries := channelUpdateWirePts(update)
|
||
if carries != tc.wantCarriesPts {
|
||
t.Fatalf("event %q carriesChannelPts=%v (%T), want %v", tc.eventType, carries, update, tc.wantCarriesPts)
|
||
}
|
||
if tc.wantCarriesPts && pts != tc.event.Pts {
|
||
t.Fatalf("event %q wire pts=%d, want %d", tc.eventType, pts, tc.event.Pts)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestChannelFanoutEntranceClassificationLocked 把设计 §5 的「channel-scoped 主动投递入口」分类
|
||
// 与当前 dispatch 方式固化为测试期边界(防回归 + 文档化为何某些带 channel pts 的入口仍同步)。
|
||
//
|
||
// 本表是人工维护的清单(不自动反射调用图),与 docs/channel-fanout-async-design.md §5 一致。
|
||
// 新增/改动 channel-scoped 主动投递入口时必须同步本表并在 review 中复核分类:
|
||
//
|
||
// 已异步(durable channel-payload-pts → 进 enqueueChannelFanout* 异步 worker):
|
||
// send / sendMedia / forward / forum-topic-msg / discussion 联动 / edit / geolive-edit / todo /
|
||
// bot-inline-edit / delete / deleteParticipantHistory / pin(updatePinnedMessage) / unpinAll /
|
||
// TTL-expiry-delete。
|
||
// 刻意保持同步且锁定(混合容器 / MustDeliver 失去 difference 恢复面 / 冷起手,详见下表 reason):
|
||
// createChannel / importChatInvite / inviteToChannel / createChat(invite) / joinChannel /
|
||
// leaveChannel / editBanned(kick) / hideChatJoinRequest / hideAllChatJoinRequests /
|
||
// editTitle / group-call started/ended/invite 服务消息。
|
||
// 无 channel pts,排除出 durable worker(participant / state / TTL-set / forum-pin / read-outbox /
|
||
// group-call version / typing);viewer-only 无 durable event(poll / reaction)——Phase 4 才迁移。
|
||
//
|
||
// 此处只断言一组「不变量级」的分类事实,作为编译期/测试期护栏。
|
||
func TestChannelFanoutEntranceClassificationLocked(t *testing.T) {
|
||
type entrance struct {
|
||
name string
|
||
carriesChanPts bool
|
||
asyncFanout bool // 当前是否走异步 durable worker
|
||
reason string // 若带 pts 却仍同步,记录原因(mixed-container / MustDeliver / 冷起手)
|
||
}
|
||
// 仅列「带 channel pts」入口(无 pts 的 participant/state/typing 由上文事件类型表与设计排除规则覆盖)。
|
||
entrances := []entrance{
|
||
{name: "send/sendMedia/forward/forum-msg/discussion/edit/geolive/todo/bot-inline/delete/pin/unpinAll/deleteParticipantHistory/expiry-delete", carriesChanPts: true, asyncFanout: true},
|
||
{name: "editTitle", carriesChanPts: true, asyncFanout: true, reason: "已拆容器:无 pts UpdateChannel 同步发(无 difference 恢复面),带 pts 标题服务消息(broadcast+megagroup 均产 pts)走异步 fan-out(有 difference 恢复面+nudge 兜底)"},
|
||
{name: "createChannel", carriesChanPts: true, asyncFanout: false, reason: "冷起手:recipients=创建者+少量初始受邀,无大群放大/无延迟收益,RPC result 与首 push 紧耦合"},
|
||
{name: "importChatInvite/inviteToChannel/createChat-invite/joinChannel/hideChatJoinRequest/hideAllChatJoinRequests", carriesChanPts: true, asyncFanout: false, reason: "经 channelOperationUpdates 混合容器(pts 服务消息 + 无 pts UpdateChannel;broadcast 仅 UpdateChannel),且新成员 MustDeliver 失去 difference 恢复面——须先拆容器+no-fold 才能异步"},
|
||
{name: "leaveChannel/editBanned(kick)", carriesChanPts: true, asyncFanout: false, reason: "离开/被踢者在 recipients 内且已失去 channel difference 恢复面(MustDeliver),可丢弃队列无 no-fold 通道;participant 部分无 pts"},
|
||
{name: "groupCallServiceMessage", carriesChanPts: true, asyncFanout: false, reason: "经 channelOperationUpdates 混合容器;version 信令须留特殊路径,只能拆出服务消息再异步"},
|
||
}
|
||
for _, e := range entrances {
|
||
if !e.carriesChanPts {
|
||
t.Fatalf("entrance %q 误入本表(本表仅列带 channel pts 入口)", e.name)
|
||
}
|
||
if !e.asyncFanout && e.reason == "" {
|
||
t.Fatalf("entrance %q 带 channel pts 却仍同步,必须记录保持同步的原因(mixed-container/MustDeliver/冷起手)", e.name)
|
||
}
|
||
}
|
||
}
|