Ctrl K
ComunidadGuías…

internal/store/postgres/bot_stars_wallet_integration_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"errors"
	"testing"
	"time"

	"telesrv/internal/domain"
)

// The bot wallet is a separate bucket from the bot user identity's personal
// Stars balance, credits are idempotent per invoice key, and lifetime revenue
// must stay monotonic across refunds.

func TestBotStarsWalletCreditIsIdempotentPerInvoice(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	suffix := randomSuffix(t)
	now := int(time.Now().Unix())
	users := NewUserStore(pool)
	payer := createTestUser(t, ctx, users, "+1892"+suffix+"01", "InvoicePayer", "")
	bot := createTestUser(t, ctx, users, "+1892"+suffix+"02", "InvoiceBot", "")

	lifecycle := NewStarGiftLifecycleStore(pool, NewMessageStore(pool), 1_000_000)

	// A bot that was never paid reads zero instead of erroring.
	if balance, err := lifecycle.BotStarsBalance(ctx, bot.ID); err != nil || balance != 0 {
		t.Fatalf("fresh bot balance = %d, err = %v, want 0, nil", balance, err)
	}

	credit := domain.BotStarsCredit{
		BotUserID: bot.ID, PayerUserID: payer.ID, Amount: 250,
		Reason: domain.StarsReasonBotInvoice, InvoiceKey: "inv-" + suffix, Date: now,
	}
	balance, credited, err := lifecycle.CreditBotStarsWallet(ctx, credit)
	if err != nil || !credited || balance != 250 {
		t.Fatalf("credit = %d, %v, %v, want 250, true, nil", balance, credited, err)
	}

	// A retried payments.sendPaymentForm must not mint a second credit.
	balance, credited, err = lifecycle.CreditBotStarsWallet(ctx, credit)
	if err != nil || credited || balance != 250 {
		t.Fatalf("replayed credit = %d, %v, %v, want 250, false, nil", balance, credited, err)
	}
	if balance, err := lifecycle.BotStarsBalance(ctx, bot.ID); err != nil || balance != 250 {
		t.Fatalf("balance after replay = %d, err = %v, want 250, nil", balance, err)
	}
	if revenue, err := lifecycle.BotStarsOverallRevenue(ctx, bot.ID); err != nil || revenue != 250 {
		t.Fatalf("revenue after replay = %d, err = %v, want 250, nil", revenue, err)
	}

	// A second, distinct invoice accumulates.
	second := credit
	second.Amount = 100
	second.InvoiceKey = "inv2-" + suffix
	if balance, credited, err := lifecycle.CreditBotStarsWallet(ctx, second); err != nil || !credited || balance != 350 {
		t.Fatalf("second credit = %d, %v, %v, want 350, true, nil", balance, credited, err)
	}

	// The wallet is separate from the payer's and the bot's personal balances:
	// crediting revenue must not move any personal Stars row.
	if balance, err := NewStarsStore(pool).GetBalance(ctx, payer.ID); err == nil && balance.Balance > 0 {
		t.Fatalf("payer personal balance = %d, want 0: bot revenue must stay separate", balance.Balance)
	}
}

