Initial open source release

This commit is contained in:
A 2026-06-04 01:37:39 +08:00
commit 74992e893f
377 changed files with 118084 additions and 0 deletions

View file

@ -0,0 +1,154 @@
package updates
import (
"context"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func seedEvent(t *testing.T, events *memory.UpdateEventStore, userID int64, pts int) {
t.Helper()
if err := events.Append(context.Background(), userID, domain.UpdateEvent{
Type: domain.UpdateEventNewMessage,
Pts: pts,
PtsCount: 1,
Date: 1700000000 + pts,
Message: domain.Message{
ID: pts,
OwnerUserID: userID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
},
}); err != nil {
t.Fatalf("seed event pts=%d: %v", pts, err)
}
}
// TestGetDifferenceStopsAtHolegetDifference 只返回从 from 起连续的事件,遇在途空洞即截断,
// State.Pts 取最后连续值;补洞后下次拉取可继续,绝不跳过空洞。
func TestGetDifferenceStopsAtHole(t *testing.T) {
ctx := context.Background()
var authKeyID [8]byte
userID := int64(1000000001)
events := memory.NewUpdateEventStore()
svc := NewService(memory.NewUpdateStateStore(), events)
// pts 1,2,3 已提交4 在途5,6 已提交 → 连续只到 3。
for _, p := range []int{1, 2, 3, 5, 6} {
seedEvent(t, events, userID, p)
}
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{})
if err != nil {
t.Fatalf("GetDifference: %v", err)
}
if len(diff.Events) != 3 || diff.State.Pts != 3 {
t.Fatalf("diff = %d events, state.pts %d; want 3 连续事件、止于空洞(pts=3)", len(diff.Events), diff.State.Pts)
}
if diff.Partial {
t.Fatalf("Partial=truewant false被空洞而非 limit 截断)")
}
// 补上 pts=4在途事务提交从 3 继续应拿到 4,5,6。
seedEvent(t, events, userID, 4)
diff, err = svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 3})
if err != nil {
t.Fatalf("GetDifference after fill: %v", err)
}
if len(diff.Events) != 3 || diff.State.Pts != 6 {
t.Fatalf("补洞后 diff = %d events, state.pts %d; want 4,5,6 到 pts=6", len(diff.Events), diff.State.Pts)
}
}
// TestGetDifferenceSliceOnLimit连续事件填满 limit 时返回 Partial(=differenceSlice)
// State.Pts 为中间态;客户端据此续拉,最终一页 Partial=false。
func TestGetDifferenceSliceOnLimit(t *testing.T) {
ctx := context.Background()
var authKeyID [8]byte
userID := int64(1000000002)
events := memory.NewUpdateEventStore()
svc := NewService(memory.NewUpdateStateStore(), events)
total := getDifferenceLimit + 25
for p := 1; p <= total; p++ {
seedEvent(t, events, userID, p)
}
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{})
if err != nil {
t.Fatalf("GetDifference: %v", err)
}
if len(diff.Events) != getDifferenceLimit || !diff.Partial || diff.State.Pts != getDifferenceLimit {
t.Fatalf("第一页 = %d events partial %v state.pts %d; want %d/true/%d",
len(diff.Events), diff.Partial, diff.State.Pts, getDifferenceLimit, getDifferenceLimit)
}
diff, err = svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: getDifferenceLimit})
if err != nil {
t.Fatalf("GetDifference page2: %v", err)
}
if len(diff.Events) != 25 || diff.Partial || diff.State.Pts != total {
t.Fatalf("第二页 = %d events partial %v state.pts %d; want 25/false/%d",
len(diff.Events), diff.Partial, diff.State.Pts, total)
}
}
// TestGetStateReportsContiguousNotMaxgetState 报告最大连续 pts而非最大已提交 pts
// 避免首次登录基线越过在途空洞而丢消息。
func TestGetStateReportsContiguousNotMax(t *testing.T) {
ctx := context.Background()
var authKeyID [8]byte
userID := int64(1000000003)
events := memory.NewUpdateEventStore()
svc := NewService(memory.NewUpdateStateStore(), events)
for _, p := range []int{1, 2, 3, 5, 6} { // 4 在途空洞,最大已提交=6
seedEvent(t, events, userID, p)
}
st, err := svc.GetState(ctx, authKeyID, userID)
if err != nil {
t.Fatalf("GetState: %v", err)
}
if st.Pts != 3 {
t.Fatalf("GetState.Pts=%d, want 3最大连续而非最大已提交 6", st.Pts)
}
}
func TestGetStateDoesNotConfirmUnfetchedEvents(t *testing.T) {
ctx := context.Background()
var authKeyID [8]byte
authKeyID[0] = 7
userID := int64(1000000004)
events := memory.NewUpdateEventStore()
states := memory.NewUpdateStateStore()
svc := NewService(states, events)
seedEvent(t, events, userID, 1)
if err := states.Save(ctx, authKeyID, userID, domain.UpdateState{Pts: 1, Date: 1700000001}); err != nil {
t.Fatalf("Save state: %v", err)
}
seedEvent(t, events, userID, 2)
st, err := svc.GetState(ctx, authKeyID, userID)
if err != nil {
t.Fatalf("GetState: %v", err)
}
if st.Pts != 1 {
t.Fatalf("GetState.Pts=%d, want existing confirmed pts=1", st.Pts)
}
diff, err := svc.GetDifference(ctx, authKeyID, userID, st)
if err != nil {
t.Fatalf("GetDifference: %v", err)
}
if len(diff.Events) != 1 || diff.Events[0].Pts != 2 || diff.State.Pts != 2 {
t.Fatalf("diff = %+v, want one event at pts=2 and confirmed state pts=2", diff)
}
st, err = svc.GetState(ctx, authKeyID, userID)
if err != nil {
t.Fatalf("GetState after difference: %v", err)
}
if st.Pts != 2 {
t.Fatalf("GetState after difference pts=%d, want confirmed pts=2", st.Pts)
}
}

