package handler import ( "bytes" "context" "encoding/json" "mime/multipart" "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" "golang.org/x/crypto/bcrypt" "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" ) // enakGameHandlers builds both EnakGame handlers on db, the way app.go does. func enakGameHandlers(db *gorm.DB) (*EnakGameAdminHandler, *EnakGameCustomerHandler) { txm := repository.NewTxManager(db) audit := processor.NewAuditLogger(repository.NewAuditLogRepository(db)) games := repository.NewEnakGameRepository(db) budgets := repository.NewGameBudgetRepository(db) vouchers := repository.NewVoucherRepository(db) wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) customers := repository.NewWalletMoveRepository(db) spendable := repository.NewWalletQueryRepository(db) metrics := processor.NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(db)) admin := NewEnakGameAdminHandler(service.NewEnakGameAdminService( processor.NewEnakGameAdminProcessor(games, audit, txm), processor.NewGameBudgetProcessor(budgets, audit, txm), metrics, processor.NewGameBudgetControllerProcessor(budgets, metrics, games, audit, txm), processor.NewGameEventProcessor(repository.NewGameEventRepository(db), games, budgets, audit, txm), processor.NewVoucherAdminProcessor(vouchers, audit, txm), processor.NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(db)))) customer := NewEnakGameCustomerHandler(service.NewEnakGameCustomerService( processor.NewGameSessionProcessor(customers, games, repository.NewGameSessionRepository(db), budgets, repository.NewGameEventRepository(db), repository.NewGameRewardCounterRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), spendable, wallet, audit, txm), processor.NewVoucherRedemptionProcessor(customers, vouchers, repository.NewVoucherRedemptionRepository(db), processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), nil, nil), spendable, wallet, processor.VoucherProviders{}, txm))) return admin, customer } // Drives /marketing/enakgame and /customer/enakgame over HTTP down to Postgres // (docs/rfc-enakgame.md §11). Needs TEST_DATABASE_URL; see // internal/repository/wallet_repository_test.go. func TestEnakGameEndpoints_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() require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame http', 'basic')`, org).Error) require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error) t.Cleanup(func() { for _, q := range []string{ `DELETE FROM audit_logs WHERE organization_id = ?`, `DELETE FROM game_reward_counters WHERE organization_id = ?`, `DELETE FROM game_sessions WHERE organization_id = ?`, `DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, `DELETE FROM wallet_lots WHERE organization_id = ?`, `DELETE FROM wallet_transactions WHERE organization_id = ?`, `DELETE FROM customer_wallets WHERE organization_id = ?`, `DELETE FROM game_event_games WHERE event_id IN (SELECT id FROM game_events WHERE organization_id = ?)`, `DELETE FROM game_events WHERE organization_id = ?`, `DELETE FROM game_reward_configs WHERE organization_id = ?`, `DELETE FROM game_budgets WHERE organization_id = ?`, `DELETE FROM games WHERE organization_id = ?`, `DELETE FROM customers WHERE organization_id = ?`, `DELETE FROM organizations WHERE id = ?`, } { db.Exec(q, org) } }) txm := repository.NewTxManager(db) wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) adminHandler, customerHandler := enakGameHandlers(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+"/enakgame", 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.POST("/games", adminHandler.CreateGame) g.GET("/games/:id", adminHandler.GetGame) g.PUT("/games/:id", adminHandler.UpdateGame) g.PUT("/games/:id/status", adminHandler.SetGameStatus) g.POST("/games/:id/reward-configs", auth.RequireLoyaltyManager(), adminHandler.CreateRewardConfig) g.POST("/reward-configs/:id/activate", auth.RequireLoyaltyManager(), adminHandler.ActivateRewardConfig) g.POST("/budgets", auth.RequireLoyaltyManager(), adminHandler.CreateBudget) g.GET("/budgets/:id/metrics", adminHandler.BudgetMetrics) } c := router.Group("/customer/enakgame", func(c *gin.Context) { c.Set("customer_id", customer.String()) }) c.GET("/games", customerHandler.ListGames) c.POST("/sessions", customerHandler.StartSession) c.GET("/sessions/:id", customerHandler.GetSession) call := func(method, path, body string, headers ...string) (int, map[string]any) { t.Helper() req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) for i := 0; i+1 < len(headers); i += 2 { req.Header.Set(headers[i], headers[i+1]) } 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.MethodPost, "/manager/enakgame/games", `{"name": "Runner", "slug": "runner", "entry_cost": 2, "status": "ACTIVE", "result_rules": {"max_score": 5000, "outcomes": ["WIN", "LOSE"]}}`) require.Equal(t, http.StatusOK, status, body) gameID := data(body)["id"].(string) assert.Equal(t, map[string]any{"max_score": float64(5000), "outcomes": []any{"WIN", "LOSE"}}, data(body)["result_rules"]) status, _ = call(http.MethodPost, "/manager/enakgame/games", `{"name": "X", "slug": "x", "entry_cost": 1, "entry_cots": 2}`) assert.Equal(t, http.StatusBadRequest, status, "a misspelt field is refused") status, _ = call(http.MethodPost, "/manager/enakgame/games", `{"name": "X", "slug": "runner", "entry_cost": 1}`) assert.Equal(t, http.StatusBadRequest, status, "slug in use") status, _ = call(http.MethodGet, "/manager/enakgame/games/"+uuid.NewString(), "") assert.Equal(t, http.StatusNotFound, status) status, _ = call(http.MethodGet, "/manager/enakgame/games/not-a-uuid", "") assert.Equal(t, http.StatusBadRequest, status) // A change keeps the fields it does not send. status, body = call(http.MethodPut, "/manager/enakgame/games/"+gameID, `{"entry_cost": 3}`) require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 3, data(body)["entry_cost"]) assert.Equal(t, "runner", data(body)["slug"]) assert.EqualValues(t, 5000, data(body)["result_rules"].(map[string]any)["max_score"]) // Reward configurations and budgets need a loyalty manager. config := `{"reward_type": "OUTCOME_BASED", "rules": {"outcomes": {"WIN": 10, "LOSE": 1}}, "max_reward": 10}` status, _ = call(http.MethodPost, "/purchasing/enakgame/games/"+gameID+"/reward-configs", config) assert.Equal(t, http.StatusForbidden, status) status, body = call(http.MethodPost, "/manager/enakgame/games/"+gameID+"/reward-configs", config) require.Equal(t, http.StatusOK, status, body) status, body = call(http.MethodPost, "/manager/enakgame/reward-configs/"+data(body)["id"].(string)+"/activate", "") require.Equal(t, http.StatusOK, status, body) assert.Equal(t, "ACTIVE", data(body)["status"]) // The customer cannot start before the period has a budget. require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ CustomerID: customer, Currency: "COIN", Type: "MIGRATION", Amount: 10, ReferenceType: "LEGACY_TOKENS", ReferenceID: uuid.New(), Description: "Saldo awal"}}) return err })) start := `{"game_id": "` + gameID + `"}` status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k1") assert.Equal(t, http.StatusBadRequest, status, body) now := time.Now().In(time.FixedZone("WIB", 7*3600)) first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) budget := `{"scope": "GLOBAL", "name": "Bulan ini", "period_start": "` + first.Format("2006-01-02") + `", "period_end": "` + first.AddDate(0, 1, -1).Format("2006-01-02") + `", "amount": 1000000}` status, _ = call(http.MethodPost, "/purchasing/enakgame/budgets", budget) assert.Equal(t, http.StatusForbidden, status) status, body = call(http.MethodPost, "/manager/enakgame/budgets", budget) require.Equal(t, http.StatusOK, status, body) status, body = call(http.MethodGet, "/purchasing/enakgame/budgets/"+data(body)["id"].(string)+"/metrics", "") require.Equal(t, http.StatusOK, status, body) assert.Equal(t, "HEALTHY", data(body)["status"]) assert.EqualValues(t, 1000000, data(body)["remaining"]) status, _ = call(http.MethodGet, "/manager/enakgame/budgets/"+uuid.NewString()+"/metrics", "") assert.Equal(t, http.StatusNotFound, status) status, body = call(http.MethodGet, "/customer/enakgame/games", "") require.Equal(t, http.StatusOK, status, body) listed := body["data"].([]any) require.Len(t, listed, 1) assert.NotContains(t, listed[0], "result_rules", "the validation limits stay internal") status, body = call(http.MethodPost, "/customer/enakgame/sessions", start) assert.Equal(t, http.StatusBadRequest, status, "Idempotency-Key is required") status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k1") require.Equal(t, http.StatusOK, status, body) sessionID := data(body)["session_id"].(string) assert.EqualValues(t, 7, data(body)["coin_balance"]) status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "X-Idempotency-Key", "k1") require.Equal(t, http.StatusOK, status, body) assert.Equal(t, sessionID, data(body)["session_id"]) assert.Equal(t, true, data(body)["replayed"]) status, body = call(http.MethodPost, "/customer/enakgame/sessions", `{}`, "Idempotency-Key", "k2") assert.Equal(t, http.StatusBadRequest, status, body) status, body = call(http.MethodGet, "/customer/enakgame/sessions/"+sessionID, "") require.Equal(t, http.StatusOK, status, body) assert.Equal(t, "STARTED", data(body)["status"]) status, _ = call(http.MethodGet, "/customer/enakgame/sessions/"+uuid.NewString(), "") assert.Equal(t, http.StatusNotFound, status) // The backend alone decides the reward (P1): a reward in the body is ignored. c.POST("/sessions/:id/complete", customerHandler.CompleteSession) status, body = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"outcome": "LOSE", "reward": 999, "reward_total": 999}`) require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 1, data(body)["reward_total"]) assert.EqualValues(t, 8, data(body)["coin_balance"]) status, body = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"outcome": "WIN"}`) require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 1, data(body)["reward_total"], "the first completion stands") status, _ = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"score": "high"}`) assert.Equal(t, http.StatusBadRequest, status) status, _ = call(http.MethodPost, "/customer/enakgame/sessions/"+uuid.NewString()+"/complete", ``) assert.Equal(t, http.StatusNotFound, status) // Deactivating the game is audited; the session waits for the job. status, body = call(http.MethodPut, "/manager/enakgame/games/"+gameID+"/status", `{"status": "INACTIVE", "reason": "bug"}`) require.Equal(t, http.StatusOK, status, body) status, _ = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k3") assert.Equal(t, http.StatusBadRequest, status, "an inactive game cannot start") } // Drives the voucher endpoints over HTTP down to Postgres (docs/rfc-enakgame.md §7.4, // §11). func TestEnakGameVoucherEndpoints_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() hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost) require.NoError(t, err) require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'voucher http', 'basic')`, org).Error) require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, pin_hash, pin_set_at) VALUES (?, ?, 'Budi', ?, NOW())`, customer, org, string(hash)).Error) t.Cleanup(func() { for _, q := range []string{ `DELETE FROM audit_logs WHERE organization_id = ?`, `DELETE FROM voucher_redemption_costs WHERE redemption_id IN (SELECT id FROM voucher_redemptions WHERE organization_id = ?)`, `DELETE FROM voucher_redemptions WHERE organization_id = ?`, `DELETE FROM voucher_codes WHERE voucher_id IN (SELECT id FROM vouchers WHERE organization_id = ?)`, `DELETE FROM vouchers WHERE organization_id = ?`, `DELETE FROM customer_security_events WHERE customer_id IN (SELECT id FROM customers WHERE organization_id = ?)`, `DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, `DELETE FROM wallet_lots WHERE organization_id = ?`, `DELETE FROM wallet_transactions WHERE organization_id = ?`, `DELETE FROM customer_wallets WHERE organization_id = ?`, `DELETE FROM customers WHERE organization_id = ?`, `DELETE FROM organizations WHERE id = ?`, } { db.Exec(q, org) } }) txm := repository.NewTxManager(db) wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ CustomerID: customer, Currency: "POINT", Type: "MIGRATION", Amount: 50_000, ReferenceType: "LEGACY_POINTS", ReferenceID: uuid.New(), Description: "Saldo awal"}}) return err })) adminHandler, customerHandler := enakGameHandlers(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.POST("/vouchers", auth.RequireLoyaltyManager(), adminHandler.CreateVoucher) g.PUT("/vouchers/:id", auth.RequireLoyaltyManager(), adminHandler.UpdateVoucher) g.POST("/vouchers/:id/codes", auth.RequireLoyaltyManager(), adminHandler.ImportVoucherCodes) g.GET("/vouchers/:id/codes", adminHandler.ListVoucherCodes) } c := router.Group("/customer", func(c *gin.Context) { c.Set("customer_id", customer.String()) }) c.GET("/vouchers", customerHandler.ListVouchers) c.POST("/vouchers/:id/redeem", customerHandler.RedeemVoucher) c.GET("/vouchers/redemptions", customerHandler.ListRedemptions) send := func(req *http.Request) (int, map[string]any) { t.Helper() 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 } call := func(method, path, body string, headers ...string) (int, map[string]any) { t.Helper() req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) for i := 0; i+1 < len(headers); i += 2 { req.Header.Set(headers[i], headers[i+1]) } return send(req) } data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) } voucher := `{"name": "Kopi", "voucher_type": "FREE_ITEM", "face_value": 20000, "point_cost": 15000, "stock_mode": "CODE_POOL", "status": "ACTIVE"}` status, _ := call(http.MethodPost, "/purchasing/vouchers", voucher) assert.Equal(t, http.StatusForbidden, status) status, body := call(http.MethodPost, "/manager/vouchers", voucher) require.Equal(t, http.StatusOK, status, body) id := data(body)["id"].(string) status, _ = call(http.MethodPut, "/manager/vouchers/"+id, `{"stock_mode": "STATIC", "stock": 5}`) assert.Equal(t, http.StatusBadRequest, status, "the stock mode stays") // Codes as a multipart file, then the same as a plain body. var form bytes.Buffer w := multipart.NewWriter(&form) part, err := w.CreateFormFile("file", "codes.csv") require.NoError(t, err) _, _ = part.Write([]byte("code\nKOPI-1\nKOPI-2\n")) require.NoError(t, w.Close()) req := httptest.NewRequest(http.MethodPost, "/manager/vouchers/"+id+"/codes", &form) req.Header.Set("Content-Type", w.FormDataContentType()) status, body = send(req) require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 2, data(body)["imported"]) status, body = call(http.MethodPost, "/manager/vouchers/"+id+"/codes", "KOPI-2\nKOPI-3\n", "Content-Type", "text/csv") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 1, data(body)["imported"]) assert.Equal(t, []any{"KOPI-2"}, data(body)["duplicates"]) status, body = call(http.MethodGet, "/manager/vouchers/"+id+"/codes", "") require.Equal(t, http.StatusOK, status, body) assert.Equal(t, map[string]any{"AVAILABLE": float64(3)}, data(body)["counts"]) status, body = call(http.MethodGet, "/customer/vouchers", "") require.Equal(t, http.StatusOK, status, body) require.Len(t, body["data"], 1) assert.EqualValues(t, 3, body["data"].([]any)[0].(map[string]any)["available"]) redeem := "/customer/vouchers/" + id + "/redeem" status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`) assert.Equal(t, http.StatusBadRequest, status, "Idempotency-Key is required") status, body = call(http.MethodPost, redeem, `{}`, "Idempotency-Key", "r1") assert.Equal(t, http.StatusBadRequest, status, "the PIN is required") status, body = call(http.MethodPost, redeem, `{"pin": "000000"}`, "Idempotency-Key", "r1") assert.Equal(t, http.StatusBadRequest, status) assert.Equal(t, "PIN_INVALID", body["errors"].([]any)[0].(map[string]any)["code"]) status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r1") require.Equal(t, http.StatusOK, status, body) code := data(body)["code"] assert.NotEmpty(t, code) assert.EqualValues(t, 35_000, data(body)["point_balance"]) status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r1") require.Equal(t, http.StatusOK, status, body) assert.Equal(t, code, data(body)["code"]) assert.Equal(t, true, data(body)["replayed"]) status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r2") require.Equal(t, http.StatusOK, status, body) status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r3") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 5_000, data(body)["point_balance"]) status, _ = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r5") assert.Equal(t, http.StatusBadRequest, status, "out of codes and of EnakPoint") status, _ = call(http.MethodPost, "/customer/vouchers/"+uuid.NewString()+"/redeem", `{"pin": "482913"}`, "Idempotency-Key", "r4") assert.Equal(t, http.StatusNotFound, status) status, body = call(http.MethodGet, "/customer/vouchers/redemptions", "") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 3, data(body)["pagination"].(map[string]any)["total_count"]) } // Drives /marketing/enakgame/events over HTTP, and the events on the customer's game // list (docs/rfc-enakgame.md §11). func TestEnakGameEventEndpoints_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() require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'event http', 'basic')`, org).Error) require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error) t.Cleanup(func() { for _, q := range []string{ `DELETE FROM audit_logs WHERE organization_id = ?`, `DELETE FROM game_event_games WHERE event_id IN (SELECT id FROM game_events WHERE organization_id = ?)`, `DELETE FROM game_events WHERE organization_id = ?`, `DELETE FROM game_budgets WHERE organization_id = ?`, `DELETE FROM games WHERE organization_id = ?`, `DELETE FROM customers WHERE organization_id = ?`, `DELETE FROM organizations WHERE id = ?`, } { db.Exec(q, org) } }) adminHandler, customerHandler := enakGameHandlers(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+"/enakgame", 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.POST("/games", adminHandler.CreateGame) g.POST("/budgets", auth.RequireLoyaltyManager(), adminHandler.CreateBudget) g.POST("/events", auth.RequireLoyaltyManager(), adminHandler.CreateEvent) g.PUT("/events/:id", auth.RequireLoyaltyManager(), adminHandler.UpdateEvent) g.GET("/events", adminHandler.ListEvents) } router.GET("/customer/enakgame/games", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerHandler.ListGames) call := func(method, path, body string) (int, map[string]any) { t.Helper() rec := httptest.NewRecorder() router.ServeHTTP(rec, httptest.NewRequest(method, path, bytes.NewBufferString(body))) 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.MethodPost, "/manager/enakgame/games", `{"name": "Runner", "slug": "runner", "entry_cost": 1, "status": "ACTIVE"}`) require.Equal(t, http.StatusOK, status, body) gameID := data(body)["id"].(string) status, body = call(http.MethodPost, "/manager/enakgame/budgets", `{"scope": "EVENT", "name": "Ramadan", "period_start": "2026-01-01", "period_end": "2030-12-31", "amount": 5000000}`) require.Equal(t, http.StatusOK, status, body) budgetID := data(body)["id"].(string) start := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) end := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) event := `{"name": "Ramadan", "slug": "ramadan", "start_at": "` + start + `", "end_at": "` + end + `", "multiplier": 2, "budget_id": "` + budgetID + `", "game_ids": ["` + gameID + `"], "status": "ACTIVE"}` status, _ = call(http.MethodPost, "/purchasing/enakgame/events", event) assert.Equal(t, http.StatusForbidden, status) status, body = call(http.MethodPost, "/manager/enakgame/events", event) require.Equal(t, http.StatusOK, status, body) eventID := data(body)["id"].(string) status, _ = call(http.MethodPut, "/manager/enakgame/events/"+eventID, `{"multipler": 3}`) assert.Equal(t, http.StatusBadRequest, status, "a misspelt field is refused") status, body = call(http.MethodPut, "/manager/enakgame/events/"+eventID, `{"bonus": 5}`) require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 2, data(body)["multiplier"], "kept") assert.EqualValues(t, 5, data(body)["bonus"]) status, body = call(http.MethodGet, "/purchasing/enakgame/events?status=active", "") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 1, data(body)["pagination"].(map[string]any)["total_count"]) status, body = call(http.MethodGet, "/customer/enakgame/games", "") require.Equal(t, http.StatusOK, status, body) games := body["data"].([]any) require.Len(t, games, 1) events := games[0].(map[string]any)["events"].([]any) require.Len(t, events, 1) assert.Equal(t, "Ramadan", events[0].(map[string]any)["name"]) assert.EqualValues(t, 5, events[0].(map[string]any)["bonus"]) assert.NotContains(t, events[0], "budget_id", "the budget stays internal") } // EG-1001: the admin steps of docs/integration-backoffice.md §8.4 make a spin wheel, and a customer // plays it through /customer/enakgame/sessions, over HTTP down to Postgres. func TestEnakGameSpin_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() require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame spin', 'basic')`, org).Error) require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error) t.Cleanup(func() { for _, q := range []string{ `DELETE FROM audit_logs WHERE organization_id = ?`, `DELETE FROM game_reward_counters WHERE organization_id = ?`, `DELETE FROM game_session_rewards WHERE session_id IN (SELECT id FROM game_sessions WHERE organization_id = ?)`, `DELETE FROM game_sessions WHERE organization_id = ?`, `DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, `DELETE FROM wallet_lots WHERE organization_id = ?`, `DELETE FROM wallet_transactions WHERE organization_id = ?`, `DELETE FROM customer_wallets WHERE organization_id = ?`, `DELETE FROM game_reward_configs WHERE organization_id = ?`, `DELETE FROM game_budgets WHERE organization_id = ?`, `DELETE FROM games WHERE organization_id = ?`, `DELETE FROM customers WHERE organization_id = ?`, `DELETE FROM organizations WHERE id = ?`, } { db.Exec(q, org) } }) txm := repository.NewTxManager(db) wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ CustomerID: customer, Currency: "COIN", Type: "MIGRATION", Amount: 20, ReferenceType: "LEGACY_TOKENS", ReferenceID: uuid.New(), Description: "Saldo awal"}}) return err })) adminHandler, customerHandler := enakGameHandlers(db) gin.SetMode(gin.TestMode) router := gin.New() m := router.Group("/marketing/enakgame", 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, "manager") c.Request = c.Request.WithContext(ctx) }) m.POST("/games", adminHandler.CreateGame) m.POST("/games/:id/reward-configs", adminHandler.CreateRewardConfig) m.POST("/reward-configs/:id/activate", adminHandler.ActivateRewardConfig) m.POST("/budgets", adminHandler.CreateBudget) c := router.Group("/customer/enakgame", func(c *gin.Context) { c.Set("customer_id", customer.String()) }) c.GET("/games", customerHandler.ListGames) c.POST("/sessions", customerHandler.StartSession) c.POST("/sessions/:id/complete", customerHandler.CompleteSession) call := func(method, path, body string, headers ...string) map[string]any { t.Helper() req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) for i := 0; i+1 < len(headers); i += 2 { req.Header.Set(headers[i], headers[i+1]) } rec := httptest.NewRecorder() router.ServeHTTP(rec, req) var out map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) require.Equal(t, http.StatusOK, rec.Code, out) return out["data"].(map[string]any) } // The admin steps. game := call(http.MethodPost, "/marketing/enakgame/games", `{"name": "Spin Harian", "slug": "spin", "type": "SPIN", "entry_cost": 5, "status": "ACTIVE"}`) gameID := game["id"].(string) config := call(http.MethodPost, "/marketing/enakgame/games/"+gameID+"/reward-configs", `{"reward_type": "PROBABILITY", "max_reward": 50, "rules": {"table": [ {"weight": 50, "amount": 0, "label": "Zonk"}, {"weight": 30, "amount": 3, "label": "3 Coin"}, {"weight": 15, "amount": 10, "label": "10 Coin"}, {"weight": 5, "amount": 50, "label": "Jackpot"}]}}`) call(http.MethodPost, "/marketing/enakgame/reward-configs/"+config["id"].(string)+"/activate", `{}`) now := time.Now().In(time.FixedZone("WIB", 7*3600)) first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) call(http.MethodPost, "/marketing/enakgame/budgets", `{"scope": "GLOBAL", "name": "Bulan ini", "period_start": "`+ first.Format("2006-01-02")+`", "period_end": "`+first.AddDate(0, 1, -1).Format("2006-01-02")+`", "amount": 1000000}`) // The wheel shows the segments, never their odds. req := httptest.NewRequest(http.MethodGet, "/customer/enakgame/games", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) var listed struct { Data []struct { ID string `json:"id"` Prizes []struct { Entry int `json:"entry"` Label *string `json:"label"` Amount int64 `json:"amount"` } `json:"prizes"` } `json:"data"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &listed)) require.Len(t, listed.Data, 1) require.Len(t, listed.Data[0].Prizes, 4) assert.Equal(t, "Jackpot", *listed.Data[0].Prizes[3].Label) assert.NotContains(t, rec.Body.String(), "weight") // Two spins: each lands on a listed segment and pays what it shows. balance := int64(20) for _, key := range []string{"spin-1", "spin-2"} { started := call(http.MethodPost, "/customer/enakgame/sessions", `{"game_id": "`+gameID+`"}`, "Idempotency-Key", key) balance -= 5 assert.EqualValues(t, balance, started["coin_balance"]) done := call(http.MethodPost, "/customer/enakgame/sessions/"+started["session_id"].(string)+"/complete", `{}`) prize := done["prize"].(map[string]any) entry := int(prize["entry"].(float64)) require.True(t, entry >= 1 && entry <= 4, prize) segment := listed.Data[0].Prizes[entry-1] assert.Equal(t, *segment.Label, prize["label"]) assert.EqualValues(t, segment.Amount, prize["amount"]) assert.EqualValues(t, segment.Amount, done["reward_total"]) balance += segment.Amount assert.EqualValues(t, balance, done["coin_balance"]) } }