Ctrl K
ComunidadGuías…

internal/store/postgres/channel_revenue_withdrawal_integration_test.go

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

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"bytes"
	"context"
	"errors"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"

	"telesrv/deploy"
	"telesrv/internal/domain"
)

// matureRevenuePasswordVersion backdates only the test fixture while bypassing
// the production credential-change trigger. testPool already requires the
// dedicated migration superuser because 0001 sets session_replication_role.
func matureRevenuePasswordVersion(t *testing.T, ctx context.Context, pool *pgxpool.Pool, userID int64) time.Time {
	t.Helper()
	tx, err := pool.Begin(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer tx.Rollback(ctx)
	if _, err := tx.Exec(ctx, `SET LOCAL session_replication_role=replica`); err != nil {
		t.Fatalf("disable password trigger for mature fixture: %v", err)
	}
	var changedAt time.Time
	if err := tx.QueryRow(ctx, `UPDATE account_passwords SET password_changed_at=now()-interval '48 hours'
WHERE user_id=$1 RETURNING password_changed_at`, userID).Scan(&changedAt); err != nil {
		t.Fatalf("backdate mature password fixture: %v", err)
	}
	if err := tx.Commit(ctx); err != nil {
		t.Fatal(err)
	}
	return changedAt
}

func matureRevenueAuthorization(t *testing.T, ctx context.Context, pool *pgxpool.Pool, userID int64) ([8]byte, time.Time) {
	t.Helper()
	keys := NewAuthKeyStore(pool)
	authKeyID := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
	if err := NewAuthorizationStore(pool).Bind(ctx, domain.Authorization{
		AuthKeyID: authKeyID, UserID: userID, Hash: time.Now().UnixNano(),
	}); err != nil {
		t.Fatalf("bind mature revenue authorization: %v", err)
	}
	var createdAt time.Time
	if err := pool.QueryRow(ctx, `UPDATE authorizations SET created_at=now()-interval '48 hours'
WHERE auth_key_id=$1 RETURNING created_at`, authKeyIDToInt64(authKeyID)).Scan(&createdAt); err != nil {
		t.Fatalf("backdate mature revenue authorization: %v", err)
	}
	return authKeyID, createdAt
}

func TestChannelRevenueWithdrawalDownRefusesCompletedClaimPostgres(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	digest := bytes.Repeat([]byte{0x84}, 32)
	if _, err := pool.Exec(ctx, `DELETE FROM channel_revenue_withdrawals WHERE token_digest=$1`, digest); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		_, _ = pool.Exec(ctx, `DELETE FROM channel_revenue_withdrawals WHERE token_digest=$1`, digest)
	})
	if _, err := pool.Exec(ctx, `INSERT INTO channel_revenue_withdrawals
    (token_digest,channel_id,creator_user_id,currency,amount,status,expires_at,created_at,
     completed_at,channel_transaction_id,user_transaction_id,channel_balance_after,user_balance_after)
VALUES($1,9184001,9184002,'stars',1,'completed',1700000900,1700000000,
       1700000010,9184003,9184004,0,1)`, digest); err != nil {
		t.Fatalf("seed completed withdrawal receipt: %v", err)
	}
	downSQL, err := deploy.Migrations.ReadFile("migrations/0179_channel_revenue_withdrawals.down.sql")
	if err != nil {
		t.Fatalf("read 0179 down: %v", err)
	}
	tx, err := pool.Begin(ctx)
	if err != nil {
		t.Fatal(err)
	}
	_, downErr := tx.Exec(ctx, string(downSQL))
	_ = tx.Rollback(ctx)
	if downErr == nil || !strings.Contains(downErr.Error(), "cannot downgrade channel revenue withdrawals after a completed claim") {
		t.Fatalf("0179 down error=%v, want completed-claim guard", downErr)
	}
	var receipts int
	if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_revenue_withdrawals WHERE token_digest=$1`, digest).Scan(&receipts); err != nil {
		t.Fatalf("withdrawal receipt table was not preserved: %v", err)
	}
	if receipts != 1 {
		t.Fatalf("completed withdrawal receipts=%d after rejected down, want 1", receipts)
	}
}

func TestChannelRevenueWithdrawalAggregatePostgres(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	suffix := randomSuffix(t)
	now := int(time.Now().Unix())
	users := NewUserStore(pool)
	creator := createTestUser(t, ctx, users, "+1887"+suffix+"01", "RevenueCreator", "")
	other := createTestUser(t, ctx, users, "+1887"+suffix+"02", "RevenueOther", "")
	created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
		CreatorUserID: creator.ID, Title: "Revenue " + suffix, Broadcast: true, Date: now,
	})
	if err != nil {
		t.Fatalf("create channel: %v", err)
	}
	channelID := created.Channel.ID
	lifecycle := NewStarGiftLifecycleStore(pool, nil, 0)
	if _, err := pool.Exec(ctx, `INSERT INTO account_passwords(user_id,has_password) VALUES($1,true)`, creator.ID); err != nil {
		t.Fatalf("create revenue 2FA state: %v", err)
	}
	passwordChangedAt := matureRevenuePasswordVersion(t, ctx, pool, creator.ID)
	creatorAuthKeyID, creatorAuthCreatedAt := matureRevenueAuthorization(t, ctx, pool, creator.ID)
	otherAuthKeyID, otherAuthCreatedAt := matureRevenueAuthorization(t, ctx, pool, other.ID)
	t.Cleanup(func() {
		_, _ = pool.Exec(ctx, `DELETE FROM channel_revenue_withdrawals WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(ctx, `DELETE FROM channel_stars_transactions WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(ctx, `DELETE FROM channel_stars_balances WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(ctx, `DELETE FROM channel_ton_transactions WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(ctx, `DELETE FROM channel_ton_balances WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(ctx, `DELETE FROM stars_transactions WHERE user_id=$1`, creator.ID)
		_, _ = pool.Exec(ctx, `DELETE FROM stars_balances WHERE user_id=$1`, creator.ID)
		_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id=$1`, creator.ID)
		_, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id=$1`, creator.ID)
		_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id=$1`, channelID)
		_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1::bigint[])`, []int64{creator.ID, other.ID})
	})
	stalePasswordChangedAt := passwordChangedAt
	if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_verifier=$2 WHERE user_id=$1`, creator.ID, []byte{1}); err != nil {
		t.Fatalf("change revenue password version: %v", err)
	}
	if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id=$1`, creator.ID).Scan(&passwordChangedAt); err != nil {
		t.Fatalf("read changed revenue 2FA version: %v", err)
	}
	if passwordChangedAt.Equal(stalePasswordChangedAt) {
		t.Fatal("password_changed_at did not advance after verifier change")
	}
	var passwordStateChanged *domain.ChannelRevenuePasswordStateChangedError
	if _, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
		Amount: 1, PasswordChangedAt: stalePasswordChangedAt, AuthKeyID: creatorAuthKeyID,
		AuthorizationCreatedAt: creatorAuthCreatedAt, TokenDigest: bytes.Repeat([]byte{0x30}, 32),
		Date: now, ExpiresAt: now + 900,
	}); !errors.As(err, &passwordStateChanged) || !passwordStateChanged.HasPassword ||
		!passwordStateChanged.PasswordChangedAt.Equal(passwordChangedAt) {
		t.Fatalf("stale password admission err=%v detail=%+v", err, passwordStateChanged)
	}
	passwordChangedAt = matureRevenuePasswordVersion(t, ctx, pool, creator.ID)
	if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,100)`, channelID); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO channel_ton_balances(channel_id,balance_nanoton) VALUES($1,900)`, channelID); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_transactions