func TestBotStarsWalletRefundKeepsRevenueMonotonicAndRejectsOverdraft(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	suffix := randomSuffix(t)
	now := int(time.Now().Unix())
	users := NewUserStore(pool)
	payer := createTestUser(t, ctx, users, "+1893"+suffix+"01", "RefundPayer", "")
	bot := createTestUser(t, ctx, users, "+1893"+suffix+"02", "RefundBot", "")

	lifecycle := NewStarGiftLifecycleStore(pool, NewMessageStore(pool), 1_000_000)
	if _, _, err := lifecycle.CreditBotStarsWallet(ctx, domain.BotStarsCredit{
		BotUserID: bot.ID, PayerUserID: payer.ID, Amount: 400,
		Reason: domain.StarsReasonBotInvoice, InvoiceKey: "refund-" + suffix, Date: now,
	}); err != nil {
		t.Fatalf("credit: %v", err)
	}

	refund := domain.BotStarsCredit{
		BotUserID: bot.ID, PayerUserID: payer.ID, Amount: -150,
		Reason: domain.StarsReasonBotRefund, InvoiceKey: "refund2-" + suffix, Date: now,
	}
	balance, credited, err := lifecycle.CreditBotStarsWallet(ctx, refund)
	if err != nil || !credited || balance != 250 {
		t.Fatalf("refund = %d, %v, %v, want 250, true, nil", balance, credited, err)
	}
	// Overall revenue is lifetime positive income: a refund lowers the balance
	// but must never lower the reported revenue.
	if revenue, err := lifecycle.BotStarsOverallRevenue(ctx, bot.ID); err != nil || revenue != 400 {
		t.Fatalf("revenue after refund = %d, err = %v, want 400, nil", revenue, err)
	}
	// A refund cannot overdraw the wallet.
	overdraw := refund
	overdraw.Amount = -1000
	overdraw.InvoiceKey = "refund3-" + suffix
	if _, _, err := lifecycle.CreditBotStarsWallet(ctx, overdraw); !errors.Is(err, domain.ErrStarsInsufficient) {
		t.Fatalf("overdraw err = %v, want ErrStarsInsufficient", err)
	}
	if balance, err := lifecycle.BotStarsBalance(ctx, bot.ID); err != nil || balance != 250 {
		t.Fatalf("balance after rejected overdraft = %d, err = %v, want 250, nil", balance, err)
	}
}

func TestBotStarsRevenueSeriesGroupsByUTCDay(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	suffix := randomSuffix(t)
	now := int(time.Now().Unix())
	users := NewUserStore(pool)
	payer := createTestUser(t, ctx, users, "+1895"+suffix+"01", "SeriesPayer", "")
	bot := createTestUser(t, ctx, users, "+1895"+suffix+"02", "SeriesBot", "")

	lifecycle := NewStarGiftLifecycleStore(pool, NewMessageStore(pool), 1_000_000)
	// 2023-11-14T22:00:00Z and the two following days, so the UTC bucketing is
	// observable rather than accidentally aligned to the insert time.
	day := (1700006400 / 86400) * 86400
	for i, step := range []struct {
		dayOffset int
		amount    int64
		reason    domain.StarsTransactionReason
	}{
		{0, 100, domain.StarsReasonBotInvoice},
		{0, 50, domain.StarsReasonBotInvoice},
		{86400, 200, domain.StarsReasonBotInvoice},
		{86400, -70, domain.StarsReasonBotRefund},
	} {
		if _, _, err := lifecycle.CreditBotStarsWallet(ctx, domain.BotStarsCredit{
			BotUserID: bot.ID, PayerUserID: payer.ID, Amount: step.amount,
			Reason: step.reason, InvoiceKey: suffix + "-series-" + string(rune('a'+i)), Date: day + step.dayOffset,
		}); err != nil {
			t.Fatalf("credit %d: %v", i, err)
		}
	}

	// A credit outside the requested window must not appear.
	if _, _, err := lifecycle.CreditBotStarsWallet(ctx, domain.BotStarsCredit{
		BotUserID: bot.ID, PayerUserID: payer.ID, Amount: 999,
		Reason: domain.StarsReasonBotInvoice, InvoiceKey: suffix + "-outside", Date: now,
	}); err != nil {
		t.Fatalf("outside credit: %v", err)
	}

	points, err := lifecycle.BotStarsRevenueSeries(ctx, bot.ID, day, day+86400)
	if err != nil {
		t.Fatalf("revenue series: %v", err)
	}
	if len(points) != 2 {
		t.Fatalf("series len = %d, want 2 days", len(points))
	}
	if points[0].DayStart != day || points[0].Amount != 150 || points[0].Spent != 0 {
		t.Fatalf("first day = %+v, want day=%d amount=150 withdrawn=0", points[0], day)
	}
	if points[1].DayStart != day+86400 || points[1].Amount != 200 || points[1].Spent != 70 {
		t.Fatalf("second day = %+v, want day=%d amount=200 withdrawn=70", points[1], day+86400)
	}
	// The out-of-window credit is excluded, proving the range filter works.
	for _, point := range points {
		if point.Amount == 999 {
			t.Fatal("out-of-window credit leaked into the series")
		}
	}

	// An inverted window must fail rather than scan the whole journal.
	if _, err := lifecycle.BotStarsRevenueSeries(ctx, bot.ID, day+86400, day); !errors.Is(err, domain.ErrStarGiftLifecycleInvalid) {
		t.Fatalf("inverted range err = %v, want ErrStarGiftLifecycleInvalid", err)
	}
	if _, err := lifecycle.BotStarsRevenueSeries(ctx, 0, day, day+86400); !errors.Is(err, domain.ErrStarGiftOwnerInvalid) {
		t.Fatalf("zero bot err = %v, want ErrStarGiftOwnerInvalid", err)
	}
}

