472 lines
20 KiB
Go
472 lines
20 KiB
Go
package repository
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"sync"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/stretchr/testify/assert"
|
||
|
|
"github.com/stretchr/testify/require"
|
||
|
|
"gorm.io/gorm"
|
||
|
|
|
||
|
|
"apskel-pos-be/internal/constants"
|
||
|
|
"apskel-pos-be/internal/entities"
|
||
|
|
)
|
||
|
|
|
||
|
|
// EnakGame tables against Postgres (docs/rfc-enakgame.md §5): what the constraints
|
||
|
|
// refuse, and what the repositories do. Needs TEST_DATABASE_URL; see
|
||
|
|
// wallet_repository_test.go.
|
||
|
|
|
||
|
|
type enakGameFixture struct {
|
||
|
|
db *gorm.DB
|
||
|
|
games EnakGameRepository
|
||
|
|
sessions GameSessionRepository
|
||
|
|
budgets GameBudgetRepository
|
||
|
|
org uuid.UUID
|
||
|
|
other uuid.UUID
|
||
|
|
customer uuid.UUID
|
||
|
|
admin uuid.UUID
|
||
|
|
}
|
||
|
|
|
||
|
|
func newEnakGameFixture(t *testing.T) *enakGameFixture {
|
||
|
|
t.Helper()
|
||
|
|
db := walletTestDB(t)
|
||
|
|
f := &enakGameFixture{
|
||
|
|
db: db, games: NewEnakGameRepository(db), sessions: NewGameSessionRepository(db), budgets: NewGameBudgetRepository(db),
|
||
|
|
org: uuid.New(), other: uuid.New(), customer: uuid.New(), admin: uuid.New(),
|
||
|
|
}
|
||
|
|
orgs := []uuid.UUID{f.org, f.other}
|
||
|
|
for _, org := range orgs {
|
||
|
|
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame test', 'basic')`, org).Error)
|
||
|
|
}
|
||
|
|
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'enakgame test')`, f.customer, f.org).Error)
|
||
|
|
t.Cleanup(func() {
|
||
|
|
for _, q := range []string{
|
||
|
|
`DELETE FROM game_session_rewards WHERE session_id IN (SELECT id FROM game_sessions WHERE organization_id IN ?)`,
|
||
|
|
`DELETE FROM game_sessions WHERE organization_id IN ?`,
|
||
|
|
`DELETE FROM game_reward_configs WHERE organization_id IN ?`,
|
||
|
|
`DELETE FROM game_budgets WHERE organization_id IN ?`,
|
||
|
|
`DELETE FROM games WHERE organization_id IN ?`,
|
||
|
|
`DELETE FROM wallet_transactions WHERE organization_id IN ?`,
|
||
|
|
`DELETE FROM customers WHERE organization_id IN ?`,
|
||
|
|
`DELETE FROM organizations WHERE id IN ?`,
|
||
|
|
} {
|
||
|
|
db.Exec(q, orgs)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
return f
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *enakGameFixture) game(t *testing.T, org uuid.UUID, slug string) *entities.Game {
|
||
|
|
t.Helper()
|
||
|
|
game := &entities.Game{
|
||
|
|
Name: "Tap " + slug, Type: entities.GameTypeMinigame, OrganizationID: &org, Slug: &slug,
|
||
|
|
Status: constants.GameStatusActive, EntryCost: 5, SessionTTLSeconds: 600,
|
||
|
|
}
|
||
|
|
require.NoError(t, f.games.CreateGame(context.Background(), game))
|
||
|
|
return game
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *enakGameFixture) config(t *testing.T, game *entities.Game) *entities.GameRewardConfig {
|
||
|
|
t.Helper()
|
||
|
|
config := &entities.GameRewardConfig{
|
||
|
|
OrganizationID: *game.OrganizationID, GameID: game.ID, RewardType: constants.GameRewardTypeFixed,
|
||
|
|
Rules: entities.JSONDocument(`{"amount": 10}`), MaxReward: 10, CreatedBy: f.admin,
|
||
|
|
}
|
||
|
|
require.NoError(t, f.games.CreateRewardConfig(context.Background(), config))
|
||
|
|
return config
|
||
|
|
}
|
||
|
|
|
||
|
|
// walletTx writes a bare ledger row for the session foreign keys to point at.
|
||
|
|
// reverses is the row a refund reverses, nil otherwise.
|
||
|
|
func (f *enakGameFixture) walletTx(t *testing.T, txType string, amount int64, reverses *uuid.UUID) uuid.UUID {
|
||
|
|
t.Helper()
|
||
|
|
id := uuid.New()
|
||
|
|
require.NoError(t, f.db.Exec(`
|
||
|
|
INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after,
|
||
|
|
reference_type, reference_id, reverses_transaction_id, description)
|
||
|
|
VALUES (?, ?, ?, 'COIN', ?, ?, 0, 'GAME_SESSION', ?, ?, 'test')`,
|
||
|
|
id, f.org, f.customer, txType, amount, uuid.New(), reverses).Error)
|
||
|
|
return id
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *enakGameFixture) session(t *testing.T, game *entities.Game, config *entities.GameRewardConfig, expiresAt time.Time) *entities.GameSession {
|
||
|
|
t.Helper()
|
||
|
|
session := &entities.GameSession{
|
||
|
|
OrganizationID: f.org, CustomerID: f.customer, GameID: game.ID, RewardConfigID: config.ID,
|
||
|
|
EntryCost: game.EntryCost, ExpiresAt: expiresAt, SpendTransactionID: f.walletTx(t, constants.WalletTxTypeGameSpend, -game.EntryCost, nil),
|
||
|
|
}
|
||
|
|
require.NoError(t, f.sessions.CreateSession(context.Background(), session))
|
||
|
|
return session
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *enakGameFixture) budget(t *testing.T, org uuid.UUID, scope string, start, end time.Time) *entities.GameBudget {
|
||
|
|
t.Helper()
|
||
|
|
budget := &entities.GameBudget{
|
||
|
|
OrganizationID: org, Scope: scope, Name: "Oktober", PeriodStart: start, PeriodEnd: end, Amount: 100_000_000, CreatedBy: f.admin,
|
||
|
|
}
|
||
|
|
require.NoError(t, f.budgets.CreateBudget(context.Background(), budget))
|
||
|
|
return budget
|
||
|
|
}
|
||
|
|
|
||
|
|
func date(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) }
|
||
|
|
|
||
|
|
func TestEnakGameSchema_RejectsWhatTheRFCForbids(t *testing.T) {
|
||
|
|
f := newEnakGameFixture(t)
|
||
|
|
game := f.game(t, f.org, "tap")
|
||
|
|
config := f.config(t, game)
|
||
|
|
ctx := context.Background()
|
||
|
|
rejected := func(t *testing.T, err error, constraint string) {
|
||
|
|
t.Helper()
|
||
|
|
require.Error(t, err)
|
||
|
|
assert.Contains(t, err.Error(), constraint)
|
||
|
|
}
|
||
|
|
|
||
|
|
t.Run("ACTIVE game without organization", func(t *testing.T) {
|
||
|
|
rejected(t, f.db.Exec(`INSERT INTO games (name, type, slug, entry_cost) VALUES ('x', 'MINIGAME', 'x', 1)`).Error, "chk_games_enakgame_identity")
|
||
|
|
})
|
||
|
|
t.Run("ACTIVE game without slug", func(t *testing.T) {
|
||
|
|
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, entry_cost) VALUES ('x', 'MINIGAME', ?, 1)`, f.org).Error, "chk_games_enakgame_identity")
|
||
|
|
})
|
||
|
|
t.Run("entry cost 0", func(t *testing.T) {
|
||
|
|
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug, entry_cost) VALUES ('x', 'MINIGAME', ?, 'free', 0)`, f.org).Error, "chk_games_entry_cost")
|
||
|
|
})
|
||
|
|
t.Run("no entry cost", func(t *testing.T) {
|
||
|
|
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug) VALUES ('x', 'MINIGAME', ?, 'none')`, f.org).Error, "entry_cost")
|
||
|
|
})
|
||
|
|
t.Run("same slug twice in an organization", func(t *testing.T) {
|
||
|
|
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug, entry_cost) VALUES ('x', 'MINIGAME', ?, 'tap', 1)`, f.org).Error, "uq_games_org_slug")
|
||
|
|
f.game(t, f.other, "tap") // another organization may use it
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("two ACTIVE configs for a game", func(t *testing.T) {
|
||
|
|
second := f.config(t, game)
|
||
|
|
ok, err := f.games.SetRewardConfigStatus(ctx, f.org, config.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.True(t, ok)
|
||
|
|
_, err = f.games.SetRewardConfigStatus(ctx, f.org, second.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
|
||
|
|
rejected(t, err, "uq_game_reward_configs_active")
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("REFUNDED session without refund transaction", func(t *testing.T) {
|
||
|
|
session := f.session(t, game, config, time.Now().Add(time.Minute))
|
||
|
|
rejected(t, f.db.Exec(`UPDATE game_sessions SET status = 'REFUNDED', refund_reason = 'SYSTEM_ERROR' WHERE id = ?`, session.ID).Error, "chk_game_sessions_refund")
|
||
|
|
rejected(t, f.db.Exec(`UPDATE game_sessions SET refund_transaction_id = spend_transaction_id, refund_reason = 'SYSTEM_ERROR' WHERE id = ?`, session.ID).Error, "chk_game_sessions_refund")
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("two GLOBAL budgets with the same start", func(t *testing.T) {
|
||
|
|
f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
|
||
|
|
err := f.budgets.CreateBudget(ctx, &entities.GameBudget{
|
||
|
|
OrganizationID: f.org, Scope: constants.GameBudgetScopeGlobal, Name: "x",
|
||
|
|
PeriodStart: date(2026, 10, 1), PeriodEnd: date(2026, 10, 15), Amount: 1, CreatedBy: f.admin,
|
||
|
|
})
|
||
|
|
assert.ErrorIs(t, err, ErrGameBudgetPeriodTaken, "uq_game_budgets_global_period")
|
||
|
|
f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 1), date(2026, 10, 7))
|
||
|
|
f.budget(t, f.other, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("same reward ledger row twice", func(t *testing.T) {
|
||
|
|
session := f.session(t, game, config, time.Now().Add(time.Minute))
|
||
|
|
global := f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 11, 1), date(2026, 11, 30))
|
||
|
|
event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 11, 1), date(2026, 11, 7))
|
||
|
|
reward := f.walletTx(t, constants.WalletTxTypeGameReward, 10, nil)
|
||
|
|
err := f.sessions.CreateSessionRewards(ctx, []entities.GameSessionReward{
|
||
|
|
{SessionID: session.ID, BudgetID: global.ID, Amount: 10, WalletTransactionID: reward},
|
||
|
|
{SessionID: session.ID, BudgetID: event.ID, Amount: 10, WalletTransactionID: reward},
|
||
|
|
})
|
||
|
|
rejected(t, err, "uq_game_session_rewards_wallet_transaction")
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// D4: of two moves out of STARTED at once, exactly one gets the row.
|
||
|
|
func TestGameSessionRepository_ConcurrentTransitionsOnlyOneWins(t *testing.T) {
|
||
|
|
f := newEnakGameFixture(t)
|
||
|
|
game := f.game(t, f.org, "race")
|
||
|
|
config := f.config(t, game)
|
||
|
|
txm := NewTxManager(f.db)
|
||
|
|
|
||
|
|
for round := 0; round < 5; round++ {
|
||
|
|
session := f.session(t, game, config, time.Now().Add(time.Minute))
|
||
|
|
refund := f.walletTx(t, constants.WalletTxTypeGameSpendRefund, game.EntryCost, &session.SpendTransactionID)
|
||
|
|
moves := []func(ctx context.Context) (bool, error){
|
||
|
|
func(ctx context.Context) (bool, error) {
|
||
|
|
return f.sessions.CompleteSession(ctx, session.ID, GameSessionCompletion{
|
||
|
|
Result: entities.JSONDocument(`{"score": 10}`), RewardTotal: 10, EndedAt: time.Now(),
|
||
|
|
})
|
||
|
|
},
|
||
|
|
func(ctx context.Context) (bool, error) {
|
||
|
|
return f.sessions.RefundSession(ctx, session.ID, refund, constants.GameSessionRefundSystemError, time.Now())
|
||
|
|
},
|
||
|
|
func(ctx context.Context) (bool, error) { return f.sessions.ExpireSession(ctx, session.ID, time.Now()) },
|
||
|
|
}
|
||
|
|
|
||
|
|
var wg sync.WaitGroup
|
||
|
|
var mu sync.Mutex
|
||
|
|
won := 0
|
||
|
|
start := make(chan struct{})
|
||
|
|
for _, move := range moves {
|
||
|
|
move := move
|
||
|
|
wg.Add(1)
|
||
|
|
go func() {
|
||
|
|
defer wg.Done()
|
||
|
|
<-start
|
||
|
|
err := txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||
|
|
ok, err := move(ctx)
|
||
|
|
if ok {
|
||
|
|
mu.Lock()
|
||
|
|
won++
|
||
|
|
mu.Unlock()
|
||
|
|
}
|
||
|
|
return err
|
||
|
|
})
|
||
|
|
assert.NoError(t, err)
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
close(start)
|
||
|
|
wg.Wait()
|
||
|
|
assert.Equal(t, 1, won, "round %d", round)
|
||
|
|
|
||
|
|
got, err := f.sessions.GetSession(context.Background(), f.org, session.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.NotEqual(t, constants.GameSessionStatusStarted, got.Status)
|
||
|
|
assert.NotNil(t, got.EndedAt)
|
||
|
|
ok, err := f.sessions.MarkCompletionFailed(context.Background(), session.ID, time.Now())
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.False(t, ok, "a session that left STARTED is not marked")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGameSessionRepository_ReadsAndMoves(t *testing.T) {
|
||
|
|
f := newEnakGameFixture(t)
|
||
|
|
game := f.game(t, f.org, "tap")
|
||
|
|
config := f.config(t, game)
|
||
|
|
ctx := context.Background()
|
||
|
|
now := time.Now()
|
||
|
|
|
||
|
|
expired := f.session(t, game, config, now.Add(-time.Minute))
|
||
|
|
open := f.session(t, game, config, now.Add(time.Hour))
|
||
|
|
|
||
|
|
got, err := f.sessions.GetCustomerSession(ctx, f.customer, open.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, constants.GameSessionStatusStarted, got.Status)
|
||
|
|
assert.Equal(t, game.EntryCost, got.EntryCost)
|
||
|
|
assert.Equal(t, config.ID, got.RewardConfigID)
|
||
|
|
_, err = f.sessions.GetCustomerSession(ctx, uuid.New(), open.ID)
|
||
|
|
assert.ErrorIs(t, err, ErrGameSessionNotFound, "another customer's session")
|
||
|
|
_, err = f.sessions.GetSession(ctx, f.other, open.ID)
|
||
|
|
assert.ErrorIs(t, err, ErrGameSessionNotFound, "another organization's session")
|
||
|
|
bySpend, err := f.sessions.GetSessionBySpendTransaction(ctx, open.SpendTransactionID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, open.ID, bySpend.ID)
|
||
|
|
|
||
|
|
due, err := f.sessions.ListExpiredSessions(ctx, now, 10)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, due, 1)
|
||
|
|
assert.Equal(t, expired.ID, due[0].ID)
|
||
|
|
byGame, err := f.sessions.ListOpenSessionsByGame(ctx, f.org, game.ID, 10)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Len(t, byGame, 2)
|
||
|
|
|
||
|
|
marked, err := f.sessions.MarkCompletionFailed(ctx, open.ID, now)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.True(t, marked)
|
||
|
|
ok, err := f.sessions.CompleteSession(ctx, open.ID, GameSessionCompletion{
|
||
|
|
Result: entities.JSONDocument(`{"score": 120}`), RewardBreakdown: entities.JSONDocument(`{"base": 10}`),
|
||
|
|
RewardTotal: 10, Flagged: true, EndedAt: now,
|
||
|
|
})
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.True(t, ok)
|
||
|
|
got, err = f.sessions.GetSession(ctx, f.org, open.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, constants.GameSessionStatusCompleted, got.Status)
|
||
|
|
assert.JSONEq(t, `{"score": 120}`, string(got.Result))
|
||
|
|
assert.JSONEq(t, `{"base": 10}`, string(got.RewardBreakdown))
|
||
|
|
assert.EqualValues(t, 10, got.RewardTotal)
|
||
|
|
assert.True(t, got.Flagged)
|
||
|
|
assert.NotNil(t, got.CompletionFailedAt)
|
||
|
|
|
||
|
|
sessions, total, err := f.sessions.ListCustomerSessions(ctx, f.customer, 0, 1)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.EqualValues(t, 2, total)
|
||
|
|
assert.Len(t, sessions, 1)
|
||
|
|
|
||
|
|
budget := f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
|
||
|
|
reward := f.walletTx(t, constants.WalletTxTypeGameReward, 10, nil)
|
||
|
|
require.NoError(t, f.sessions.CreateSessionRewards(ctx, []entities.GameSessionReward{
|
||
|
|
{SessionID: open.ID, BudgetID: budget.ID, Amount: 10, WalletTransactionID: reward},
|
||
|
|
}))
|
||
|
|
rewards, err := f.sessions.ListSessionRewards(ctx, open.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, []entities.GameSessionReward{{SessionID: open.ID, BudgetID: budget.ID, Amount: 10, WalletTransactionID: reward}}, rewards)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEnakGameRepository_GamesAndRewardConfigs(t *testing.T) {
|
||
|
|
f := newEnakGameFixture(t)
|
||
|
|
ctx := context.Background()
|
||
|
|
game := f.game(t, f.org, "tap")
|
||
|
|
theirs := f.game(t, f.other, "theirs")
|
||
|
|
max := int64(1000)
|
||
|
|
game.ResultRules = entities.GameResultRules{MaxScore: &max, Outcomes: []string{"WIN", "LOSE"}}
|
||
|
|
game.EntryCost = 7
|
||
|
|
require.NoError(t, f.games.UpdateGame(ctx, game))
|
||
|
|
|
||
|
|
got, err := f.games.GetGame(ctx, f.org, game.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.EqualValues(t, 7, got.EntryCost)
|
||
|
|
assert.Equal(t, game.ResultRules, got.ResultRules)
|
||
|
|
_, err = f.games.GetGame(ctx, f.org, theirs.ID)
|
||
|
|
assert.ErrorIs(t, err, ErrEnakGameNotFound, "another organization's game")
|
||
|
|
theirs.OrganizationID = &f.org
|
||
|
|
assert.ErrorIs(t, f.games.UpdateGame(ctx, theirs), ErrEnakGameNotFound)
|
||
|
|
assert.ErrorIs(t, f.games.SetGameStatus(ctx, f.org, theirs.ID, constants.GameStatusInactive), ErrEnakGameNotFound)
|
||
|
|
|
||
|
|
games, total, err := f.games.ListGames(ctx, EnakGameFilter{OrganizationID: f.org, Limit: 10})
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.EqualValues(t, 1, total, "only the organization's games; the archived old ones have none")
|
||
|
|
assert.Equal(t, game.ID, games[0].ID)
|
||
|
|
|
||
|
|
require.NoError(t, f.games.SetGameStatus(ctx, f.org, game.ID, constants.GameStatusArchived))
|
||
|
|
assert.ErrorIs(t, f.games.SetGameStatus(ctx, f.org, game.ID, constants.GameStatusActive), ErrEnakGameNotFound, "an archived game never changes")
|
||
|
|
assert.ErrorIs(t, f.games.UpdateGame(ctx, game), ErrEnakGameNotFound)
|
||
|
|
games, _, err = f.games.ListGames(ctx, EnakGameFilter{OrganizationID: f.org, Statuses: []string{constants.GameStatusActive}, Limit: 10})
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Empty(t, games)
|
||
|
|
|
||
|
|
// Reward configs: versions count up per game, only status ever changes.
|
||
|
|
other := f.game(t, f.org, "other")
|
||
|
|
v1, v2 := f.config(t, other), f.config(t, other)
|
||
|
|
assert.Equal(t, 1, v1.Version)
|
||
|
|
assert.Equal(t, 2, v2.Version)
|
||
|
|
assert.Equal(t, 1, f.config(t, game).Version, "per game")
|
||
|
|
err = f.games.CreateRewardConfig(ctx, &entities.GameRewardConfig{
|
||
|
|
OrganizationID: f.other, GameID: other.ID, RewardType: constants.GameRewardTypeFixed,
|
||
|
|
Rules: entities.JSONDocument(`{}`), CreatedBy: f.admin,
|
||
|
|
})
|
||
|
|
assert.ErrorIs(t, err, ErrEnakGameNotFound, "a config for another organization's game")
|
||
|
|
|
||
|
|
_, err = f.games.GetActiveRewardConfig(ctx, f.org, other.ID)
|
||
|
|
assert.ErrorIs(t, err, ErrGameRewardConfigNotFound)
|
||
|
|
ok, err := f.games.SetRewardConfigStatus(ctx, f.org, v2.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.True(t, ok)
|
||
|
|
ok, err = f.games.SetRewardConfigStatus(ctx, f.org, v2.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.False(t, ok, "no longer DRAFT")
|
||
|
|
ok, err = f.games.SetRewardConfigStatus(ctx, f.other, v1.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusRetired)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.False(t, ok, "another organization")
|
||
|
|
|
||
|
|
active, err := f.games.GetActiveRewardConfig(ctx, f.org, other.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, v2.ID, active.ID)
|
||
|
|
assert.JSONEq(t, `{"amount": 10}`, string(active.Rules))
|
||
|
|
configs, err := f.games.ListRewardConfigs(ctx, f.org, other.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, configs, 2)
|
||
|
|
assert.Equal(t, []int{2, 1}, []int{configs[0].Version, configs[1].Version})
|
||
|
|
_, err = f.games.GetRewardConfig(ctx, f.other, v1.ID)
|
||
|
|
assert.ErrorIs(t, err, ErrGameRewardConfigNotFound)
|
||
|
|
|
||
|
|
// LockGame takes the row lock: a second locker waits for the first transaction.
|
||
|
|
txm := NewTxManager(f.db)
|
||
|
|
locked := make(chan struct{})
|
||
|
|
release := make(chan struct{})
|
||
|
|
go func() {
|
||
|
|
_ = txm.WithTransaction(ctx, func(ctx context.Context) error {
|
||
|
|
_, err := f.games.LockGame(ctx, f.org, other.ID)
|
||
|
|
assert.NoError(t, err)
|
||
|
|
close(locked)
|
||
|
|
<-release
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
}()
|
||
|
|
<-locked
|
||
|
|
acquired := make(chan struct{})
|
||
|
|
go func() {
|
||
|
|
_ = txm.WithTransaction(ctx, func(ctx context.Context) error {
|
||
|
|
_, err := f.games.LockGame(ctx, f.org, other.ID)
|
||
|
|
assert.NoError(t, err)
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
close(acquired)
|
||
|
|
}()
|
||
|
|
select {
|
||
|
|
case <-acquired:
|
||
|
|
t.Fatal("the second lock did not wait")
|
||
|
|
case <-time.After(200 * time.Millisecond):
|
||
|
|
}
|
||
|
|
close(release)
|
||
|
|
<-acquired
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGameBudgetRepository(t *testing.T) {
|
||
|
|
f := newEnakGameFixture(t)
|
||
|
|
ctx := context.Background()
|
||
|
|
// A late evening in Jakarta is still the previous day in UTC: the date is taken as
|
||
|
|
// given, not converted.
|
||
|
|
jakarta := time.FixedZone("WIB", 7*3600)
|
||
|
|
october := f.budget(t, f.org, constants.GameBudgetScopeGlobal,
|
||
|
|
time.Date(2026, 10, 1, 0, 30, 0, 0, jakarta), time.Date(2026, 10, 31, 23, 30, 0, 0, jakarta))
|
||
|
|
f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 11, 1), date(2026, 11, 30))
|
||
|
|
event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 10), date(2026, 10, 20))
|
||
|
|
f.budget(t, f.other, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
|
||
|
|
|
||
|
|
got, err := f.budgets.GetBudget(ctx, f.org, october.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, "2026-10-01", got.PeriodStart.Format("2006-01-02"))
|
||
|
|
assert.Equal(t, "2026-10-31", got.PeriodEnd.Format("2006-01-02"))
|
||
|
|
assert.JSONEq(t, `{}`, string(got.Thresholds))
|
||
|
|
_, err = f.budgets.GetBudget(ctx, f.other, october.ID)
|
||
|
|
assert.ErrorIs(t, err, ErrGameBudgetNotFound)
|
||
|
|
|
||
|
|
for day, want := range map[time.Time]uuid.UUID{
|
||
|
|
date(2026, 10, 1): october.ID,
|
||
|
|
date(2026, 10, 15): october.ID,
|
||
|
|
date(2026, 10, 31): october.ID,
|
||
|
|
} {
|
||
|
|
got, err := f.budgets.GetGlobalBudgetOn(ctx, f.org, day)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, want, got.ID, day)
|
||
|
|
}
|
||
|
|
_, err = f.budgets.GetGlobalBudgetOn(ctx, f.org, date(2026, 9, 30))
|
||
|
|
assert.ErrorIs(t, err, ErrGameBudgetNotFound)
|
||
|
|
|
||
|
|
event.Amount = 5_000_000
|
||
|
|
event.Thresholds = entities.JSONDocument(`{"warning": 70, "critical": 90}`)
|
||
|
|
require.NoError(t, f.budgets.UpdateBudget(ctx, event))
|
||
|
|
got, err = f.budgets.GetBudget(ctx, f.org, event.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.EqualValues(t, 5_000_000, got.Amount)
|
||
|
|
assert.JSONEq(t, `{"warning": 70, "critical": 90}`, string(got.Thresholds))
|
||
|
|
event.OrganizationID = f.other
|
||
|
|
assert.ErrorIs(t, f.budgets.UpdateBudget(ctx, event), ErrGameBudgetNotFound)
|
||
|
|
|
||
|
|
budgets, total, err := f.budgets.ListBudgets(ctx, GameBudgetFilter{OrganizationID: f.org, Scope: constants.GameBudgetScopeGlobal, Limit: 10})
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.EqualValues(t, 2, total)
|
||
|
|
assert.Equal(t, "2026-11-01", budgets[0].PeriodStart.Format("2006-01-02"), "latest period first")
|
||
|
|
}
|
||
|
|
|
||
|
|
// EG-701: what game_events refuses.
|
||
|
|
func TestGameEventSchema_RejectsWhatTheRFCForbids(t *testing.T) {
|
||
|
|
f := newEnakGameFixture(t)
|
||
|
|
event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 1), date(2026, 10, 7))
|
||
|
|
t.Cleanup(func() { f.db.Exec(`DELETE FROM game_events WHERE organization_id = ?`, f.org) })
|
||
|
|
insert := func(budget any, start, end string) error {
|
||
|
|
return f.db.Exec(`INSERT INTO game_events (organization_id, name, slug, start_at, end_at, budget_id)
|
||
|
|
VALUES (?, 'e', ?, ?, ?, ?)`, f.org, uuid.NewString(), start, end, budget).Error
|
||
|
|
}
|
||
|
|
err := insert(nil, "2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z")
|
||
|
|
require.Error(t, err)
|
||
|
|
assert.Contains(t, err.Error(), "budget_id")
|
||
|
|
err = insert(event.ID, "2026-10-02T00:00:00Z", "2026-10-02T00:00:00Z")
|
||
|
|
require.Error(t, err)
|
||
|
|
assert.Contains(t, err.Error(), "chk_game_events_period")
|
||
|
|
err = insert(event.ID, "2026-10-02T00:00:00Z", "2026-10-01T00:00:00Z")
|
||
|
|
require.Error(t, err)
|
||
|
|
assert.Contains(t, err.Error(), "chk_game_events_period")
|
||
|
|
require.NoError(t, insert(event.ID, "2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z"))
|
||
|
|
}
|