Files
apskel-pos-backend/internal/processor/wallet_processor_db_test.go
T
2026-09-30 15:31:11 +07:00

143 lines
5.5 KiB
Go

package processor
import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/repository"
)
// Runs the engine against Postgres, to show the rows it writes pass the database
// constraints and reconcile the way §7.5 requires. Needs TEST_DATABASE_URL pointing
// at a migrated database; see internal/repository/wallet_repository_test.go.
func TestWalletProcessor_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
org, a, b := uuid.New(), uuid.New(), uuid.New()
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet test', 'basic')`, org).Error)
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, a, org, b, org).Error)
customers := []uuid.UUID{a, b}
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
p := NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
now := time.Now()
inTx := func(fn func(ctx context.Context) error) {
t.Helper()
require.NoError(t, txm.WithTransaction(context.Background(), fn))
}
// Without a transaction nothing moves.
_, err = p.Credit(context.Background(), earn(a, 10, nil))
assert.ErrorIs(t, err, repository.ErrWalletTxRequired)
var earned *WalletResult
inTx(func(ctx context.Context) error {
soon := now.Add(time.Hour)
earned, err = p.Credit(ctx, earn(a, 100, &soon))
require.NoError(t, err)
_, err = p.Credit(ctx, earn(a, 50, nil))
return err
})
// Transfer 120 from A to B, spanning both of A's lots.
inTx(func(ctx context.Context) error {
require.NoError(t, p.LockWallets(ctx, a, b))
group, outID, inID := uuid.New(), uuid.New(), uuid.New()
out, err := p.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
TransactionID: outID, CustomerID: a, Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeTransferOut, Amount: 120,
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID,
GroupID: &group, CounterpartyCustomerID: &b, Description: "Transfer ke B",
}})
require.NoError(t, err)
_, err = p.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
TransactionID: inID, CustomerID: b, Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeTransferIn, Amount: 120,
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID,
GroupID: &group, CounterpartyCustomerID: &a, Description: "Transfer dari A",
},
Lots: out.CarryOver(),
})
return err
})
// Reversing the 100 earned leaves A 30 short. The retry reads the shortfall back
// out of JSONB and takes nothing more.
rev := reversal(a, 100, earned)
rev.IdempotencyKey = "reverse:" + earned.Transaction.ID.String()
var first, second *WalletResult
inTx(func(ctx context.Context) error {
first, err = p.DebitUpTo(ctx, rev)
return err
})
inTx(func(ctx context.Context) error {
second, err = p.DebitUpTo(ctx, rev)
return err
})
assert.Equal(t, int64(-30), first.Transaction.Amount)
assert.Equal(t, int64(70), first.Shortfall)
assert.True(t, second.Replayed)
assert.Equal(t, int64(70), second.Shortfall)
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
// Overdraw fails and rolls back cleanly.
err = txm.WithTransaction(context.Background(), func(ctx context.Context) error {
_, err := p.Debit(ctx, pay(b, 121))
return err
})
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
var balances []struct {
CustomerID uuid.UUID
PointBalance int64
}
require.NoError(t, db.Raw(`SELECT customer_id, point_balance FROM customer_wallets WHERE customer_id IN ?`, customers).Scan(&balances).Error)
got := map[uuid.UUID]int64{}
for _, row := range balances {
got[row.CustomerID] = row.PointBalance
}
assert.Equal(t, map[uuid.UUID]int64{a: 0, b: 120}, got)
// §7.5, straight from the tables.
var broken []string
require.NoError(t, db.Raw(`
SELECT 'wallet ' || w.customer_id FROM customer_wallets w
WHERE w.customer_id IN ? AND (
w.point_balance <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions t WHERE t.customer_id = w.customer_id AND t.currency = 'POINT')
OR w.point_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'POINT'))
UNION ALL
SELECT 'lot ' || l.id FROM wallet_lots l
WHERE l.customer_id IN ? AND l.original_amount - l.remaining_amount
<> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.lot_id = l.id)
UNION ALL
SELECT 'debit ' || t.id FROM wallet_transactions t
WHERE t.customer_id IN ? AND t.amount < 0
AND -t.amount <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.transaction_id = t.id)`,
customers, customers, customers).Scan(&broken).Error)
assert.Empty(t, broken)
}