(channel_id,actor_user_id,amount,reason,date) VALUES($1,$2,100,'gift',$3)`, channelID, creator.ID, now); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO channel_ton_transactions
(channel_id,actor_user_id,amount_nanoton,reason,date) VALUES($1,$2,900,'gift',$3)`, channelID, creator.ID, now); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO stars_balances(user_id,balance,granted) VALUES($1,7,true)`, creator.ID); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,5,true)`, creator.ID); err != nil {
		t.Fatal(err)
	}

	starsDigest := bytes.Repeat([]byte{0x31}, 32)
	issued, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
		Amount: 40, PasswordChangedAt: passwordChangedAt, AuthKeyID: creatorAuthKeyID,
		AuthorizationCreatedAt: creatorAuthCreatedAt, TokenDigest: starsDigest, Date: now, ExpiresAt: now + 900,
	})
	if err != nil || issued.Amount != 40 || issued.Status != "pending" {
		t.Fatalf("issue stars withdrawal = %+v err=%v", issued, err)
	}
	if _, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: other.ID, Currency: domain.ChannelRevenueStars,
		Amount: 1, PasswordChangedAt: passwordChangedAt, AuthKeyID: otherAuthKeyID,
		AuthorizationCreatedAt: otherAuthCreatedAt, TokenDigest: bytes.Repeat([]byte{0x32}, 32), Date: now, ExpiresAt: now + 900,
	}); !errors.Is(err, domain.ErrChannelRevenueWithdrawalInvalid) {
		t.Fatalf("non-creator issue err=%v, want invalid", err)
	}

	completed, err := lifecycle.CompleteChannelRevenueWithdrawal(ctx, starsDigest, now+1)
	if err != nil || completed.Status != "completed" || completed.ChannelBalanceAfter != 60 || completed.UserBalanceAfter != 47 ||
		completed.ChannelTransactionID <= 0 || completed.UserTransactionID <= 0 {
		t.Fatalf("complete stars withdrawal = %+v err=%v", completed, err)
	}
	replayed, err := lifecycle.CompleteChannelRevenueWithdrawal(ctx, starsDigest, now+2)
	if err != nil || replayed.ChannelTransactionID != completed.ChannelTransactionID || replayed.UserTransactionID != completed.UserTransactionID ||
		replayed.CompletedAt != completed.CompletedAt {
		t.Fatalf("replay stars withdrawal = %+v err=%v, want receipt %+v", replayed, err, completed)
	}
	var channelTxnCount, userTxnCount int
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_stars_transactions WHERE channel_id=$1 AND reason='withdrawal'`, channelID).Scan(&channelTxnCount); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM stars_transactions WHERE user_id=$1 AND reason='withdrawal'`, creator.ID).Scan(&userTxnCount); err != nil {
		t.Fatal(err)
	}
	if channelTxnCount != 1 || userTxnCount != 1 {
		t.Fatalf("idempotent transaction counts channel=%d user=%d", channelTxnCount, userTxnCount)
	}
	if overall, err := lifecycle.ChannelStarsOverallRevenue(ctx, channelID); err != nil || overall != 100 {
		t.Fatalf("stars overall revenue=%d err=%v, want lifetime 100 after claim", overall, err)
	}

	tonDigest := bytes.Repeat([]byte{0x41}, 32)
	tonIssued, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueTON,
		PasswordChangedAt: passwordChangedAt, AuthKeyID: creatorAuthKeyID,
		AuthorizationCreatedAt: creatorAuthCreatedAt, TokenDigest: tonDigest, Date: now, ExpiresAt: now + 900,
	})
	if err != nil || tonIssued.Amount != 900 {
		t.Fatalf("issue full TON withdrawal = %+v err=%v", tonIssued, err)
	}
	tonCompleted, err := lifecycle.CompleteChannelRevenueWithdrawal(ctx, tonDigest, now+1)
	if err != nil || tonCompleted.ChannelBalanceAfter != 0 || tonCompleted.UserBalanceAfter != 905 {
		t.Fatalf("complete TON withdrawal = %+v err=%v", tonCompleted, err)
	}
	if overall, err := lifecycle.ChannelTonOverallRevenue(ctx, channelID); err != nil || overall != 900 {
		t.Fatalf("TON overall revenue=%d err=%v, want lifetime 900 after claim", overall, err)
	}

	// Concurrent confirmation requests serialize on the durable command and all
	// receive the exact same receipt; only one pair of ledger rows is created.
	concurrentDigest := bytes.Repeat([]byte{0x49}, 32)
	if _, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
		Amount: 10, PasswordChangedAt: passwordChangedAt, AuthKeyID: creatorAuthKeyID,
		AuthorizationCreatedAt: creatorAuthCreatedAt, TokenDigest: concurrentDigest, Date: now, ExpiresAt: now + 900,
	}); err != nil {
		t.Fatalf("issue concurrent case: %v", err)
	}
	const parallel = 8
	results := make(chan domain.ChannelRevenueWithdrawal, parallel)
	errs := make(chan error, parallel)
	var wg sync.WaitGroup
	for i := 0; i < parallel; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			result, completeErr := lifecycle.CompleteChannelRevenueWithdrawal(ctx, concurrentDigest, now+3)
			results <- result
			errs <- completeErr
		}()
	}
	wg.Wait()
	close(results)
	close(errs)
	for completeErr := range errs {
		if completeErr != nil {
			t.Fatalf("concurrent completion: %v", completeErr)
		}
	}
	var concurrentReceipt domain.ChannelRevenueWithdrawal
	for result := range results {
		if concurrentReceipt.ID == 0 {
			concurrentReceipt = result
		}
		if result.ChannelTransactionID != concurrentReceipt.ChannelTransactionID ||
			result.UserTransactionID != concurrentReceipt.UserTransactionID || result.CompletedAt != concurrentReceipt.CompletedAt {
			t.Fatalf("non-exact concurrent receipt: first=%+v got=%+v", concurrentReceipt, result)
		}
	}
	if concurrentReceipt.ChannelBalanceAfter != 50 || concurrentReceipt.UserBalanceAfter != 57 {
		t.Fatalf("concurrent receipt balances=%+v", concurrentReceipt)
	}

	// A command is not a reservation. If another legitimate debit consumes the
	// available balance before confirmation, completion fails with no partial
	// creator credit or withdrawal ledger row.
	insufficientDigest := bytes.Repeat([]byte{0x4a}, 32)
	if _, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
		Amount: 40, PasswordChangedAt: passwordChangedAt, AuthKeyID: creatorAuthKeyID,
		AuthorizationCreatedAt: creatorAuthCreatedAt, TokenDigest: insufficientDigest, Date: now, ExpiresAt: now + 900,
	}); err != nil {
		t.Fatalf("issue insufficient race case: %v", err)
	}
	tx, err := pool.Begin(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if _, err = tx.Exec(ctx, `UPDATE channel_stars_balances SET balance=5 WHERE channel_id=$1`, channelID); err == nil {
		_, err = tx.Exec(ctx, `INSERT INTO channel_stars_transactions
(channel_id,actor_user_id,amount,reason,date) VALUES($1,$2,-45,'adjust',$3)`, channelID, creator.ID, now+4)
	}
	if err != nil {
		_ = tx.Rollback(ctx)
		t.Fatal(err)
	}
	if err = tx.Commit(ctx); err != nil {
		t.Fatal(err)
	}
	if _, err := lifecycle.CompleteChannelRevenueWithdrawal(ctx, insufficientDigest, now+5); !errors.Is(err, domain.ErrChannelRevenueInsufficient) {
		t.Fatalf("balance-race completion err=%v, want insufficient", err)
	}
	var starsBalance, personalBalance, withdrawals int64
	if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, channelID).Scan(&starsBalance); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, creator.ID).Scan(&personalBalance); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_stars_transactions WHERE channel_id=$1 AND reason='withdrawal'`, channelID).Scan(&withdrawals); err != nil {
		t.Fatal(err)
	}
	pending, found, err := lifecycle.ResolveChannelRevenueWithdrawal(ctx, insufficientDigest)
	if err != nil || !found || pending.Status != "pending" || starsBalance != 5 || personalBalance != 57 || withdrawals != 2 {
		t.Fatalf("insufficient rollback pending=%+v found=%v balances=%d/%d withdrawals=%d err=%v",
			pending, found, starsBalance, personalBalance, withdrawals, err)
	}

	expiredDigest := bytes.Repeat([]byte{0x51}, 32)
	if _, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
		Amount: 1, PasswordChangedAt: passwordChangedAt, AuthKeyID: creatorAuthKeyID,
		AuthorizationCreatedAt: creatorAuthCreatedAt, TokenDigest: expiredDigest, Date: now, ExpiresAt: now + 1,
	}); err != nil {
		t.Fatalf("issue expiry case: %v", err)
	}
	if _, err := lifecycle.CompleteChannelRevenueWithdrawal(ctx, expiredDigest, now+1); !errors.Is(err, domain.ErrChannelRevenueWithdrawalExpired) {
		t.Fatalf("expired completion err=%v", err)
	}

	ownershipDigest := bytes.Repeat([]byte{0x52}, 32)
	if _, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
		Amount: 2, PasswordChangedAt: passwordChangedAt, AuthKeyID: creatorAuthKeyID,
		AuthorizationCreatedAt: creatorAuthCreatedAt, TokenDigest: ownershipDigest, Date: now, ExpiresAt: now + 900,
	}); err != nil {
		t.Fatalf("issue ownership-transfer case: %v", err)
	}
	if _, found, err := lifecycle.ResolveChannelRevenueWithdrawal(ctx, expiredDigest); err != nil || found {
		t.Fatalf("replacement retained stale pending token found=%v err=%v", found, err)
	}
	var liveStarCommands int
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_revenue_withdrawals
WHERE channel_id=$1 AND currency='stars' AND status='pending'`, channelID).Scan(&liveStarCommands); err != nil || liveStarCommands != 1 {
		t.Fatalf("live stars commands=%d err=%v, want bounded one", liveStarCommands, err)
	}
	if _, err := pool.Exec(ctx, `UPDATE channels SET creator_user_id=$2 WHERE id=$1`, channelID, other.ID); err != nil {
		t.Fatal(err)
	}
	if _, err := lifecycle.CompleteChannelRevenueWithdrawal(ctx, ownershipDigest, now+6); !errors.Is(err, domain.ErrChannelRevenueWithdrawalInvalid) {
		t.Fatalf("former creator completion err=%v, want invalid", err)
	}
	if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, channelID).Scan(&starsBalance); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, creator.ID).Scan(&personalBalance); err != nil {
		t.Fatal(err)
	}
	if starsBalance != 5 || personalBalance != 57 {
		t.Fatalf("ownership transfer moved funds: channel=%d former_creator=%d", starsBalance, personalBalance)
	}
	if _, err := pool.Exec(ctx, `UPDATE channels SET creator_user_id=$2 WHERE id=$1`, channelID, creator.ID); err != nil {
		t.Fatal(err)
	}

	// The durable creator id alone is insufficient: a creator who has left or
	// been demoted cannot issue or complete a claim through a stale projection.
	if _, err := pool.Exec(ctx, `UPDATE channel_members SET status='left' WHERE channel_id=$1 AND user_id=$2`, channelID, creator.ID); err != nil {
		t.Fatal(err)
	}
	if _, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
		Amount: 1, PasswordChangedAt: passwordChangedAt, AuthKeyID: creatorAuthKeyID,
		AuthorizationCreatedAt: creatorAuthCreatedAt, TokenDigest: bytes.Repeat([]byte{0x53}, 32), Date: now, ExpiresAt: now + 900,
	}); !errors.Is(err, domain.ErrChannelRevenueWithdrawalInvalid) {
		t.Fatalf("inactive creator member issue err=%v, want invalid", err)
	}
	if _, err := pool.Exec(ctx, `UPDATE channel_members SET status='active' WHERE channel_id=$1 AND user_id=$2`, channelID, creator.ID); err != nil {
		t.Fatal(err)
	}

	// A token issued while the creator is active must not credit a tombstoned
	// personal ledger after account deletion. Logical deletion deliberately
	// keeps creator_user_id and memberships, so the user lifecycle row is the
	// authoritative gate.
	deletedDigest := bytes.Repeat([]byte{0x54}, 32)
	if _, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
		Amount: 2, PasswordChangedAt: passwordChangedAt, AuthKeyID: creatorAuthKeyID,
		AuthorizationCreatedAt: creatorAuthCreatedAt, TokenDigest: deletedDigest, Date: now, ExpiresAt: now + 900,
	}); err != nil {
		t.Fatalf("issue pre-deletion claim: %v", err)
	}
	deletion, err := NewAccountLifecycleStore(pool).ExecuteAccountDeletion(ctx, creator.ID, domain.AccountDeletionManual,
		"revenue withdrawal test", time.Unix(int64(now+7), 0).UTC())
	if err != nil || !deletion.Changed {
		t.Fatalf("delete creator = %+v err=%v", deletion, err)
	}
	if _, err := lifecycle.CompleteChannelRevenueWithdrawal(ctx, deletedDigest, now+8); !errors.Is(err, domain.ErrChannelRevenueWithdrawalInvalid) {
		t.Fatalf("deleted creator completion err=%v, want invalid", err)
	}
	if _, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
		Amount: 1, PasswordChangedAt: passwordChangedAt, AuthKeyID: creatorAuthKeyID,
		AuthorizationCreatedAt: creatorAuthCreatedAt, TokenDigest: bytes.Repeat([]byte{0x55}, 32), Date: now + 8, ExpiresAt: now + 900,
	}); !errors.Is(err, domain.ErrChannelRevenueWithdrawalInvalid) {
		t.Fatalf("deleted creator issue err=%v, want invalid", err)
	}
	if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, channelID).Scan(&starsBalance); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, creator.ID).Scan(&personalBalance); err != nil {
		t.Fatal(err)
	}
	deletedPending, found, err := lifecycle.ResolveChannelRevenueWithdrawal(ctx, deletedDigest)
	if err != nil || !found || deletedPending.Status != "pending" || starsBalance != 5 || personalBalance != 57 {
		t.Fatalf("deleted creator moved funds: pending=%+v found=%v balances=%d/%d err=%v",
			deletedPending, found, starsBalance, personalBalance, err)
	}
}

