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" applogger "apskel-pos-be/internal/logger" "apskel-pos-be/internal/middleware" "apskel-pos-be/internal/processor" "apskel-pos-be/internal/repository" "apskel-pos-be/internal/service" ) // Drives GET/PUT /marketing/loyalty-settings and its history over HTTP down to // Postgres. Needs TEST_DATABASE_URL pointing at a migrated database; see // internal/repository/wallet_repository_test.go. func TestOrganizationLoyaltySettingsEndpoints_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) org, admin, customer := uuid.New(), uuid.New(), 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')`, org) exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org) t.Cleanup(func() { 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 loyalty_setting_changes WHERE organization_id = ?`, org) db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org) db.Exec(`DELETE FROM customers WHERE id = ?`, customer) db.Exec(`DELETE FROM organizations WHERE id = ?`, org) }) // Balances in circulation: 50.000 EnakPoint and 105 EnakCoin. txm := repository.NewTxManager(db) wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) for _, c := range []struct { currency, ref string amount int64 }{{"POINT", "LEGACY_POINTS", 50000}, {"COIN", "LEGACY_TOKENS", 105}} { require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ CustomerID: customer, Currency: c.currency, Type: "MIGRATION", Amount: c.amount, ReferenceType: c.ref, ReferenceID: uuid.New(), Description: "Saldo awal", Metadata: map[string]interface{}{"frozen": true}}}) return err })) } settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db))) auth := middleware.NewAuthMiddleware(nil) gin.SetMode(gin.TestMode) router := gin.New() for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} { role := role g := router.Group(prefix, func(c *gin.Context) { ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String()) ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String()) ctx = context.WithValue(ctx, appcontext.UserRoleKey, role) c.Request = c.Request.WithContext(ctx) }) g.GET("/loyalty-settings", h.GetOrganizationSettings) g.PUT("/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOrganizationSettings) g.GET("/loyalty-settings/history", h.ListHistory) } call := func(method, path, body string) (int, map[string]any) { t.Helper() req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) 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 } data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) } status, body := call(http.MethodGet, "/manager/loyalty-settings", "") require.Equal(t, http.StatusOK, status, body) got := data(body) assert.EqualValues(t, 1, got["point_value"]) assert.Equal(t, map[string]any{"coin_amount": float64(1), "point_amount": float64(1)}, got["exchange"]) assert.Equal(t, true, got["transfer"].(map[string]any)["enabled"]) assert.Equal(t, "MONTH", got["point_expiry"].(map[string]any)["unit"]) assert.Equal(t, map[string]any{"user_daily_limit": float64(0), "global_daily_limit": float64(0)}, got["enakgame"], "0 is no limit") impact := got["impact"].(map[string]any) assert.EqualValues(t, 50000, impact["outstanding_points"]) assert.EqualValues(t, impact["point_rupiah_before"], impact["point_rupiah_after"]) // A dry run shows what the balances in circulation would be worth, and saves nothing. change := `{"point_value": 100, "exchange": {"coin_amount": 10, "point_amount": 1}}` status, body = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", change) require.Equal(t, http.StatusOK, status, body) got = data(body) assert.Equal(t, true, got["dry_run"]) impact = got["impact"].(map[string]any) assert.EqualValues(t, 50000, impact["point_rupiah_before"]) assert.EqualValues(t, 5000000, impact["point_rupiah_after"]) assert.EqualValues(t, 105, impact["coins_as_points_before"]) assert.EqualValues(t, 10, impact["coins_as_points_after"]) assert.Len(t, got["changes"], 2) status, body = call(http.MethodGet, "/manager/loyalty-settings", "") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 1, data(body)["point_value"], "a dry run saves nothing") status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 0, data(body)["pagination"].(map[string]any)["total_count"]) // Saving records every change with who made it. status, body = call(http.MethodPut, "/manager/loyalty-settings", change) require.Equal(t, http.StatusOK, status, body) assert.Equal(t, false, data(body)["dry_run"]) assert.EqualValues(t, 100, data(body)["point_value"]) status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "") require.Equal(t, http.StatusOK, status, body) history := data(body)["data"].([]any) require.Len(t, history, 2) var keys []string for _, raw := range history { row := raw.(map[string]any) keys = append(keys, row["key"].(string)) assert.Equal(t, admin.String(), row["changed_by"]) assert.Nil(t, row["old_value"], "was on its default") assert.Nil(t, row["outlet_id"]) } assert.ElementsMatch(t, []string{"loyalty.point.value", "loyalty.exchange.coin_amount"}, keys) // Nothing already written changes: balances and ledger rows keep their numbers. var rows []struct { Amount int64 Metadata string } require.NoError(t, db.Raw(`SELECT amount, metadata::text AS metadata FROM wallet_transactions WHERE customer_id = ? ORDER BY currency`, customer).Scan(&rows).Error) require.Len(t, rows, 2) assert.Equal(t, int64(105), rows[0].Amount) assert.Equal(t, int64(50000), rows[1].Amount) for _, r := range rows { assert.Contains(t, r.Metadata, `"frozen": true`) } // Out of bounds, unknown fields and the wrong role are refused. for name, bad := range map[string]string{ "point value 0": `{"point_value": 0}`, "exchange 0": `{"exchange": {"coin_amount": 0}}`, "expiry unit YEAR": `{"coin_expiry": {"unit": "YEAR"}}`, "unknown field": `{"point_valeu": 5}`, } { status, _ = call(http.MethodPut, "/manager/loyalty-settings", bad) assert.Equal(t, http.StatusBadRequest, status, name) status, _ = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", bad) assert.Equal(t, http.StatusBadRequest, status, name+" (dry run)") } status, _ = call(http.MethodPut, "/purchasing/loyalty-settings", `{"point_value": 5}`) assert.Equal(t, http.StatusForbidden, status) status, body = call(http.MethodGet, "/manager/loyalty-settings", "") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 100, data(body)["point_value"]) // The EnakGame limits (docs/rfc-enakgame.md ยง5.10) go through the same endpoint and // history. status, body = call(http.MethodPut, "/manager/loyalty-settings", `{"enakgame": {"user_daily_limit": 50, "global_daily_limit": 10000}}`) require.Equal(t, http.StatusOK, status, body) assert.Equal(t, map[string]any{"user_daily_limit": float64(50), "global_daily_limit": float64(10000)}, data(body)["enakgame"]) var limits []struct{ Key, NewValue string } require.NoError(t, db.Raw(`SELECT key, new_value FROM loyalty_setting_changes WHERE organization_id = ? AND key LIKE 'enakgame.%' ORDER BY key`, org).Scan(&limits).Error) assert.Equal(t, []struct{ Key, NewValue string }{{"enakgame.limit.global_daily", "10000"}, {"enakgame.limit.user_daily", "50"}}, limits) status, _ = call(http.MethodPut, "/manager/loyalty-settings", `{"enakgame": {"user_daily_limit": -1}}`) assert.Equal(t, http.StatusBadRequest, status) status, _ = call(http.MethodPut, "/purchasing/loyalty-settings", `{"enakgame": {"user_daily_limit": 1}}`) assert.Equal(t, http.StatusForbidden, status) }