func TestBotStarsWalletTransactionsPageAndRejectsInvalidCredit(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	suffix := randomSuffix(t)
	now := int(time.Now().Unix())
	users := NewUserStore(pool)
	payer := createTestUser(t, ctx, users, "+1894"+suffix+"01", "HistoryPayer", "")
	bot := createTestUser(t, ctx, users, "+1894"+suffix+"02", "HistoryBot", "")

	lifecycle := NewStarGiftLifecycleStore(pool, NewMessageStore(pool), 1_000_000)
	for i, amount := range []int64{30, 20} {
		if _, _, err := lifecycle.CreditBotStarsWallet(ctx, domain.BotStarsCredit{
			BotUserID: bot.ID, PayerUserID: payer.ID, Amount: amount,
			Reason: domain.StarsReasonBotInvoice, InvoiceKey: suffix + "-" + string(rune('a'+i)), Date: now,
		}); err != nil {
			t.Fatalf("credit %d: %v", i, err)
		}
	}

	page, err := lifecycle.BotStarsTransactions(ctx, bot.ID, domain.StarsTransactionQuery{
		Limit: 10, Direction: domain.StarsTransactionDirectionIncoming,
	})
	if err != nil {
		t.Fatalf("transactions: %v", err)
	}
	if len(page.Transactions) != 2 {
		t.Fatalf("transactions len = %d, want 2", len(page.Transactions))
	}
	if page.Balance != 50 {
		t.Fatalf("page balance = %d, want 50", page.Balance)
	}
	for _, txn := range page.Transactions {
		if txn.UserID != bot.ID || txn.Reason != domain.StarsReasonBotInvoice {
			t.Fatalf("transaction owner/reason = %d/%s, want %d/%s",
				txn.UserID, txn.Reason, bot.ID, domain.StarsReasonBotInvoice)
		}
	}

	// A malformed credit must never reach SQL.
	if _, _, err := lifecycle.CreditBotStarsWallet(ctx, domain.BotStarsCredit{
		BotUserID: bot.ID, PayerUserID: payer.ID, Amount: 10,
		Reason: domain.StarsReasonAdjust, InvoiceKey: "bad-" + suffix, Date: now,
	}); !errors.Is(err, domain.ErrStarGiftLifecycleInvalid) {
		t.Fatalf("unsupported reason err = %v, want ErrStarGiftLifecycleInvalid", err)
	}
	if _, _, err := lifecycle.CreditBotStarsWallet(ctx, domain.BotStarsCredit{
		BotUserID: 0, PayerUserID: payer.ID, Amount: 10,
		Reason: domain.StarsReasonBotInvoice, InvoiceKey: "bad2-" + suffix, Date: now,
	}); !errors.Is(err, domain.ErrStarGiftLifecycleInvalid) {
		t.Fatalf("zero bot err = %v, want ErrStarGiftLifecycleInvalid", err)
	}
}