func TestChannelRevenueWithdrawalSerializesWithAccountDeletionPostgres(t *testing.T) {
	pool := testPool(t)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	suffix := randomSuffix(t)
	now := int(time.Now().Unix())
	users := NewUserStore(pool)
	creator := createTestUser(t, ctx, users, "+1888"+suffix+"01", "RevenueRace", "")
	created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
		CreatorUserID: creator.ID, Title: "Revenue race " + suffix, Broadcast: true, Date: now,
	})
	if err != nil {
		t.Fatalf("create channel: %v", err)
	}
	channelID := created.Channel.ID
	lifecycle := NewStarGiftLifecycleStore(pool, nil, 0)
	if _, err := pool.Exec(ctx, `INSERT INTO account_passwords(user_id,has_password) VALUES($1,true)`, creator.ID); err != nil {
		t.Fatalf("create revenue race 2FA state: %v", err)
	}
	passwordChangedAt := matureRevenuePasswordVersion(t, ctx, pool, creator.ID)
	authKeyID, authorizationCreatedAt := matureRevenueAuthorization(t, ctx, pool, creator.ID)
	t.Cleanup(func() {
		cleanupCtx := context.Background()
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM channel_revenue_withdrawals WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM channel_stars_transactions WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM channel_stars_balances WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM stars_transactions WHERE user_id=$1`, creator.ID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM stars_balances WHERE user_id=$1`, creator.ID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM channels WHERE id=$1`, channelID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM users WHERE id=$1`, creator.ID)
	})
	if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,10)`, channelID); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_transactions
(channel_id,actor_user_id,amount,reason,date) VALUES($1,$2,10,'gift',$3)`, channelID, creator.ID, now); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO stars_balances(user_id,balance,granted) VALUES($1,0,true)`, creator.ID); err != nil {
		t.Fatal(err)
	}
	digest := bytes.Repeat([]byte{0x61}, 32)
	if _, err := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
		Amount: 10, PasswordChangedAt: passwordChangedAt, AuthKeyID: authKeyID,
		AuthorizationCreatedAt: authorizationCreatedAt, TokenDigest: digest, Date: now, ExpiresAt: now + 900,
	}); err != nil {
		t.Fatalf("issue race claim: %v", err)
	}

	type completionResult struct {
		value domain.ChannelRevenueWithdrawal
		err   error
	}
	type deletionResult struct {
		value domain.AccountDeletionResult
		err   error
	}
	start := make(chan struct{})
	completed := make(chan completionResult, 1)
	deleted := make(chan deletionResult, 1)
	go func() {
		<-start
		value, completeErr := lifecycle.CompleteChannelRevenueWithdrawal(ctx, digest, now+1)
		completed <- completionResult{value: value, err: completeErr}
	}()
	go func() {
		<-start
		value, deleteErr := NewAccountLifecycleStore(pool).ExecuteAccountDeletion(ctx, creator.ID,
			domain.AccountDeletionManual, "concurrent revenue claim", time.Unix(int64(now+1), 0).UTC())
		deleted <- deletionResult{value: value, err: deleteErr}
	}()
	close(start)
	completion := <-completed
	deletion := <-deleted
	if deletion.err != nil || !deletion.value.Changed {
		t.Fatalf("concurrent deletion = %+v err=%v", deletion.value, deletion.err)
	}
	if completion.err != nil && !errors.Is(completion.err, domain.ErrChannelRevenueWithdrawalInvalid) {
		t.Fatalf("concurrent completion err=%v", completion.err)
	}

	var channelBalance, userBalance, channelWithdrawals, userWithdrawals int64
	if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, channelID).Scan(&channelBalance); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, creator.ID).Scan(&userBalance); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_stars_transactions WHERE channel_id=$1 AND reason='withdrawal'`, channelID).Scan(&channelWithdrawals); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM stars_transactions WHERE user_id=$1 AND reason='withdrawal'`, creator.ID).Scan(&userWithdrawals); err != nil {
		t.Fatal(err)
	}
	stored, found, err := lifecycle.ResolveChannelRevenueWithdrawal(ctx, digest)
	if err != nil || !found {
		t.Fatalf("resolve raced command found=%v err=%v", found, err)
	}
	if completion.err == nil {
		// Completion acquired the active-user lock first and linearized before
		// deletion; deletion then tombstoned the already-credited account.
		if stored.Status != "completed" || channelBalance != 0 || userBalance != 10 ||
			channelWithdrawals != 1 || userWithdrawals != 1 {
			t.Fatalf("completion-first race stored=%+v balances=%d/%d ledgers=%d/%d", stored,
				channelBalance, userBalance, channelWithdrawals, userWithdrawals)
		}
	} else if stored.Status != "pending" || channelBalance != 10 || userBalance != 0 ||
		channelWithdrawals != 0 || userWithdrawals != 0 {
		t.Fatalf("deletion-first race stored=%+v balances=%d/%d ledgers=%d/%d", stored,
			channelBalance, userBalance, channelWithdrawals, userWithdrawals)
	}
}

func TestChannelRevenueWithdrawalSerializesWithPasswordChangePostgres(t *testing.T) {
	pool := testPool(t)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	suffix := randomSuffix(t)
	now := int(time.Now().Unix())
	creator := createTestUser(t, ctx, NewUserStore(pool), "+1889"+suffix+"01", "RevenuePasswordRace", "")
	created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
		CreatorUserID: creator.ID, Title: "Revenue password race " + suffix, Broadcast: true, Date: now,
	})
	if err != nil {
		t.Fatalf("create channel: %v", err)
	}
	channelID := created.Channel.ID
	lifecycle := NewStarGiftLifecycleStore(pool, nil, 0)
	t.Cleanup(func() {
		cleanupCtx := context.Background()
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM channel_revenue_withdrawals WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM channel_stars_balances WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM channels WHERE id=$1`, channelID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM users WHERE id=$1`, creator.ID)
	})
	if _, err := pool.Exec(ctx, `INSERT INTO account_passwords(user_id,has_password) VALUES($1,true)`, creator.ID); err != nil {
		t.Fatalf("create 2FA state: %v", err)
	}
	expectedPasswordChangedAt := matureRevenuePasswordVersion(t, ctx, pool, creator.ID)
	authKeyID, authorizationCreatedAt := matureRevenueAuthorization(t, ctx, pool, creator.ID)
	if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,10)`, channelID); err != nil {
		t.Fatal(err)
	}

	digest := bytes.Repeat([]byte{0x71}, 32)
	type issueResult struct {
		value domain.ChannelRevenueWithdrawal
		err   error
	}
	start := make(chan struct{})
	issued := make(chan issueResult, 1)
	passwordChanged := make(chan error, 1)
	go func() {
		<-start
		value, issueErr := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
			ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
			Amount: 1, PasswordChangedAt: expectedPasswordChangedAt, AuthKeyID: authKeyID,
			AuthorizationCreatedAt: authorizationCreatedAt, TokenDigest: digest,
			Date: now, ExpiresAt: now + 900,
		})
		issued <- issueResult{value: value, err: issueErr}
	}()
	go func() {
		<-start
		_, changeErr := pool.Exec(ctx, `UPDATE account_passwords SET srp_verifier=$2 WHERE user_id=$1`, creator.ID, []byte{2})
		passwordChanged <- changeErr
	}()
	close(start)
	issue := <-issued
	if err := <-passwordChanged; err != nil {
		t.Fatalf("concurrent password change: %v", err)
	}
	var currentPasswordChangedAt time.Time
	if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id=$1`, creator.ID).
		Scan(&currentPasswordChangedAt); err != nil {
		t.Fatal(err)
	}
	if currentPasswordChangedAt.Equal(expectedPasswordChangedAt) {
		t.Fatal("concurrent credential update did not advance password version")
	}
	var commands int
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_revenue_withdrawals WHERE token_digest=$1`, digest).Scan(&commands); err != nil {
		t.Fatal(err)
	}
	if issue.err == nil {
		// Issuance held the password row first and committed the command before
		// the change; the update then advanced the password version.
		if issue.value.Status != "pending" || commands != 1 {
			t.Fatalf("issue-first password race value=%+v commands=%d", issue.value, commands)
		}
		return
	}
	var stateChanged *domain.ChannelRevenuePasswordStateChangedError
	if !errors.As(issue.err, &stateChanged) || commands != 0 || !stateChanged.HasPassword ||
		!stateChanged.PasswordChangedAt.Equal(currentPasswordChangedAt) {
		t.Fatalf("password-first race issue=%v detail=%+v commands=%d current=%v", issue.err,
			stateChanged, commands, currentPasswordChangedAt)
	}
}

