Files
apskel-pos-backend/internal/processor/game_budget_controller_processor_test.go
T
efrilmandClaude Opus 5.5 296708244e feat(enakgame): budget controller recommendations and analytics
EnakGame phase 9 of docs/tasks-enakgame.md (EG-901 to EG-903).

Budget Controller (EG-901, EG-902)
- GET /marketing/enakgame/budgets/:id/recommendation, GLOBAL budgets only: the
  multiplier (budget − realized) / (forecast − realized), within one step of 1,
  rounded down to two decimals, either way. Shows each game's new rules.
- POST .../recommendation/accept with the multiplier the admin saw: recomputed in the
  transaction, then one new ACTIVE version per game, the old one RETIRED, audited
  with source budget_controller and RECOMMENDATION_ACCEPTED on the budget.
- Migration 000112: base_config_id, multiplier and budget_id on
  game_reward_configs. Rules are always scaled from the admin's last version, so
  rounding does not compound and min/max are against what the admin set.
- Guardrails in game_budgets.thresholds: max_step_percent 10, min/max multiplier
  50-150%, cooldown_days 7 per organization. Provisional pending RFC §19.2 #4.
- RewardCalculator.Scale for the four reward types: amounts only, rounded down.

Analytics (EG-903)
- GET /marketing/enakgame/analytics/games and /analytics/economy over a range of
  Asia/Jakarta days (at most 366), from game_sessions and the wallet ledger.
- Migrations 000113 (game_sessions by organization and start) and 000114
  (wallet_transactions by organization and time, CONCURRENTLY).

The Postgres tests for accepting and analytics were not run: no test database here.
Migrations 000112-000114 have not been run anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:18:31 +07:00

102 lines
5.3 KiB
Go