View file

@ -0,0 +1,3 @@
// Package updates 是更新状态机与投递user 级 pts/qts/seq/date、离线差量updates.getDifference
// 在线推送。第一阶段持久化 auth_key 维度的初始空状态,消息事件队列留第二阶段。
package updates

View file

@ -0,0 +1,465 @@
package updates
import (
"context"
"sort"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service 提供 update 状态查询。
type Service struct {
states store.UpdateStateStore
events store.UpdateEventStore
pts store.PtsAllocator
}
type dispatchingEventAppender interface {
AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error
}
// ServiceOption 调整 updates 服务的运行时依赖。
type ServiceOption func(*Service)
// WithPtsAllocator 使用外部 pts 分配器推进账号级 pts。
func WithPtsAllocator(pts store.PtsAllocator) ServiceOption {
return func(s *Service) {
s.pts = pts
}
}
// NewService 创建 updates 服务。
func NewService(states store.UpdateStateStore, events store.UpdateEventStore, opts ...ServiceOption) *Service {
s := &Service{states: states}
s.events = events
for _, opt := range opts {
opt(s)
}
return s
}
// UsesReliableDispatch 表示设置类 update 已写入 transactional outbox由 outbox worker 投递在线 session。
func (s *Service) UsesReliableDispatch() bool {
if s == nil || s.events == nil {
return false
}
_, ok := s.events.(dispatchingEventAppender)
return ok
}
// GetState 返回当前 auth_key + user 维度已确认的 update 状态。
// user_update_events 是账号级 durable logauth_key 维度只保存设备已经通过
// getDifference 确认到的状态,不能在 getState 中直接推进到账号最新水位。
func (s *Service) GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) {
now := int(time.Now().Unix())
// 私聊阶段不维护账号级 seq对外 UpdateState.Seq 恒为 0客户端仅靠 pts 同步、
// 跳过 seq gap 检测(推送信封 seq 同样恒 0
if s.states == nil {
current, err := s.currentPts(ctx, userID)
if err != nil {
return domain.UpdateState{}, err
}
return domain.UpdateState{Pts: current, Date: now, Seq: 0}, nil
}
st, found, err := s.states.Get(ctx, authKeyID, userID)
if err != nil {
return domain.UpdateState{}, err
}
if found {
st.Seq = 0
if st.Date == 0 {
st.Date = now
}
return st, nil
}
current, err := s.currentPts(ctx, userID)
if err != nil {
return domain.UpdateState{}, err
}
st = domain.UpdateState{Pts: current, Date: now, Seq: 0}
if err := s.states.Save(ctx, authKeyID, userID, st); err != nil {
return domain.UpdateState{}, err
}
return st, nil
}
// CurrentState 返回账号当前最大连续 update 状态,不修改任何设备已确认水位。
func (s *Service) CurrentState(ctx context.Context, userID int64) (domain.UpdateState, error) {
return s.currentState(ctx, userID)
}
// getDifferenceLimit 是单次 getDifference 返回的最大连续事件数;超出置 Partial 让客户端翻页。
const getDifferenceLimit = 100
// GetDifference 返回当前 user 从 from 状态之后的增量事件。
//
// 对齐 MTProto只返回从 from.Pts 起「连续」的事件遇空洞即截断State.Pts 取最后连续值,
// 绝不让客户端跳过在途空洞而丢消息——空洞由并发发送的在途事务造成,提交/补洞后客户端下次拉取即可补齐。
// 连续事件填满 limit 时置 Partial映射 differenceSlice客户端据返回 State 继续翻页。
func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateDifference, error) {
st, err := s.currentState(ctx, userID)
if err != nil {
return domain.UpdateDifference{}, err
}
if s.events == nil || from.Pts >= st.Pts {
if from.Date != 0 {
st.Date = from.Date
}
if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil {
return domain.UpdateDifference{}, err
}
return domain.UpdateDifference{State: st}, nil
}
events, err := s.events.ListAfter(ctx, userID, from.Pts, getDifferenceLimit)
if err != nil {
return domain.UpdateDifference{}, err
}
contiguous := contiguousPrefix(events, from.Pts)
last := from.Pts
if len(contiguous) > 0 {
last = contiguous[len(contiguous)-1].Pts
}
out := st
out.Pts = last
out.Seq = 0 // seq 恒 0见 GetState 注释
if len(contiguous) > 0 {
out.Date = contiguous[len(contiguous)-1].Date
}
if err := s.saveConfirmedState(ctx, authKeyID, userID, out); err != nil {
return domain.UpdateDifference{}, err
}
return domain.UpdateDifference{
State: out,
Events: contiguous,
Partial: len(contiguous) == getDifferenceLimit,
}, nil
}
func (s *Service) currentState(ctx context.Context, userID int64) (domain.UpdateState, error) {
current, err := s.currentPts(ctx, userID)
if err != nil {
return domain.UpdateState{}, err
}
return domain.UpdateState{
Pts: current,
Date: int(time.Now().Unix()),
Seq: 0,
}, nil
}
func (s *Service) saveConfirmedState(ctx context.Context, authKeyID [8]byte, userID int64, st domain.UpdateState) error {
if s.states == nil {
return nil
}
st.Seq = 0
return s.states.Save(ctx, authKeyID, userID, st)
}
// contiguousPrefix 返回从 from 起 pts 严格连续from+1, from+2, ...)的事件前缀。
// 先按 pts 升序排序以兼容存储返回顺序,遇到空洞即停。
func contiguousPrefix(events []domain.UpdateEvent, from int) []domain.UpdateEvent {
if len(events) == 0 {
return nil
}
sorted := make([]domain.UpdateEvent, len(events))
copy(sorted, events)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Pts < sorted[j].Pts })
cursor := from
out := make([]domain.UpdateEvent, 0, len(sorted))
for _, event := range sorted {
ptsCount := event.PtsCount
if ptsCount <= 0 {
ptsCount = 1
}
if event.Pts != cursor+ptsCount {
break
}
out = append(out, event)
cursor = event.Pts
}
return out
}
// ClearAuthKey 清理某 auth_key 的设备状态。
// user_update_events 是账号级 durable log不能因设备退出登录被删除。
func (s *Service) ClearAuthKey(ctx context.Context, authKeyID [8]byte) error {
if s.states != nil {
if err := s.states.DeleteAuthKey(ctx, authKeyID); err != nil {
return err
}
}
return nil
}
// RecordNewMessage 推进 update 状态并追加一条 new_message 事件。
func (s *Service) RecordNewMessage(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
if userID == 0 {
userID = msg.OwnerUserID
}
date := msg.Date
if date == 0 {
date = int(time.Now().Unix())
}
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventNewMessage,
Date: date,
Message: msg,
PtsCount: 1,
}, false, 0)
}
// RecordMessageReactions records a durable marker for message reaction changes.
//
// updateMessageReactions has no pts fields in Layer 225, but TDesktop still
// needs getDifference to advance account pts and carry the latest reaction
// aggregate for offline devices.
func (s *Service) RecordMessageReactions(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
if userID == 0 {
userID = msg.OwnerUserID
}
date := msg.Date
if date == 0 {
date = int(time.Now().Unix())
}
return s.recordEventWithoutState(ctx, userID, domain.UpdateEvent{
Type: domain.UpdateEventMessageReactions,
Date: date,
Message: msg,
Peer: msg.Peer,
PtsCount: 1,
})
}
// RecordReadHistory 推进 update 状态并追加一条 read_history_inbox 事件。
func (s *Service) RecordReadHistory(ctx context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
if userID == 0 {
userID = read.OwnerUserID
}
date := int(time.Now().Unix())
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventReadHistoryInbox,
Date: date,
Peer: read.Peer,
MaxID: read.MaxID,
StillUnreadCount: read.StillUnreadCount,
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordContactsReset 记录通讯录视角变化,供离线设备通过 updates.getDifference 触发重拉。
func (s *Service) RecordContactsReset(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventContactsReset,
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordDialogPinned 记录单个会话置顶状态变化。
func (s *Service) RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventDialogPinned,
Peer: peer,
Bool: pinned,
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordPinnedDialogs 记录置顶会话顺序变化,并把新顺序持久化给 getDifference/outbox。
func (s *Service) RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventPinnedDialogs,
Peers: append([]domain.Peer(nil), order...),
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordDialogUnreadMark 记录手动未读标记变化。
func (s *Service) RecordDialogUnreadMark(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventDialogUnreadMark,
Peer: peer,
Bool: unread,
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordChannelViewForumAsMessages records a per-account forum presentation state change.
func (s *Service) RecordChannelViewForumAsMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventChannelViewForum,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
Bool: enabled,
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordPeerSettings 记录 peer settings 变化。
func (s *Service) RecordPeerSettings(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventPeerSettings,
Peer: peer,
Settings: settings,
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordDialogFilter 记录单个 filter 的创建、更新或删除folder 为 nil 表示删除。
func (s *Service) RecordDialogFilter(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
var copyFolder *domain.DialogFolder
if folder != nil {
f := *folder
copyFolder = &f
}
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventDialogFilter,
FilterID: folderID,
DialogFilter: copyFolder,
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordDialogFilterOrder 记录 filter 顺序变化。
func (s *Service) RecordDialogFilterOrder(ctx context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventDialogFilterOrder,
FilterOrder: append([]int(nil), order...),
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordDialogFiltersReload 通知其他设备重新拉取 filter 列表。
func (s *Service) RecordDialogFiltersReload(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventDialogFilters,
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordFolderPeers 记录归档/还原会话的 folder_id 变化。
func (s *Service) RecordFolderPeers(ctx context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventFolderPeers,
FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...),
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordChannelAvailableMessages records a local channel history clear for multi-device sync.
func (s *Service) RecordChannelAvailableMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventChannelAvailable,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
MaxID: availableMinID,
PtsCount: 1,
}, true, excludeSessionID)
}
func (s *Service) recordEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEventCore(ctx, authKeyID, userID, event, dispatch, excludeSessionID, true)
}
func (s *Service) recordEventWithoutState(ctx context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, domain.UpdateState, error) {
return s.recordEventCore(ctx, [8]byte{}, userID, event, false, 0, false)
}
func (s *Service) recordEventCore(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64, saveState bool) (domain.UpdateEvent, domain.UpdateState, error) {
date := event.Date
if date == 0 {
date = int(time.Now().Unix())
}
if event.PtsCount == 0 {
event.PtsCount = 1
}
pts, err := s.nextPtsN(ctx, userID, event.PtsCount)
if err != nil {
return domain.UpdateEvent{}, domain.UpdateState{}, err
}
st := domain.UpdateState{Pts: pts, Date: date, Seq: 0}
event.UserID = userID
event.Pts = st.Pts
event.Date = date
if s.events != nil {
var err error
if dispatch {
if appender, ok := s.events.(dispatchingEventAppender); ok {
err = appender.AppendWithDispatch(ctx, userID, event, authKeyID, excludeSessionID)
} else {
err = s.events.Append(ctx, userID, event)
}
} else {
err = s.events.Append(ctx, userID, event)
}
if err != nil {
if !dispatch {
_ = s.events.Append(ctx, userID, domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventNoop,
Pts: pts,
PtsCount: event.PtsCount,
Date: date,
})
}
return domain.UpdateEvent{}, domain.UpdateState{}, err
}
}
if saveState && s.states != nil {
if err := s.states.Save(ctx, authKeyID, userID, st); err != nil {
return domain.UpdateEvent{}, domain.UpdateState{}, err
}
}
return event, st, nil
}
// currentPts 供 GetState 报告「当前 pts」。对齐 MTProto报告最大连续已提交 pts
// 而非 Redis allocator 的最大已分配值——后者在并发发送在途时会超前于已提交事件,
// 会让首次登录基线越过在途空洞而丢消息。allocator 仅在无 events 存储时兜底。
func (s *Service) currentPts(ctx context.Context, userID int64) (int, error) {
if s.events != nil {
return s.events.MaxContiguousPts(ctx, userID)
}
if s.pts != nil {
return s.pts.CurrentPts(ctx, userID)
}
return 0, nil
}
func (s *Service) nextPts(ctx context.Context, userID int64) (int, error) {
if s.pts != nil {
return s.pts.NextPts(ctx, userID)
}
current, err := s.currentPts(ctx, userID)
if err != nil {
return 0, err
}
return current + 1, nil
}
func (s *Service) nextPtsN(ctx context.Context, userID int64, count int) (int, error) {
if count <= 0 {
count = 1
}
if count == 1 {
return s.nextPts(ctx, userID)
}
if s.pts != nil {
if ranges, ok := s.pts.(store.PtsRangeAllocator); ok {
return ranges.NextPtsN(ctx, userID, count)
}
var pts int
var err error
for i := 0; i < count; i++ {
pts, err = s.pts.NextPts(ctx, userID)
if err != nil {
return 0, err
}
}
return pts, nil
}
current, err := s.currentPts(ctx, userID)
if err != nil {
return 0, err
}
return current + count, nil
}

View file

@ -0,0 +1,252 @@
package updates
import (
"context"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestRecordNewMessageFeedsGetDifference(t *testing.T) {
ctx := context.Background()
var authKeyID [8]byte
authKeyID[0] = 1
svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
msg := domain.Message{
ID: 10,
OwnerUserID: 1000000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
Date: 1700000000,
Body: "Login code: 12345",
}
event, state, err := svc.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg)
if err != nil {
t.Fatalf("RecordNewMessage: %v", err)
}
if event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 || state.Seq != 0 {
t.Fatalf("event/state = %+v / %+v, want first pts event with seq=0", event, state)
}
diff, err := svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, domain.UpdateState{})
if err != nil {
t.Fatalf("GetDifference: %v", err)
}
if diff.State != state || len(diff.Events) != 1 || diff.Events[0].Message.ID != msg.ID {
t.Fatalf("diff = %+v, want recorded login message event and state %+v", diff, state)
}
diff, err = svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, state)
if err != nil {
t.Fatalf("GetDifference current: %v", err)
}
if len(diff.Events) != 0 || diff.State != state {
t.Fatalf("current diff = %+v, want empty events and same state", diff)
}
}
func TestRecordReadHistoryFeedsGetDifference(t *testing.T) {
ctx := context.Background()
var authKeyID [8]byte
authKeyID[0] = 2
svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}
ownerUserID := int64(1000000001)
event, state, err := svc.RecordReadHistory(ctx, authKeyID, ownerUserID, domain.ReadHistoryResult{
OwnerUserID: ownerUserID,
Peer: peer,
MaxID: 10,
Changed: true,
}, 0)
if err != nil {
t.Fatalf("RecordReadHistory: %v", err)
}
if event.Type != domain.UpdateEventReadHistoryInbox || event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 {
t.Fatalf("event/state = %+v / %+v, want read history event with first pts", event, state)
}
diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{})
if err != nil {
t.Fatalf("GetDifference: %v", err)
}
if diff.State != state || len(diff.Events) != 1 || diff.Events[0].Peer != peer || diff.Events[0].MaxID != 10 {
t.Fatalf("diff = %+v, want recorded read history event and state %+v", diff, state)
}
}
func TestRecordSettingsEventsFeedGetDifference(t *testing.T) {
ctx := context.Background()
var authKeyID [8]byte
authKeyID[0] = 3
svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
ownerUserID := int64(1000000001)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
if _, _, err := svc.RecordContactsReset(ctx, authKeyID, ownerUserID, 0); err != nil {
t.Fatalf("RecordContactsReset: %v", err)
}
if _, _, err := svc.RecordDialogPinned(ctx, authKeyID, ownerUserID, peer, true, 0); err != nil {
t.Fatalf("RecordDialogPinned: %v", err)
}
order := []domain.Peer{peer}
if _, _, err := svc.RecordPinnedDialogs(ctx, authKeyID, ownerUserID, order, 0); err != nil {
t.Fatalf("RecordPinnedDialogs: %v", err)
}
if _, _, err := svc.RecordDialogUnreadMark(ctx, authKeyID, ownerUserID, peer, false, 0); err != nil {
t.Fatalf("RecordDialogUnreadMark: %v", err)
}
settings := domain.PeerSettings{ShareContact: true}
stateEvent, state, err := svc.RecordPeerSettings(ctx, authKeyID, ownerUserID, peer, settings, 0)
if err != nil {
t.Fatalf("RecordPeerSettings: %v", err)
}
if stateEvent.Pts != 5 || state.Pts != 5 {
t.Fatalf("last event/state = %+v / %+v, want pts=5", stateEvent, state)
}
diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{})
if err != nil {
t.Fatalf("GetDifference: %v", err)
}
if diff.State.Pts != 5 || len(diff.Events) != 5 {
t.Fatalf("diff = %+v, want five settings events", diff)
}
wantTypes := []domain.UpdateEventType{
domain.UpdateEventContactsReset,
domain.UpdateEventDialogPinned,
domain.UpdateEventPinnedDialogs,
domain.UpdateEventDialogUnreadMark,
domain.UpdateEventPeerSettings,
}
for i, typ := range wantTypes {
if diff.Events[i].Type != typ || diff.Events[i].Pts != i+1 || diff.Events[i].PtsCount != 1 {
t.Fatalf("event[%d] = %+v, want type=%s pts=%d pts_count=1", i, diff.Events[i], typ, i+1)
}
}
if diff.Events[1].Peer != peer || !diff.Events[1].Bool {
t.Fatalf("dialog pinned event = %+v, want peer and pinned=true", diff.Events[1])
}
if diff.Events[3].Peer != peer || diff.Events[3].Bool {
t.Fatalf("unread mark event = %+v, want peer and unread=false", diff.Events[3])
}
if len(diff.Events[2].Peers) != 1 || diff.Events[2].Peers[0] != peer {
t.Fatalf("pinned dialogs event = %+v, want order peer", diff.Events[2])
}
if diff.Events[4].Peer != peer || !diff.Events[4].Settings.ShareContact {
t.Fatalf("peer settings event = %+v, want peer and settings", diff.Events[4])
}
}
func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) {
ctx := context.Background()
var authKeyID [8]byte
authKeyID[0] = 4
events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()}
svc := NewService(memory.NewUpdateStateStore(), events)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
event, state, err := svc.RecordDialogPinned(ctx, authKeyID, 1000000001, peer, true, 42)
if err != nil {
t.Fatalf("RecordDialogPinned: %v", err)
}
if event.Pts != 1 || state.Pts != 1 {
t.Fatalf("event/state = %+v / %+v, want first pts", event, state)
}
if !events.dispatched || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 42 || events.event.Type != domain.UpdateEventDialogPinned || events.event.Peer != peer {
t.Fatalf("dispatch capture = %+v exclude_auth=%v exclude_session=%d dispatched=%v, want dialog_pinned outbox", events.event, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched)
}
}
func TestClearAuthKeyDropsStateAndEvents(t *testing.T) {
ctx := context.Background()
var authKeyID [8]byte
authKeyID[0] = 8
states := memory.NewUpdateStateStore()
events := memory.NewUpdateEventStore()
svc := NewService(states, events)
msg := domain.Message{
ID: 1,
OwnerUserID: 1000000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
Date: 1700000000,
}
if _, _, err := svc.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg); err != nil {
t.Fatalf("RecordNewMessage: %v", err)
}
if err := svc.ClearAuthKey(ctx, authKeyID); err != nil {
t.Fatalf("ClearAuthKey: %v", err)
}
diff, err := svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, domain.UpdateState{})
if err != nil {
t.Fatalf("GetDifference: %v", err)
}
if diff.State.Pts != 1 || len(diff.Events) != 1 {
t.Fatalf("difference after clear = %+v, want durable user events to remain", diff)
}
diff, err = svc.GetDifference(ctx, authKeyID, msg.OwnerUserID+1, domain.UpdateState{})
if err != nil {
t.Fatalf("GetDifference other user: %v", err)
}
if diff.State.Pts != 0 || len(diff.Events) != 0 {
t.Fatalf("difference for other user after clear = %+v, want no cross-account events", diff)
}
}
func TestDeleteMessagesPtsRangeFeedsGetDifference(t *testing.T) {
ctx := context.Background()
var authKeyID [8]byte
authKeyID[0] = 9
userID := int64(1000000001)
events := memory.NewUpdateEventStore()
svc := NewService(memory.NewUpdateStateStore(), events)
for _, event := range []domain.UpdateEvent{
{UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 1, PtsCount: 1, Date: 1700000001, Message: domain.Message{ID: 1, OwnerUserID: userID}},
{UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 2, PtsCount: 1, Date: 1700000002, Message: domain.Message{ID: 2, OwnerUserID: userID}},
{UserID: userID, Type: domain.UpdateEventDeleteMessages, Pts: 4, PtsCount: 2, Date: 1700000003, MessageIDs: []int{1, 2}},
} {
if err := events.Append(ctx, userID, event); err != nil {
t.Fatalf("append event pts=%d: %v", event.Pts, err)
}
}
state, err := svc.GetState(ctx, authKeyID, userID)
if err != nil {
t.Fatalf("GetState: %v", err)
}
if state.Pts != 4 {
t.Fatalf("state = %+v, want contiguous pts=4 across delete range", state)
}
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 2})
if err != nil {
t.Fatalf("GetDifference: %v", err)
}
if diff.State.Pts != 4 || len(diff.Events) != 1 {
t.Fatalf("diff = %+v, want one delete event ending at pts=4", diff)
}
got := diff.Events[0]
if got.Type != domain.UpdateEventDeleteMessages || got.Pts != 4 || got.PtsCount != 2 || len(got.MessageIDs) != 2 {
t.Fatalf("delete event = %+v, want pts=4 pts_count=2 ids", got)
}
}
type captureDispatchAppender struct {
*memory.UpdateEventStore
dispatched bool
userID int64
event domain.UpdateEvent
excludeAuthKeyID [8]byte
excludeSessionID int64
}
func (s *captureDispatchAppender) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error {
s.dispatched = true
s.userID = userID
s.event = event
s.excludeAuthKeyID = excludeAuthKeyID
s.excludeSessionID = excludeSessionID
return s.UpdateEventStore.Append(ctx, userID, event)
}