Files
apskel-pos-backend/internal/processor/enakgame_analytics_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

79 lines
3.6 KiB
Go

package processor
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
func TestAnalyticsRange(t *testing.T) {
start, end, err := analyticsRange(models.EnakGameAnalyticsQuery{From: "2026-10-01", To: "2026-10-31"})
require.NoError(t, err)
assert.Equal(t, time.Date(2026, 9, 30, 17, 0, 0, 0, time.UTC), start.UTC(), "midnight in Jakarta")
assert.Equal(t, time.Date(2026, 10, 31, 17, 0, 0, 0, time.UTC), end.UTC(), "the last day included")
_, _, err = analyticsRange(models.EnakGameAnalyticsQuery{From: "2026-01-01", To: "2027-01-01"})
require.NoError(t, err, "366 days")
for name, q := range map[string]models.EnakGameAnalyticsQuery{
"no from": {To: "2026-10-31"},
"no to": {From: "2026-10-01"},
"backwards": {From: "2026-10-31", To: "2026-10-01"},
"too long": {From: "2026-01-01", To: "2027-01-02"},
"not dates": {From: "1 Oct", To: "31 Oct"},
} {
_, _, err := analyticsRange(q)
assert.ErrorIs(t, err, ErrEnakGameRejected, name)
}
}
func TestGameStats(t *testing.T) {
s := gameStats(repository.GameSessionStats{Plays: 3, Completed: 2, CoinIssued: 25, EntryCost: 6, CoinRefunded: 2})
assert.EqualValues(t, 12.5, s.AverageReward)
assert.InDelta(t, 8.33, s.RewardPerPlay, 1e-9)
assert.EqualValues(t, 6, s.EntryCostPaid)
s = gameStats(repository.GameSessionStats{})
assert.Zero(t, s.AverageReward, "no division by zero")
assert.Zero(t, s.RewardPerPlay)
assert.Nil(t, s.AverageScore)
}
// EG-903: the economy's headline numbers from the ledger's totals.
func TestEconomy(t *testing.T) {
coin, point := constants.WalletCurrencyCoin, constants.WalletCurrencyPoint
flows := []repository.WalletFlow{
{Currency: coin, Type: constants.WalletTxTypeGameReward, Credit: 500, Transactions: 40},
{Currency: coin, Type: constants.WalletTxTypeMigration, Credit: 100, Transactions: 2},
{Currency: coin, Type: constants.WalletTxTypeAdjustment, Credit: 30, Debit: 10, Transactions: 2},
{Currency: coin, Type: constants.WalletTxTypeGameSpend, Debit: 200, Transactions: 50},
{Currency: coin, Type: constants.WalletTxTypeGameSpendRefund, Credit: 8, Transactions: 2},
{Currency: coin, Type: constants.WalletTxTypeExchangeOut, Debit: 120, Transactions: 3},
{Currency: coin, Type: constants.WalletTxTypeExpire, Debit: 15, Transactions: 1},
{Currency: coin, Type: constants.WalletTxTypeTransferOut, Debit: 50, Transactions: 1},
{Currency: coin, Type: constants.WalletTxTypeTransferIn, Credit: 50, Transactions: 1},
{Currency: point, Type: constants.WalletTxTypeEarn, Credit: 1_000, Transactions: 9},
{Currency: point, Type: constants.WalletTxTypeEarnReversal, Debit: 100, Transactions: 1},
{Currency: point, Type: constants.WalletTxTypeExchangeIn, Credit: 120, Transactions: 3},
{Currency: point, Type: constants.WalletTxTypeRewardRedeem, Debit: 700, Transactions: 7},
{Currency: point, Type: constants.WalletTxTypeRewardRedeemRefund, Credit: 100, Transactions: 1},
{Currency: point, Type: constants.WalletTxTypeExpire, Debit: 40, Transactions: 2},
}
e := economy(flows, map[string]int64{coin: 900, point: 2_500})
assert.Equal(t, models.EnakGameCoinFlows{
Generated: 630, GameRewards: 500, SpentOnGames: 192, Exchanged: 120, Spent: 312, Expired: 15, Outstanding: 900,
}, e.Coin, "transfers count nowhere")
assert.Equal(t, models.EnakGamePointFlows{Earned: 900, Exchanged: 120, Redeemed: 600, Expired: 40, Balance: 2_500}, e.Point)
assert.Len(t, e.ByType, len(flows))
empty := economy(nil, map[string]int64{})
assert.Zero(t, empty.Coin)
assert.NotNil(t, empty.ByType, "an empty list, not null")
}