func TestChannelRevenueWithdrawalBindsFullyAuthorizedSessionPostgres(t *testing.T) {
	pool := testPool(t)
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()
	suffix := randomSuffix(t)
	now := int(time.Now().Unix())
	users := NewUserStore(pool)
	creator := createTestUser(t, ctx, users, "+1890"+suffix+"01", "RevenueSession", "")
	other := createTestUser(t, ctx, users, "+1890"+suffix+"02", "RevenueSessionOther", "")
	created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
		CreatorUserID: creator.ID, Title: "Revenue session " + suffix, Broadcast: true, Date: now,
	})
	if err != nil {
		t.Fatalf("create channel: %v", err)
	}
	channelID := created.Channel.ID
	lifecycle := NewStarGiftLifecycleStore(pool, nil, 0)
	if _, err := pool.Exec(ctx, `INSERT INTO account_passwords(user_id,has_password) VALUES($1,true)`, creator.ID); err != nil {
		t.Fatalf("create session-race 2FA state: %v", err)
	}
	passwordChangedAt := matureRevenuePasswordVersion(t, ctx, pool, creator.ID)
	if _, err := pool.Exec(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,10)`, channelID); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		cleanupCtx := context.Background()
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM channel_revenue_withdrawals WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM channel_stars_balances WHERE channel_id=$1`, channelID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM channels WHERE id=$1`, channelID)
		_, _ = pool.Exec(cleanupCtx, `DELETE FROM users WHERE id=ANY($1::bigint[])`, []int64{creator.ID, other.ID})
	})

	auths := NewAuthorizationStore(pool)
	keys := NewAuthKeyStore(pool)
	pendingKey := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
	if err := auths.Bind(ctx, domain.Authorization{
		AuthKeyID: pendingKey, UserID: creator.ID, Hash: time.Now().UnixNano(), PasswordPending: true,
	}); err != nil {
		t.Fatalf("bind pending authorization: %v", err)
	}
	if _, err := pool.Exec(ctx, `UPDATE authorizations SET created_at=now()-interval '48 hours'
