Files
apskel-pos-backend/internal/handler/enakgame_db_test.go
T

610 lines
32 KiB
Go
Raw Normal View History

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+"/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("/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/enakgame", func(c *gin.Context) { c.Set("customer_id", customer.String()) })
c.GET("/vouchers", customerHandler.ListVouchers)
c.POST("/vouchers/:id/redeem", customerHandler.RedeemVoucher)
c.GET("/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/enakgame/vouchers", voucher)
assert.Equal(t, http.StatusForbidden, status)
status, body := call(http.MethodPost, "/manager/enakgame/vouchers", voucher)
require.Equal(t, http.StatusOK, status, body)
id := data(body)["id"].(string)
status, _ = call(http.MethodPut, "/manager/enakgame/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/enakgame/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/enakgame/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/enakgame/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/enakgame/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/enakgame/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/enakgame/vouchers/"+uuid.NewString()+"/redeem", `{"pin": "482913"}`, "Idempotency-Key", "r4")
assert.Equal(t, http.StatusNotFound, status)
status, body = call(http.MethodGet, "/customer/enakgame/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/enakgame-spin.md 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"])
}
}