143 lines
5.5 KiB
Go
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)
|
|
}
|