WHERE auth_key_id=$1`, authKeyIDToInt64(pendingKey)); err != nil {
		t.Fatalf("age pending authorization: %v", err)
	}
	if err := auths.MarkPasswordPassed(ctx, pendingKey, creator.ID); err != nil {
		t.Fatalf("complete pending authorization: %v", err)
	}
	passed, found, err := auths.ByAuthKey(ctx, pendingKey)
	if err != nil || !found || passed.PasswordPending {
		t.Fatalf("completed pending authorization=%+v found=%v err=%v", passed, found, err)
	}
	pendingDigest := bytes.Repeat([]byte{0x81}, 32)
	_, err = lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
		ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
		Amount: 1, PasswordChangedAt: passwordChangedAt, AuthKeyID: pendingKey,
		AuthorizationCreatedAt: passed.CreatedAt, TokenDigest: pendingDigest, Date: now, ExpiresAt: now + 900,
	})
	var freshSession *domain.ChannelRevenueAuthorizationStateChangedError
	if !errors.As(err, &freshSession) || !freshSession.HasAuthorization || !freshSession.OwnerMatches ||
		freshSession.PasswordPending || !freshSession.CreatedAt.Equal(passed.CreatedAt) {
		t.Fatalf("post-2FA immediate issue err=%v detail=%+v, want fresh-session rejection", err, freshSession)
	}
	var commands int
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_revenue_withdrawals WHERE token_digest=$1`, pendingDigest).Scan(&commands); err != nil {
		t.Fatal(err)
	}
	if commands != 0 {
		t.Fatalf("fresh post-2FA session persisted %d withdrawal commands", commands)
	}

	// A rebind racing durable issuance has two legal serializations: issuance
	// commits under the still-current mature session, or issuance observes the
	// newly bound owner/timestamp and fails without leaving a bearer command.
	matureKey, matureCreatedAt := matureRevenueAuthorization(t, ctx, pool, creator.ID)
	raceDigest := bytes.Repeat([]byte{0x82}, 32)
	type issueResult struct {
		value domain.ChannelRevenueWithdrawal
		err   error
	}
	start := make(chan struct{})
	issued := make(chan issueResult, 1)
	rebound := make(chan error, 1)
	go func() {
		<-start
		value, issueErr := lifecycle.IssueChannelRevenueWithdrawal(ctx, domain.ChannelRevenueWithdrawalRequest{
			ChannelID: channelID, CreatorUserID: creator.ID, Currency: domain.ChannelRevenueStars,
			Amount: 1, PasswordChangedAt: passwordChangedAt, AuthKeyID: matureKey,
			AuthorizationCreatedAt: matureCreatedAt, TokenDigest: raceDigest, Date: now, ExpiresAt: now + 900,
		})
		issued <- issueResult{value: value, err: issueErr}
	}()
	go func() {
		<-start
		rebound <- auths.Bind(ctx, domain.Authorization{
			AuthKeyID: matureKey, UserID: other.ID, Hash: time.Now().UnixNano(),
		})
	}()
	close(start)
	issue := <-issued
	if err := <-rebound; err != nil {
		t.Fatalf("concurrent authorization rebind: %v", err)
	}
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_revenue_withdrawals WHERE token_digest=$1`, raceDigest).Scan(&commands); err != nil {
		t.Fatal(err)
	}
	if issue.err == nil {
		if issue.value.Status != "pending" || commands != 1 {
			t.Fatalf("issue-first authorization race value=%+v commands=%d", issue.value, commands)
		}
	} else {
		var changed *domain.ChannelRevenueAuthorizationStateChangedError
		if !errors.As(issue.err, &changed) || commands != 0 || !changed.HasAuthorization || changed.OwnerMatches {
			t.Fatalf("rebind-first authorization race err=%v detail=%+v commands=%d", issue.err, changed, commands)
		}
	}
	current, found, err := auths.ByAuthKey(ctx, matureKey)
	if err != nil || !found || current.UserID != other.ID || !current.CreatedAt.After(matureCreatedAt) {
		t.Fatalf("authorization after rebind=%+v found=%v err=%v", current, found, err)
	}
}