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
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- Data-only viewer projection and durable PTS repair. Reintroducing the leaked
|
||||||
|
-- owner hash or receiver-only capability into the sender box would be unsafe,
|
||||||
|
-- and emitted updateEditMessage events cannot be retracted.
|
||||||
|
SELECT 1;
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
-- inputInvoiceStarGiftPrepaidUpgrade is for prepaying someone else's gift:
|
||||||
|
-- its peer is the gift owner. Earlier user-gift purchases copied both
|
||||||
|
-- receiver-only can_upgrade and non-owner prepaid_upgrade_hash into both
|
||||||
|
-- private message boxes. DrKLO therefore preferred the prepaid invoice on the
|
||||||
|
-- owner's incoming card and substituted the private dialog peer (the sender),
|
||||||
|
-- which correctly failed STARGIFT_INVALID.
|
||||||
|
--
|
||||||
|
-- Repair both viewer projections and publish a real account-scoped edit for
|
||||||
|
-- every changed box. The saved-gift aggregate and its owner/hash remain intact;
|
||||||
|
-- only the wire capability surface changes.
|
||||||
|
|
||||||
|
LOCK TABLE public.peer_star_gifts, public.message_boxes,
|
||||||
|
public.private_messages IN SHARE ROW EXCLUSIVE MODE;
|
||||||
|
|
||||||
|
CREATE TEMP TABLE star_gift_prepaid_viewer_sources ON COMMIT DROP AS
|
||||||
|
SELECT gift.id AS saved_gift_id,
|
||||||
|
gift.owner_peer_id AS owner_user_id,
|
||||||
|
gift.from_user_id AS sender_user_id,
|
||||||
|
gift.msg_id AS owner_box_id,
|
||||||
|
gift.gift_id,
|
||||||
|
gift.prepaid_upgrade_hash,
|
||||||
|
owner_box.message_sender_id,
|
||||||
|
owner_box.private_message_id
|
||||||
|
FROM public.peer_star_gifts gift
|
||||||
|
JOIN public.message_boxes owner_box
|
||||||
|
ON owner_box.owner_user_id = gift.owner_peer_id
|
||||||
|
AND owner_box.box_id = gift.msg_id
|
||||||
|
AND NOT owner_box.deleted
|
||||||
|
WHERE gift.owner_peer_type = 'user'
|
||||||
|
AND gift.lifecycle_status = 'active'
|
||||||
|
AND gift.unique_gift_id IS NULL
|
||||||
|
AND gift.prepaid_upgrade_stars = 0
|
||||||
|
AND gift.prepaid_upgrade_hash <> '';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.peer_star_gifts gift
|
||||||
|
WHERE gift.owner_peer_type = 'user'
|
||||||
|
AND gift.lifecycle_status = 'active'
|
||||||
|
AND gift.unique_gift_id IS NULL
|
||||||
|
AND gift.prepaid_upgrade_stars = 0
|
||||||
|
AND gift.prepaid_upgrade_hash <> ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM star_gift_prepaid_viewer_sources source
|
||||||
|
WHERE source.saved_gift_id = gift.id
|
||||||
|
)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'active user prepaid-upgrade entitlement is missing its owner message box';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM star_gift_prepaid_viewer_sources source
|
||||||
|
JOIN public.message_boxes box
|
||||||
|
ON box.message_sender_id = source.message_sender_id
|
||||||
|
AND box.private_message_id = source.private_message_id
|
||||||
|
AND NOT box.deleted
|
||||||
|
WHERE box.owner_user_id NOT IN (source.owner_user_id, source.sender_user_id)
|
||||||
|
OR box.media #>> '{service_action,kind}' IS DISTINCT FROM 'star_gift'
|
||||||
|
OR box.media #>> '{service_action,star_gift,gift_id}' IS DISTINCT FROM source.gift_id::text
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'ordinary star gift private projections disagree with the saved aggregate';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TEMP TABLE star_gift_prepaid_viewer_repairs (
|
||||||
|
owner_user_id bigint NOT NULL,
|
||||||
|
box_id integer NOT NULL,
|
||||||
|
peer_type text NOT NULL,
|
||||||
|
peer_id bigint NOT NULL,
|
||||||
|
message_sender_id bigint NOT NULL,
|
||||||
|
private_message_id bigint NOT NULL,
|
||||||
|
repaired_media jsonb NOT NULL,
|
||||||
|
PRIMARY KEY (owner_user_id, box_id)
|
||||||
|
) ON COMMIT DROP;
|
||||||
|
|
||||||
|
INSERT INTO star_gift_prepaid_viewer_repairs(
|
||||||
|
owner_user_id, box_id, peer_type, peer_id,
|
||||||
|
message_sender_id, private_message_id, repaired_media
|
||||||
|
)
|
||||||
|
SELECT box.owner_user_id,
|
||||||
|
box.box_id,
|
||||||
|
box.peer_type,
|
||||||
|
box.peer_id,
|
||||||
|
box.message_sender_id,
|
||||||
|
box.private_message_id,
|
||||||
|
CASE
|
||||||
|
WHEN box.owner_user_id = source.owner_user_id THEN
|
||||||
|
box.media #- '{service_action,star_gift,prepaid_upgrade_hash}'
|
||||||
|
ELSE
|
||||||
|
box.media #- '{service_action,star_gift,can_upgrade}'
|
||||||
|
END
|
||||||
|
FROM star_gift_prepaid_viewer_sources source
|
||||||
|
JOIN public.message_boxes box
|
||||||
|
ON box.message_sender_id = source.message_sender_id
|
||||||
|
AND box.private_message_id = source.private_message_id
|
||||||
|
AND NOT box.deleted
|
||||||
|
WHERE box.owner_user_id IN (source.owner_user_id, source.sender_user_id)
|
||||||
|
AND box.media IS DISTINCT FROM CASE
|
||||||
|
WHEN box.owner_user_id = source.owner_user_id THEN
|
||||||
|
box.media #- '{service_action,star_gift,prepaid_upgrade_hash}'
|
||||||
|
ELSE
|
||||||
|
box.media #- '{service_action,star_gift,can_upgrade}'
|
||||||
|
END;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
repair_row record;
|
||||||
|
next_pts integer;
|
||||||
|
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||||
|
BEGIN
|
||||||
|
FOR repair_row IN
|
||||||
|
SELECT owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||||
|
FROM star_gift_prepaid_viewer_repairs
|
||||||
|
ORDER BY owner_user_id, box_id
|
||||||
|
LOOP
|
||||||
|
INSERT INTO public.user_update_watermarks(user_id, contiguous_pts)
|
||||||
|
VALUES(repair_row.owner_user_id, 0)
|
||||||
|
ON CONFLICT(user_id) DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE public.user_update_watermarks
|
||||||
|
SET contiguous_pts = contiguous_pts + 1,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE user_id = repair_row.owner_user_id
|
||||||
|
RETURNING contiguous_pts INTO next_pts;
|
||||||
|
|
||||||
|
UPDATE public.message_boxes
|
||||||
|
SET media = repair_row.repaired_media,
|
||||||
|
pts = next_pts
|
||||||
|
WHERE owner_user_id = repair_row.owner_user_id
|
||||||
|
AND box_id = repair_row.box_id
|
||||||
|
AND NOT deleted;
|
||||||
|
|
||||||
|
INSERT INTO public.user_update_events(
|
||||||
|
user_id, pts, pts_count, date, event_type,
|
||||||
|
message_box_id, peer_type, peer_id
|
||||||
|
) VALUES (
|
||||||
|
repair_row.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||||
|
repair_row.box_id, repair_row.peer_type, repair_row.peer_id
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.dispatch_outbox(
|
||||||
|
target_user_id, pts, event_type,
|
||||||
|
exclude_auth_key_id, exclude_session_id
|
||||||
|
) VALUES(repair_row.owner_user_id, next_pts, 'edit_message', 0, 0);
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- A shared private-message envelope is not a viewer projection. Keep neither
|
||||||
|
-- the owner-only nor non-owner-only capability there; both durable boxes above
|
||||||
|
-- remain authoritative for replay and history.
|
||||||
|
UPDATE public.private_messages private_message
|
||||||
|
SET media = private_message.media
|
||||||
|
#- '{service_action,star_gift,prepaid_upgrade_hash}'
|
||||||
|
#- '{service_action,star_gift,can_upgrade}'
|
||||||
|
FROM star_gift_prepaid_viewer_sources source
|
||||||
|
WHERE private_message.sender_user_id = source.message_sender_id
|
||||||
|
AND private_message.id = source.private_message_id;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM star_gift_prepaid_viewer_sources source
|
||||||
|
JOIN public.message_boxes box
|
||||||
|
ON box.message_sender_id = source.message_sender_id
|
||||||
|
AND box.private_message_id = source.private_message_id
|
||||||
|
AND NOT box.deleted
|
||||||
|
WHERE (box.owner_user_id = source.owner_user_id AND
|
||||||
|
box.media #> '{service_action,star_gift,prepaid_upgrade_hash}' IS NOT NULL)
|
||||||
|
OR (box.owner_user_id = source.sender_user_id AND
|
||||||
|
box.owner_user_id <> source.owner_user_id AND
|
||||||
|
box.media #> '{service_action,star_gift,can_upgrade}' IS NOT NULL)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'star gift prepaid viewer projection repair did not converge';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
@ -166,4 +166,10 @@ func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testi
|
||||||
if !pattern.Attributes[1].TextColor {
|
if !pattern.Attributes[1].TextColor {
|
||||||
t.Fatalf("pattern render attribute = %+v, want text_color", pattern.Attributes[1])
|
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) {
|
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 {
|
if s == nil || s.store == nil || giftID <= 0 {
|
||||||
return domain.StarGiftUpgradePreview{}, false, nil
|
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 {
|
if err != nil || !ok || !revision.Published {
|
||||||
return domain.StarGiftUpgradePreview{}, false, err
|
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) {
|
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 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
|
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
|
var token [32]byte
|
||||||
if _, err := rand.Read(token[:]); err != nil {
|
if _, err := rand.Read(token[:]); err != nil {
|
||||||
return 0, fmt.Errorf("generate prepaid star gift upgrade hash: %w", err)
|
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),
|
Peers: tgPeerList(shared.Peers),
|
||||||
}
|
}
|
||||||
case domain.MessageServiceActionStarGift:
|
case domain.MessageServiceActionStarGift:
|
||||||
return tgMessageActionStarGift(m.ServiceAction.StarGift)
|
return tgMessageActionStarGiftForViewer(m.ServiceAction.StarGift, msg.OwnerUserID)
|
||||||
case domain.MessageServiceActionStarGiftUnique:
|
case domain.MessageServiceActionStarGiftUnique:
|
||||||
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
|
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
|
||||||
case domain.MessageServiceActionStarGiftOffer:
|
case domain.MessageServiceActionStarGiftOffer:
|
||||||
|
|
|
||||||
|
|
@ -1118,6 +1118,7 @@ type GiftsService interface {
|
||||||
GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error)
|
GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error)
|
||||||
GiftRevisionByID(ctx context.Context, revisionID 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)
|
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)
|
CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error)
|
||||||
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
|
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
|
||||||
UniqueByID(ctx context.Context, uniqueGiftID int64) (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 {
|
if giftID <= 0 || r.deps.Gifts == nil {
|
||||||
return nil, starGiftInvalidErr()
|
return nil, starGiftInvalidErr()
|
||||||
}
|
}
|
||||||
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, giftID)
|
preview, found, err := r.deps.Gifts.CollectiblePreviewSample(ctx, giftID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -876,7 +876,7 @@ func (r *Router) tgSavedStarGiftsResponse(ctx context.Context, viewerUserID int6
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
projected := tgSavedStarGifts(gifts, catalog, availability)
|
projected := tgSavedStarGifts(viewerUserID, gifts, catalog, availability)
|
||||||
out := &tg.PaymentsSavedStarGifts{
|
out := &tg.PaymentsSavedStarGifts{
|
||||||
Count: count,
|
Count: count,
|
||||||
Gifts: projected,
|
Gifts: projected,
|
||||||
|
|
@ -990,6 +990,30 @@ func tgStarGift(g domain.StarGift) *tg.StarGift {
|
||||||
}
|
}
|
||||||
|
|
||||||
// tgMessageActionStarGift 把礼物服务消息载荷投影为 messageActionStarGift。
|
// 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 {
|
func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionClass {
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return &tg.MessageActionEmpty{}
|
return &tg.MessageActionEmpty{}
|
||||||
|
|
@ -1088,7 +1112,7 @@ func (r *Router) resolveStarGiftCatalog(ctx context.Context, gifts []domain.Save
|
||||||
}
|
}
|
||||||
|
|
||||||
// tgSavedStarGifts 把已收到礼物实例投影为 []tg.SavedStarGift。
|
// 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))
|
out := make([]tg.SavedStarGift, 0, len(gifts))
|
||||||
for _, g := range gifts {
|
for _, g := range gifts {
|
||||||
item := tg.SavedStarGift{
|
item := tg.SavedStarGift{
|
||||||
|
|
@ -1126,7 +1150,11 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
|
||||||
item.SetUpgradeStars(g.PrepaidUpgradeStars)
|
item.SetUpgradeStars(g.PrepaidUpgradeStars)
|
||||||
item.CanUpgrade = true
|
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)
|
item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package rpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/iamxvbaba/td/bin"
|
"github.com/iamxvbaba/td/bin"
|
||||||
|
|
@ -304,7 +305,7 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
|
||||||
historical.ID: {UpgradeStars: 75, SupplyTotal: 500, Issued: 12},
|
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 {
|
if len(projected) != 1 || !projected[0].CanUpgrade {
|
||||||
t.Fatalf("saved gift = %#v, want current pool to make historical gift upgradable", projected)
|
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}
|
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 {
|
if soldOut.CanUpgrade {
|
||||||
t.Fatal("sold-out collectible pool must not advertise upgrade")
|
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")
|
t.Fatal("sold-out catalog projection must not expose upgrade_stars")
|
||||||
}
|
}
|
||||||
saved.PrepaidUpgradeStars = 75
|
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 {
|
if soldOutPrepaid.CanUpgrade {
|
||||||
t.Fatal("sold-out prepaid gift must not advertise an upgrade the aggregate will reject")
|
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,
|
CanExportAt: exportAt, TransferStars: 25, CanTransferAt: transferAt, CanResellAt: resellAt,
|
||||||
DropOriginalDetailsStars: 30, CanCraftAt: readyAt,
|
DropOriginalDetailsStars: 30, CanCraftAt: readyAt,
|
||||||
}
|
}
|
||||||
projected := tgSavedStarGifts([]domain.SavedStarGift{saved}, nil, nil)
|
projected := tgSavedStarGifts(0, []domain.SavedStarGift{saved}, nil, nil)
|
||||||
if len(projected) != 1 {
|
if len(projected) != 1 {
|
||||||
t.Fatalf("saved lifecycle projection count = %d", len(projected))
|
t.Fatalf("saved lifecycle projection count = %d", len(projected))
|
||||||
}
|
}
|
||||||
|
|
@ -401,7 +402,7 @@ func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertLifecycle(t, projected[0])
|
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,
|
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, GiftID: giftID, RevisionID: revision,
|
||||||
MsgID: 45, Date: 101, UniqueGiftID: unique.ID, Unique: &unique,
|
MsgID: 45, Date: 101, UniqueGiftID: unique.ID, Unique: &unique,
|
||||||
}}, nil, nil)[0]
|
}}, nil, nil)[0]
|
||||||
|
|
@ -427,7 +428,7 @@ func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) {
|
||||||
channelSaved.Owner = domain.Peer{Type: domain.PeerTypeChannel, ID: 8102}
|
channelSaved.Owner = domain.Peer{Type: domain.PeerTypeChannel, ID: 8102}
|
||||||
channelSaved.MsgID = 0
|
channelSaved.MsgID = 0
|
||||||
channelSaved.SavedID = 51
|
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 {
|
if _, ok := channelProjected.GetCanCraftAt(); ok {
|
||||||
t.Fatal("channel can_craft_at must be absent until channel Craft is executable")
|
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) {
|
func TestStarGiftUpgradeRPCReplaysCommittedReceiptAfterTerminalTransition(t *testing.T) {
|
||||||
r, sender, owner, gift := starGiftTestRouter(t)
|
r, sender, owner, gift := starGiftTestRouter(t)
|
||||||
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
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) {
|
func TestStarGiftCollectionsCRUDFilterOrderAndPin(t *testing.T) {
|
||||||
r, _, owner, gift := starGiftTestRouter(t)
|
r, _, owner, gift := starGiftTestRouter(t)
|
||||||
ctx := WithUserID(context.Background(), owner.ID)
|
ctx := WithUserID(context.Background(), owner.ID)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package memory
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"math/rand/v2"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -253,6 +254,20 @@ func (s *StarGiftStore) ActiveCollectibleRevision(_ context.Context, giftID int6
|
||||||
return cloneCollectibleRevision(revision), ok, nil
|
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) {
|
func (s *StarGiftStore) CollectibleAvailability(_ context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
@ -849,6 +864,34 @@ func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.Star
|
||||||
return out
|
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 {
|
func cloneStarGiftCollections(in []domain.StarGiftCollection) []domain.StarGiftCollection {
|
||||||
out := make([]domain.StarGiftCollection, len(in))
|
out := make([]domain.StarGiftCollection, len(in))
|
||||||
for i, collection := range 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) {
|
func (s *StarGiftStore) ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error) {
|
||||||
var revisionID int64
|
revisionID, ok, err := activeCollectibleRevisionID(ctx, s.db, giftID)
|
||||||
err := s.db.QueryRow(ctx, `
|
if err != nil || !ok {
|
||||||
SELECT collectible_revision_id FROM star_gift_catalog
|
return domain.StarGiftCollectibleRevision{}, ok, err
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
revision, err := collectibleRevisionByID(ctx, s.db, revisionID)
|
revision, err := collectibleRevisionByID(ctx, s.db, revisionID)
|
||||||
if err != nil {
|
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
|
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) {
|
func (s *StarGiftStore) CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
|
||||||
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
|
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
|
||||||
if len(giftIDs) == 0 {
|
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) {
|
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 revision domain.StarGiftCollectibleRevision
|
||||||
var status string
|
var status string
|
||||||
var publishedAt pgtype.Timestamptz
|
var publishedAt pgtype.Timestamptz
|
||||||
|
|
@ -213,40 +246,67 @@ FROM star_gift_collectible_revisions WHERE id=$1`, revisionID).Scan(
|
||||||
revision.PublishedAt = publishedAt.Time
|
revision.PublishedAt = publishedAt.Time
|
||||||
}
|
}
|
||||||
var err error
|
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
|
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
|
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 domain.StarGiftCollectibleRevision{}, err
|
||||||
}
|
}
|
||||||
return revision, nil
|
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"
|
table := "star_gift_collectible_models"
|
||||||
if kind == domain.StarGiftCollectiblePattern {
|
if kind == domain.StarGiftCollectiblePattern {
|
||||||
table = "star_gift_collectible_patterns"
|
table = "star_gift_collectible_patterns"
|
||||||
} else if kind != domain.StarGiftCollectibleModel {
|
} else if kind != domain.StarGiftCollectibleModel {
|
||||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
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(`
|
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),
|
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,
|
%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,
|
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.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
|
||||||
d.attributes::text, d.thumbs::text
|
d.attributes::text, d.thumbs::text
|
||||||
FROM %s a JOIN documents d ON d.id=a.document_id
|
FROM %s
|
||||||
WHERE a.collectible_revision_id=$1 ORDER BY a.sort_order, a.id`,
|
%s
|
||||||
|
ORDER BY a.sort_order, a.id`, prefix, craftedExpression, animationJSONExpression, from,
|
||||||
func() string {
|
func() string {
|
||||||
if kind == domain.StarGiftCollectibleModel {
|
if options.samplePerKind > 0 {
|
||||||
return "a.crafted"
|
return ""
|
||||||
}
|
}
|
||||||
return "false"
|
return "WHERE a.collectible_revision_id=$1"
|
||||||
}(), table), revisionID)
|
}()), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list collectible %s attributes: %w", kind, err)
|
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()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64) ([]domain.StarGiftCollectibleAttribute, error) {
|
func listCollectibleBackdrops(ctx context.Context, db sqlcgen.DBTX, revisionID int64, options collectibleRevisionReadOptions) ([]domain.StarGiftCollectibleAttribute, error) {
|
||||||
rows, err := db.Query(ctx, `
|
prefix := ""
|
||||||
SELECT id, collectible_revision_id, name, backdrop_id, center_color, edge_color, pattern_color,
|
from := "star_gift_collectible_backdrops a"
|
||||||
text_color, rarity_kind, COALESCE(rarity_permille,0), sort_order
|
where := "WHERE a.collectible_revision_id=$1"
|
||||||
FROM star_gift_collectible_backdrops WHERE collectible_revision_id=$1 ORDER BY sort_order, id`, revisionID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list collectible backdrops: %w", err)
|
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 {
|
poolRevision.Models[1].RarityPermille != 0 || poolRevision.Models[0].OfficialDocumentID != 5100000000000000001 {
|
||||||
t.Fatalf("published pool = %+v", poolRevision)
|
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})
|
availability, err := gifts.CollectibleAvailability(ctx, []int64{entry.Gift.ID, entry.Gift.ID + 1})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("collectible availability: %v", err)
|
t.Fatalf("collectible availability: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -89,15 +89,23 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
|
||||||
}
|
}
|
||||||
ordinaryAction := purchased.Send.RecipientMessage.Media.ServiceAction.StarGift
|
ordinaryAction := purchased.Send.RecipientMessage.Media.ServiceAction.StarGift
|
||||||
if ordinaryAction == nil || !ordinaryAction.CanUpgrade || ordinaryAction.PrepaidUpgrade ||
|
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)
|
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)
|
replayedPurchase, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
|
||||||
if err != nil || !replayedPurchase.Duplicate || replayedPurchase.Saved.ID != purchased.Saved.ID || replayedPurchase.Balance.Balance != 9950 ||
|
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.SenderMessage.ID != purchased.Send.SenderMessage.ID ||
|
||||||
replayedPurchase.Send.RecipientMessage.ID != purchased.Send.RecipientMessage.ID {
|
replayedPurchase.Send.RecipientMessage.ID != purchased.Send.RecipientMessage.ID {
|
||||||
t.Fatalf("purchase replay = %+v err %v", replayedPurchase, err)
|
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)
|
target, price, err := lifecycle.PrepaidUpgradeTarget(ctx, ownerPeer, purchased.Saved.PrepaidUpgradeHash)
|
||||||
if err != nil || target.ID != purchased.Saved.ID || price != 100 {
|
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)
|
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 {
|
func craftedSourceEditForUserAndGift(result domain.StarGiftCraftResult, userID, uniqueGiftID int64) domain.EditedMessageForUser {
|
||||||
for _, edit := range result.SourceEdits {
|
for _, edit := range result.SourceEdits {
|
||||||
if edit.UserID != userID {
|
if edit.UserID != userID {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,61 @@
|
||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"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) {
|
func TestTransferUniqueActionSavedIDNamespace(t *testing.T) {
|
||||||
saved := domain.SavedStarGift{SavedID: 42, CanCraftAt: 1_780_000_123}
|
saved := domain.SavedStarGift{SavedID: 42, CanCraftAt: 1_780_000_123}
|
||||||
unique := domain.UniqueStarGift{ID: 7}
|
unique := domain.UniqueStarGift{ID: 7}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,12 @@ func projectPrivateStarGiftSourceRef(
|
||||||
sharedAction.GiftMsgID = 0
|
sharedAction.GiftMsgID = 0
|
||||||
senderAction.GiftMsgID = 0
|
senderAction.GiftMsgID = 0
|
||||||
recipientAction.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 {
|
if req.SenderUserID == sourceOwnerUserID {
|
||||||
senderAction.GiftMsgID = sourceOwnerBoxID
|
senderAction.GiftMsgID = sourceOwnerBoxID
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -66,6 +72,66 @@ func projectPrivateStarGiftSourceRef(
|
||||||
return privateSendMediaProjection{Shared: shared, Sender: sender, Recipient: recipient}, nil
|
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) {
|
func cloneMessageMedia(media *domain.MessageMedia) (*domain.MessageMedia, error) {
|
||||||
encoded, err := encodeMessageMedia(media)
|
encoded, err := encodeMessageMedia(media)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -97,6 +163,8 @@ func encodeSharedPrivateStarGiftMedia(media *domain.MessageMedia) ([]byte, error
|
||||||
action.UpgradeMsgID = 0
|
action.UpgradeMsgID = 0
|
||||||
if action.PeerUserID > 0 || action.To.Type == domain.PeerTypeUser {
|
if action.PeerUserID > 0 || action.To.Type == domain.PeerTypeUser {
|
||||||
action.SavedID = 0
|
action.SavedID = 0
|
||||||
|
action.PrepaidUpgradeHash = ""
|
||||||
|
action.CanUpgrade = false
|
||||||
}
|
}
|
||||||
case privateStarGiftUniqueAction(shared) != nil:
|
case privateStarGiftUniqueAction(shared) != nil:
|
||||||
action := privateStarGiftUniqueAction(shared)
|
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
|
result.Gift, result.Saved, result.Balance = gift, saved, balance
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
projectMedia: projectPrivateStarGiftPurchase,
|
||||||
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
|
||||||
msgID := sent.RecipientMessage.ID
|
msgID := sent.RecipientMessage.ID
|
||||||
if msgID <= 0 {
|
if msgID <= 0 {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ type StarGiftStore interface {
|
||||||
// PublishCollectibleRevision validates and atomically publishes a new immutable attribute pool.
|
// PublishCollectibleRevision validates and atomically publishes a new immutable attribute pool.
|
||||||
PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error)
|
PublishCollectibleRevision(ctx context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error)
|
||||||
ActiveCollectibleRevision(ctx context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, 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)
|
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)
|
CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
||||||
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
|
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue