Files
apskel-pos-backend/internal/processor/game_budget_controller_db_test.go
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

165 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}