fix: sync harden star gift upgrade projections
This commit is contained in:
parent
ee74d941bb
commit
35d3908660
17 changed files with 772 additions and 40 deletions
|
|
@ -166,4 +166,10 @@ func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testi
|
|||
if !pattern.Attributes[1].TextColor {
|
||||
t.Fatalf("pattern render attribute = %+v, want text_color", pattern.Attributes[1])
|
||||
}
|
||||
preview, found, err := svc.CollectiblePreviewSample(ctx, result.Catalog.Gift.ID)
|
||||
if err != nil || !found || len(preview.Models) != 2 || len(preview.Patterns) != 2 || len(preview.Backdrops) != 2 ||
|
||||
preview.Models[0].Animation == nil || len(preview.Models[0].Animation.JSON) != 0 ||
|
||||
preview.Patterns[0].Animation == nil || len(preview.Patterns[0].Animation.JSON) != 0 {
|
||||
t.Fatalf("collectible preview sample = found:%v err:%v value:%+v", found, err, preview)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -442,10 +442,22 @@ func collectibleDocumentAttributes(kind domain.StarGiftCollectibleAttributeKind)
|
|||
}
|
||||
|
||||
func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
return s.collectiblePreview(ctx, giftID, 0)
|
||||
}
|
||||
|
||||
// CollectiblePreviewSample returns the small randomized working set consumed by official-client
|
||||
// upgrade rollers. The complete published pool remains available through CollectiblePreview for
|
||||
// payments.getStarGiftUpgradeAttributes and the admin editor.
|
||||
func (s *Service) CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
const attributesPerKind = 3
|
||||
return s.collectiblePreview(ctx, giftID, attributesPerKind)
|
||||
}
|
||||
|
||||
func (s *Service) collectiblePreview(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftUpgradePreview, bool, error) {
|
||||
if s == nil || s.store == nil || giftID <= 0 {
|
||||
return domain.StarGiftUpgradePreview{}, false, nil
|
||||
}
|
||||
revision, ok, err := s.store.ActiveCollectibleRevision(ctx, giftID)
|
||||
revision, ok, err := s.store.ActiveCollectibleProjection(ctx, giftID, samplePerKind)
|
||||
if err != nil || !ok || !revision.Published {
|
||||
return domain.StarGiftUpgradePreview{}, false, err
|
||||
}
|
||||
|
|
@ -899,9 +911,11 @@ func (s *Service) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs
|
|||
|
||||
func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) {
|
||||
if gift.UniqueGiftID == 0 && gift.PrepaidUpgradeStars == 0 && gift.PrepaidUpgradeHash == "" && s.store != nil {
|
||||
if revision, ok, err := s.store.ActiveCollectibleRevision(ctx, gift.GiftID); err != nil {
|
||||
availability, err := s.store.CollectibleAvailability(ctx, []int64{gift.GiftID})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
} else if ok && revision.Published && revision.Issued < revision.SupplyTotal {
|
||||
}
|
||||
if current, ok := availability[gift.GiftID]; ok && current.Issued < current.SupplyTotal {
|
||||
var token [32]byte
|
||||
if _, err := rand.Read(token[:]); err != nil {
|
||||
return 0, fmt.Errorf("generate prepaid star gift upgrade hash: %w", err)
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
|
|||
Peers: tgPeerList(shared.Peers),
|
||||
}
|
||||
case domain.MessageServiceActionStarGift:
|
||||
return tgMessageActionStarGift(m.ServiceAction.StarGift)
|
||||
return tgMessageActionStarGiftForViewer(m.ServiceAction.StarGift, msg.OwnerUserID)
|
||||
case domain.MessageServiceActionStarGiftUnique:
|
||||
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
|
||||
case domain.MessageServiceActionStarGiftOffer:
|
||||
|
|
|
|||
|
|
@ -1118,6 +1118,7 @@ type GiftsService interface {
|
|||
GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error)
|
||||
GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error)
|
||||
CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error)
|
||||
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
|
||||
UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error)
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID
|
|||
if giftID <= 0 || r.deps.Gifts == nil {
|
||||
return nil, starGiftInvalidErr()
|
||||
}
|
||||
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, giftID)
|
||||
preview, found, err := r.deps.Gifts.CollectiblePreviewSample(ctx, giftID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -876,7 +876,7 @@ func (r *Router) tgSavedStarGiftsResponse(ctx context.Context, viewerUserID int6
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projected := tgSavedStarGifts(gifts, catalog, availability)
|
||||
projected := tgSavedStarGifts(viewerUserID, gifts, catalog, availability)
|
||||
out := &tg.PaymentsSavedStarGifts{
|
||||
Count: count,
|
||||
Gifts: projected,
|
||||
|
|
@ -990,6 +990,30 @@ func tgStarGift(g domain.StarGift) *tg.StarGift {
|
|||
}
|
||||
|
||||
// tgMessageActionStarGift 把礼物服务消息载荷投影为 messageActionStarGift。
|
||||
func tgMessageActionStarGiftForViewer(in *domain.MessageStarGiftAction, viewerUserID int64) tg.MessageActionClass {
|
||||
if in == nil {
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
ownerUserID := in.PeerUserID
|
||||
if ownerUserID == 0 && in.To.Type == domain.PeerTypeUser {
|
||||
ownerUserID = in.To.ID
|
||||
}
|
||||
if ownerUserID <= 0 || viewerUserID <= 0 {
|
||||
return tgMessageActionStarGift(in)
|
||||
}
|
||||
projected := *in
|
||||
if viewerUserID == ownerUserID {
|
||||
// The owner upgrades through InputSavedStarGift. Exposing the separate
|
||||
// prepayment hash makes DrKLO prefer the wrong invoice family and use the
|
||||
// private dialog peer (the sender) as the alleged gift owner.
|
||||
projected.PrepaidUpgradeHash = ""
|
||||
} else {
|
||||
// Telegram defines messageActionStarGift.can_upgrade as receiver-only.
|
||||
projected.CanUpgrade = false
|
||||
}
|
||||
return tgMessageActionStarGift(&projected)
|
||||
}
|
||||
|
||||
func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionClass {
|
||||
if in == nil {
|
||||
return &tg.MessageActionEmpty{}
|
||||
|
|
@ -1088,7 +1112,7 @@ func (r *Router) resolveStarGiftCatalog(ctx context.Context, gifts []domain.Save
|
|||
}
|
||||
|
||||
// tgSavedStarGifts 把已收到礼物实例投影为 []tg.SavedStarGift。
|
||||
func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.StarGift, availability map[int64]domain.StarGiftCollectibleAvailability) []tg.SavedStarGift {
|
||||
func tgSavedStarGifts(viewerUserID int64, gifts []domain.SavedStarGift, catalog map[int64]domain.StarGift, availability map[int64]domain.StarGiftCollectibleAvailability) []tg.SavedStarGift {
|
||||
out := make([]tg.SavedStarGift, 0, len(gifts))
|
||||
for _, g := range gifts {
|
||||
item := tg.SavedStarGift{
|
||||
|
|
@ -1126,7 +1150,11 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
|
|||
item.SetUpgradeStars(g.PrepaidUpgradeStars)
|
||||
item.CanUpgrade = true
|
||||
}
|
||||
if g.PrepaidUpgradeHash != "" && g.PrepaidUpgradeStars == 0 && canIssue {
|
||||
// This hash starts the separate "prepay someone else's upgrade"
|
||||
// invoice. The owner must use InputSavedStarGift instead; otherwise
|
||||
// DrKLO prefers the hash path and substitutes the private dialog peer.
|
||||
ownerIsViewer := g.Owner.Type == domain.PeerTypeUser && g.Owner.ID == viewerUserID
|
||||
if g.PrepaidUpgradeHash != "" && g.PrepaidUpgradeStars == 0 && canIssue && !ownerIsViewer {
|
||||
item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
|
|
@ -304,7 +305,7 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
|
|||
historical.ID: {UpgradeStars: 75, SupplyTotal: 500, Issued: 12},
|
||||
}
|
||||
|
||||
projected := tgSavedStarGifts([]domain.SavedStarGift{saved}, map[int64]domain.StarGift{historical.RevisionID: historical}, availability)
|
||||
projected := tgSavedStarGifts(0, []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)
|
||||
}
|
||||
|
|
@ -339,7 +340,7 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
|
|||
}
|
||||
|
||||
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]
|
||||
soldOut := tgSavedStarGifts(0, []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")
|
||||
}
|
||||
|
|
@ -349,7 +350,7 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
|
|||
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]
|
||||
soldOutPrepaid := tgSavedStarGifts(0, []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")
|
||||
}
|
||||
|
|
@ -375,7 +376,7 @@ func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) {
|
|||
CanExportAt: exportAt, TransferStars: 25, CanTransferAt: transferAt, CanResellAt: resellAt,
|
||||
DropOriginalDetailsStars: 30, CanCraftAt: readyAt,
|
||||
}
|
||||
projected := tgSavedStarGifts([]domain.SavedStarGift{saved}, nil, nil)
|
||||
projected := tgSavedStarGifts(0, []domain.SavedStarGift{saved}, nil, nil)
|
||||
if len(projected) != 1 {
|
||||
t.Fatalf("saved lifecycle projection count = %d", len(projected))
|
||||
}
|
||||
|
|
@ -401,7 +402,7 @@ func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) {
|
|||
}
|
||||
}
|
||||
assertLifecycle(t, projected[0])
|
||||
zero := tgSavedStarGifts([]domain.SavedStarGift{{
|
||||
zero := tgSavedStarGifts(0, []domain.SavedStarGift{{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, GiftID: giftID, RevisionID: revision,
|
||||
MsgID: 45, Date: 101, UniqueGiftID: unique.ID, Unique: &unique,
|
||||
}}, nil, nil)[0]
|
||||
|
|
@ -427,7 +428,7 @@ func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) {
|
|||
channelSaved.Owner = domain.Peer{Type: domain.PeerTypeChannel, ID: 8102}
|
||||
channelSaved.MsgID = 0
|
||||
channelSaved.SavedID = 51
|
||||
channelProjected := tgSavedStarGifts([]domain.SavedStarGift{channelSaved}, nil, nil)[0]
|
||||
channelProjected := tgSavedStarGifts(0, []domain.SavedStarGift{channelSaved}, nil, nil)[0]
|
||||
if _, ok := channelProjected.GetCanCraftAt(); ok {
|
||||
t.Fatal("channel can_craft_at must be absent until channel Craft is executable")
|
||||
}
|
||||
|
|
@ -584,6 +585,59 @@ func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing
|
|||
}
|
||||
}
|
||||
|
||||
func TestStarGiftPrepaidUpgradeProjectionIsViewerScoped(t *testing.T) {
|
||||
const (
|
||||
senderID = int64(7101)
|
||||
ownerID = int64(7102)
|
||||
giftID = int64(8101)
|
||||
revision = int64(9101)
|
||||
)
|
||||
action := &domain.MessageStarGiftAction{
|
||||
GiftID: giftID, PeerUserID: ownerID, To: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID},
|
||||
CanUpgrade: true, PrepaidUpgradeHash: "prepaid-upgrade-hash-0123456789",
|
||||
}
|
||||
ownerMessage := domain.Message{
|
||||
ID: 20, OwnerUserID: ownerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID},
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGift, StarGift: action,
|
||||
}},
|
||||
}
|
||||
ownerAction := tgMessage(ownerMessage).(*tg.MessageService).Action.(*tg.MessageActionStarGift)
|
||||
if !ownerAction.CanUpgrade {
|
||||
t.Fatal("owner message lost receiver-only can_upgrade")
|
||||
}
|
||||
if hash, ok := ownerAction.GetPrepaidUpgradeHash(); ok || hash != "" {
|
||||
t.Fatalf("owner message exposed prepaid hash %q set=%v", hash, ok)
|
||||
}
|
||||
|
||||
senderMessage := ownerMessage
|
||||
senderMessage.OwnerUserID = senderID
|
||||
senderMessage.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}
|
||||
senderMessage.Out = true
|
||||
senderAction := tgMessage(senderMessage).(*tg.MessageService).Action.(*tg.MessageActionStarGift)
|
||||
if senderAction.CanUpgrade {
|
||||
t.Fatal("sender message exposed receiver-only can_upgrade")
|
||||
}
|
||||
if hash, ok := senderAction.GetPrepaidUpgradeHash(); !ok || hash != action.PrepaidUpgradeHash {
|
||||
t.Fatalf("sender message prepaid hash = %q set=%v", hash, ok)
|
||||
}
|
||||
|
||||
saved := domain.SavedStarGift{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}, GiftID: giftID, RevisionID: revision,
|
||||
MsgID: 20, Date: 100, PrepaidUpgradeHash: action.PrepaidUpgradeHash,
|
||||
}
|
||||
catalog := map[int64]domain.StarGift{revision: {ID: giftID, RevisionID: revision}}
|
||||
availability := map[int64]domain.StarGiftCollectibleAvailability{giftID: {UpgradeStars: 25, SupplyTotal: 10}}
|
||||
ownerSaved := tgSavedStarGifts(ownerID, []domain.SavedStarGift{saved}, catalog, availability)[0]
|
||||
if hash, ok := ownerSaved.GetPrepaidUpgradeHash(); ok || hash != "" {
|
||||
t.Fatalf("owner saved gift exposed prepaid hash %q set=%v", hash, ok)
|
||||
}
|
||||
viewerSaved := tgSavedStarGifts(senderID, []domain.SavedStarGift{saved}, catalog, availability)[0]
|
||||
if hash, ok := viewerSaved.GetPrepaidUpgradeHash(); !ok || hash != action.PrepaidUpgradeHash {
|
||||
t.Fatalf("non-owner saved gift prepaid hash = %q set=%v", hash, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftUpgradeRPCReplaysCommittedReceiptAfterTerminalTransition(t *testing.T) {
|
||||
r, sender, owner, gift := starGiftTestRouter(t)
|
||||
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||||
|
|
@ -761,6 +815,76 @@ func TestStarGiftCollectiblePreviewUpgradeFormUniqueAndServiceProjection(t *test
|
|||
}
|
||||
}
|
||||
|
||||
func TestStarGiftUpgradePreviewBoundsRandomSampleWithoutShrinkingFullAttributes(t *testing.T) {
|
||||
r, _, owner, gift := starGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), owner.ID)
|
||||
giftService, ok := r.deps.Gifts.(*appstargifts.Service)
|
||||
if !ok {
|
||||
t.Fatalf("gift service = %T", r.deps.Gifts)
|
||||
}
|
||||
|
||||
models := make([]domain.StarGiftCollectibleAttribute, 0, 7)
|
||||
patterns := make([]domain.StarGiftCollectibleAttribute, 0, 6)
|
||||
backdrops := make([]domain.StarGiftCollectibleAttribute, 0, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
models = append(models, collectibleRPCAttribute(domain.StarGiftCollectibleModel, int64(8200+i), fmt.Sprintf("Model %d", i)))
|
||||
patterns = append(patterns, collectibleRPCAttribute(domain.StarGiftCollectiblePattern, int64(8300+i), fmt.Sprintf("Pattern %d", i)))
|
||||
backdrops = append(backdrops, collectibleRPCAttribute(domain.StarGiftCollectibleBackdrop, int64(8400+i), fmt.Sprintf("Backdrop %d", i)))
|
||||
}
|
||||
crafted := collectibleRPCAttribute(domain.StarGiftCollectibleModel, 8299, "Crafted")
|
||||
crafted.Crafted = true
|
||||
crafted.RarityKind = domain.StarGiftRarityLegendary
|
||||
crafted.RarityPermille = 0
|
||||
models = append(models, crafted)
|
||||
if _, err := giftService.PublishCollectibleRevision(context.Background(), domain.StarGiftCollectibleWrite{
|
||||
GiftID: gift.ID, UpgradeStars: 75, SupplyTotal: 500, SlugPrefix: "bounded-preview",
|
||||
Models: models, Patterns: patterns, Backdrops: backdrops, Actor: "test", CommandID: "bounded-preview",
|
||||
}); err != nil {
|
||||
t.Fatalf("publish collectible pool: %v", err)
|
||||
}
|
||||
|
||||
preview, err := r.onPaymentsGetStarGiftUpgradePreview(ctx, gift.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get bounded upgrade preview: %v", err)
|
||||
}
|
||||
counts := map[domain.StarGiftCollectibleAttributeKind]int{}
|
||||
identities := map[string]struct{}{}
|
||||
for _, attribute := range preview.SampleAttributes {
|
||||
var kind domain.StarGiftCollectibleAttributeKind
|
||||
var identity string
|
||||
switch value := attribute.(type) {
|
||||
case *tg.StarGiftAttributeModel:
|
||||
kind = domain.StarGiftCollectibleModel
|
||||
if value.Crafted {
|
||||
t.Fatal("ordinary upgrade preview included crafted model")
|
||||
}
|
||||
identity = fmt.Sprintf("model:%d", value.Document.GetID())
|
||||
case *tg.StarGiftAttributePattern:
|
||||
kind = domain.StarGiftCollectiblePattern
|
||||
identity = fmt.Sprintf("pattern:%d", value.Document.GetID())
|
||||
case *tg.StarGiftAttributeBackdrop:
|
||||
kind = domain.StarGiftCollectibleBackdrop
|
||||
identity = fmt.Sprintf("backdrop:%d", value.BackdropID)
|
||||
default:
|
||||
t.Fatalf("preview attribute = %T", attribute)
|
||||
}
|
||||
if _, duplicate := identities[identity]; duplicate {
|
||||
t.Fatalf("duplicate preview identity %q", identity)
|
||||
}
|
||||
identities[identity] = struct{}{}
|
||||
counts[kind]++
|
||||
}
|
||||
if len(preview.SampleAttributes) != 9 || counts[domain.StarGiftCollectibleModel] != 3 ||
|
||||
counts[domain.StarGiftCollectiblePattern] != 3 || counts[domain.StarGiftCollectibleBackdrop] != 3 {
|
||||
t.Fatalf("bounded preview count = %d kinds=%v, want three per kind", len(preview.SampleAttributes), counts)
|
||||
}
|
||||
|
||||
all, err := r.onPaymentsGetStarGiftUpgradeAttributes(ctx, gift.ID)
|
||||
if err != nil || len(all.Attributes) != 19 {
|
||||
t.Fatalf("complete upgrade attributes = %d err=%v, want 19", len(all.Attributes), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarGiftCollectionsCRUDFilterOrderAndPin(t *testing.T) {
|
||||
r, _, owner, gift := starGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), owner.ID)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package memory
|
|||
|
||||
import (
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -253,6 +254,20 @@ func (s *StarGiftStore) ActiveCollectibleRevision(_ context.Context, giftID int6
|
|||
return cloneCollectibleRevision(revision), ok, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ActiveCollectibleProjection(_ context.Context, giftID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
revision, ok := s.collectibles[giftID]
|
||||
if !ok {
|
||||
return domain.StarGiftCollectibleRevision{}, false, nil
|
||||
}
|
||||
projection := cloneCollectibleRevision(revision)
|
||||
projection.Models = projectCollectibleAttributes(projection.Models, domain.StarGiftCollectibleModel, samplePerKind)
|
||||
projection.Patterns = projectCollectibleAttributes(projection.Patterns, domain.StarGiftCollectiblePattern, samplePerKind)
|
||||
projection.Backdrops = projectCollectibleAttributes(projection.Backdrops, domain.StarGiftCollectibleBackdrop, samplePerKind)
|
||||
return projection, true, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CollectibleAvailability(_ context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -849,6 +864,34 @@ func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.Star
|
|||
return out
|
||||
}
|
||||
|
||||
func projectCollectibleAttributes(in []domain.StarGiftCollectibleAttribute, kind domain.StarGiftCollectibleAttributeKind, samplePerKind int) []domain.StarGiftCollectibleAttribute {
|
||||
out := in
|
||||
if samplePerKind > 0 {
|
||||
out = make([]domain.StarGiftCollectibleAttribute, 0, len(in))
|
||||
for _, attribute := range in {
|
||||
if attribute.RarityKind != domain.StarGiftRarityPermille || attribute.RarityPermille <= 0 ||
|
||||
(kind == domain.StarGiftCollectibleModel && attribute.Crafted) {
|
||||
continue
|
||||
}
|
||||
out = append(out, attribute)
|
||||
}
|
||||
for i := 0; i < len(out) && i < samplePerKind; i++ {
|
||||
j := i + rand.IntN(len(out)-i)
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
if len(out) > samplePerKind {
|
||||
out = out[:samplePerKind]
|
||||
}
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].Animation != nil {
|
||||
out[i].Animation.JSON = nil
|
||||
out[i].Animation.TGS = nil
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStarGiftCollections(in []domain.StarGiftCollection) []domain.StarGiftCollection {
|
||||
out := make([]domain.StarGiftCollection, len(in))
|
||||
for i, collection := range in {
|
||||
|
|
|
|||
|
|
@ -149,15 +149,9 @@ UPDATE star_gift_catalog SET collectible_revision_id=$2, updated_at=now() WHERE
|
|||
}
|
||||
|
||||
func (s *StarGiftStore) ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error) {
|
||||
var revisionID int64
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT collectible_revision_id FROM star_gift_catalog
|
||||
WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisionID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.StarGiftCollectibleRevision{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, false, fmt.Errorf("get active collectible revision: %w", err)
|
||||
revisionID, ok, err := activeCollectibleRevisionID(ctx, s.db, giftID)
|
||||
if err != nil || !ok {
|
||||
return domain.StarGiftCollectibleRevision{}, ok, err
|
||||
}
|
||||
revision, err := collectibleRevisionByID(ctx, s.db, revisionID)
|
||||
if err != nil {
|
||||
|
|
@ -166,6 +160,32 @@ WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisio
|
|||
return revision, true, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ActiveCollectibleProjection(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, bool, error) {
|
||||
revisionID, ok, err := activeCollectibleRevisionID(ctx, s.db, giftID)
|
||||
if err != nil || !ok {
|
||||
return domain.StarGiftCollectibleRevision{}, ok, err
|
||||
}
|
||||
revision, err := collectibleRevisionProjectionByID(ctx, s.db, revisionID, samplePerKind)
|
||||
if err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, false, err
|
||||
}
|
||||
return revision, true, nil
|
||||
}
|
||||
|
||||
func activeCollectibleRevisionID(ctx context.Context, db sqlcgen.DBTX, giftID int64) (int64, bool, error) {
|
||||
var revisionID int64
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT collectible_revision_id FROM star_gift_catalog
|
||||
WHERE gift_id=$1 AND collectible_revision_id IS NOT NULL`, giftID).Scan(&revisionID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("get active collectible revision: %w", err)
|
||||
}
|
||||
return revisionID, true, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
|
||||
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
|
||||
if len(giftIDs) == 0 {
|
||||
|
|
@ -195,6 +215,19 @@ WHERE c.gift_id=ANY($1) AND r.status='published'`, giftIDs)
|
|||
}
|
||||
|
||||
func collectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64) (domain.StarGiftCollectibleRevision, error) {
|
||||
return readCollectibleRevisionByID(ctx, db, revisionID, collectibleRevisionReadOptions{includeAnimationJSON: true})
|
||||
}
|
||||
|
||||
func collectibleRevisionProjectionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, error) {
|
||||
return readCollectibleRevisionByID(ctx, db, revisionID, collectibleRevisionReadOptions{samplePerKind: samplePerKind})
|
||||
}
|
||||
|
||||
type collectibleRevisionReadOptions struct {
|
||||
includeAnimationJSON bool
|
||||
samplePerKind int
|
||||
}
|
||||
|
||||
func readCollectibleRevisionByID(ctx context.Context, db sqlcgen.DBTX, revisionID int64, options collectibleRevisionReadOptions) (domain.StarGiftCollectibleRevision, error) {
|
||||
var revision domain.StarGiftCollectibleRevision
|
||||
var status string
|
||||
var publishedAt pgtype.Timestamptz
|
||||
|
|
@ -213,40 +246,67 @@ FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan(
|
|||
revision.PublishedAt = publishedAt.Time
|
||||
}
|
||||
var err error
|
||||
if revision.Models, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectibleModel); err != nil {
|
||||
if revision.Models, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectibleModel, options); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
if revision.Patterns, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectiblePattern); err != nil {
|
||||
if revision.Patterns, err = listAnimatedCollectibleAttributes(ctx, db, revisionID, domain.StarGiftCollectiblePattern, options); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
if revision.Backdrops, err = listCollectibleBackdrops(ctx, db, revisionID); err != nil {
|
||||
if revision.Backdrops, err = listCollectibleBackdrops(ctx, db, revisionID, options); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, revisionID int64, kind domain.StarGiftCollectibleAttributeKind) ([]domain.StarGiftCollectibleAttribute, error) {
|
||||
func listAnimatedCollectibleAttributes(ctx context.Context, db sqlcgen.DBTX, revisionID int64, kind domain.StarGiftCollectibleAttributeKind, options collectibleRevisionReadOptions) ([]domain.StarGiftCollectibleAttribute, error) {
|
||||
table := "star_gift_collectible_models"
|
||||
if kind == domain.StarGiftCollectiblePattern {
|
||||
table = "star_gift_collectible_patterns"
|
||||
} else if kind != domain.StarGiftCollectibleModel {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
craftedExpression := "false"
|
||||
if kind == domain.StarGiftCollectibleModel {
|
||||
craftedExpression = "a.crafted"
|
||||
}
|
||||
animationJSONExpression := "''::text"
|
||||
if options.includeAnimationJSON {
|
||||
animationJSONExpression = "a.animation_json::text"
|
||||
}
|
||||
prefix := ""
|
||||
from := fmt.Sprintf("%s a JOIN documents d ON d.id=a.document_id", table)
|
||||
args := []any{revisionID}
|
||||
if options.samplePerKind > 0 {
|
||||
extra := ""
|
||||
if kind == domain.StarGiftCollectibleModel {
|
||||
extra = " AND NOT crafted"
|
||||
}
|
||||
prefix = fmt.Sprintf(`WITH picked AS MATERIALIZED (
|
||||
SELECT id FROM %s
|
||||
WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0%s
|
||||
ORDER BY random()
|
||||
LIMIT $2
|
||||
)`, table, extra)
|
||||
from = fmt.Sprintf("picked p JOIN %s a ON a.id=p.id JOIN documents d ON d.id=a.document_id", table)
|
||||
args = append(args, options.samplePerKind)
|
||||
}
|
||||
rows, err := db.Query(ctx, fmt.Sprintf(`
|
||||
%s
|
||||
SELECT a.id, a.collectible_revision_id, a.name, a.rarity_kind, COALESCE(a.rarity_permille,0),
|
||||
%s, COALESCE(a.official_document_id,0), a.sort_order,
|
||||
a.animation_json::text, a.animation_sha256, a.source_name, a.source_format,
|
||||
%s, a.animation_sha256, a.source_name, a.source_format,
|
||||
a.width, a.height, a.frame_rate, a.in_point, a.out_point,
|
||||
d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
|
||||
d.attributes::text, d.thumbs::text
|
||||
FROM %s a JOIN documents d ON d.id=a.document_id
|
||||
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`,
|
||||
FROM %s
|
||||
%s
|
||||
ORDER BY a.sort_order, a.id`, prefix, craftedExpression, animationJSONExpression, from,
|
||||
func() string {
|
||||
if kind == domain.StarGiftCollectibleModel {
|
||||
return "a.crafted"
|
||||
if options.samplePerKind > 0 {
|
||||
return ""
|
||||
}
|
||||
return "false"
|
||||
}(), table), revisionID)
|
||||
return "WHERE a.collectible_revision_id=$1"
|
||||
}()), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err)
|
||||
}
|
||||
|
|
@ -275,11 +335,29 @@ WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`,
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) {
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
|
||||
text_color, rarity_kind, COALESCE(rarity_permille,0), sort_order
|
||||
FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID)
|
||||
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64, options collectibleRevisionReadOptions) ([]domain.StarGiftCollectibleAttribute, error) {
|
||||
prefix := ""
|
||||
from := "star_gift_collectible_backdrops a"
|
||||
where := "WHERE a.collectible_revision_id=$1"
|
||||
args := []any{revisionID}
|
||||
if options.samplePerKind > 0 {
|
||||
prefix = `WITH picked AS MATERIALIZED (
|
||||
SELECT id FROM star_gift_collectible_backdrops
|
||||
WHERE collectible_revision_id=$1 AND rarity_kind='permille' AND rarity_permille > 0
|
||||
ORDER BY random()
|
||||
LIMIT $2
|
||||
)`
|
||||
from = "picked p JOIN star_gift_collectible_backdrops a ON a.id=p.id"
|
||||
where = ""
|
||||
args = append(args, options.samplePerKind)
|
||||
}
|
||||
rows, err := db.Query(ctx, fmt.Sprintf(`
|
||||
%s
|
||||
SELECT a.id, a.collectible_revision_id, a.name, a.backdrop_id, a.center_color, a.edge_color, a.pattern_color,
|
||||
a.text_color, a.rarity_kind, COALESCE(a.rarity_permille,0), a.sort_order
|
||||
FROM %s
|
||||
%s
|
||||
ORDER BY a.sort_order, a.id`, prefix, from, where), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list collectible backdrops: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,22 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
|
|||
poolRevision.Models[1].RarityPermille != 0 || poolRevision.Models[0].OfficialDocumentID != 5100000000000000001 {
|
||||
t.Fatalf("published pool = %+v", poolRevision)
|
||||
}
|
||||
storedRevision, found, err := gifts.ActiveCollectibleRevision(ctx, entry.Gift.ID)
|
||||
if err != nil || !found || storedRevision.Models[0].Animation == nil || len(storedRevision.Models[0].Animation.JSON) == 0 {
|
||||
t.Fatalf("full active collectible revision = found:%v err:%v value:%+v", found, err, storedRevision)
|
||||
}
|
||||
fullProjection, found, err := gifts.ActiveCollectibleProjection(ctx, entry.Gift.ID, 0)
|
||||
if err != nil || !found || len(fullProjection.Models) != 3 || len(fullProjection.Patterns) != 2 || len(fullProjection.Backdrops) != 2 ||
|
||||
fullProjection.Models[0].Animation == nil || len(fullProjection.Models[0].Animation.JSON) != 0 ||
|
||||
fullProjection.Patterns[0].Animation == nil || len(fullProjection.Patterns[0].Animation.JSON) != 0 {
|
||||
t.Fatalf("complete collectible projection = found:%v err:%v value:%+v", found, err, fullProjection)
|
||||
}
|
||||
sampleProjection, found, err := gifts.ActiveCollectibleProjection(ctx, entry.Gift.ID, 1)
|
||||
if err != nil || !found || len(sampleProjection.Models) != 1 || len(sampleProjection.Patterns) != 1 || len(sampleProjection.Backdrops) != 1 ||
|
||||
sampleProjection.Models[0].Crafted || sampleProjection.Models[0].RarityKind != domain.StarGiftRarityPermille ||
|
||||
sampleProjection.Models[0].Animation == nil || len(sampleProjection.Models[0].Animation.JSON) != 0 {
|
||||
t.Fatalf("sampled collectible projection = found:%v err:%v value:%+v", found, err, sampleProjection)
|
||||
}
|
||||
availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1})
|
||||
if err != nil {
|
||||
t.Fatalf("collectible availability: %v", err)
|
||||
|
|
|
|||
|
|
@ -89,15 +89,23 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
|
|||
}
|
||||
ordinaryAction := purchased.Send.RecipientMessage.Media.ServiceAction.StarGift
|
||||
if ordinaryAction == nil || !ordinaryAction.CanUpgrade || ordinaryAction.PrepaidUpgrade ||
|
||||
ordinaryAction.UpgradePriceStars != 100 || ordinaryAction.UpgradeStars != 0 {
|
||||
ordinaryAction.UpgradePriceStars != 100 || ordinaryAction.UpgradeStars != 0 ||
|
||||
ordinaryAction.PrepaidUpgradeHash != "" {
|
||||
t.Fatalf("ordinary purchase action mixed paid price with prepaid amount: %+v", ordinaryAction)
|
||||
}
|
||||
senderOrdinaryAction := purchased.Send.SenderMessage.Media.ServiceAction.StarGift
|
||||
if senderOrdinaryAction == nil || senderOrdinaryAction.CanUpgrade ||
|
||||
senderOrdinaryAction.PrepaidUpgradeHash != purchased.Saved.PrepaidUpgradeHash {
|
||||
t.Fatalf("ordinary sender purchase projection = %+v", senderOrdinaryAction)
|
||||
}
|
||||
replayedPurchase, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
|
||||
if err != nil || !replayedPurchase.Duplicate || replayedPurchase.Saved.ID != purchased.Saved.ID || replayedPurchase.Balance.Balance != 9950 ||
|
||||
replayedPurchase.Send.SenderMessage.ID != purchased.Send.SenderMessage.ID ||
|
||||
replayedPurchase.Send.RecipientMessage.ID != purchased.Send.RecipientMessage.ID {
|
||||
t.Fatalf("purchase replay = %+v err %v", replayedPurchase, err)
|
||||
}
|
||||
verifyPrepaidViewerProjectionMigration(t, ctx, pool, purchased.Saved.ID, owner.ID, buyer.ID,
|
||||
purchased.Send.RecipientMessage.ID, purchased.Send.SenderMessage.ID)
|
||||
|
||||
target, price, err := lifecycle.PrepaidUpgradeTarget(ctx, ownerPeer, purchased.Saved.PrepaidUpgradeHash)
|
||||
if err != nil || target.ID != purchased.Saved.ID || price != 100 {
|
||||
|
|
@ -1526,6 +1534,110 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, userID, pts).
|
|||
assertMigratedAction(payerUserID, payerPrepayMessageID, 0)
|
||||
}
|
||||
|
||||
func verifyPrepaidViewerProjectionMigration(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
savedGiftID int64,
|
||||
ownerUserID int64,
|
||||
senderUserID int64,
|
||||
ownerMessageID int,
|
||||
senderMessageID int,
|
||||
) {
|
||||
t.Helper()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin prepaid viewer migration probe: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
|
||||
var hash string
|
||||
if err := tx.QueryRow(ctx, `SELECT prepaid_upgrade_hash FROM peer_star_gifts WHERE id=$1`, savedGiftID).Scan(&hash); err != nil || hash == "" {
|
||||
t.Fatalf("load prepaid hash for viewer migration probe: hash=%q err=%v", hash, err)
|
||||
}
|
||||
var ownerPtsBefore, senderPtsBefore int
|
||||
if err := tx.QueryRow(ctx, `SELECT contiguous_pts FROM user_update_watermarks WHERE user_id=$1`, ownerUserID).Scan(&ownerPtsBefore); err != nil {
|
||||
t.Fatalf("load owner watermark before viewer migration: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT contiguous_pts FROM user_update_watermarks WHERE user_id=$1`, senderUserID).Scan(&senderPtsBefore); err != nil {
|
||||
t.Fatalf("load sender watermark before viewer migration: %v", err)
|
||||
}
|
||||
|
||||
var messageSenderID, privateMessageID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT message_sender_id,private_message_id FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, ownerUserID, ownerMessageID).
|
||||
Scan(&messageSenderID, &privateMessageID); err != nil {
|
||||
t.Fatalf("load ordinary gift message root for viewer migration: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE message_boxes
|
||||
SET media=jsonb_set(jsonb_set(media,'{service_action,star_gift,can_upgrade}','true'::jsonb,true),
|
||||
'{service_action,star_gift,prepaid_upgrade_hash}',to_jsonb($3::text),true)
|
||||
WHERE (owner_user_id=$1 AND box_id=$4) OR (owner_user_id=$2 AND box_id=$5)`,
|
||||
ownerUserID, senderUserID, hash, ownerMessageID, senderMessageID); err != nil {
|
||||
t.Fatalf("restore stale prepaid viewer boxes: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE private_messages
|
||||
SET media=jsonb_set(jsonb_set(media,'{service_action,star_gift,can_upgrade}','true'::jsonb,true),
|
||||
'{service_action,star_gift,prepaid_upgrade_hash}',to_jsonb($3::text),true)
|
||||
WHERE sender_user_id=$1 AND id=$2`, messageSenderID, privateMessageID, hash); err != nil {
|
||||
t.Fatalf("restore stale prepaid shared message: %v", err)
|
||||
}
|
||||
|
||||
migrationSQL, err := deploy.Migrations.ReadFile("migrations/0163_star_gift_prepaid_viewer_projection.up.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read prepaid viewer migration: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(migrationSQL)); err != nil {
|
||||
t.Fatalf("apply prepaid viewer migration probe: %v", err)
|
||||
}
|
||||
|
||||
assertProjection := func(userID int64, messageID int, wantCanUpgrade bool, wantHash string, ptsBefore int) {
|
||||
t.Helper()
|
||||
var mediaJSON string
|
||||
var pts int
|
||||
if err := tx.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, userID, messageID).Scan(&mediaJSON, &pts); err != nil {
|
||||
t.Fatalf("load migrated viewer box %d/%d: %v", userID, messageID, err)
|
||||
}
|
||||
media, err := decodeMessageMedia(mediaJSON)
|
||||
action := privateStarGiftAction(media)
|
||||
if err != nil || action == nil || action.CanUpgrade != wantCanUpgrade || action.PrepaidUpgradeHash != wantHash {
|
||||
t.Fatalf("migrated viewer box %d/%d = %+v err=%v", userID, messageID, action, err)
|
||||
}
|
||||
if pts != ptsBefore+1 {
|
||||
t.Fatalf("migrated viewer box %d/%d pts=%d want=%d", userID, messageID, pts, ptsBefore+1)
|
||||
}
|
||||
var eventCount, outboxCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
|
||||
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, userID, pts, messageID).Scan(&eventCount); err != nil {
|
||||
t.Fatalf("load migrated viewer event: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
|
||||
WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, userID, pts).Scan(&outboxCount); err != nil {
|
||||
t.Fatalf("load migrated viewer outbox: %v", err)
|
||||
}
|
||||
if eventCount != 1 || outboxCount != 1 {
|
||||
t.Fatalf("migrated viewer event/outbox counts=%d/%d", eventCount, outboxCount)
|
||||
}
|
||||
}
|
||||
assertProjection(ownerUserID, ownerMessageID, true, "", ownerPtsBefore)
|
||||
assertProjection(senderUserID, senderMessageID, false, hash, senderPtsBefore)
|
||||
|
||||
var sharedHash, sharedCanUpgrade *string
|
||||
if err := tx.QueryRow(ctx, `SELECT media #>> '{service_action,star_gift,prepaid_upgrade_hash}',
|
||||
media #>> '{service_action,star_gift,can_upgrade}' FROM private_messages WHERE sender_user_id=$1 AND id=$2`,
|
||||
messageSenderID, privateMessageID).Scan(&sharedHash, &sharedCanUpgrade); err != nil {
|
||||
t.Fatalf("load migrated shared viewer projection: %v", err)
|
||||
}
|
||||
if sharedHash != nil || sharedCanUpgrade != nil {
|
||||
t.Fatalf("shared viewer projection retained hash/can_upgrade: hash=%v can=%v", sharedHash, sharedCanUpgrade)
|
||||
}
|
||||
var storedHash string
|
||||
if err := tx.QueryRow(ctx, `SELECT prepaid_upgrade_hash FROM peer_star_gifts WHERE id=$1`, savedGiftID).Scan(&storedHash); err != nil || storedHash != hash {
|
||||
t.Fatalf("viewer migration changed aggregate hash=%q want=%q err=%v", storedHash, hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
func craftedSourceEditForUserAndGift(result domain.StarGiftCraftResult, userID, uniqueGiftID int64) domain.EditedMessageForUser {
|
||||
for _, edit := range result.SourceEdits {
|
||||
if edit.UserID != userID {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,61 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestProjectPrivateStarGiftPurchaseScopesViewerCapabilities(t *testing.T) {
|
||||
media := &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionStarGift,
|
||||
StarGift: &domain.MessageStarGiftAction{
|
||||
PeerUserID: 200, To: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
|
||||
CanUpgrade: true, PrepaidUpgradeHash: "prepaid-upgrade-hash-0123456789",
|
||||
},
|
||||
},
|
||||
}
|
||||
req := &domain.SendPrivateTextRequest{SenderUserID: 100, RecipientUserID: 200, Media: media}
|
||||
projection, err := projectPrivateStarGiftPurchase(context.Background(), nil, req)
|
||||
if err != nil {
|
||||
t.Fatalf("project purchase: %v", err)
|
||||
}
|
||||
shared := privateStarGiftAction(projection.Shared)
|
||||
sender := privateStarGiftAction(projection.Sender)
|
||||
recipient := privateStarGiftAction(projection.Recipient)
|
||||
if shared == nil || shared.CanUpgrade || shared.PrepaidUpgradeHash != "" {
|
||||
t.Fatalf("shared projection retained viewer capability: %+v", shared)
|
||||
}
|
||||
if sender == nil || sender.CanUpgrade || sender.PrepaidUpgradeHash == "" {
|
||||
t.Fatalf("sender projection = %+v, want hash without can_upgrade", sender)
|
||||
}
|
||||
if recipient == nil || !recipient.CanUpgrade || recipient.PrepaidUpgradeHash != "" {
|
||||
t.Fatalf("recipient projection = %+v, want can_upgrade without hash", recipient)
|
||||
}
|
||||
if original := privateStarGiftAction(media); original == nil || !original.CanUpgrade || original.PrepaidUpgradeHash == "" {
|
||||
t.Fatalf("source projection was mutated: %+v", original)
|
||||
}
|
||||
|
||||
selfReq := &domain.SendPrivateTextRequest{SenderUserID: 200, RecipientUserID: 200, Media: media}
|
||||
selfProjection, err := projectPrivateStarGiftPurchase(context.Background(), nil, selfReq)
|
||||
if err != nil {
|
||||
t.Fatalf("project self purchase: %v", err)
|
||||
}
|
||||
self := privateStarGiftAction(selfProjection.Sender)
|
||||
if self == nil || !self.CanUpgrade || self.PrepaidUpgradeHash != "" {
|
||||
t.Fatalf("self-owner projection = %+v, want can_upgrade without hash", self)
|
||||
}
|
||||
|
||||
bad := *req
|
||||
bad.RecipientUserID = 300
|
||||
if _, err := projectPrivateStarGiftPurchase(context.Background(), nil, &bad); err == nil {
|
||||
t.Fatal("mismatched gift owner and recipient was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferUniqueActionSavedIDNamespace(t *testing.T) {
|
||||
saved := domain.SavedStarGift{SavedID: 42, CanCraftAt: 1_780_000_123}
|
||||
unique := domain.UniqueStarGift{ID: 7}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ func projectPrivateStarGiftSourceRef(
|
|||
sharedAction.GiftMsgID = 0
|
||||
senderAction.GiftMsgID = 0
|
||||
recipientAction.GiftMsgID = 0
|
||||
// messageActionStarGift.can_upgrade is receiver-only. A separate
|
||||
// prepayment notification shares one logical private message, but its
|
||||
// payer box must not advertise owner actions.
|
||||
sharedAction.CanUpgrade = false
|
||||
senderAction.CanUpgrade = req.SenderUserID == sourceOwnerUserID && senderAction.CanUpgrade
|
||||
recipientAction.CanUpgrade = req.RecipientUserID == sourceOwnerUserID && recipientAction.CanUpgrade
|
||||
if req.SenderUserID == sourceOwnerUserID {
|
||||
senderAction.GiftMsgID = sourceOwnerBoxID
|
||||
} else {
|
||||
|
|
@ -66,6 +72,66 @@ func projectPrivateStarGiftSourceRef(
|
|||
return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil
|
||||
}
|
||||
|
||||
// projectPrivateStarGiftPurchase scopes the two mutually exclusive actions of
|
||||
// an ordinary user gift to the correct account-local message box:
|
||||
// - the gift owner/receiver may upgrade it and must not receive the separate
|
||||
// prepayment hash;
|
||||
// - the non-owner sender may use the hash to prepay for the owner's upgrade,
|
||||
// but must not receive the receiver-only can_upgrade capability.
|
||||
//
|
||||
// The shared logical envelope carries neither viewer-only field. This keeps a
|
||||
// future read/replay path from accidentally treating it as either participant's
|
||||
// projection while the two message_boxes remain the durable wire truth.
|
||||
func projectPrivateStarGiftPurchase(
|
||||
_ context.Context,
|
||||
_ pgx.Tx,
|
||||
req *domain.SendPrivateTextRequest,
|
||||
) (privateSendMediaProjection, error) {
|
||||
if req == nil || req.Media == nil || req.SenderUserID <= 0 || req.RecipientUserID <= 0 {
|
||||
return privateSendMediaProjection{}, fmt.Errorf("project private star gift purchase: invalid scope")
|
||||
}
|
||||
action := privateStarGiftAction(req.Media)
|
||||
if action == nil {
|
||||
return privateSendMediaProjection{}, fmt.Errorf("project private star gift purchase: unsupported media")
|
||||
}
|
||||
ownerUserID := action.PeerUserID
|
||||
if ownerUserID == 0 && action.To.Type == domain.PeerTypeUser {
|
||||
ownerUserID = action.To.ID
|
||||
}
|
||||
if ownerUserID <= 0 || ownerUserID != req.RecipientUserID {
|
||||
return privateSendMediaProjection{}, fmt.Errorf(
|
||||
"project private star gift purchase: owner %d does not match recipient %d",
|
||||
ownerUserID, req.RecipientUserID,
|
||||
)
|
||||
}
|
||||
|
||||
shared, err := cloneMessageMedia(req.Media)
|
||||
if err != nil {
|
||||
return privateSendMediaProjection{}, err
|
||||
}
|
||||
sender, err := cloneMessageMedia(req.Media)
|
||||
if err != nil {
|
||||
return privateSendMediaProjection{}, err
|
||||
}
|
||||
recipient, err := cloneMessageMedia(req.Media)
|
||||
if err != nil {
|
||||
return privateSendMediaProjection{}, err
|
||||
}
|
||||
|
||||
sharedAction := privateStarGiftAction(shared)
|
||||
senderAction := privateStarGiftAction(sender)
|
||||
recipientAction := privateStarGiftAction(recipient)
|
||||
sharedAction.PrepaidUpgradeHash = ""
|
||||
sharedAction.CanUpgrade = false
|
||||
if req.SenderUserID == ownerUserID {
|
||||
senderAction.PrepaidUpgradeHash = ""
|
||||
} else {
|
||||
senderAction.CanUpgrade = false
|
||||
}
|
||||
recipientAction.PrepaidUpgradeHash = ""
|
||||
return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil
|
||||
}
|
||||
|
||||
func cloneMessageMedia(media *domain.MessageMedia) (*domain.MessageMedia, error) {
|
||||
encoded, err := encodeMessageMedia(media)
|
||||
if err != nil {
|
||||
|
|
@ -97,6 +163,8 @@ func encodeSharedPrivateStarGiftMedia(media *domain.MessageMedia) ([]byte, error
|
|||
action.UpgradeMsgID = 0
|
||||
if action.PeerUserID > 0 || action.To.Type == domain.PeerTypeUser {
|
||||
action.SavedID = 0
|
||||
action.PrepaidUpgradeHash = ""
|
||||
action.CanUpgrade = false
|
||||
}
|
||||
case privateStarGiftUniqueAction(shared) != nil:
|
||||
action := privateStarGiftUniqueAction(shared)
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ func (s *StarGiftLifecycleStore) PurchaseStarGift(ctx context.Context, req domai
|
|||
result.Gift, result.Saved, result.Balance = gift, saved, balance
|
||||
return nil
|
||||
},
|
||||
projectMedia: projectPrivateStarGiftPurchase,
|
||||
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||
msgID := sent.RecipientMessage.ID
|
||||
if msgID <= 0 {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ type StarGiftStore interface {
|
|||
// PublishCollectibleRevision validates and atomically publishes a new immutable attribute pool.
|
||||
PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error)
|
||||
ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error)
|
||||
// ActiveCollectibleProjection omits heavyweight animation bodies from read-only client/admin
|
||||
// projections. samplePerKind=0 returns the complete attribute metadata; a positive value
|
||||
// returns at most that many randomly selected ordinary-upgrade attributes per kind.
|
||||
ActiveCollectibleProjection(ctx context.Context, giftID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, bool, error)
|
||||
CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error)
|
||||
CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
||||
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue