Files
apskel-pos-backend/internal/processor/game_budget_controller_db_test.go
T

165 lines
8.8 KiB
Go
Raw Normal View History

package processor
import (
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// realizedCost records a completed redemption whose face value the budget paid,
// recognized at at.
func (e *enakGameEnv) realizedCost(voucherID, budgetID uuid.UUID, amount int64, at time.Time) {
e.t.Helper()
debit, redemption := uuid.New(), uuid.New()
require.NoError(e.t, e.db.Exec(`INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description)
VALUES (?, ?, ?, 'POINT', 'REWARD_REDEEM', -1, 0, 'REWARD_REDEMPTION', ?, 'x')`, debit, e.orgA, e.bob, redemption).Error)
require.NoError(e.t, e.db.Exec(`INSERT INTO voucher_redemptions (id, organization_id, customer_id, voucher_id, idempotency_key, status, face_value, point_cost, debit_transaction_id, completed_at)
VALUES (?, ?, ?, ?, ?, 'COMPLETED', ?, 1, ?, ?)`, redemption, e.orgA, e.bob, voucherID, redemption.String(), amount, debit, at).Error)
require.NoError(e.t, e.db.Exec(`INSERT INTO voucher_redemption_costs (redemption_id, budget_id, source_type, points, cost, recognized_at) VALUES (?, ?, 'GAME_REWARD', 1, ?, ?)`,
redemption, budgetID, amount, at).Error)
}
func (e *enakGameEnv) activeConfig(gameID uuid.UUID) *entities.GameRewardConfig {
e.t.Helper()
c, err := repository.NewEnakGameRepository(e.db).GetActiveRewardConfig(e.ctx(), e.orgA, gameID)
require.NoError(e.t, err)
return c
}
// EG-901 and EG-902 against Postgres, from the PRD §30 numbers. Needs
// TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go.
func TestGameBudgetController_AgainstPostgres(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
e.now = time.Date(2026, 10, 21, 12, 0, 0, 0, walletDisplayLocation)
budgets := repository.NewGameBudgetRepository(e.db)
metrics := NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(e.db))
controller := NewGameBudgetControllerProcessor(budgets, metrics, repository.NewEnakGameRepository(e.db),
NewAuditLogger(repository.NewAuditLogRepository(e.db)), e.txm)
controller.now = func() time.Time { return e.now }
october := e.globalBudget(e.orgA, e.now)
tap := e.gameWith("tap", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 10)
run := e.gameWith("run", entities.GameResultRules{}, constants.GameRewardTypeScoreBased,
`{"bands": [{"min": 0, "max": 100, "amount": 5}, {"min": 101, "amount": 20}]}`, 20)
tapV1, runV1 := e.activeConfig(tap.ID), e.activeConfig(run.ID)
// Rp60M realized, Rp5,5M a day over the last week, 10 days left → Rp115M.
voucher := e.staticVoucher(1_000, 1, 1)
e.realizedCost(voucher.ID, october.ID, 21_500_000, time.Date(2026, 10, 5, 10, 0, 0, 0, walletDisplayLocation))
for day := 15; day <= 21; day++ {
e.realizedCost(voucher.ID, october.ID, 5_500_000, time.Date(2026, 10, day, 0, 0, 0, 0, walletDisplayLocation))
}
rec, err := controller.Recommendation(ctx, e.orgA, october.ID)
require.NoError(t, err)
assert.Equal(t, constants.GameBudgetRecommended, rec.State)
assert.EqualValues(t, 115_000_000, rec.Metrics.ForecastCost)
assert.InDelta(t, 0.7272, *rec.TargetMultiplier, 1e-9)
assert.EqualValues(t, 0.9, rec.Multiplier, "one step of 10%")
assert.EqualValues(t, 10, *rec.Guardrails.MaxStepPercent)
require.Len(t, rec.Games, 2)
assert.Equal(t, run.ID, rec.Games[0].GameID, "by game name")
assert.JSONEq(t, `{"bands":[{"min":0,"max":100,"amount":4},{"min":101,"amount":18}]}`, string(rec.Games[0].NewRules))
assert.EqualValues(t, 18, rec.Games[0].NewMaxReward)
assert.JSONEq(t, `{"amount":9}`, string(rec.Games[1].NewRules))
assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE organization_id = ? AND budget_id IS NOT NULL`, e.orgA), "showing changes nothing")
// Only what the admin saw is applied.
_, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{})
assert.ErrorIs(t, err, ErrEnakGameRejected)
_, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.85)})
assert.ErrorIs(t, err, ErrEnakGameRejected)
assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE organization_id = ? AND budget_id IS NOT NULL`, e.orgA))
accepted, err := controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{
Multiplier: ptr(0.9), Reason: ptr("Burn rate terlalu tinggi"),
})
require.NoError(t, err)
require.Len(t, accepted.RewardConfigs, 2)
tapV2 := e.activeConfig(tap.ID)
assert.Equal(t, 2, tapV2.Version)
assert.JSONEq(t, `{"amount":9}`, string(tapV2.Rules))
assert.EqualValues(t, 9, tapV2.MaxReward)
assert.EqualValues(t, 0.9, *tapV2.Multiplier)
assert.Equal(t, tapV1.ID, *tapV2.BaseConfigID)
assert.Equal(t, october.ID, *tapV2.BudgetID)
assert.Equal(t, "Burn rate terlalu tinggi", *tapV2.Reason)
assert.EqualValues(t, 1, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE id = ? AND status = 'RETIRED'`, tapV1.ID))
assert.EqualValues(t, 1, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE id = ? AND status = 'RETIRED'`, runV1.ID))
assert.Equal(t, []string{"CREATED", constants.AuditActionRecommendationAccepted}, e.auditActions(constants.AuditEntityGameBudget, october.ID))
assert.Equal(t, []string{"ACTIVATED", "CREATED"}, e.auditActions(constants.AuditEntityGameRewardConfig, tapV2.ID))
assert.EqualValues(t, 7, e.count(`SELECT COUNT(*) FROM audit_logs WHERE organization_id = ? AND source = ?`, e.orgA, constants.AuditSourceBudgetController),
"per game: retired, created, activated; and the budget")
// The game pays the new amount.
e.coins(e.alice, 2, nil)
started, err := e.sessions.Start(ctx, e.alice, tap.ID, "after")
require.NoError(t, err)
done, err := e.sessions.Complete(ctx, e.alice, started.SessionID, models.GameSessionCompleteInput{})
require.NoError(t, err)
assert.EqualValues(t, 9, done.RewardTotal)
// Not again within the cooldown, though the forecast is still over.
rec, err = controller.Recommendation(ctx, e.orgA, october.ID)
require.NoError(t, err)
assert.Equal(t, constants.GameBudgetRecommendationCooldown, rec.State)
require.NotNil(t, rec.CooldownUntil)
assert.WithinDuration(t, e.now.AddDate(0, 0, 7), *rec.CooldownUntil, time.Second)
assert.EqualValues(t, 0.81, rec.Games[1].NewMultiplier, "what it would be")
_, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.9)})
assert.ErrorIs(t, err, ErrEnakGameRejected)
// No cooldown and a floor of 85%: 0.81 stops at 0.85, scaled from version 1.
current, err := e.budgets.GetBudget(ctx, e.orgA, october.ID)
require.NoError(t, err)
in := GameBudgetInputFrom(current)
in.Thresholds.CooldownDays, in.Thresholds.MinMultiplierPercent = ptr(int64(0)), ptr(int64(85))
_, err = e.budgets.UpdateBudget(ctx, e.orgA, e.manager, october.ID, in)
require.NoError(t, err)
rec, err = controller.Recommendation(ctx, e.orgA, october.ID)
require.NoError(t, err)
require.Equal(t, constants.GameBudgetRecommended, rec.State)
_, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.9)})
require.NoError(t, err)
tapV3 := e.activeConfig(tap.ID)
assert.EqualValues(t, 0.85, *tapV3.Multiplier)
assert.JSONEq(t, `{"amount":8}`, string(tapV3.Rules), "10 × 0.85, rounded down")
assert.Equal(t, tapV1.ID, *tapV3.BaseConfigID, "always from the admin's version")
rec, err = controller.Recommendation(ctx, e.orgA, october.ID)
require.NoError(t, err)
assert.Equal(t, constants.GameBudgetRecommendationAtLimit, rec.State)
assert.Empty(t, rec.Games)
// An admin's new version starts over from 1.
config, err := e.admin.CreateRewardConfig(ctx, e.orgA, e.manager, tap.ID, models.GameRewardConfigInput{
RewardType: constants.GameRewardTypeFixed, Rules: json.RawMessage(`{"amount": 12}`), MaxReward: 12,
})
require.NoError(t, err)
_, err = e.admin.ActivateRewardConfig(ctx, e.orgA, e.manager, config.ID, models.GameRewardConfigActivateInput{})
require.NoError(t, err)
rec, err = controller.Recommendation(ctx, e.orgA, october.ID)
require.NoError(t, err)
require.Equal(t, constants.GameBudgetRecommended, rec.State)
require.Len(t, rec.Games, 1)
assert.Equal(t, config.ID, rec.Games[0].BaseConfigID)
assert.JSONEq(t, `{"amount":10}`, string(rec.Games[0].NewRules), "12 × 0.9, rounded down")
// Event budgets pay for what events add, which is set on the event.
event := e.eventBudget(e.orgA, "Ramadan")
_, err = controller.Recommendation(ctx, e.orgA, event.ID)
assert.ErrorIs(t, err, ErrEnakGameRejected)
_, err = controller.Recommendation(ctx, e.orgB, october.ID)
assert.ErrorIs(t, err, repository.ErrGameBudgetNotFound)
}