234 lines
10 KiB
Go
234 lines
10 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"sync"
|
|
"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/models"
|
|
"apskel-pos-be/internal/repository"
|
|
)
|
|
|
|
// failingSettings fails for the outlet settings until healed, to stand in for the
|
|
// database being unreachable right after a payment.
|
|
type failingSettings struct {
|
|
real earningSettingsReader
|
|
mu sync.Mutex
|
|
fail bool
|
|
}
|
|
|
|
func (f *failingSettings) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
|
|
f.mu.Lock()
|
|
fail := f.fail
|
|
f.mu.Unlock()
|
|
if fail {
|
|
return nil, errors.New("connection refused")
|
|
}
|
|
return f.real.Outlet(ctx, outletID)
|
|
}
|
|
|
|
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
|
// internal/repository/wallet_repository_test.go.
|
|
func TestEarningProcessor_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)
|
|
ctx := context.Background()
|
|
|
|
org, user := uuid.New(), uuid.New()
|
|
earningOutlet, quietOutlet := uuid.New(), uuid.New()
|
|
regular, inactive := uuid.New(), uuid.New()
|
|
var walkIn uuid.UUID
|
|
var customers []uuid.UUID
|
|
exec := func(q string, args ...any) {
|
|
t.Helper()
|
|
require.NoError(t, db.Exec(q, args...).Error)
|
|
}
|
|
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'earning test', 'basic')`, org)
|
|
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test")
|
|
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang'), (?, ?, 'Tanpa Poin')`, earningOutlet, org, quietOutlet, org)
|
|
exec(`INSERT INTO customers (id, organization_id, name, is_default, is_active) VALUES
|
|
(?, ?, 'Budi', false, true), (?, ?, 'Nonaktif', false, false)`, regular, org, inactive, org)
|
|
// Creating the organization created its walk-in customer (trigger_create_default_customer).
|
|
var walkInID string
|
|
require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, org).Scan(&walkInID).Error)
|
|
walkIn = uuid.MustParse(walkInID)
|
|
customers = []uuid.UUID{regular, walkIn, inactive}
|
|
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 orders WHERE organization_id = ?`, org)
|
|
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
|
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{earningOutlet, quietOutlet})
|
|
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
|
db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{earningOutlet, quietOutlet})
|
|
db.Exec(`DELETE FROM users WHERE id = ?`, user)
|
|
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
|
})
|
|
|
|
txm := repository.NewTxManager(db)
|
|
settingsProcessor := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
|
outletSettings, err := settingsProcessor.Outlet(ctx, earningOutlet)
|
|
require.NoError(t, err)
|
|
outletSettings.Point.Enabled = true
|
|
outletSettings.Coin.Enabled = true
|
|
_, err = settingsProcessor.UpdateOutlet(ctx, org, earningOutlet, user, *outletSettings)
|
|
require.NoError(t, err)
|
|
|
|
settings := &failingSettings{real: settingsProcessor}
|
|
earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, NewWalletProcessor(repository.NewWalletRepository(db)), txm)
|
|
|
|
orderNo := 0
|
|
newOrder := func(outlet uuid.UUID, customer *uuid.UUID, paymentStatus string, isVoid bool, subtotal, discount float64) uuid.UUID {
|
|
t.Helper()
|
|
orderNo++
|
|
id := uuid.New()
|
|
exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
|
|
subtotal, discount_amount, tax_amount, total_amount, payment_status, is_void)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, ?, 0, ?, ?, ?)`,
|
|
id, org, outlet, user, customer, id.String()[:8]+"-"+string(rune('A'+orderNo)), subtotal, discount, subtotal-discount, paymentStatus, isVoid)
|
|
return id
|
|
}
|
|
earnRows := func(orderID uuid.UUID) map[string]int64 {
|
|
t.Helper()
|
|
var rows []struct {
|
|
Currency string
|
|
Amount int64
|
|
}
|
|
require.NoError(t, db.Raw(`SELECT currency, amount FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, orderID).Scan(&rows).Error)
|
|
out := map[string]int64{}
|
|
for _, r := range rows {
|
|
out[r.Currency] += r.Amount
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Paid in full: the PRD example, 875 EnakPoint and 3 EnakCoin.
|
|
paid := newOrder(earningOutlet, ®ular, "completed", false, 97500, 10000)
|
|
earning.OnOrderPaid(ctx, paid)
|
|
assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid))
|
|
|
|
var row struct {
|
|
Description string
|
|
Metadata string
|
|
OutletID string
|
|
}
|
|
require.NoError(t, db.Raw(`SELECT description, metadata::text AS metadata, outlet_id::text AS outlet_id FROM wallet_transactions
|
|
WHERE reference_id = ? AND currency = 'POINT'`, paid).Scan(&row).Error)
|
|
assert.Contains(t, row.Description, "di Kemang")
|
|
assert.Contains(t, row.Metadata, `"earn_per_amount": 100`, "the settings used are frozen on the row")
|
|
assert.Contains(t, row.Metadata, `"basis": 87500`)
|
|
assert.Equal(t, earningOutlet.String(), row.OutletID)
|
|
|
|
// Called again, and five times at once, it is still one earning.
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 5; i++ {
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); earning.OnOrderPaid(ctx, paid) }()
|
|
}
|
|
wg.Wait()
|
|
outcome, err := earning.EarnForOrder(ctx, paid)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(875), outcome.Points, "a repeat reports the first earning")
|
|
assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid))
|
|
|
|
// A self-order goes through the same payment path and the same rules.
|
|
selfOrder := newOrder(earningOutlet, ®ular, "completed", false, 25000, 0)
|
|
earning.OnOrderPaid(ctx, selfOrder)
|
|
assert.Equal(t, map[string]int64{"POINT": 250, "COIN": 1}, earnRows(selfOrder))
|
|
|
|
// A split bill earns once, on the payment that settles it: while partial, nothing.
|
|
split := newOrder(earningOutlet, ®ular, "partial", false, 60000, 0)
|
|
earning.OnOrderPaid(ctx, split)
|
|
assert.Empty(t, earnRows(split))
|
|
exec(`UPDATE orders SET payment_status = 'completed' WHERE id = ?`, split)
|
|
earning.OnOrderPaid(ctx, split)
|
|
assert.Equal(t, map[string]int64{"POINT": 600, "COIN": 2}, earnRows(split))
|
|
|
|
// Orders that must not earn.
|
|
for name, c := range map[string]struct {
|
|
id uuid.UUID
|
|
skip string
|
|
}{
|
|
"walk-in customer": {newOrder(earningOutlet, &walkIn, "completed", false, 50000, 0), EarningSkipDefaultCustomer},
|
|
"inactive customer": {newOrder(earningOutlet, &inactive, "completed", false, 50000, 0), EarningSkipInactiveCustomer},
|
|
"no customer": {newOrder(earningOutlet, nil, "completed", false, 50000, 0), EarningSkipNoCustomer},
|
|
"void": {newOrder(earningOutlet, ®ular, "completed", true, 50000, 0), EarningSkipVoid},
|
|
"unpaid": {newOrder(earningOutlet, ®ular, "pending", false, 50000, 0), EarningSkipNotPaid},
|
|
"outlet not earning": {newOrder(quietOutlet, ®ular, "completed", false, 50000, 0), EarningSkipNothingToEarn},
|
|
} {
|
|
outcome, err := earning.EarnForOrder(ctx, c.id)
|
|
require.NoError(t, err, name)
|
|
assert.Equal(t, c.skip, outcome.Skipped, name)
|
|
assert.Empty(t, earnRows(c.id), name)
|
|
}
|
|
|
|
// An earning that fails does not surface to the payment, and the job picks it up.
|
|
settings.mu.Lock()
|
|
settings.fail = true
|
|
settings.mu.Unlock()
|
|
missed := newOrder(earningOutlet, ®ular, "completed", false, 40000, 0)
|
|
assert.NotPanics(t, func() { earning.OnOrderPaid(ctx, missed) })
|
|
assert.Empty(t, earnRows(missed))
|
|
|
|
settings.mu.Lock()
|
|
settings.fail = false
|
|
settings.mu.Unlock()
|
|
since := time.Now().Add(-time.Hour)
|
|
checked, earned, err := earning.EarnMissing(ctx, since, 1000)
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, earned, 1)
|
|
assert.GreaterOrEqual(t, checked, earned)
|
|
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
|
|
|
|
// The job only looks at orders that could earn and have not.
|
|
candidates, err := repository.NewEarningRepository(db).ListPaidOrdersWithoutEarning(ctx, since, nil, 1000)
|
|
require.NoError(t, err)
|
|
var ours []uuid.UUID
|
|
for _, c := range candidates {
|
|
var n int64
|
|
db.Raw(`SELECT COUNT(*) FROM orders WHERE id = ? AND organization_id = ?`, c.ID, org).Scan(&n)
|
|
if n > 0 {
|
|
ours = append(ours, c.ID)
|
|
}
|
|
}
|
|
assert.Empty(t, ours, "every eligible order of ours has earned; walk-in, inactive, void, unpaid and non-earning outlets are never candidates")
|
|
|
|
// A second run finds nothing more to do for these orders.
|
|
_, _, err = earning.EarnMissing(ctx, since, 1000)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
|
|
|
|
// What the order screen and receipt show is what the ledger has.
|
|
shown, err := earning.EarnedByOrders(ctx, []uuid.UUID{paid, split, missed, uuid.New()})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, OrderEarned{Points: 875, Coins: 3}, shown[paid])
|
|
assert.Equal(t, OrderEarned{Points: 600, Coins: 2}, shown[split])
|
|
assert.Equal(t, OrderEarned{Points: 400, Coins: 1}, shown[missed])
|
|
assert.Len(t, shown, 3, "an order without earning is absent, so it shows 0")
|
|
|
|
balance := struct{ PointBalance, CoinBalance int64 }{}
|
|
require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, regular).Scan(&balance).Error)
|
|
assert.Equal(t, int64(875+250+600+400), balance.PointBalance)
|
|
assert.Equal(t, int64(3+1+2+1), balance.CoinBalance)
|
|
}
|
|
|
|
func (f *failingSettings) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
|
|
return f.real.Organization(ctx, organizationID)
|
|
}
|