Files
apskel-pos-backend/internal/handler/enakgame_db_test.go
efrilmandClaude Opus 5.5 1bb071aec6 refactor(vouchers): move voucher routes out of /enakgame
Vouchers are what EnakPoint is redeemed for, wherever it came from, so they are
not part of EnakGame. Their only link to it is the budget attribution, which
does not change.

- Admin: /marketing/enakgame/vouchers... -> /marketing/vouchers...
- Customer: /customer/enakgame/vouchers -> /customer/vouchers,
  /customer/enakgame/vouchers/:id/redeem -> /customer/vouchers/:id/redeem,
  /customer/enakgame/redemptions -> /customer/vouchers/redemptions

Roles, handlers and logic stay the same. No client calls these endpoints yet.
RFC §7.4 and §11 updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:09:57 +07:00

610 lines
31 KiB
Go

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"])
}
}