219 lines
9.8 KiB
Go
219 lines
9.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"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"
|
|
applogger "apskel-pos-be/internal/logger"
|
|
"apskel-pos-be/internal/processor"
|
|
"apskel-pos-be/internal/repository"
|
|
"apskel-pos-be/internal/service"
|
|
)
|
|
|
|
// Drives the customer wallet endpoints over HTTP down to Postgres, to show the app
|
|
// gets the right numbers from the new endpoints and from the old /points, /tokens
|
|
// and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see
|
|
// internal/repository/wallet_repository_test.go.
|
|
func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL not set")
|
|
}
|
|
applogger.Setup("fatal", "json") // the handlers log their errors; the server sets this up at startup
|
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
require.NoError(t, err)
|
|
|
|
org, customer, other := uuid.New(), uuid.New(), uuid.New()
|
|
customers := []uuid.UUID{customer, other}
|
|
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 (?, 'wallet api test', 'basic')`, org)
|
|
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, customer, org, other, org)
|
|
exec(`INSERT INTO organization_settings (organization_id, key, value) VALUES (?, ?, '100')`, org, constants.LoyaltyPointValueKey)
|
|
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 organization_settings WHERE organization_id = ?`, org)
|
|
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
|
})
|
|
|
|
// Balance: 300 earned in two lots that expire on the same Jakarta day but on
|
|
// different UTC days, 8 coins, 50 paid, and 20 in a lot that has expired but
|
|
// that the expiry job has not processed.
|
|
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
|
txm := repository.NewTxManager(db)
|
|
next := time.Now().Year() + 1
|
|
lateNightUTC := time.Date(next, 7, 1, 17, 30, 0, 0, time.UTC) // 2 July 00:30 WIB
|
|
afternoonUTC := time.Date(next, 7, 2, 10, 0, 0, 0, time.UTC) // 2 July 17:00 WIB
|
|
expired := time.Now().Add(-time.Hour)
|
|
outlet, order, payment := uuid.New(), uuid.New(), uuid.New()
|
|
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
|
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{
|
|
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
|
|
Type: constants.WalletTxTypeEarn, Amount: 300, ReferenceType: constants.WalletRefTypeOrder,
|
|
ReferenceID: order, OutletID: &outlet, Description: "Belanja #ORD-1"},
|
|
Lots: []processor.WalletLotInput{{Amount: 200, ExpiresAt: &afternoonUTC}, {Amount: 100, ExpiresAt: &lateNightUTC}},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
|
CustomerID: customer, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration,
|
|
Amount: 8, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: customer,
|
|
Description: "Saldo awal dari sistem lama"}}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
|
|
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
|
|
Amount: 50, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, OutletID: &outlet,
|
|
Description: "Bayar #ORD-2"}}); err != nil {
|
|
return err
|
|
}
|
|
_, err := wallet.Credit(ctx, processor.WalletCreditInput{
|
|
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
|
|
Type: constants.WalletTxTypeEarn, Amount: 20, ReferenceType: constants.WalletRefTypeOrder,
|
|
ReferenceID: uuid.New(), OutletID: &outlet, Description: "Belanja #ORD-0"},
|
|
Lots: []processor.WalletLotInput{{Amount: 20, ExpiresAt: &expired}},
|
|
})
|
|
return err
|
|
}))
|
|
|
|
h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
|
processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))),
|
|
nil,
|
|
)))
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
as := func(id uuid.UUID) gin.HandlerFunc {
|
|
return func(c *gin.Context) { c.Set("customer_id", id.String()) }
|
|
}
|
|
for _, who := range []struct {
|
|
prefix string
|
|
id uuid.UUID
|
|
}{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} {
|
|
g := router.Group(who.prefix, as(who.id))
|
|
g.GET("/points", h.GetCustomerPoints)
|
|
g.GET("/tokens", h.GetCustomerTokens)
|
|
g.GET("/wallet", h.GetCustomerWallet)
|
|
g.GET("/wallet/transactions", h.GetCustomerWalletTransactions)
|
|
}
|
|
get := func(path string) (int, map[string]any) {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
var body map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), rec.Body.String())
|
|
return rec.Code, body
|
|
}
|
|
// The balance endpoints wrap their data in {status, message, data}.
|
|
inner := func(body map[string]any) map[string]any {
|
|
return body["data"].(map[string]any)["data"].(map[string]any)
|
|
}
|
|
|
|
// Old app: /points and /tokens keep their shape and show the spendable balance.
|
|
status, body := get("/a/points")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable")
|
|
assert.Len(t, inner(body)["points_history"], 3)
|
|
|
|
status, body = get("/a/tokens")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 8, inner(body)["total_tokens"])
|
|
|
|
// /wallet: new fields, with the old ones still there.
|
|
status, body = get("/a/wallet")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
w := inner(body)
|
|
assert.EqualValues(t, 250, w["point_balance"])
|
|
assert.EqualValues(t, 8, w["coin_balance"])
|
|
assert.EqualValues(t, 100, w["point_value"])
|
|
assert.EqualValues(t, 25000, w["point_discount_value"])
|
|
nearest := w["nearest_expiring"].(map[string]any)
|
|
// The payment took 50 from the lot expiring first, leaving 50 + 200.
|
|
assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")},
|
|
nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days")
|
|
assert.Nil(t, nearest["coin"])
|
|
for _, legacy := range []string{"total_points", "total_tokens", "points_history", "tokens_history", "last_updated"} {
|
|
assert.Contains(t, w, legacy)
|
|
}
|
|
assert.EqualValues(t, 250, w["total_points"])
|
|
|
|
recent := w["recent_transactions"].([]any)
|
|
require.Len(t, recent, 4)
|
|
for _, raw := range recent {
|
|
tx := raw.(map[string]any)
|
|
assert.NotContains(t, tx, "counterparty_customer_id")
|
|
assert.NotContains(t, tx, "metadata")
|
|
if tx["amount"].(float64) > 0 {
|
|
assert.Contains(t, tx, "source", tx["type"])
|
|
assert.NotContains(t, tx, "destination")
|
|
} else {
|
|
assert.Equal(t, map[string]any{"type": "PAYMENT", "id": payment.String()}, tx["destination"])
|
|
assert.NotContains(t, tx, "source")
|
|
}
|
|
}
|
|
|
|
// History with filters.
|
|
status, body = get("/a/wallet/transactions?currency=POINT&type=EARN&limit=1&page=2")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
page := body["data"].(map[string]any)
|
|
assert.Equal(t, map[string]any{"page": float64(2), "limit": float64(1), "total_count": float64(2), "total_pages": float64(2)}, page["pagination"])
|
|
rows := page["data"].([]any)
|
|
require.Len(t, rows, 1)
|
|
first := rows[0].(map[string]any)
|
|
assert.Equal(t, "Belanja #ORD-1", first["description"], "newest first, so page 2 is the older earning")
|
|
assert.Equal(t, map[string]any{"type": "ORDER", "id": order.String()}, first["source"])
|
|
assert.Equal(t, lateNightUTC.Format(time.RFC3339), parseTime(t, first["expires_at"]).UTC().Format(time.RFC3339))
|
|
assert.Len(t, first["lots"], 2)
|
|
|
|
today := time.Now().In(time.FixedZone("WIB", 7*3600)).Format("2006-01-02")
|
|
status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=PAYMENT,MIGRATION")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 2, body["data"].(map[string]any)["pagination"].(map[string]any)["total_count"])
|
|
|
|
status, _ = get("/a/wallet/transactions?currency=GOLD")
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
|
status, _ = get("/a/wallet/transactions?limit=abc")
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
|
|
|
// A customer who has never had a wallet sees zeros, not an error.
|
|
status, body = get("/b/wallet")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 0, inner(body)["point_balance"])
|
|
assert.Equal(t, []any{}, inner(body)["recent_transactions"])
|
|
status, body = get("/b/wallet/transactions")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.Equal(t, []any{}, body["data"].(map[string]any)["data"])
|
|
|
|
// A token for a customer that no longer exists.
|
|
status, _ = get("/gone/wallet")
|
|
assert.Equal(t, http.StatusNotFound, status)
|
|
}
|
|
|
|
func parseTime(t *testing.T, v any) time.Time {
|
|
t.Helper()
|
|
s, ok := v.(string)
|
|
require.True(t, ok, "expected a time string, got %v", v)
|
|
parsed, err := time.Parse(time.RFC3339Nano, s)
|
|
require.NoError(t, err)
|
|
return parsed
|
|
}
|