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, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), 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)))))) 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") }