Files
apskel-pos-backend/internal/handler/wallet_admin_db_test.go
T
efrilmandClaude Opus 5.5 8bf1d5c1a8 feat(loyalty): trace a wallet row lot by lot in the dashboard
Adds GET /marketing/wallet-transactions/:id/trace (docs/prd-point-coin.md
F7, §8.1, PC-404).

From any ledger row of the organization, the trace lists the lots a debit
took from, with how much it took from each, or the lots a credit created.
Each lot is followed back through origin_lot_id, across transfers,
exchanges and refunds, to the lot an EARN, ADJUSTMENT or MIGRATION first
created. Every step shows the lot and the row that created it, with the
real name of the customer it belongs to, so the example of §8 (A sends 120
to B, B pays 30) leads from B's payment to A's order #ORD-1.

Lots are loaded a generation at a time, and a chain stops at 100 steps or
at a lot it has already seen, which only bad data could cause. A row of
another organization answers 404.

The dashboard's wallet view now builds its lots with the same helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:25:17 +07:00

204 lines
9.3 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"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/appcontext"
"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"
"apskel-pos-be/internal/validator"
)
// Drives the dashboard wallet endpoints over HTTP down to Postgres. Needs
// TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
applogger.Setup("fatal", "json")
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
orgA, orgB := uuid.New(), uuid.New()
adminA, adminB := uuid.New(), uuid.New()
customer := uuid.New()
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 (?, 'A', 'basic'), (?, 'B', 'basic')`, orgA, orgB)
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES
(?, ?, 'Admin Satu', ?, 'x', 'admin'), (?, ?, 'Admin Lain', ?, 'x', 'admin')`,
adminA, orgA, adminA.String()+"@test", adminB, orgB, adminB.String()+"@test")
exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Budi Santoso', '081234567890')`, customer, orgA)
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id = ?)`, customer)
db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
db.Exec(`DELETE FROM users WHERE id IN ?`, []uuid.UUID{adminA, adminB})
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{orgA, orgB})
})
walletRepo := repository.NewWalletRepository(db)
queryRepo := repository.NewWalletQueryRepository(db)
txm := repository.NewTxManager(db)
wallet := processor.NewWalletProcessor(walletRepo)
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
outlet := uuid.New()
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn,
Amount: 100, ReferenceType: constants.WalletRefTypeOrder, ReferenceID: uuid.New(), OutletID: &outlet,
Description: "Belanja #ORD-1"}})
return err
}))
h := NewWalletAdminHandler(
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))),
validator.NewWalletValidator(),
)
gin.SetMode(gin.TestMode)
router := gin.New()
as := func(org, user uuid.UUID) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
ctx = context.WithValue(ctx, appcontext.UserIDKey, user.String())
c.Request = c.Request.WithContext(ctx)
}
}
for prefix, who := range map[string][2]uuid.UUID{"/a": {orgA, adminA}, "/b": {orgB, adminB}} {
g := router.Group(prefix, as(who[0], who[1]))
g.GET("/customers/:id/wallet", h.GetCustomerWallet)
g.POST("/customers/:id/wallet/adjust", h.AdjustCustomerWallet)
}
call := func(method, path string, body any) (int, map[string]any) {
t.Helper()
var buf bytes.Buffer
if body != nil {
require.NoError(t, json.NewEncoder(&buf).Encode(body))
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var out map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
return rec.Code, out
}
adjust := func(prefix string, body map[string]any) (int, map[string]any) {
return call(http.MethodPost, prefix+"/customers/"+customer.String()+"/wallet/adjust", body)
}
spendable := func() float64 {
t.Helper()
status, body := call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet", nil)
require.Equal(t, http.StatusOK, status, body)
return body["data"].(map[string]any)["spendable_point_balance"].(float64)
}
// Add 50.
status, body := adjust("/a", map[string]any{"currency": "point", "amount": 50, "reason": "komplain #45"})
require.Equal(t, http.StatusOK, status, body)
tx := body["data"].(map[string]any)["transaction"].(map[string]any)
assert.Equal(t, "ADJUSTMENT", tx["type"])
assert.EqualValues(t, 50, tx["amount"])
assert.Equal(t, "komplain #45", tx["reason"])
assert.Equal(t, "Koreksi oleh admin: komplain #45", tx["description"])
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, tx["created_by"])
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, tx["source"])
assert.EqualValues(t, 150, body["data"].(map[string]any)["spendable_point_balance"])
// Taking more than the customer can spend is refused and changes nothing.
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -151, "reason": "salah input"})
assert.Equal(t, http.StatusBadRequest, status, body)
assert.Equal(t, float64(150), spendable())
// Taking away, retried with the same key, happens once.
for i, wantReplayed := range []bool{false, true} {
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -30, "reason": "salah input", "idempotency_key": "req-1"})
require.Equal(t, http.StatusOK, status, body)
assert.Equal(t, wantReplayed, body["data"].(map[string]any)["replayed"], "call %d", i+1)
}
assert.Equal(t, float64(120), spendable())
// Bad requests.
for name, req := range map[string]map[string]any{
"cash-out": {"currency": "POINT", "amount": -10, "reason": "pencairan saldo"},
"zero amount": {"currency": "POINT", "amount": 0, "reason": "x"},
"no reason": {"currency": "POINT", "amount": 10},
"bad currency": {"currency": "GOLD", "amount": 10, "reason": "x"},
} {
status, _ = adjust("/a", req)
assert.Equal(t, http.StatusBadRequest, status, name)
}
status, _ = call(http.MethodPost, "/a/customers/not-a-uuid/wallet/adjust", map[string]any{"currency": "POINT", "amount": 1, "reason": "x"})
assert.Equal(t, http.StatusBadRequest, status)
assert.Equal(t, float64(120), spendable())
// Another organization's admin cannot see or touch this customer.
status, _ = call(http.MethodGet, "/b/customers/"+customer.String()+"/wallet", nil)
assert.Equal(t, http.StatusNotFound, status)
status, _ = adjust("/b", map[string]any{"currency": "POINT", "amount": 1000, "reason": "x"})
assert.Equal(t, http.StatusNotFound, status)
assert.Equal(t, float64(120), spendable())
// The dashboard view.
status, body = call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet?type=ADJUSTMENT", nil)
require.Equal(t, http.StatusOK, status, body)
w := body["data"].(map[string]any)
assert.Equal(t, map[string]any{"id": customer.String(), "name": "Budi Santoso", "phone": "081234567890"}, w["customer"])
assert.EqualValues(t, 120, w["point_balance"])
assert.EqualValues(t, 120, w["spendable_point_balance"])
lots := w["lots"].([]any)
var lotTotal float64
for _, l := range lots {
lotTotal += l.(map[string]any)["remaining_amount"].(float64)
}
assert.Equal(t, float64(120), lotTotal)
rows := w["transactions"].(map[string]any)["data"].([]any)
require.Len(t, rows, 2, "the two adjustments, newest first")
newest := rows[0].(map[string]any)
assert.EqualValues(t, -30, newest["amount"])
assert.Equal(t, "salah input", newest["reason"])
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, newest["created_by"])
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, newest["destination"])
// The customer's own history shows the adjustment too, with the reason in the
// description and without the admin's identity fields.
customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil)))
crouter := gin.New()
crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions)
rec := httptest.NewRecorder()
crouter.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/wallet/transactions?type=ADJUSTMENT", nil))
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var cbody map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cbody))
crows := cbody["data"].(map[string]any)["data"].([]any)
require.Len(t, crows, 2)
first := crows[0].(map[string]any)
assert.Equal(t, "Koreksi oleh admin: salah input", first["description"])
assert.NotContains(t, first, "created_by")
assert.NotContains(t, first, "reason")
}