package processor
import (
"testing"
"github.com/stretchr/testify/assert"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
var defaultGuardrails = budgetGuardrails{step: 10, min: 50, max: 150, cooldownDays: 7}
// EG-901: the multiplier of one recommendation.
func TestBudgetStepMultiplier(t *testing.T) {
// metrics is a running budget of Rp100M: realized so far, forecast at period end.
metrics := func(realized, forecast int64) models.GameBudgetMetrics {
return models.GameBudgetMetrics{Amount: 100_000_000, RealizedCost: realized, ForecastCost: forecast, WindowDays: 7, RemainingDays: 10}
}
for name, tc := range map[string]struct {
m models.GameBudgetMetrics
g budgetGuardrails
wantTarget *int64
wantStep int64
wantState string
}{
// PRD §30: Rp40M left for Rp55M forecast → 0.7272, one step down.
"PRD §30": {metrics(60_000_000, 115_000_000), defaultGuardrails, ptr(int64(7272)), 9_000, ""},
"wider step": {metrics(60_000_000, 115_000_000), budgetGuardrails{step: 30, min: 50, max: 150}, ptr(int64(7272)), 7_200, ""},
"slightly over": {metrics(60_000_000, 100_400_000), defaultGuardrails, ptr(int64(9900)), 9_900, ""},
"under budget": {metrics(20_000_000, 60_000_000), defaultGuardrails, ptr(int64(20000)), 11_000, ""},
"slightly under": {metrics(60_000_000, 99_000_000), defaultGuardrails, ptr(int64(10256)), 10_200, ""},
"on budget": {metrics(60_000_000, 100_000_000), defaultGuardrails, ptr(int64(10000)), 10_000, constants.GameBudgetRecommendationNoChange},
"a hair under": {metrics(60_000_000, 99_900_000), defaultGuardrails, ptr(int64(10025)), 10_000, constants.GameBudgetRecommendationNoChange},
"already over budget": {metrics(110_000_000, 120_000_000), defaultGuardrails, ptr(int64(0)), 9_000, ""},
"no recent cost": {metrics(60_000_000, 60_000_000), defaultGuardrails, nil, 10_000, constants.GameBudgetRecommendationNoData},
"not started": {models.GameBudgetMetrics{Amount: 100_000_000}, defaultGuardrails,
nil, 10_000, constants.GameBudgetRecommendationOutOfPeriod},
"last day": {models.GameBudgetMetrics{Amount: 100_000_000, RealizedCost: 1, ForecastCost: 1, WindowDays: 7}, defaultGuardrails,
nil, 10_000, constants.GameBudgetRecommendationOutOfPeriod},
} {
target, step, state := budgetStepMultiplier(tc.m, tc.g)
assert.Equal(t, tc.wantTarget, target, name)
assert.Equal(t, tc.wantStep, step, name)
assert.Equal(t, tc.wantState, state, name)
}
}
// EG-901: a game's multiplier after a step, against its admin's configuration.
func TestNextGameMultiplier(t *testing.T) {
for name, tc := range map[string]struct {
current, step, want int64
g budgetGuardrails
}{
"first step down": {10_000, 9_000, 9_000, defaultGuardrails},
"compounds": {9_000, 9_000, 8_100, defaultGuardrails},
"rounds down": {8_100, 9_900, 8_019, defaultGuardrails},
"stops at min": {5_500, 9_000, 5_000, defaultGuardrails},
"stays at min": {5_000, 9_000, 5_000, defaultGuardrails},
"stops at max": {14_000, 11_000, 15_000, defaultGuardrails},
"up from a cut": {8_100, 11_000, 8_910, defaultGuardrails},
"min raised since: up": {4_000, 11_000, 5_000, defaultGuardrails},
"min raised since: stays": {4_000, 9_000, 4_000, defaultGuardrails},
"max lowered since: down": {20_000, 9_000, 15_000, defaultGuardrails},
"max lowered since: stay": {20_000, 11_000, 20_000, defaultGuardrails},
"own bounds": {10_000, 9_000, 9_500, budgetGuardrails{step: 10, min: 95, max: 100}},
} {
assert.Equal(t, tc.want, nextGameMultiplier(tc.current, tc.step, tc.g), name)
}
}
func TestGuardrailsOf(t *testing.T) {
assert.Equal(t, defaultGuardrails, guardrailsOf(&entities.GameBudget{}), "the defaults")
assert.Equal(t, defaultGuardrails, guardrailsOf(&entities.GameBudget{Thresholds: entities.JSONDocument(`{"warning": 50}`)}))
assert.Equal(t, budgetGuardrails{step: 5, min: 80, max: 150, cooldownDays: 0},
guardrailsOf(&entities.GameBudget{Thresholds: entities.JSONDocument(`{"max_step_percent": 5, "min_multiplier_percent": 80, "cooldown_days": 0}`)}))
}
func TestGameBudgetGuardrailValidation(t *testing.T) {
in := func(th models.GameBudgetThresholds) models.GameBudgetInput {
return models.GameBudgetInput{Scope: "GLOBAL", Name: "Oktober", PeriodStart: "2026-10-01", PeriodEnd: "2026-10-31", Amount: 1, Thresholds: th}
}
_, err := gameBudgetFromInput(in(models.GameBudgetThresholds{
MaxStepPercent: ptr(int64(50)), MinMultiplierPercent: ptr(int64(1)), MaxMultiplierPercent: ptr(int64(1000)), CooldownDays: ptr(int64(0)),
}))
assert.NoError(t, err)
for name, th := range map[string]models.GameBudgetThresholds{
"step 0": {MaxStepPercent: ptr(int64(0))},
"step 51": {MaxStepPercent: ptr(int64(51))},
"min 0": {MinMultiplierPercent: ptr(int64(0))},
"min above 1x": {MinMultiplierPercent: ptr(int64(101))},
"max below 1x": {MaxMultiplierPercent: ptr(int64(99))},
"max too high": {MaxMultiplierPercent: ptr(int64(1001))},
"cooldown -1": {CooldownDays: ptr(int64(-1))},
"cooldown 91": {CooldownDays: ptr(int64(91))},
} {
_, err := gameBudgetFromInput(in(th))
assert.ErrorIs(t, err, ErrEnakGameRejected, name)
}
}