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