removed all "paid" features - no more stars, gifts, or grams

This commit is contained in:
onysd 2026-08-07 01:50:10 +03:00
parent d4451d753c
commit 21d8e91756
165 changed files with 318 additions and 40948 deletions

View file

@ -3,8 +3,6 @@ package admin
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"reflect"
"sort"
@ -12,21 +10,15 @@ import (
"testing"
"time"
ratingapp "telesrv/internal/app/rating"
stargiftapp "telesrv/internal/app/stargifts"
usernamesapp "telesrv/internal/app/usernames"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
"telesrv/internal/store/memory"
)
// Compile-time proof that the shipped use-case services satisfy the admin ports.
// cmd/telesrv wires *usernames.Service and *rating.Service into
// Dependencies.Usernames / Dependencies.Rating directly, so a drifting method set
// has to fail here rather than at integration time.
// cmd/telesrv wires *usernames.Service into Dependencies.Usernames directly,
// so a drifting method set has to fail here rather than at integration time.
var (
_ CollectibleUsernamesService = (*usernamesapp.Service)(nil)
_ AccountRatingService = (*ratingapp.Service)(nil)
)
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
@ -352,59 +344,6 @@ func TestGrantPremiumDryRunExecuteAndIdempotency(t *testing.T) {
}
}
func TestGrantStarsDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
users := &fakeUsersService{users: map[int64]domain.User{
1001: {ID: 1001, Phone: "1001", Username: "alice", FirstName: "Alice"},
}}
stars := &fakeStarsService{balances: map[int64]domain.StarsBalance{
1001: {UserID: 1001, Balance: 1000, Granted: true},
}}
notifier := &fakeStarsNotifier{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Users: users,
Stars: stars,
StarsNotifier: notifier,
Now: fixedNow,
})
dry, err := svc.GrantStars(ctx, GrantStarsRequest{
CommandMeta: CommandMeta{CommandID: "dry-stars", Actor: "ops", Reason: "test", DryRun: true},
UserID: 1001,
Amount: 250,
})
if err != nil {
t.Fatalf("dry-run stars: %v", err)
}
if !dry.DryRun || stars.creditCalls != 0 || len(notifier.balances) != 0 {
t.Fatalf("dry=%+v creditCalls=%d notified=%v, want no mutation", dry, stars.creditCalls, notifier.balances)
}
req := GrantStarsRequest{
CommandMeta: CommandMeta{CommandID: "exec-stars", Actor: "ops", Reason: "ops grant"},
UserID: 1001,
Amount: 250,
}
exec, err := svc.GrantStars(ctx, req)
if err != nil {
t.Fatalf("execute stars: %v", err)
}
if exec.Status != string(domain.AdminCommandCompleted) || stars.creditCalls != 1 || stars.lastAmount != 250 || stars.lastReason != domain.StarsReasonAdjust || len(notifier.balances) != 1 {
t.Fatalf("exec=%+v creditCalls=%d amount=%d reason=%s notified=%v", exec, stars.creditCalls, stars.lastAmount, stars.lastReason, notifier.balances)
}
if exec.Details["updated_balance"] != int64(1250) {
t.Fatalf("updated_balance=%v, want 1250", exec.Details["updated_balance"])
}
again, err := svc.GrantStars(ctx, req)
if err != nil {
t.Fatalf("duplicate stars: %v", err)
}
if !again.AlreadyExecuted || stars.creditCalls != 1 || len(notifier.balances) != 1 {
t.Fatalf("again=%+v creditCalls=%d notified=%v, want idempotent replay", again, stars.creditCalls, notifier.balances)
}
}
func TestSetVerifiedDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
users := &fakeUsersService{users: map[int64]domain.User{
@ -897,47 +836,6 @@ func (f *fakeUsersService) UpdateEmojiStatus(_ context.Context, userID int64, st
return u, nil
}
type fakeStarsService struct {
balances map[int64]domain.StarsBalance
creditCalls int
lastUserID int64
lastAmount int64
lastReason domain.StarsTransactionReason
lastPeer domain.Peer
lastTitle string
lastDesc string
}
func (f *fakeStarsService) Credit(_ context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) {
f.creditCalls++
f.lastUserID = userID
f.lastAmount = amount
f.lastReason = reason
f.lastPeer = peer
f.lastTitle = title
f.lastDesc = desc
if amount <= 0 {
return domain.StarsBalance{}, domain.ErrStarsInvalidAmount
}
if f.balances == nil {
f.balances = map[int64]domain.StarsBalance{}
}
balance := f.balances[userID]
balance.UserID = userID
balance.Balance += amount
f.balances[userID] = balance
return balance, nil
}
type fakeStarsNotifier struct {
balances []domain.StarsBalance
}
func (f *fakeStarsNotifier) NotifyStarsBalanceChanged(_ context.Context, balance domain.StarsBalance) error {
f.balances = append(f.balances, balance)
return nil
}
type fakeUserNotifier struct {
users []int64
}
@ -1054,424 +952,6 @@ type fakeChannelNotifier struct {
channels []int64
}
func TestImportStarGiftDryRunThenConfirm(t *testing.T) {
gifts := &fakeGiftsService{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
base := ImportStarGiftRequest{
Title: "Cake", Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 3,
FileName: "cake.lottie", Data: []byte(`{"v":"5.7"}`),
}
base.CommandMeta = CommandMeta{CommandID: "dry-gift", Actor: "ops", Reason: "catalog", DryRun: true}
preview, err := svc.ImportStarGift(context.Background(), base)
if err != nil || gifts.createCalls != 0 || preview.Details["source_format"] != domain.StarGiftAnimationLottie {
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
}
base.CommandMeta = CommandMeta{CommandID: "exec-gift", Actor: "ops", Reason: "catalog", DryRun: false}
result, err := svc.ImportStarGift(context.Background(), base)
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "22" {
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
}
}
func TestCommandIDConflictRejectsDifferentGiftBytes(t *testing.T) {
gifts := &fakeGiftsService{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
req := ImportStarGiftRequest{
CommandMeta: CommandMeta{CommandID: "same", Actor: "ops", Reason: "catalog", DryRun: true},
Title: "Gift", Stars: 10, ConvertStars: 5, Enabled: true, FileName: "a.lottie", Data: []byte("one"),
}
if _, err := svc.ImportStarGift(context.Background(), req); err != nil {
t.Fatal(err)
}
req.Data = []byte("two")
if _, err := svc.ImportStarGift(context.Background(), req); err == nil || err.Error() != "COMMAND_ID_CONFLICT" {
t.Fatalf("conflict err=%v", err)
}
}
func TestPublishStarGiftCollectiblesDryRunThenConfirm(t *testing.T) {
gifts := &fakeGiftsService{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, Now: fixedNow})
base := PublishStarGiftCollectiblesRequest{
GiftID: 11, UpgradeStars: 125, SupplyTotal: 100, SlugPrefix: "cake",
Models: []StarGiftCollectibleAnimationUpload{
{Name: "Ruby", RarityPermille: 500, FileKey: "model-0", FileName: "ruby.lottie", Data: []byte("model")},
{Name: "Sapphire", RarityPermille: 500, FileKey: "model-1", FileName: "sapphire.lottie", Data: []byte("model-1")},
},
Patterns: []StarGiftCollectibleAnimationUpload{
{Name: "Stars", RarityPermille: 500, FileKey: "pattern-0", FileName: "stars.tgs", Data: []byte("pattern")},
{Name: "Moons", RarityPermille: 500, FileKey: "pattern-1", FileName: "moons.tgs", Data: []byte("pattern-1")},
},
Backdrops: []StarGiftCollectibleBackdropInput{
{Name: "Night", BackdropID: 1, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityPermille: 500},
{Name: "Day", BackdropID: 2, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityPermille: 500},
},
}
base.CommandMeta = CommandMeta{CommandID: "dry-collectibles", Actor: "ops", Reason: "pool", DryRun: true}
preview, err := svc.PublishStarGiftCollectibles(context.Background(), base)
if err != nil || gifts.createCalls != 0 || preview.Details["models"] == nil {
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
}
base.CommandMeta = CommandMeta{CommandID: "exec-collectibles", Actor: "ops", Reason: "pool", DryRun: false}
result, err := svc.PublishStarGiftCollectibles(context.Background(), base)
if err != nil || gifts.createCalls != 1 || result.Details["revision_id"] != "33" || result.Details["published"] != true {
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
}
}
func TestPublishStarGiftCollectiblesRejectsUnsafeClientPreviewPool(t *testing.T) {
valid := func() PublishStarGiftCollectiblesRequest {
return PublishStarGiftCollectiblesRequest{
CommandMeta: CommandMeta{CommandID: "unsafe-pool", Actor: "ops", Reason: "regression", DryRun: true},
GiftID: 11, UpgradeStars: 125, SupplyTotal: 100, SlugPrefix: "cake",
Models: []StarGiftCollectibleAnimationUpload{
{Name: "Ruby", RarityPermille: 500, FileName: "ruby.lottie", Data: []byte("ruby")},
{Name: "Sapphire", RarityPermille: 500, FileName: "sapphire.lottie", Data: []byte("sapphire")},
},
Patterns: []StarGiftCollectibleAnimationUpload{
{Name: "Stars", RarityPermille: 500, FileName: "stars.lottie", Data: []byte("stars")},
{Name: "Moons", RarityPermille: 500, FileName: "moons.lottie", Data: []byte("moons")},
},
Backdrops: []StarGiftCollectibleBackdropInput{
{Name: "Night", BackdropID: 1, RarityPermille: 500},
{Name: "Day", BackdropID: 2, RarityPermille: 500},
},
}
}
tests := map[string]func(*PublishStarGiftCollectiblesRequest){
"single model": func(req *PublishStarGiftCollectiblesRequest) { req.Models = req.Models[:1] },
"single pattern": func(req *PublishStarGiftCollectiblesRequest) { req.Patterns = req.Patterns[:1] },
"single backdrop": func(req *PublishStarGiftCollectiblesRequest) { req.Backdrops = req.Backdrops[:1] },
"duplicate backdrop id": func(req *PublishStarGiftCollectiblesRequest) {
req.Backdrops[1].BackdropID = req.Backdrops[0].BackdropID
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
req := valid()
mutate(&req)
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: &fakeGiftsService{}, Now: fixedNow})
if _, err := svc.PublishStarGiftCollectibles(context.Background(), req); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
})
}
}
func TestImportOfficialStarGiftPreservesCraftedRarityAndPublishesBundle(t *testing.T) {
permille := 922
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
ManifestSHA256: bytesOf(0x42, 32),
SourceJSON: []byte(`{"id":5170145012310081615,"limited":true,"sold_out":true,"availability_total":10,"availability_resale":4}`),
Gift: officialgifts.Gift{
ID: 5170145012310081615, Stars: 50, ConvertStars: 25, UpgradeStars: 100, DocumentID: 1,
Limited: true, SoldOut: true, AvailabilityTotal: 10, AvailabilityRemains: 0,
AvailabilityResale: 4, FirstSaleDate: 100, LastSaleDate: 200, ResellMinStars: 75,
},
BaseDocument: officialgifts.Document{ID: 1, FileName: "gift.tgs", SHA256: strings.Repeat("a", 64), Data: []byte("gift")},
Collectible: &officialgifts.CollectibleSet{
Models: []officialgifts.Model{
{Name: "Regular", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 2, FileName: "regular.tgs", SHA256: strings.Repeat("b", 64), Data: []byte("regular")}},
{Name: "Regular Two", DocumentID: 5, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 5, FileName: "regular-two.tgs", SHA256: strings.Repeat("e", 64), Data: []byte("regular-two")}},
{Name: "Crafted", DocumentID: 3, Crafted: true, Rarity: officialgifts.Rarity{Kind: "legendary"}, Document: officialgifts.Document{ID: 3, FileName: "crafted.tgs", SHA256: strings.Repeat("c", 64), Data: []byte("crafted")}},
},
Patterns: []officialgifts.Pattern{
{Name: "Pattern", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 4, FileName: "pattern.tgs", SHA256: strings.Repeat("d", 64), Data: []byte("pattern")}},
{Name: "Pattern Two", DocumentID: 6, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: officialgifts.Document{ID: 6, FileName: "pattern-two.tgs", SHA256: strings.Repeat("f", 64), Data: []byte("pattern-two")}},
},
Backdrops: []officialgifts.Backdrop{
{Name: "Black", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
{Name: "White", BackdropID: 1, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
},
},
}}
gifts := &fakeGiftsService{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: gifts, OfficialGifts: source, Now: fixedNow})
req := ImportOfficialStarGiftRequest{SourceGiftID: "5170145012310081615", Enabled: true, IncludeCollectible: true}
req.CommandMeta = CommandMeta{CommandID: "dry-official", Actor: "ops", Reason: "official snapshot", DryRun: true}
preview, err := svc.ImportOfficialStarGift(context.Background(), req)
if err != nil || gifts.createCalls != 0 || preview.Details["crafted_models"] != 1 {
t.Fatalf("preview=%+v err=%v create=%d", preview, err, gifts.createCalls)
}
req.CommandMeta = CommandMeta{CommandID: "exec-official", Actor: "ops", Reason: "official snapshot", DryRun: false}
result, err := svc.ImportOfficialStarGift(context.Background(), req)
if err != nil || gifts.createCalls != 1 || result.Details["collectible_revision_id"] != "33" {
t.Fatalf("result=%+v err=%v create=%d", result, err, gifts.createCalls)
}
models := gifts.lastBundle.Collectible.Models
if len(models) != 3 || !models[2].Crafted || models[2].RarityKind != domain.StarGiftRarityLegendary || models[2].RarityPermille != 0 ||
models[0].RarityPermille != 922 || gifts.lastBundle.Collectible.Backdrops[0].BackdropID != 0 {
t.Fatalf("imported models=%+v backdrops=%+v", models, gifts.lastBundle.Collectible.Backdrops)
}
catalog := gifts.lastBundle.Catalog
if catalog.Limited || catalog.SoldOut || catalog.AvailabilityTotal != 0 || catalog.AvailabilityRemains != 0 ||
catalog.AvailabilityResale != 0 || catalog.FirstSaleDate != 0 || catalog.LastSaleDate != 0 || catalog.ResellMinStars != 0 {
t.Fatalf("official global market state leaked into local catalog: %+v", catalog)
}
if catalog.OfficialGiftID != source.bundle.Gift.ID || !bytes.Equal(catalog.OfficialSourceJSON, source.bundle.SourceJSON) ||
!bytes.Equal(catalog.SourceManifestSHA256, source.bundle.ManifestSHA256) {
t.Fatalf("official provenance was not preserved: %+v", catalog)
}
}
func TestImportDefaultStarGiftPublishesThroughRealService(t *testing.T) {
ctx := context.Background()
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: giftService, Now: fixedNow})
if list := svc.DefaultStarGifts(); len(list) != 3 {
t.Fatalf("default gifts = %d, want 3", len(list))
}
// Dry-run must not write to the catalog.
dry := ImportDefaultStarGiftRequest{ID: 3, CommandMeta: CommandMeta{CommandID: "dry-default-3", Actor: "ops", Reason: "demo", DryRun: true}}
if _, err := svc.ImportDefaultStarGift(ctx, dry); err != nil {
t.Fatalf("dry run: %v", err)
}
if cat, _ := giftService.Catalog(ctx); len(cat) != 0 {
t.Fatalf("dry run wrote %d gifts", len(cat))
}
// Confirmed import-all creates the whole demo set, enabled per the request flag.
all := ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all", Actor: "ops", Reason: "demo"}, Enabled: true}
result, err := svc.ImportAllDefaultStarGifts(ctx, all)
if err != nil || result.Details["imported"] != 3 {
t.Fatalf("import all: result=%+v err=%v", result, err)
}
catalog, err := giftService.Catalog(ctx)
if err != nil || len(catalog) != 3 {
t.Fatalf("catalog=%d err=%v, want 3", len(catalog), err)
}
limited, premium, upgradeable := 0, 0, 0
var craftGiftID int64
for _, g := range catalog {
if g.Limited {
limited++
}
if g.RequirePremium {
premium++
}
if g.UpgradeStars > 0 {
upgradeable++
}
if g.Title == "OwpenGram Coin" {
craftGiftID = g.ID
}
}
if limited != 0 || premium != 0 || upgradeable != 2 {
t.Fatalf("stored flags limited=%d premium=%d upgradeable=%d", limited, premium, upgradeable)
}
// The craftable gift must carry a craft-only (named-rarity) model.
preview, ok, err := giftService.CollectiblePreview(ctx, craftGiftID)
if err != nil || !ok {
t.Fatalf("coin preview ok=%v err=%v", ok, err)
}
crafted := 0
for _, m := range preview.Models {
if m.Crafted {
crafted++
}
}
if crafted == 0 {
t.Fatalf("coin has no craft-only model: %+v", preview.Models)
}
// Re-running import-all is idempotent: everything already present -> skipped.
// Enabled must match the first run's value (true) or the per-gift replay
// hits COMMAND_ID_CONFLICT since the payload would differ from the cached one.
result, err = svc.ImportAllDefaultStarGifts(ctx, ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all-2", Actor: "ops", Reason: "demo"}, Enabled: true})
if err != nil || result.Details["imported"] != 0 || result.Details["skipped"] != 3 {
t.Fatalf("re-import: result=%+v err=%v", result, err)
}
}
// TestImportDefaultStarGiftRespectsEnabledFlag is a regression test: a single
// default gift import must honor the request's Enabled flag rather than
// always landing enabled (or, before this fix, always disabled regardless of
// the admin console checkbox).
func TestImportDefaultStarGiftRespectsEnabledFlag(t *testing.T) {
ctx := context.Background()
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: giftService, Now: fixedNow})
off := ImportDefaultStarGiftRequest{ID: 1, CommandMeta: CommandMeta{CommandID: "default-1-off", Actor: "ops", Reason: "demo"}, Enabled: false}
if _, err := svc.ImportDefaultStarGift(ctx, off); err != nil {
t.Fatalf("import disabled: %v", err)
}
on := ImportDefaultStarGiftRequest{ID: 2, CommandMeta: CommandMeta{CommandID: "default-2-on", Actor: "ops", Reason: "demo"}, Enabled: true}
if _, err := svc.ImportDefaultStarGift(ctx, on); err != nil {
t.Fatalf("import enabled: %v", err)
}
// Catalog() only ever returns enabled gifts, so presence/absence here
// directly proves whether the Enabled flag was honored.
catalog, err := giftService.Catalog(ctx)
if err != nil || len(catalog) != 1 {
t.Fatalf("catalog=%d err=%v, want 1 (only the enabled gift)", len(catalog), err)
}
if catalog[0].Title != "OwpenGram Star" {
t.Fatalf("unexpected gift in catalog: %q, want %q", catalog[0].Title, "OwpenGram Star")
}
}
func TestImportOfficialStarGiftPublishesThroughRealGiftService(t *testing.T) {
const lottie = `{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"layers":[{"ty":4}],"assets":[]}`
document := func(id int64, name string) officialgifts.Document {
raw := []byte(lottie)
sum := sha256.Sum256(raw)
return officialgifts.Document{ID: id, FileName: name, SHA256: hex.EncodeToString(sum[:]), Data: raw}
}
permille := 1000
source := &fakeOfficialGiftsSource{bundle: officialgifts.Bundle{
ManifestSHA256: bytesOf(0x24, sha256.Size),
SourceJSON: []byte(`{"id":6003643167683903930,"title":"Party Sparkler"}`),
Gift: officialgifts.Gift{
ID: 6003643167683903930, Title: "Party Sparkler", Stars: 15, ConvertStars: 13,
UpgradeStars: 25, AvailabilityTotal: 400000, DocumentID: 1,
},
BaseDocument: document(1, "gift.json"),
Collectible: &officialgifts.CollectibleSet{
Models: []officialgifts.Model{
{Name: "Model", DocumentID: 2, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(2, "model.json")},
{Name: "Model Two", DocumentID: 4, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(4, "model-two.json")},
},
Patterns: []officialgifts.Pattern{
{Name: "Pattern", DocumentID: 3, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(3, "pattern.json")},
{Name: "Pattern Two", DocumentID: 5, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}, Document: document(5, "pattern-two.json")},
},
Backdrops: []officialgifts.Backdrop{
{Name: "Backdrop", BackdropID: 0, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
{Name: "Backdrop Two", BackdropID: 1, Rarity: officialgifts.Rarity{Kind: "permille", Permille: &permille}},
},
},
}}
ctx := context.Background()
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(), Gifts: giftService, OfficialGifts: source, Now: fixedNow,
})
req := ImportOfficialStarGiftRequest{
SourceGiftID: "6003643167683903930", Enabled: true, IncludeCollectible: true,
CommandMeta: CommandMeta{CommandID: "exec-official-real-service", Actor: "ops", Reason: "regression", DryRun: false},
}
result, err := svc.ImportOfficialStarGift(ctx, req)
if err != nil {
t.Fatalf("import official collectible through real service: result=%+v err=%v", result, err)
}
catalog, err := giftService.Catalog(ctx)
if err != nil || len(catalog) != 1 {
t.Fatalf("catalog=%+v err=%v, want one imported gift", catalog, err)
}
preview, ok, err := giftService.CollectiblePreview(ctx, catalog[0].ID)
if err != nil || !ok || len(preview.Models) != 2 || len(preview.Patterns) != 2 {
t.Fatalf("preview=%+v ok=%v err=%v", preview, ok, err)
}
model := preview.Models[0].Document
pattern := preview.Patterns[0].Document
if model == nil || !model.IsSticker() || model.IsCustomEmoji() || pattern == nil ||
pattern.IsSticker() || !pattern.IsCustomEmoji() || len(pattern.Thumbs) != 1 ||
pattern.Thumbs[0].Kind != domain.PhotoSizeKindPath || len(pattern.Thumbs[0].Bytes) == 0 {
t.Fatalf("materialized model=%+v pattern=%+v", model, pattern)
}
}
type adminGiftBlob struct{ data map[string][]byte }
func (b *adminGiftBlob) Name() string { return "localfs" }
func (b *adminGiftBlob) Put(_ context.Context, data []byte) (string, error) {
sum := sha256.Sum256(data)
key := hex.EncodeToString(sum[:])
b.data[key] = append([]byte(nil), data...)
return key, nil
}
func (b *adminGiftBlob) Get(_ context.Context, key string) ([]byte, error) {
return append([]byte(nil), b.data[key]...), nil
}
func bytesOf(value byte, count int) []byte {
out := make([]byte, count)
for i := range out {
out[i] = value
}
return out
}
type fakeOfficialGiftsSource struct{ bundle officialgifts.Bundle }
func (f *fakeOfficialGiftsSource) List(context.Context) ([]officialgifts.GiftSummary, error) {
return nil, nil
}
func (f *fakeOfficialGiftsSource) Bundle(_ context.Context, giftID int64, include bool) (officialgifts.Bundle, error) {
if giftID != f.bundle.Gift.ID {
return officialgifts.Bundle{}, officialgifts.ErrNotFound
}
out := f.bundle
if !include {
out.Collectible = nil
}
return out, nil
}
type fakeGiftsService struct {
createCalls int
lastBundle domain.StarGiftCatalogBundleWrite
}
func (f *fakeGiftsService) GiftByID(_ context.Context, id int64) (domain.StarGift, bool, error) {
if id <= 0 {
return domain.StarGift{}, false, nil
}
return domain.StarGift{ID: id, Stars: 50, Title: "Test Gift"}, true, nil
}
func (f *fakeGiftsService) PrepareAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
sum := sha256.Sum256(data)
return domain.StarGiftAnimation{
SourceName: name, SourceFormat: domain.StarGiftAnimationLottie,
JSON: []byte(`{"v":"5.7"}`), TGS: []byte("tgs"), SHA256: sum[:], Width: 512, Height: 512, FrameRate: 30,
}, nil
}
func (f *fakeGiftsService) Catalog(context.Context) ([]domain.StarGift, error) {
return nil, nil
}
func (f *fakeGiftsService) PrepareOfficialAnimation(name string, data []byte) (domain.StarGiftAnimation, error) {
return f.PrepareAnimation(name, data)
}
func (f *fakeGiftsService) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
f.createCalls++
return domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Stars}, Revision: 1}, nil
}
func (f *fakeGiftsService) CreateCatalogBundle(_ context.Context, write domain.StarGiftCatalogBundleWrite) (domain.StarGiftCatalogBundleResult, error) {
f.createCalls++
f.lastBundle = write
entry := domain.StarGiftCatalogEntry{Gift: domain.StarGift{ID: 11, RevisionID: 22, Stars: write.Catalog.Stars}, Revision: 1}
result := domain.StarGiftCatalogBundleResult{Catalog: entry}
if write.Collectible != nil {
revision := domain.StarGiftCollectibleRevision{ID: 33, GiftID: 11, Revision: 1, Published: true}
result.Collectible = &revision
}
return result, nil
}
func (*fakeGiftsService) SetCatalogEnabled(context.Context, int64, bool) (bool, error) {
return true, nil
}
func (*fakeGiftsService) SetCatalogSortOrder(context.Context, int64, int) (bool, error) {
return true, nil
}
func (*fakeGiftsService) AnimationJSON(context.Context, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7"}`), true, nil
}
func (f *fakeGiftsService) CreateCollectibleRevision(_ context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
f.createCalls++
return domain.StarGiftCollectibleRevision{ID: 33, GiftID: write.GiftID, Revision: 2, Published: true}, nil
}
func (*fakeGiftsService) CollectiblePreview(context.Context, int64) (domain.StarGiftUpgradePreview, bool, error) {
return domain.StarGiftUpgradePreview{}, false, nil
}
func (*fakeGiftsService) CollectibleAnimationJSON(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
return []byte(`{"v":"5.7"}`), true, nil
}
func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.Channel) error {
f.channels = append(f.channels, ch.ID)
return nil
@ -1627,82 +1107,6 @@ func (f *fakeCollectibleUsernamesService) byID(id int64) domain.CollectibleUsern
return domain.CollectibleUsername{}
}
// fakeAccountRatingService recomputes from the manual component only, which
// keeps the arithmetic in the domain and the fake focused on ledger replay.
type fakeAccountRatingService struct {
ratings map[int64]domain.AccountRating
manual map[int64]int64
commandKeys map[string]bool
recomputeCalls int
adjustCalls int
}
func newFakeAccountRating() *fakeAccountRatingService {
return &fakeAccountRatingService{
ratings: map[int64]domain.AccountRating{},
manual: map[int64]int64{},
commandKeys: map[string]bool{},
}
}
func (f *fakeAccountRatingService) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
rating, ok := f.ratings[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
func (f *fakeAccountRatingService) Recompute(_ context.Context, userID int64) (domain.AccountRating, error) {
f.recomputeCalls++
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{
UserID: userID, StarsReceived: 5000, Manual: f.manual[userID],
}, domain.DefaultAccountRatingWeights(), fixedNow())
rating.Version = f.ratings[userID].Version + 1
f.ratings[userID] = rating
return rating, nil
}
func (f *fakeAccountRatingService) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
f.adjustCalls++
if err := req.Validate(); err != nil {
return domain.AccountRating{}, false, err
}
applied := true
if f.commandKeys[req.CommandKey] {
applied = false
} else {
f.commandKeys[req.CommandKey] = true
f.manual[req.UserID] += req.Amount
}
rating, err := f.Recompute(ctx, req.UserID)
if err != nil {
return domain.AccountRating{}, applied, err
}
return rating, applied, nil
}
func (f *fakeAccountRatingService) List(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
out := make([]domain.AccountRating, 0, len(f.ratings))
for _, rating := range f.ratings {
if rating.Level < filter.MinLevel {
continue
}
out = append(out, rating)
}
sort.Slice(out, func(i, j int) bool { return out[i].UserID < out[j].UserID })
return out, nil
}
func (f *fakeAccountRatingService) Events(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
if f.manual[userID] == 0 {
return nil, nil
}
return []domain.AccountRatingEvent{{
ID: 1, UserID: userID, Kind: domain.AccountRatingEventManual, Amount: f.manual[userID],
}}, nil
}
func TestMintCollectibleUsernameDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
@ -1929,101 +1333,7 @@ func TestCollectibleUsernameByIDUsesKeysetFallback(t *testing.T) {
}
}
func TestAdjustAccountRatingDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
rating := newFakeAccountRating()
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
dry, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "dry-adjust", Actor: "ops", Reason: "penalty", DryRun: true},
UserID: 1001, Amount: -2500,
})
if err != nil {
t.Fatalf("dry-run adjust: %v", err)
}
if rating.adjustCalls != 0 || dry.Details["previous_found"] != false || dry.Details["amount"] != "-2500" {
t.Fatalf("dry-run adjust result=%+v adjustCalls=%d", dry, rating.adjustCalls)
}
execReq := AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "exec-adjust", Actor: "ops", Reason: "penalty"},
UserID: 1001, Amount: -2500,
}
exec, err := svc.AdjustAccountRating(ctx, execReq)
if err != nil {
t.Fatalf("execute adjust: %v", err)
}
if rating.adjustCalls != 1 || exec.Details["applied"] != true ||
exec.Details["manual_component"] != "-2500" || exec.Details["stars"] != "2500" {
t.Fatalf("execute adjust result=%+v adjustCalls=%d", exec, rating.adjustCalls)
}
again, err := svc.AdjustAccountRating(ctx, execReq)
if err != nil {
t.Fatalf("replay adjust: %v", err)
}
if !again.AlreadyExecuted || rating.adjustCalls != 1 || rating.manual[1001] != -2500 {
t.Fatalf("replay adjust result=%+v adjustCalls=%d manual=%d", again, rating.adjustCalls, rating.manual[1001])
}
for _, amount := range []int64{0, maxAccountRatingAdjustment + 1, -maxAccountRatingAdjustment - 1} {
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "bad-adjust", Actor: "ops", Reason: "invalid"},
UserID: 1001, Amount: amount,
}); err == nil || !strings.Contains(err.Error(), CodeRatingAdjustmentInvalid) {
t.Fatalf("adjust by %d err=%v, want %s", amount, err, CodeRatingAdjustmentInvalid)
}
}
if _, journalled := repo.items["bad-adjust"]; journalled {
t.Fatal("journalled a rejected adjustment")
}
}
func TestRecomputeAccountRatingDryRunAndExecute(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
rating := newFakeAccountRating()
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
if _, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "rc-invalid", Actor: "ops", Reason: "support"},
}); err == nil || !strings.Contains(err.Error(), "user_id") {
t.Fatalf("recompute without user err=%v", err)
}
dry, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "rc-dry", Actor: "ops", Reason: "support", DryRun: true},
UserID: 1001,
})
if err != nil {
t.Fatalf("dry-run recompute: %v", err)
}
if rating.recomputeCalls != 0 || dry.Details["previous_found"] != false {
t.Fatalf("dry-run recompute result=%+v recomputeCalls=%d", dry, rating.recomputeCalls)
}
exec, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "rc-exec", Actor: "ops", Reason: "support"},
UserID: 1001,
})
if err != nil {
t.Fatalf("execute recompute: %v", err)
}
if rating.recomputeCalls != 1 || exec.Details["stars"] != "5000" || exec.Details["version"] != "1" {
t.Fatalf("execute recompute result=%+v recomputeCalls=%d", exec, rating.recomputeCalls)
}
stored, err := svc.AccountRating(ctx, 1001)
if err != nil || stored.Stars != 5000 {
t.Fatalf("AccountRating = %+v err=%v", stored, err)
}
if events, err := svc.AccountRatingEvents(ctx, 1001, 10); err != nil || len(events) != 0 {
t.Fatalf("AccountRatingEvents = %+v err=%v, want an empty ledger", events, err)
}
}
func TestCollectibleAndRatingCommandsRequireConfiguredDependencies(t *testing.T) {
func TestCollectibleCommandsRequireConfiguredDependencies(t *testing.T) {
ctx := context.Background()
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
if _, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
@ -2032,18 +1342,9 @@ func TestCollectibleAndRatingCommandsRequireConfiguredDependencies(t *testing.T)
}); err == nil || !strings.Contains(err.Error(), "collectible username dependency") {
t.Fatalf("mint without dependency err=%v", err)
}
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "c-2", Actor: "ops", Reason: "x"},
UserID: 1001, Amount: 5,
}); err == nil || !strings.Contains(err.Error(), "account rating dependency") {
t.Fatalf("adjust without dependency err=%v", err)
}
if _, err := svc.CollectibleUsernames(ctx, domain.CollectibleUsernameFilter{}); err == nil {
t.Fatal("listing without dependency succeeded")
}
if _, err := svc.AccountRatings(ctx, domain.AccountRatingFilter{}); err == nil {
t.Fatal("rating listing without dependency succeeded")
}
}
// TestDeleteCollectibleUsernameCommand covers the hard-delete command: the