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
|
|
@ -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