owpengram-server/internal/rpc/payments_star_gifts_rpc_test.go

850 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
"github.com/iamxvbaba/td/tlprofile"
appchannels "telesrv/internal/app/channels"
appmessages "telesrv/internal/app/messages"
appstargifts "telesrv/internal/app/stargifts"
appstars "telesrv/internal/app/stars"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) {
t.Helper()
ctx := context.Background()
users := memory.NewUserStore()
dialogs := memory.NewDialogStore()
msgStore := memory.NewMessageStore(dialogs)
channelStore := memory.NewChannelStore()
sender, err := users.Create(ctx, domain.User{AccessHash: 7101, Phone: "15550007101", FirstName: "Sender"})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{AccessHash: 7102, Phone: "15550007102", FirstName: "Recipient"})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
gift := domain.StarGift{
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 50, Title: "Cake",
Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
}
giftStore := memory.NewStarGiftStore()
giftStore.SeedCatalog([]domain.StarGift{gift})
gifts := appstargifts.NewService(giftStore, nil, 2)
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(users),
Messages: appmessages.NewService(msgStore, dialogs),
Channels: appchannels.NewService(channelStore),
Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)),
Gifts: gifts,
}, zaptest.NewLogger(t), clock.System)
return r, sender, recipient, gift
}
type uniqueGiftRPCService struct {
GiftsService
unique domain.UniqueStarGift
}
func (s *uniqueGiftRPCService) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
return s.unique, slug == s.unique.Slug, nil
}
func collectibleRPCAttribute(kind domain.StarGiftCollectibleAttributeKind, id int64, name string) domain.StarGiftCollectibleAttribute {
attribute := domain.StarGiftCollectibleAttribute{Kind: kind, Name: name, RarityPermille: 1000}
if kind == domain.StarGiftCollectibleBackdrop {
attribute.BackdropID = int(id)
attribute.CenterColor = 0x112233
attribute.EdgeColor = 0x223344
attribute.PatternColor = 0x334455
attribute.TextColor = 0xffffff
return attribute
}
attribute.Document = &domain.Document{
ID: id, AccessHash: id + 1, FileReference: []byte("collectible-rpc"), Date: 1700000000,
MimeType: "application/x-tgsticker", Size: 3, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}, {Kind: domain.DocAttrFilename, FileName: "gift.tgs"}},
}
attribute.Animation = &domain.StarGiftAnimation{
SourceName: "gift.tgs", SourceFormat: domain.StarGiftAnimationTGS,
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: make([]byte, 32), Width: 512, Height: 512,
}
attribute.Blob = &domain.FileBlob{LocationKey: "doc:test", Backend: domain.MediaBackendLocalFS, ObjectKey: "test", Size: 3}
return attribute
}
func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleAvailability(t *testing.T) {
historical := domain.StarGift{
ID: 8001, RevisionID: 9001, Stars: 50, ConvertStars: 25, Title: "Historical Cake",
Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker"},
}
saved := domain.SavedStarGift{GiftID: historical.ID, RevisionID: historical.RevisionID, MsgID: 44, Date: 100, ConvertStars: historical.ConvertStars}
availability := map[int64]domain.StarGiftCollectibleAvailability{
historical.ID: {UpgradeStars: 75, SupplyTotal: 500, Issued: 12},
}
projected := tgSavedStarGifts([]domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)
if len(projected) != 1 || !projected[0].CanUpgrade {
t.Fatalf("saved gift = %#v, want current pool to make historical gift upgradable", projected)
}
gift, ok := projected[0].Gift.(*tg.StarGift)
if !ok {
t.Fatalf("saved gift inner = %T, want *tg.StarGift", projected[0].Gift)
}
if gift.Title != historical.Title || gift.Stars != historical.Stars || gift.ConvertStars != historical.ConvertStars {
t.Fatalf("historical snapshot changed: %#v", gift)
}
if upgradeStars, ok := gift.GetUpgradeStars(); !ok || upgradeStars != 75 {
t.Fatalf("upgrade_stars = %d ok=%v, want current price 75", upgradeStars, ok)
}
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
wire := &tg.PaymentsSavedStarGifts{Count: 1, Gifts: projected, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
encoded := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, wire, encoded); err != nil {
t.Fatalf("encode Layer %d saved gift: %v", profile, err)
}
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: encoded.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d saved gift: %v", profile, err)
}
decoded, ok := decodedObject.(*tg.PaymentsSavedStarGifts)
if !ok {
t.Fatalf("decode Layer %d saved gift type = %T", profile, decodedObject)
}
inner, ok := decoded.Gifts[0].Gift.(*tg.StarGift)
if !ok || !decoded.Gifts[0].CanUpgrade || inner.UpgradeStars != 75 {
t.Fatalf("Layer %d projection lost upgrade flags: %#v", profile, decoded.Gifts[0])
}
}
availability[historical.ID] = domain.StarGiftCollectibleAvailability{UpgradeStars: 75, SupplyTotal: 500, Issued: 500}
soldOut := tgSavedStarGifts([]domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)[0]
if soldOut.CanUpgrade {
t.Fatal("sold-out collectible pool must not advertise upgrade")
}
if gift, ok := soldOut.Gift.(*tg.StarGift); !ok {
t.Fatalf("sold-out inner = %T, want *tg.StarGift", soldOut.Gift)
} else if _, ok := gift.GetUpgradeStars(); ok {
t.Fatal("sold-out catalog projection must not expose upgrade_stars")
}
saved.PrepaidUpgradeStars = 75
soldOutPrepaid := tgSavedStarGifts([]domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)[0]
if soldOutPrepaid.CanUpgrade {
t.Fatal("sold-out prepaid gift must not advertise an upgrade the aggregate will reject")
}
if _, ok := soldOutPrepaid.GetUpgradeStars(); ok {
t.Fatal("sold-out prepaid gift must not expose stale prepaid upgrade_stars")
}
}
func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *testing.T) {
r, sender, owner, gift := starGiftTestRouter(t)
ctx := context.Background()
ownerCtx := WithUserID(ctx, owner.ID)
giftService, ok := r.deps.Gifts.(*appstargifts.Service)
if !ok {
t.Fatalf("gift service = %T", r.deps.Gifts)
}
model := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8101, "Aurora")
pattern := collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8102, "Orbit")
backdrop := collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 1, "Midnight")
if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 500, SlugPrefix: "cake",
Models: []domain.StarGiftCollectibleAttribute{model}, Patterns: []domain.StarGiftCollectibleAttribute{pattern},
Backdrops: []domain.StarGiftCollectibleAttribute{backdrop}, Actor: "test", CommandID: "collectible-rpc",
}); err != nil {
t.Fatalf("publish collectible pool: %v", err)
}
if _, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, FromUserID: sender.ID,
GiftID: gift.ID, RevisionID: gift.RevisionID, MsgID: 444, Date: 1700000000, ConvertStars: gift.ConvertStars,
}); err != nil {
t.Fatalf("record upgrade target: %v", err)
}
preview, err := r.onPaymentsGetStarGiftUpgradePreview(ownerCtx, gift.ID)
if err != nil || len(preview.SampleAttributes) != 3 {
t.Fatalf("upgrade preview = %#v err %v", preview, err)
}
invoice := &tg.InputInvoiceStarGiftUpgrade{Stargift: &tg.InputSavedStarGiftUser{MsgID: 444}}
formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
if err != nil {
t.Fatalf("get upgrade payment form: %v", err)
}
form, ok := formClass.(*tg.PaymentsPaymentFormStarGift)
if !ok || form.FormID == 0 || form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 75 {
t.Fatalf("upgrade payment form = %T %#v", formClass, formClass)
}
unique := domain.UniqueStarGift{
ID: 9200000000000001, GiftID: gift.ID, Title: gift.Title, Slug: "cake-1", Num: 1,
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
Model: model, Pattern: pattern, Backdrop: backdrop,
AvailabilityIssued: 1, AvailabilityTotal: 500, KeepOriginalDetails: true,
OriginalFromUserID: sender.ID, OriginalOwner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
OriginalDate: 1700000000, OriginalMessage: "hello",
}
r.deps.Gifts = &uniqueGiftRPCService{GiftsService: r.deps.Gifts, unique: unique}
uniqueResponse, err := r.onPaymentsGetUniqueStarGift(WithUserID(ctx, sender.ID), unique.Slug)
if err != nil {
t.Fatalf("get unique gift = %#v err %v", uniqueResponse, err)
}
uniqueGift, ok := uniqueResponse.Gift.(*tg.StarGiftUnique)
if !ok || uniqueGift.Slug != unique.Slug || len(uniqueGift.Attributes) != 4 || len(uniqueResponse.Users) != 2 {
t.Fatalf("get unique gift = %#v", uniqueResponse)
}
message := domain.Message{Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: unique, FromUserID: sender.ID, Peer: unique.Owner, Upgrade: true, Saved: true,
},
}}}
action, ok := tgMessageServiceAction(message).(*tg.MessageActionStarGiftUnique)
if !ok {
t.Fatalf("unique service action type = %T", tgMessageServiceAction(message))
}
projectedGift, giftOK := action.Gift.(*tg.StarGiftUnique)
if !giftOK || !action.Upgrade || !action.Saved || projectedGift.ID != unique.ID || projectedGift.Slug != unique.Slug {
t.Fatalf("unique service action = %#v", tgMessageServiceAction(message))
}
if peer, ok := action.GetPeer(); !ok {
t.Fatal("unique service action missing owner peer")
} else if user, ok := peer.(*tg.PeerUser); !ok || user.UserID != owner.ID {
t.Fatalf("unique service action peer = %#v", peer)
}
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
responseWire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, uniqueResponse, responseWire); err != nil {
t.Fatalf("encode Layer %d unique response: %v", profile, err)
}
decodedResponseObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: responseWire.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d unique response: %v", profile, err)
}
decodedResponse, ok := decodedResponseObject.(*tg.PaymentsUniqueStarGift)
if !ok {
t.Fatalf("decode Layer %d unique response type = %T", profile, decodedResponseObject)
}
decodedGift, ok := decodedResponse.Gift.(*tg.StarGiftUnique)
if !ok || decodedGift.Slug != unique.Slug || len(decodedGift.Attributes) != 4 {
t.Fatalf("Layer %d unique response lost fields: %#v", profile, decodedResponse.Gift)
}
actionWire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, action, actionWire); err != nil {
t.Fatalf("encode Layer %d unique action: %v", profile, err)
}
decodedActionObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: actionWire.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d unique action: %v", profile, err)
}
decodedAction, ok := decodedActionObject.(*tg.MessageActionStarGiftUnique)
if !ok {
t.Fatalf("decode Layer %d unique action type = %T", profile, decodedActionObject)
}
if decodedActionGift, ok := decodedAction.Gift.(*tg.StarGiftUnique); !ok || !decodedAction.Upgrade || decodedActionGift.Slug != unique.Slug {
t.Fatalf("Layer %d unique action lost fields: %#v", profile, decodedAction)
}
}
}
func TestStarGiftCollectionsCRUDFilterOrderAndPin(t *testing.T) {
r, _, owner, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), owner.ID)
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
for _, msgID := range []int{101, 102} {
if _, err := r.deps.Gifts.RecordSavedGift(context.Background(), domain.SavedStarGift{
Owner: ownerPeer, GiftID: gift.ID, RevisionID: gift.RevisionID,
MsgID: msgID, Date: 1700000000 + msgID, ConvertStars: gift.ConvertStars,
}); err != nil {
t.Fatalf("record saved gift %d: %v", msgID, err)
}
}
ref101 := tg.InputSavedStarGiftClass(&tg.InputSavedStarGiftUser{MsgID: 101})
ref102 := tg.InputSavedStarGiftClass(&tg.InputSavedStarGiftUser{MsgID: 102})
first, err := r.onPaymentsCreateStarGiftCollection(ctx, &tg.PaymentsCreateStarGiftCollectionRequest{
Peer: &tg.InputPeerSelf{}, Title: " Favorites ", Stargift: []tg.InputSavedStarGiftClass{ref101},
})
if err != nil {
t.Fatalf("create first collection: %v", err)
}
second, err := r.onPaymentsCreateStarGiftCollection(ctx, &tg.PaymentsCreateStarGiftCollectionRequest{
Peer: &tg.InputPeerSelf{}, Title: "Archive", Stargift: []tg.InputSavedStarGiftClass{ref102},
})
if err != nil {
t.Fatalf("create second collection: %v", err)
}
if first.Title != "Favorites" || first.GiftsCount != 1 || second.GiftsCount != 1 {
t.Fatalf("created collections = %#v / %#v", first, second)
}
listedClass, err := r.onPaymentsGetStarGiftCollections(ctx, &tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
t.Fatalf("list collections: %v", err)
}
listed, ok := listedClass.(*tg.PaymentsStarGiftCollections)
if !ok || len(listed.Collections) != 2 {
t.Fatalf("list collections = %T %#v, want two", listedClass, listedClass)
}
domainCollections, err := r.deps.Gifts.ListCollections(context.Background(), ownerPeer)
if err != nil {
t.Fatalf("list domain collections: %v", err)
}
if notModified, err := r.onPaymentsGetStarGiftCollections(ctx, &tg.PaymentsGetStarGiftCollectionsRequest{
Peer: &tg.InputPeerSelf{}, Hash: domain.StarGiftCollectionsHash(domainCollections),
}); err != nil {
t.Fatalf("hash list collections: %v", err)
} else if _, ok := notModified.(*tg.PaymentsStarGiftCollectionsNotModified); !ok {
t.Fatalf("hash response = %T, want not modified", notModified)
}
update := &tg.PaymentsUpdateStarGiftCollectionRequest{Peer: &tg.InputPeerSelf{}, CollectionID: first.CollectionID}
update.SetTitle("Best")
update.SetAddStargift([]tg.InputSavedStarGiftClass{ref102})
update.SetOrder([]tg.InputSavedStarGiftClass{ref102, ref101})
updated, err := r.onPaymentsUpdateStarGiftCollection(ctx, update)
if err != nil {
t.Fatalf("update collection: %v", err)
}
if updated.Title != "Best" || updated.GiftsCount != 2 {
t.Fatalf("updated collection = %#v", updated)
}
filteredReq := &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 10}
filteredReq.SetCollectionID(first.CollectionID)
filtered, err := r.onPaymentsGetSavedStarGifts(ctx, filteredReq)
if err != nil {
t.Fatalf("filter saved gifts by collection: %v", err)
}
if filtered.Count != 2 || len(filtered.Gifts) != 2 {
t.Fatalf("filtered gifts count=%d len=%d, want 2/2", filtered.Count, len(filtered.Gifts))
}
for _, saved := range filtered.Gifts {
ids, ok := saved.GetCollectionID()
if !ok || len(ids) == 0 || ids[0] != first.CollectionID {
t.Fatalf("saved gift collection projection = %#v", saved)
}
}
if ok, err := r.onPaymentsToggleStarGiftsPinnedToTop(ctx, &tg.PaymentsToggleStarGiftsPinnedToTopRequest{
Peer: &tg.InputPeerSelf{}, Stargift: []tg.InputSavedStarGiftClass{ref101, ref102},
}); err != nil || !ok {
t.Fatalf("pin gifts = %v err %v", ok, err)
}
pinned, err := r.onPaymentsGetSavedStarGifts(ctx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 10})
if err != nil {
t.Fatalf("list pinned gifts: %v", err)
}
if len(pinned.Gifts) != 2 || !pinned.Gifts[0].PinnedToTop || !pinned.Gifts[1].PinnedToTop {
t.Fatalf("pinned projection = %#v", pinned.Gifts)
}
if ok, err := r.onPaymentsReorderStarGiftCollections(ctx, &tg.PaymentsReorderStarGiftCollectionsRequest{
Peer: &tg.InputPeerSelf{}, Order: []int{second.CollectionID, first.CollectionID},
}); err != nil || !ok {
t.Fatalf("reorder collections = %v err %v", ok, err)
}
reorderedClass, err := r.onPaymentsGetStarGiftCollections(ctx, &tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
t.Fatalf("list reordered collections: %v", err)
}
reordered := reorderedClass.(*tg.PaymentsStarGiftCollections)
if reordered.Collections[0].CollectionID != second.CollectionID {
t.Fatalf("reordered collections = %#v", reordered.Collections)
}
if ok, err := r.onPaymentsDeleteStarGiftCollection(ctx, &tg.PaymentsDeleteStarGiftCollectionRequest{
Peer: &tg.InputPeerSelf{}, CollectionID: first.CollectionID,
}); err != nil || !ok {
t.Fatalf("delete collection = %v err %v", ok, err)
}
afterDelete, err := r.onPaymentsGetSavedStarGifts(ctx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 10})
if err != nil {
t.Fatalf("list gifts after collection delete: %v", err)
}
for _, saved := range afterDelete.Gifts {
if ids, ok := saved.GetCollectionID(); ok {
for _, id := range ids {
if id == first.CollectionID {
t.Fatalf("deleted collection %d leaked in saved gift %#v", id, saved)
}
}
}
}
}
// 完整 star gift sagacatalog → getPaymentForm(paymentFormStarGift) → sendStarsForm(扣费+服务消息
// +paymentResult) → 收礼人 getSavedStarGifts → save/convert。
func TestStarGiftSaga(t *testing.T) {
r, sender, recipient, gift := starGiftTestRouter(t)
ctx := context.Background()
senderCtx := WithUserID(ctx, sender.ID)
recipientCtx := WithUserID(ctx, recipient.ID)
// 1. 目录。
catRes, err := r.onPaymentsGetStarGifts(senderCtx, 0)
if err != nil {
t.Fatalf("getStarGifts: %v", err)
}
cat, ok := catRes.(*tg.PaymentsStarGifts)
if !ok || len(cat.Gifts) != 1 {
t.Fatalf("catalog = %T %+v, want 1 gift", catRes, catRes)
}
if g, ok := cat.Gifts[0].(*tg.StarGift); !ok || g.ID != gift.ID || g.Stars != 50 {
t.Fatalf("catalog gift = %#v, want id %d stars 50", cat.Gifts[0], gift.ID)
}
// 即便 hash 命中也始终回完整目录DrKLO force-stop 后保留 hash 但丢礼物缓存,
// 返 NotModified 会让送礼选择器永远空)。
if again, err := r.onPaymentsGetStarGifts(senderCtx, cat.Hash); err != nil {
t.Fatalf("getStarGifts hash: %v", err)
} else if full, ok := again.(*tg.PaymentsStarGifts); !ok || len(full.Gifts) != 1 {
t.Fatalf("hash match = %T, want full catalog (不返 NotModified)", again)
}
inv := &tg.InputInvoiceStarGift{
Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
GiftID: gift.ID,
}
// 2. getPaymentForm → paymentFormStarGiftXTR + 非空 prices
formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("getPaymentForm: %v", err)
}
form, ok := formRes.(*tg.PaymentsPaymentFormStarGift)
if !ok {
t.Fatalf("form = %T, want *tg.PaymentsPaymentFormStarGift (TDesktop 单分支 match)", formRes)
}
if form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 50 {
t.Fatalf("form invoice = %+v, want XTR + 1 price 50", form.Invoice)
}
// 3. sendStarsForm → paymentResult扣费 + 服务消息 + updateStarsBalance
payRes, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
if err != nil {
t.Fatalf("sendStarsForm: %v", err)
}
pay, ok := payRes.(*tg.PaymentsPaymentResult)
if !ok {
t.Fatalf("pay result = %T, want *tg.PaymentsPaymentResult (DrKLO 强转)", payRes)
}
updates, ok := pay.Updates.(*tg.Updates)
if !ok {
t.Fatalf("pay updates = %T, want *tg.Updates", pay.Updates)
}
hasBalance, hasGiftMsg := false, false
for _, up := range updates.Updates {
switch u := up.(type) {
case *tg.UpdateStarsBalance:
hasBalance = true
if amt, ok := u.Balance.(*tg.StarsAmount); !ok || amt.Amount != 950 {
t.Fatalf("updateStarsBalance = %#v, want 950", u.Balance)
}
case *tg.UpdateNewMessage:
if svc, ok := u.Message.(*tg.MessageService); ok {
if _, ok := svc.Action.(*tg.MessageActionStarGift); ok {
hasGiftMsg = true
}
}
}
}
if !hasBalance || !hasGiftMsg {
t.Fatalf("pay updates: balance=%v giftMsg=%v, want both (崩溃约束:必返合法 Updates)", hasBalance, hasGiftMsg)
}
// 送礼人余额扣 50。
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 950 {
t.Fatalf("sender balance = %d, want 950", bal.Balance)
}
// 4. 收礼人 getSavedStarGifts。
savedRes, err := r.onPaymentsGetSavedStarGifts(recipientCtx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
t.Fatalf("getSavedStarGifts: %v", err)
}
if savedRes.Count != 1 || len(savedRes.Gifts) != 1 {
t.Fatalf("saved gifts = count %d len %d, want 1/1", savedRes.Count, len(savedRes.Gifts))
}
saved := savedRes.Gifts[0]
msgID, ok := saved.GetMsgID()
if !ok || msgID <= 0 {
t.Fatalf("saved gift msg_id = %d ok %v, want positive", msgID, ok)
}
if g, ok := saved.Gift.(*tg.StarGift); !ok || g.ID != gift.ID {
t.Fatalf("saved gift inner = %#v, want gift %d", saved.Gift, gift.ID)
}
if from, ok := saved.GetFromID(); !ok {
t.Fatalf("saved gift from = %v, want sender peer", from)
}
// 4b. 收礼人 userFull 必须带 stargifts_count否则客户端资料页 Gifts 区段不出现)。
fullRes, err := r.onUsersGetFullUser(senderCtx, &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash})
if err != nil {
t.Fatalf("getFullUser: %v", err)
}
if cnt, ok := fullRes.FullUser.GetStargiftsCount(); !ok || cnt != 1 {
t.Fatalf("recipient userFull stargifts_count = %d ok %v, want 1 (资料页 Gifts 门控)", cnt, ok)
}
// 5. saveStarGift隐藏
if ok, err := r.onPaymentsSaveStarGift(recipientCtx, &tg.PaymentsSaveStarGiftRequest{
Unsave: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: msgID},
}); err != nil || !ok {
t.Fatalf("saveStarGift hide = %v err %v", ok, err)
}
// 6. convertStarGift转回 Stars收礼人 +50
recipBefore, _ := r.deps.Stars.GetBalance(ctx, recipient.ID)
if ok, err := r.onPaymentsConvertStarGift(recipientCtx, &tg.InputSavedStarGiftUser{MsgID: msgID}); err != nil || !ok {
t.Fatalf("convertStarGift = %v err %v", ok, err)
}
recipAfter, _ := r.deps.Stars.GetBalance(ctx, recipient.ID)
if recipAfter.Balance != recipBefore.Balance+50 {
t.Fatalf("recipient balance %d -> %d, want +50", recipBefore.Balance, recipAfter.Balance)
}
// 转换后从列表消失。
afterRes, _ := r.onPaymentsGetSavedStarGifts(recipientCtx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}})
if afterRes.Count != 0 {
t.Fatalf("saved gifts after convert = %d, want 0", afterRes.Count)
}
// 重复转换被拒。
if _, err := r.onPaymentsConvertStarGift(recipientCtx, &tg.InputSavedStarGiftUser{MsgID: msgID}); err == nil {
t.Fatalf("double convert should error")
}
}
// 频道 star gift sagachannel peer 能付款发送,但不生成频道历史消息;
// saved gift 用 inputSavedStarGiftChat.saved_id 定位Recent Actions 用 admin log 快照承载。
func TestStarGiftChannelSaga(t *testing.T) {
r, sender, owner, gift := starGiftTestRouter(t)
ctx := context.Background()
senderCtx := WithUserID(ctx, sender.ID)
ownerCtx := WithUserID(ctx, owner.ID)
created, err := r.deps.Channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Gift Channel",
Broadcast: true,
MemberUserIDs: []int64{sender.ID},
Date: 1700001000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channel := created.Channel
channelPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
channelInput := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
giftService := r.deps.Gifts.(*appstargifts.Service)
if _, err := giftService.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 10, SlugPrefix: "channel-cake",
Models: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8201, "Aurora")},
Patterns: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectiblePattern, 8202, "Orbit")},
Backdrops: []domain.StarGiftCollectibleAttribute{collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, 2, "Midnight")},
Actor: "test", CommandID: "channel-collectible-rpc",
}); err != nil {
t.Fatalf("publish channel collectible pool: %v", err)
}
if _, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: &tg.InputInvoiceStarGift{
Peer: channelPeer, GiftID: gift.ID, IncludeUpgrade: true,
}}); err == nil {
t.Fatal("channel include_upgrade must be rejected while channel upgrade is blocked")
}
inv := &tg.InputInvoiceStarGift{
Peer: channelPeer,
GiftID: gift.ID,
}
formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("getPaymentForm(channel): %v", err)
}
form, ok := formRes.(*tg.PaymentsPaymentFormStarGift)
if !ok || form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 {
t.Fatalf("form(channel) = %T %+v, want star gift XTR form", formRes, formRes)
}
payRes, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
if err != nil {
t.Fatalf("sendStarsForm(channel): %v", err)
}
pay, ok := payRes.(*tg.PaymentsPaymentResult)
if !ok {
t.Fatalf("pay result = %T, want *tg.PaymentsPaymentResult", payRes)
}
updates, ok := pay.Updates.(*tg.Updates)
if !ok {
t.Fatalf("pay updates = %T, want *tg.Updates", pay.Updates)
}
var (
hasBalance bool
)
for _, up := range updates.Updates {
switch u := up.(type) {
case *tg.UpdateStarsBalance:
hasBalance = true
if amt, ok := u.Balance.(*tg.StarsAmount); !ok || amt.Amount != 950 {
t.Fatalf("channel updateStarsBalance = %#v, want 950", u.Balance)
}
case *tg.UpdateNewChannelMessage:
t.Fatalf("channel gift must not be pushed as UpdateNewChannelMessage: %#v", u.Message)
}
}
if !hasBalance {
t.Fatalf("channel pay updates: balance=%v, want updateStarsBalance", hasBalance)
}
savedRes, err := r.onPaymentsGetSavedStarGifts(senderCtx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: channelPeer})
if err != nil {
t.Fatalf("getSavedStarGifts(channel): %v", err)
}
if savedRes.Count != 1 || len(savedRes.Gifts) != 1 {
t.Fatalf("channel saved gifts = count %d len %d, want 1/1", savedRes.Count, len(savedRes.Gifts))
}
if savedRes.Gifts[0].CanUpgrade {
t.Fatal("channel saved gift must not advertise upgrade while channel aggregate is blocked")
}
savedID, ok := savedRes.Gifts[0].GetSavedID()
if !ok || savedID <= 0 {
t.Fatalf("saved gift saved_id = %d ok %v, want positive", savedID, ok)
}
if _, ok := savedRes.Gifts[0].GetMsgID(); ok {
t.Fatalf("channel saved gift should not expose inputSavedStarGiftUser.msg_id")
}
nextHistoryID := channel.TopMessageID + 1
history, err := r.onChannelsGetMessages(ownerCtx, &tg.ChannelsGetMessagesRequest{
Channel: channelInput,
ID: []tg.InputMessageClass{&tg.InputMessageID{ID: nextHistoryID}},
})
if err != nil {
t.Fatalf("get channel message after gift payment: %v", err)
}
gotMessages := history.(*tg.MessagesMessages).Messages
if len(gotMessages) != 1 {
t.Fatalf("channel getMessages len = %d, want 1 messageEmpty", len(gotMessages))
}
if _, ok := gotMessages[0].(*tg.MessageEmpty); !ok {
t.Fatalf("channel gift leaked into message history as %T", gotMessages[0])
}
sendFilter := tg.ChannelAdminLogEventsFilter{}
sendFilter.SetSend(true)
adminReq := &tg.ChannelsGetAdminLogRequest{Channel: channelInput, Limit: 10}
adminReq.SetEventsFilter(sendFilter)
adminLog, err := r.onChannelsGetAdminLog(ownerCtx, adminReq)
if err != nil {
t.Fatalf("getAdminLog(channel gift): %v", err)
}
foundAdminGift := false
for _, event := range adminLog.Events {
send, ok := event.Action.(*tg.ChannelAdminLogEventActionSendMessage)
if !ok {
continue
}
svc, ok := send.Message.(*tg.MessageService)
if !ok {
continue
}
action, ok := svc.Action.(*tg.MessageActionStarGift)
if !ok {
continue
}
if got, ok := action.GetSavedID(); !ok || got != savedID {
t.Fatalf("admin log star gift saved_id = %d ok %v, want %d", got, ok, savedID)
}
if peer, ok := action.GetPeer(); !ok {
t.Fatalf("admin log star gift peer missing")
} else if ch, ok := peer.(*tg.PeerChannel); !ok || ch.ChannelID != channel.ID {
t.Fatalf("admin log star gift peer = %#v, want channel %d", peer, channel.ID)
}
foundAdminGift = true
}
if !foundAdminGift {
t.Fatalf("admin log did not include star gift send_message action")
}
oneRes, err := r.onPaymentsGetSavedStarGift(senderCtx, []tg.InputSavedStarGiftClass{
&tg.InputSavedStarGiftChat{Peer: channelPeer, SavedID: savedID},
})
if err != nil || oneRes.Count != 1 || len(oneRes.Gifts) != 1 {
t.Fatalf("getSavedStarGift(channel) count=%d len=%d err=%v, want 1/1", oneRes.Count, len(oneRes.Gifts), err)
}
fullRes, err := r.onChannelsGetFullChannel(ownerCtx, channelInput)
if err != nil {
t.Fatalf("getFullChannel: %v", err)
}
full, ok := fullRes.FullChat.(*tg.ChannelFull)
if !ok {
t.Fatalf("full chat = %T, want *tg.ChannelFull", fullRes.FullChat)
}
if cnt, ok := full.GetStargiftsCount(); !ok || cnt != 1 {
t.Fatalf("channelFull stargifts_count = %d ok %v, want 1", cnt, ok)
}
if !full.GetStargiftsAvailable() {
t.Fatalf("channelFull stargifts_available = false, want true for broadcast channel gift entry")
}
if ok, err := r.onPaymentsSaveStarGift(ownerCtx, &tg.PaymentsSaveStarGiftRequest{
Unsave: true,
Stargift: &tg.InputSavedStarGiftChat{Peer: channelPeer, SavedID: savedID},
}); err != nil || !ok {
t.Fatalf("saveStarGift(channel hide) = %v err %v", ok, err)
}
hiddenRes, err := r.onPaymentsGetSavedStarGifts(senderCtx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: channelPeer, ExcludeUnsaved: true})
if err != nil || hiddenRes.Count != 0 || len(hiddenRes.Gifts) != 0 {
t.Fatalf("channel excludeUnsaved after hide = count %d len %d err %v, want 0/0", hiddenRes.Count, len(hiddenRes.Gifts), err)
}
}
// 余额不足 → sendStarsForm 返回 BALANCE_TOO_LOW不发礼、不扣费
func TestStarGiftInsufficientBalance(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
dialogs := memory.NewDialogStore()
msgStore := memory.NewMessageStore(dialogs)
sender, _ := users.Create(ctx, domain.User{AccessHash: 7201, Phone: "15550007201", FirstName: "Poor"})
recipient, _ := users.Create(ctx, domain.User{AccessHash: 7202, Phone: "15550007202", FirstName: "Rich"})
gift := domain.StarGift{ID: 8002, RevisionID: 9002, Stars: 5000, ConvertStars: 5000, Title: "Expensive",
Sticker: domain.Document{ID: 701, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}}
giftStore := memory.NewStarGiftStore()
giftStore.SeedCatalog([]domain.StarGift{gift})
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(users),
Messages: appmessages.NewService(msgStore, dialogs),
Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)), // < 5000
Gifts: appstargifts.NewService(giftStore, nil, 2),
}, zaptest.NewLogger(t), clock.System)
senderCtx := WithUserID(ctx, sender.ID)
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: starGiftFormID(sender.ID, peer, gift), Invoice: inv}); err == nil {
t.Fatalf("over-budget gift should error BALANCE_TOO_LOW")
}
// 余额未变。
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 1000 {
t.Fatalf("sender balance = %d, want 1000 unchanged", bal.Balance)
}
}
func TestStarGiftFormBindsCatalogRevisionAndPrice(t *testing.T) {
r, sender, recipient, gift := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), sender.ID)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
base := starGiftFormID(sender.ID, peer, gift)
changedRevision := gift
changedRevision.RevisionID++
changedPrice := gift
changedPrice.Stars++
changedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID + 1}
if base == starGiftFormID(sender.ID, peer, changedRevision) || base == starGiftFormID(sender.ID, peer, changedPrice) || base == starGiftFormID(sender.ID, changedPeer, gift) {
t.Fatal("star gift form id must bind revision, price and recipient")
}
inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID}
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: base + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("bad form err=%v", err)
}
}
func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) {
r, sender, _, _ := starGiftTestRouter(t)
ctx := context.Background()
senderCtx := WithUserID(ctx, sender.ID)
opt := devStarsTopupOptions()[1]
inv := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
Stars: opt.Stars,
Currency: opt.Currency,
Amount: opt.Amount,
}}
formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if err != nil {
t.Fatalf("getPaymentForm topup: %v", err)
}
form, ok := formRes.(*tg.PaymentsPaymentFormStars)
if !ok {
t.Fatalf("form = %T, want *tg.PaymentsPaymentFormStars", formRes)
}
if form.FormID != starsTopupFormID(sender.ID, opt.Stars, opt.Currency, opt.Amount) {
t.Fatalf("form id = %d, want deterministic topup id", form.FormID)
}
if form.BotID != domain.OfficialSystemUserID || len(form.Users) != 1 {
t.Fatalf("form bot/users = %d/%d, want official system user", form.BotID, len(form.Users))
}
if form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != opt.Stars {
t.Fatalf("form invoice = %+v, want XTR + 1 price %d", form.Invoice, opt.Stars)
}
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("sendStarsForm bad form err = %v, want STARS_FORM_AMOUNT_MISMATCH", err)
}
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 1000 {
t.Fatalf("balance after bad form = %d, want 1000 unchanged", bal.Balance)
}
payRes, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
if err != nil {
t.Fatalf("sendStarsForm topup: %v", err)
}
pay, ok := payRes.(*tg.PaymentsPaymentResult)
if !ok {
t.Fatalf("pay result = %T, want *tg.PaymentsPaymentResult", payRes)
}
updates, ok := pay.Updates.(*tg.Updates)
if !ok {
t.Fatalf("pay updates = %T, want *tg.Updates", pay.Updates)
}
foundBalance := false
for _, up := range updates.Updates {
if balance, ok := up.(*tg.UpdateStarsBalance); ok {
foundBalance = true
if amt, ok := balance.Balance.(*tg.StarsAmount); !ok || amt.Amount != 3500 {
t.Fatalf("updateStarsBalance = %#v, want 3500", balance.Balance)
}
}
}
if !foundBalance {
t.Fatalf("payment updates missing updateStarsBalance: %#v", updates.Updates)
}
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 3500 {
t.Fatalf("balance after topup = %d, want 3500", bal.Balance)
}
page, err := r.deps.Stars.ListTransactions(ctx, sender.ID, "", 10)
if err != nil {
t.Fatalf("list transactions: %v", err)
}
hasTopup := false
for _, tx := range page.Transactions {
if tx.Reason == domain.StarsReasonTopup && tx.Amount == opt.Stars {
hasTopup = true
}
}
if !hasTopup {
t.Fatalf("transactions missing topup %d: %+v", opt.Stars, page.Transactions)
}
}
func TestStarsTopupRejectsUnlistedAmount(t *testing.T) {
r, sender, _, _ := starGiftTestRouter(t)
ctx := WithUserID(context.Background(), sender.ID)
inv := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
Stars: 2501,
Currency: "USD",
Amount: 199,
}}
_, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
if !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("getPaymentForm unlisted err = %v, want STARS_FORM_AMOUNT_MISMATCH", err)
}
}