fix: sync stars topup and paid reaction settings

This commit is contained in:
A 2026-07-08 01:19:22 +08:00
parent 4e18d31e16
commit 1f11b0fd1b
12 changed files with 506 additions and 24 deletions

View file

@ -178,6 +178,9 @@ func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) {
if fullChannel.GetPaidReactionsAvailable() {
t.Fatalf("full channel paid reactions = true, want false without paid_enabled flag")
}
if !fullChannel.GetPaidMediaAllowed() {
t.Fatalf("broadcast full channel paid_media_allowed = false, want true for Android paid reaction editor")
}
}
func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
@ -253,6 +256,159 @@ func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
if fullChannel.GetPaidReactionsAvailable() {
t.Fatalf("paid reactions after explicit false = true, want false")
}
if !fullChannel.GetPaidMediaAllowed() {
t.Fatalf("broadcast paid_media_allowed after paid disable = false, want capability preserved")
}
}
func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002204", FirstName: "Owner"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "TDesktop Paid Sentinel",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast channel: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
tdesktopReq := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{
&tg.ReactionPaid{},
&tg.ReactionEmoji{Emoticon: "\U0001f44d"},
&tg.ReactionEmoji{Emoticon: "\u2764"},
&tg.ReactionPaid{},
}},
}
tdesktopReq.SetReactionsLimit(11)
tdesktopReq.SetPaidEnabled(true)
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), tdesktopReq); err != nil {
t.Fatalf("set TDesktop paid sentinel reactions: %v", err)
}
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel after TDesktop sentinel set: %v", err)
}
fullChannel := full.FullChat.(*tg.ChannelFull)
if !fullChannel.GetPaidReactionsAvailable() {
t.Fatalf("paid reactions after TDesktop sentinel set = false, want true")
}
some := mustChannelFullSomeReactions(t, full)
if len(some.Reactions) != 2 {
t.Fatalf("stored reactions after stripping sentinel = %d, want 2", len(some.Reactions))
}
for i, reaction := range some.Reactions {
if _, ok := reaction.(*tg.ReactionPaid); ok {
t.Fatalf("stored reaction[%d] = reactionPaid, want paid state only in paid_reactions_available", i)
}
}
fullCatalog := make([]tg.ReactionClass, 0, domain.MaxChannelReactionTypes+1)
fullCatalog = append(fullCatalog, &tg.ReactionPaid{})
for i := 0; i < domain.MaxChannelReactionTypes; i++ {
fullCatalog = append(fullCatalog, &tg.ReactionEmoji{Emoticon: fmt.Sprintf("r%03d", i)})
}
maxReq := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: fullCatalog},
}
maxReq.SetPaidEnabled(true)
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), maxReq); err != nil {
t.Fatalf("set max normal reactions plus paid sentinel: %v", err)
}
full, err = r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel after max sentinel set: %v", err)
}
some = mustChannelFullSomeReactions(t, full)
if len(some.Reactions) != domain.MaxChannelReactionTypes {
t.Fatalf("stored reactions after max sentinel set = %d, want %d", len(some.Reactions), domain.MaxChannelReactionTypes)
}
}
func TestChannelFullPaidReactionCapabilityOnlyBroadcast(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002202", FirstName: "Owner"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
broadcastCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Paid Reaction Broadcast",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast channel: %v", err)
}
broadcast := broadcastCreated.(*tg.Updates).Chats[0].(*tg.Channel)
broadcastFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
if err != nil {
t.Fatalf("get broadcast full channel: %v", err)
}
broadcastFullChannel := broadcastFull.FullChat.(*tg.ChannelFull)
if !broadcastFullChannel.GetPaidMediaAllowed() {
t.Fatalf("broadcast paid_media_allowed = false, want true")
}
if broadcastFullChannel.GetPaidReactionsAvailable() {
t.Fatalf("broadcast paid_reactions_available = true before paid_enabled, want false")
}
enablePaid := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash},
AvailableReactions: &tg.ChatReactionsAll{},
}
enablePaid.SetPaidEnabled(true)
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enablePaid); err != nil {
t.Fatalf("enable broadcast paid reactions: %v", err)
}
broadcastFull, err = r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
if err != nil {
t.Fatalf("get broadcast full channel after paid enable: %v", err)
}
broadcastFullChannel = broadcastFull.FullChat.(*tg.ChannelFull)
if !broadcastFullChannel.GetPaidMediaAllowed() || !broadcastFullChannel.GetPaidReactionsAvailable() {
t.Fatalf("broadcast flags after enable: paid_media_allowed=%v paid_reactions_available=%v, want both true",
broadcastFullChannel.GetPaidMediaAllowed(), broadcastFullChannel.GetPaidReactionsAvailable())
}
megaCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Paid Reaction Mega",
Megagroup: true,
})
if err != nil {
t.Fatalf("create megagroup: %v", err)
}
mega := megaCreated.(*tg.Updates).Chats[0].(*tg.Channel)
enableMegaPaid := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: &tg.InputPeerChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash},
AvailableReactions: &tg.ChatReactionsAll{},
}
enableMegaPaid.SetPaidEnabled(true)
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enableMegaPaid); err != nil {
t.Fatalf("set megagroup paid_enabled request: %v", err)
}
megaFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash})
if err != nil {
t.Fatalf("get megagroup full channel: %v", err)
}
megaFullChannel := megaFull.FullChat.(*tg.ChannelFull)
if megaFullChannel.GetPaidMediaAllowed() || megaFullChannel.GetPaidReactionsAvailable() {
t.Fatalf("megagroup flags: paid_media_allowed=%v paid_reactions_available=%v, want both false",
megaFullChannel.GetPaidMediaAllowed(), megaFullChannel.GetPaidReactionsAvailable())
}
}
func TestAndroidChannelReactionEditorProjectsDefaultEmojiAsDocuments(t *testing.T) {

View file

@ -107,12 +107,16 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
policy.Type = domain.ChannelReactionPolicyAll
policy.AllowCustom = reactions.AllowCustom
case *tg.ChatReactionsSome:
if len(reactions.Reactions) > domain.MaxChannelReactionTypes {
return domain.ChannelReactionPolicy{}, limitInvalidErr()
}
policy.Type = domain.ChannelReactionPolicySome
seen := make(map[string]struct{}, len(reactions.Reactions))
for _, reaction := range reactions.Reactions {
// TDesktop models the paid toggle as a pseudo reaction in the
// selector and may submit reactionPaid in chatReactionsSome. The
// durable paid state is carried by paid_enabled, not the normal
// reaction whitelist.
if paidReactionSentinel(reaction) {
continue
}
parsed, err := domainMessageReactionFromTL(reaction)
if err != nil {
return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID")
@ -122,6 +126,9 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
if _, ok := seen[key]; ok {
continue
}
if len(seen) >= domain.MaxChannelReactionTypes {
return domain.ChannelReactionPolicy{}, limitInvalidErr()
}
seen[key] = struct{}{}
switch parsed.Type {
case domain.MessageReactionEmoji:
@ -135,3 +142,8 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
}
return policy, nil
}
func paidReactionSentinel(reaction tg.ReactionClass) bool {
paid, ok := reaction.(*tg.ReactionPaid)
return ok && paid != nil
}

View file

@ -569,15 +569,19 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
if ch.ReactionPolicy.Limit > 0 {
full.SetReactionsLimit(ch.ReactionPolicy.Limit)
}
if ch.Broadcast && !ch.Megagroup {
// Android uses paid_media_allowed as the capability gate for showing the
// paid-reaction setting; paid_reactions_available below remains the saved
// on/off state.
full.SetPaidMediaAllowed(true)
full.SetStargiftsAvailable(true)
}
// paid_reactions_available reflects the saved chat policy, not mere broadcast
// capability. Android counts this flag as an extra available reaction in the
// settings row, so advertising it without paid_enabled corrupts the UI count.
if ch.ReactionPolicy.PaidEnabled {
if ch.Broadcast && !ch.Megagroup && ch.ReactionPolicy.PaidEnabled {
full.SetPaidReactionsAvailable(true)
}
if ch.Broadcast && !ch.Megagroup {
full.SetStargiftsAvailable(true)
}
return full
}

View file

@ -126,6 +126,10 @@ func balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") }
func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") }
func starsFormAmountMismatchErr() error { return tgerr.New(406, "STARS_FORM_AMOUNT_MISMATCH") }
func formIDEmptyErr() error { return tgerr.New(400, "FORM_ID_EMPTY") }
func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") }
func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") }

View file

@ -15,7 +15,7 @@ import (
// gift/auction/revenue 第一阶段兼容桩。
func (r *Router) registerPayments(d *tg.ServerDispatcher) {
d.OnPaymentsGetStarsTopupOptions(func(ctx context.Context) ([]tg.StarsTopupOption, error) {
return []tg.StarsTopupOption{}, nil
return devStarsTopupOptions(), nil
})
// premium 订阅赠送 telesrv 不实现无支付流返回空选项。关键作用TDesktop 送礼框
// ShowStarGiftBox 的 ready() 门控要求 getPremiumGiftCodeOptions 成功返回(on_next)才置

View file

@ -16,6 +16,14 @@ import (
func starGiftInvalidErr() error { return tgerr.New(400, "STARGIFT_INVALID") }
func devStarsTopupOptions() []tg.StarsTopupOption {
return []tg.StarsTopupOption{
{Stars: 1000, Currency: "USD", Amount: 99},
{Stars: 2500, Currency: "USD", Amount: 199},
{Stars: 5000, Currency: "USD", Amount: 399},
}
}
// onPaymentsGetStarGifts 返回可购买礼物目录hash 命中返回 NotModified
func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) {
if r.deps.Gifts == nil {
@ -41,13 +49,35 @@ func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.Payme
}, nil
}
// onPaymentsGetPaymentForm 仅处理 inputInvoiceStarGift返回 paymentFormStarGift。
// onPaymentsGetPaymentForm 处理 Stars 专用 invoice
// - inputInvoiceStarGift 返回 paymentFormStarGift。
// - inputInvoiceStars(inputStorePaymentStarsTopup) 返回 paymentFormStars。
//
// 崩溃约束star gift invoice 必须返 paymentFormStarGift#b425cfe1TDesktop 单分支 match
// Invoice.Prices 必须非空DrKLO/TDesktop 读 prices.front())。
// Stars 表单 Invoice.Prices 必须非空且 Currency=XTRDrKLO/TDesktop 读 prices.front())。
func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsGetPaymentFormRequest) (tg.PaymentsPaymentFormClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
purpose, ok := starsTopupPurpose(inv)
if !ok {
return nil, notImplementedErr()
}
if r.deps.Stars == nil {
return nil, notImplementedErr()
}
if _, _, err := r.validateStarsTopupPurpose(ctx, userID, purpose); err != nil {
return nil, err
}
return r.starsTopupPaymentForm(userID, purpose), nil
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
return nil, notImplementedErr()
@ -55,10 +85,6 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
if r.deps.Gifts == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer); err != nil {
return nil, err
}
@ -75,21 +101,29 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
}, nil
}
// onPaymentsSendStarsForm 仅处理 inputInvoiceStarGiftDebit→投递/记账,
// 返回 paymentResult{updates}(含 updateStarsBalance用户礼物还含私聊服务消息。失败补偿退款。
// onPaymentsSendStarsForm 处理 star gift 与 Stars topup
// - star gift: Debit→投递/记账,失败补偿退款。
// - topup: 校验测试包白名单→Credit 本地账本。
//
// 返回 paymentResult{updates}(含 updateStarsBalance用户礼物还含私聊服务消息
// 崩溃约束:必须返回合法 paymentResult{非空 Updates}DrKLO 强转)。
func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSendStarsFormRequest) (tg.PaymentsPaymentResultClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
}
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
if !ok {
return nil, notImplementedErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
if err != nil {
return nil, err
@ -149,6 +183,83 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
func starsTopupPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsTopup, bool) {
if inv == nil {
return nil, false
}
purpose, ok := inv.Purpose.(*tg.InputStorePaymentStarsTopup)
return purpose, ok && purpose != nil
}
func (r *Router) validateStarsTopupPurpose(ctx context.Context, userID int64, purpose *tg.InputStorePaymentStarsTopup) (tg.StarsTopupOption, domain.Peer, error) {
if purpose == nil || purpose.Stars <= 0 || purpose.Amount <= 0 || purpose.Currency == "" {
return tg.StarsTopupOption{}, domain.Peer{}, starsAmountInvalidErr()
}
var matched tg.StarsTopupOption
found := false
for _, opt := range devStarsTopupOptions() {
if opt.Stars == purpose.Stars && opt.Currency == purpose.Currency && opt.Amount == purpose.Amount {
matched = opt
found = true
break
}
}
if !found {
return tg.StarsTopupOption{}, domain.Peer{}, starsFormAmountMismatchErr()
}
peer := domain.Peer{}
if purpose.SpendPurposePeer != nil {
var err error
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, purpose.SpendPurposePeer)
if err != nil {
return tg.StarsTopupOption{}, domain.Peer{}, err
}
}
return matched, peer, nil
}
func (r *Router) starsTopupPaymentForm(userID int64, purpose *tg.InputStorePaymentStarsTopup) *tg.PaymentsPaymentFormStars {
return &tg.PaymentsPaymentFormStars{
FormID: starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount),
BotID: domain.OfficialSystemUserID,
Title: "Telegram Stars",
Description: "telesrv dev Stars top-up",
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: "Telegram Stars", Amount: purpose.Stars}},
},
Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}),
}
}
func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStars) (tg.PaymentsPaymentResultClass, error) {
purpose, ok := starsTopupPurpose(inv)
if !ok {
return nil, notImplementedErr()
}
if formID == 0 {
return nil, formIDEmptyErr()
}
if r.deps.Stars == nil {
return nil, notImplementedErr()
}
_, peer, err := r.validateStarsTopupPurpose(ctx, userID, purpose)
if err != nil {
return nil, err
}
if formID != starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount) {
return nil, starsFormAmountMismatchErr()
}
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
return nil, starsErr(err)
}
balance, err := r.deps.Stars.Credit(ctx, userID, purpose.Stars, domain.StarsReasonTopup, peer, "Stars top-up", "telesrv dev purchase")
if err != nil {
return nil, starsErr(err)
}
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil
}
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) {
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message)
@ -668,6 +779,29 @@ func starGiftFormID(userID, giftID int64) int64 {
return id
}
func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 {
id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253
for _, ch := range currency {
id = id*131 + int64(ch)
}
if id < 0 {
id = ^id
}
if id == 0 {
id = 0x5354
}
return id
}
func starsBalanceUpdates(balance int64, unixDate int64) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance}}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(unixDate),
}
}
func giftPriceLabel(g domain.StarGift) string {
if g.Title != "" {
return g.Title

View file

@ -6,6 +6,7 @@ import (
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
@ -383,3 +384,95 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
t.Fatalf("sender balance = %d, want 1000 unchanged", bal.Balance)
}
}
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)
}
}