package processor import ( "context" "encoding/json" "fmt" "sync" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) // EnakGame vouchers and redemptions against Postgres (docs/rfc-enakgame.md §7.4, §7.6). // Needs TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go. func (e *enakGameEnv) voucher(org uuid.UUID, in models.VoucherInput) *models.Voucher { e.t.Helper() if in.Name == "" { in.Name = "Voucher" } if in.VoucherType == "" { in.VoucherType = constants.VoucherTypeFixedValue } if in.Status == "" { in.Status = constants.VoucherStatusActive } v, err := e.vouchers.CreateVoucher(e.ctx(), org, e.manager, in) require.NoError(e.t, err) return v } func (e *enakGameEnv) staticVoucher(stock, faceValue, pointCost int64) *models.Voucher { return e.voucher(e.orgA, models.VoucherInput{FaceValue: faceValue, PointCost: pointCost, StockMode: constants.VoucherStockStatic, Stock: &stock}) } // points gives a customer EnakPoint that did not come from EnakGame. func (e *enakGameEnv) points(customer uuid.UUID, txType string, amount int64) { e.t.Helper() entry := WalletEntry{ CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: txType, Amount: amount, ReferenceID: uuid.New(), Description: txType, } switch txType { case constants.WalletTxTypeEarn: entry.ReferenceType, entry.OutletID = constants.WalletRefTypeOrder, ptr(uuid.New()) default: entry.ReferenceType, entry.CreatedByUser, entry.Reason = constants.WalletRefTypeUser, &e.manager, ptr("test") } require.NoError(e.t, e.txm.WithTransaction(e.ctx(), func(ctx context.Context) error { _, err := e.wallet.Credit(ctx, WalletCreditInput{WalletEntry: entry}) return err })) } // rewardCoins plays a FIXED game of org A paying amount and returns the global budget // that paid it. func (e *enakGameEnv) rewardCoins(customer uuid.UUID, amount int64) uuid.UUID { e.t.Helper() slug := fmt.Sprintf("pay-%d-%s", amount, uuid.NewString()[:8]) game := e.gameWith(slug, entities.GameResultRules{}, constants.GameRewardTypeFixed, fmt.Sprintf(`{"amount": %d}`, amount), 0) e.coins(customer, 2, nil) started, err := e.sessions.Start(e.ctx(), customer, game.ID, slug) require.NoError(e.t, err) out, err := e.sessions.Complete(e.ctx(), customer, started.SessionID, models.GameSessionCompleteInput{}) require.NoError(e.t, err) require.EqualValues(e.t, amount, out.RewardTotal) var budget string require.NoError(e.t, e.db.Raw(`SELECT budget_id::text FROM game_session_rewards WHERE session_id = ?`, started.SessionID).Scan(&budget).Error) return uuid.MustParse(budget) } func (e *enakGameEnv) exchangeCoins(customer uuid.UUID, coins int64) { e.t.Helper() _, err := e.exchange.Exchange(e.ctx(), customer, coins, testPin, uuid.NewString(), models.CustomerPinRequestInfo{}) require.NoError(e.t, err) } func (e *enakGameEnv) pointBalance(customer uuid.UUID) int64 { e.t.Helper() var balance int64 require.NoError(e.t, e.db.Raw(`SELECT COALESCE((SELECT point_balance FROM customer_wallets WHERE customer_id = ?), 0)`, customer).Scan(&balance).Error) return balance } func (e *enakGameEnv) redeemVoucher(customer, voucherID uuid.UUID, key string) (*models.VoucherRedeemResult, error) { return e.redeem.Redeem(e.ctx(), customer, voucherID, testPin, key, models.CustomerPinRequestInfo{}) } type costRow struct { BudgetID *string SourceType string Points int64 Cost int64 } func (e *enakGameEnv) costs(redemptionID uuid.UUID) []costRow { e.t.Helper() var rows []costRow require.NoError(e.t, e.db.Raw(`SELECT budget_id::text AS budget_id, source_type, points, cost FROM voucher_redemption_costs WHERE redemption_id = ? ORDER BY cost DESC`, redemptionID).Scan(&rows).Error) return rows } // EG-502 func TestVoucherAdmin(t *testing.T) { e := newEnakGameEnv(t) ctx := e.ctx() for name, in := range map[string]models.VoucherInput{ "no name": {VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))}, "unknown type": {Name: "v", VoucherType: "CASHBACK", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))}, "no face value": {Name: "v", VoucherType: "FIXED_VALUE", PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))}, "no point cost": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, StockMode: "STATIC", Stock: ptr(int64(1))}, "static no stock": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "STATIC"}, "pool with stock": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", Stock: ptr(int64(1))}, "external no provdr": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "EXTERNAL"}, "max per customer 0": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", MaxPerCustomer: ptr(0)}, "ends before it ends": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", ValidFrom: ptr(e.now), ValidUntil: ptr(e.now)}, "terms not an object": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", Terms: json.RawMessage(`[1]`)}, "archived on create": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", Status: "ARCHIVED"}, } { _, err := e.vouchers.CreateVoucher(ctx, e.orgA, e.manager, in) assert.ErrorIs(t, err, ErrEnakGameRejected, name) } pool := e.voucher(e.orgA, models.VoucherInput{Name: "Kopi", FaceValue: 20_000, PointCost: 15_000, StockMode: "code_pool", Terms: json.RawMessage(`{"outlet": "semua"}`), Status: "DRAFT"}) assert.Equal(t, constants.VoucherStockCodePool, pool.StockMode) assert.JSONEq(t, `{"outlet": "semua"}`, string(pool.Terms)) // Importing the same file twice adds nothing the second time. csv := []byte("code,expires_at\nKOPI-1,2030-12-31\nKOPI-2\nKOPI-2\n,\nKOPI-3,31/12/2030\nKOPI-4\n") result, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, csv) require.NoError(t, err) assert.Equal(t, 3, result.Imported) assert.Equal(t, []string{"KOPI-2"}, result.Duplicates, "repeated in the file") require.Len(t, result.Invalid, 1) assert.Equal(t, 6, result.Invalid[0].Line) result, err = e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, csv) require.NoError(t, err) assert.Zero(t, result.Imported) assert.Equal(t, 4, result.DuplicateCount) codes, err := e.vouchers.Codes(ctx, e.orgA, pool.ID, models.VoucherCodeListQuery{}) require.NoError(t, err) assert.Equal(t, map[string]int64{"AVAILABLE": 3}, codes.Counts) require.Len(t, codes.Codes.Data, 3) var expiring *time.Time for _, c := range codes.Codes.Data { if c.Code == "KOPI-1" { expiring = c.ExpiresAt } } require.NotNil(t, expiring) assert.Equal(t, "2030-12-31 23:59:59", expiring.In(walletDisplayLocation).Format("2006-01-02 15:04:05"), "the end of that day in Jakarta") static := e.staticVoucher(5, 10_000, 8_000) _, err = e.vouchers.ImportCodes(ctx, e.orgA, e.manager, static.ID, csv) assert.ErrorIs(t, err, ErrEnakGameRejected, "a STATIC voucher holds no codes") _, err = e.vouchers.ImportCodes(ctx, e.orgB, e.manager, pool.ID, csv) assert.ErrorIs(t, err, repository.ErrVoucherNotFound, "another organization's voucher") _, err = e.vouchers.GetVoucher(ctx, e.orgB, pool.ID) assert.ErrorIs(t, err, repository.ErrVoucherNotFound) in := VoucherInputFrom(static) in.Stock, in.PointCost = ptr(int64(9)), 7_500 updated, err := e.vouchers.UpdateVoucher(ctx, e.orgA, e.manager, static.ID, in) require.NoError(t, err) assert.EqualValues(t, 9, *updated.Stock) in.StockMode = constants.VoucherStockCodePool _, err = e.vouchers.UpdateVoucher(ctx, e.orgA, e.manager, static.ID, in) assert.ErrorIs(t, err, ErrEnakGameRejected, "the stock mode stays") _, err = e.vouchers.SetVoucherStatus(ctx, e.orgA, e.manager, static.ID, models.VoucherStatusInput{Status: "ARCHIVED"}) require.NoError(t, err) _, err = e.vouchers.UpdateVoucher(ctx, e.orgA, e.manager, static.ID, VoucherInputFrom(updated)) assert.ErrorIs(t, err, ErrEnakGameRejected) list, err := e.vouchers.ListVouchers(ctx, e.orgA, models.VoucherListQuery{}) require.NoError(t, err) assert.Len(t, list.Data, 1, "archived left out") assert.Equal(t, []string{"CREATED", "UPDATED", "STATUS_CHANGED"}, e.auditActions(constants.AuditEntityVoucher, static.ID)) assert.Equal(t, []string{"CREATED", "CODES_IMPORTED", "CODES_IMPORTED"}, e.auditActions(constants.AuditEntityVoucher, pool.ID)) } // EG-504 func TestVoucherRedemption(t *testing.T) { e := newEnakGameEnv(t) ctx := e.ctx() e.points(e.alice, constants.WalletTxTypeAdjustment, 100_000) e.points(e.bob, constants.WalletTxTypeAdjustment, 100_000) t.Run("STATIC: the last one goes to exactly one of two customers", func(t *testing.T) { last := e.staticVoucher(1, 10_000, 8_000) var wg sync.WaitGroup results := make([]error, 2) for i, customer := range []uuid.UUID{e.alice, e.bob} { i, customer := i, customer wg.Add(1) go func() { defer wg.Done() _, results[i] = e.redeemVoucher(customer, last.ID, "last") }() } wg.Wait() won := 0 for _, err := range results { if err == nil { won++ } else { assert.ErrorIs(t, err, ErrVoucherRedemptionRejected) } } assert.Equal(t, 1, won) assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM voucher_redemptions WHERE voucher_id = ?`, last.ID)) assert.EqualValues(t, 200_000-8_000, e.pointBalance(e.alice)+e.pointBalance(e.bob), "only the winner paid") assert.Zero(t, e.count(`SELECT stock FROM vouchers WHERE id = ?`, last.ID)) }) t.Run("CODE_POOL: redemptions at once get different codes", func(t *testing.T) { pool := e.voucher(e.orgA, models.VoucherInput{FaceValue: 5_000, PointCost: 1_000, StockMode: constants.VoucherStockCodePool}) _, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, []byte("A1\nA2\nA3\nA4\nA5\nA6")) require.NoError(t, err) var wg sync.WaitGroup var mu sync.Mutex got := map[string]int{} for i := 0; i < 6; i++ { customer := []uuid.UUID{e.alice, e.bob}[i%2] key := fmt.Sprintf("pool-%d", i) wg.Add(1) go func() { defer wg.Done() res, err := e.redeemVoucher(customer, pool.ID, key) if assert.NoError(t, err) { mu.Lock() got[*res.Code]++ mu.Unlock() } }() } wg.Wait() assert.Len(t, got, 6, "six different codes") for code, n := range got { assert.Equal(t, 1, n, code) } _, err = e.redeemVoucher(e.alice, pool.ID, "pool-empty") assert.ErrorIs(t, err, ErrVoucherRedemptionRejected, "the pool is empty") }) t.Run("the same key twice: one debit, one code", func(t *testing.T) { pool := e.voucher(e.orgA, models.VoucherInput{FaceValue: 5_000, PointCost: 1_000, StockMode: constants.VoucherStockCodePool}) _, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, []byte("B1\nB2")) require.NoError(t, err) before := e.pointBalance(e.alice) first, err := e.redeemVoucher(e.alice, pool.ID, "twice") require.NoError(t, err) assert.False(t, first.Replayed) require.NotNil(t, first.Code) assert.EqualValues(t, before-1_000, first.PointBalance) // The voucher is turned off meanwhile; the retry still gets its code. _, err = e.vouchers.SetVoucherStatus(ctx, e.orgA, e.manager, pool.ID, models.VoucherStatusInput{Status: "INACTIVE"}) require.NoError(t, err) second, err := e.redeemVoucher(e.alice, pool.ID, "twice") require.NoError(t, err) assert.True(t, second.Replayed) assert.Equal(t, first.ID, second.ID) assert.Equal(t, *first.Code, *second.Code) assert.EqualValues(t, before-1_000, e.pointBalance(e.alice)) assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'REWARD_REDEEM' AND reference_id = ?`, e.alice, first.ID)) _, err = e.redeemVoucher(e.alice, uuid.New(), "twice") assert.ErrorIs(t, err, ErrVoucherRedemptionRejected, "the key redeemed another voucher") }) t.Run("refusals record nothing", func(t *testing.T) { pricey := e.staticVoucher(5, 10_000, 1_000_000) limited := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), MaxPerCustomer: ptr(1)}) later := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), ValidFrom: ptr(e.now.Add(time.Hour))}) ended := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), ValidUntil: ptr(e.now.Add(-time.Second))}) draft := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), Status: "DRAFT"}) external := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "EXTERNAL", Provider: ptr("no-adapter")}) theirs := e.voucher(e.orgB, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5))}) _, err := e.redeemVoucher(e.bob, limited.ID, "limit-1") require.NoError(t, err) count := func() int64 { return e.count(`SELECT COUNT(*) FROM voucher_redemptions WHERE organization_id IN (?, ?)`, e.orgA, e.orgB) + e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'REWARD_REDEEM' AND organization_id IN (?, ?)`, e.orgA, e.orgB) } before, balance := count(), e.pointBalance(e.bob) for name, tc := range map[string]struct { voucher uuid.UUID pin string want error }{ "not enough EnakPoint": {pricey.ID, testPin, ErrVoucherRedemptionRejected}, "max per customer": {limited.ID, testPin, ErrVoucherRedemptionRejected}, "not started": {later.ID, testPin, ErrVoucherRedemptionRejected}, "ended": {ended.ID, testPin, ErrVoucherRedemptionRejected}, "draft": {draft.ID, testPin, ErrVoucherRedemptionRejected}, "external, no adapter": {external.ID, testPin, ErrVoucherRedemptionRejected}, "another org": {theirs.ID, testPin, repository.ErrVoucherNotFound}, "wrong PIN": {limited.ID, "000000", nil}, } { if name == "wrong PIN" { // limited is used up for Bob; Alice has it free, so only the PIN stands in the way. _, err := e.redeem.Redeem(ctx, e.alice, tc.voucher, tc.pin, "pin", models.CustomerPinRequestInfo{}) var pinErr *PinError assert.ErrorAs(t, err, &pinErr, name) continue } _, err := e.redeem.Redeem(ctx, e.bob, tc.voucher, tc.pin, "refused-"+name, models.CustomerPinRequestInfo{}) assert.ErrorIs(t, err, tc.want, name) } assert.Equal(t, before, count(), "no redemption, no ledger row") assert.Equal(t, balance, e.pointBalance(e.bob)) assert.EqualValues(t, 5, e.count(`SELECT stock FROM vouchers WHERE id = ?`, pricey.ID), "the stock taken for a refused redemption is back") assert.Zero(t, e.count(`SELECT pin_failed_attempts FROM customers WHERE id = ?`, e.bob), "a refusal before the PIN costs no attempt") }) } // EG-505 func TestVoucherRedemption_CostAttribution(t *testing.T) { e := newEnakGameEnv(t) voucher := e.staticVoucher(100, 10_000, 8_000) t.Run("GAME_REWARD → exchange → redeem", func(t *testing.T) { budget := e.rewardCoins(e.alice, 8_000) e.exchangeCoins(e.alice, 8_000) res, err := e.redeemVoucher(e.alice, voucher.ID, "chain-1") require.NoError(t, err) b := budget.String() assert.Equal(t, []costRow{{BudgetID: &b, SourceType: "GAME_REWARD", Points: 8_000, Cost: 10_000}}, e.costs(res.ID)) }) t.Run("GAME_REWARD → transfer → exchange → redeem", func(t *testing.T) { budget := e.rewardCoins(e.alice, 8_000) // Alice sends the reward to Bob, who exchanges and redeems it. require.NoError(t, e.txm.WithTransaction(e.ctx(), func(ctx context.Context) error { require.NoError(t, e.wallet.LockWallets(ctx, e.alice, e.bob)) group, outID, inID := uuid.New(), uuid.New(), uuid.New() out, err := e.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ TransactionID: outID, CustomerID: e.alice, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeTransferOut, Amount: 8_000, ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID, GroupID: &group, CounterpartyCustomerID: &e.bob, Description: "ke Bob", }}) require.NoError(t, err) _, err = e.wallet.Credit(ctx, WalletCreditInput{ WalletEntry: WalletEntry{ TransactionID: inID, CustomerID: e.bob, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeTransferIn, Amount: 8_000, ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID, GroupID: &group, CounterpartyCustomerID: &e.alice, Description: "dari Alice", }, Lots: out.CarryOver(), }) return err })) e.exchangeCoins(e.bob, 8_000) res, err := e.redeemVoucher(e.bob, voucher.ID, "chain-2") require.NoError(t, err) b := budget.String() assert.Equal(t, []costRow{{BudgetID: &b, SourceType: "GAME_REWARD", Points: 8_000, Cost: 10_000}}, e.costs(res.ID)) }) t.Run("GAME_REWARD + EARN: only the reward counts against the budget", func(t *testing.T) { // RFC §7.6: Rp10.000 for 8.000 EnakPoint, 6.000 from EnakGame and 2.000 from // shopping: Rp7.500 to the budget, Rp2.500 to none. dina := e.newCustomer(e.orgA) budget := e.rewardCoins(dina, 6_000) e.exchangeCoins(dina, 6_000) e.points(dina, constants.WalletTxTypeEarn, 2_000) res, err := e.redeemVoucher(dina, voucher.ID, "mixed") require.NoError(t, err) b := budget.String() assert.Equal(t, []costRow{ {BudgetID: &b, SourceType: "GAME_REWARD", Points: 6_000, Cost: 7_500}, {BudgetID: nil, SourceType: "EARN", Points: 2_000, Cost: 2_500}, }, e.costs(res.ID)) var recognized time.Time require.NoError(t, e.db.Raw(`SELECT recognized_at FROM voucher_redemption_costs WHERE redemption_id = ? LIMIT 1`, res.ID).Scan(&recognized).Error) assert.WithinDuration(t, *res.CompletedAt, recognized, time.Millisecond, "recognized when the redemption completed") }) t.Run("the costs add up to the face value when they do not divide evenly", func(t *testing.T) { odd := e.staticVoucher(10, 1_000, 3) eko := e.newCustomer(e.orgA) budget := e.rewardCoins(eko, 1) e.exchangeCoins(eko, 1) e.points(eko, constants.WalletTxTypeEarn, 1) e.points(eko, constants.WalletTxTypeAdjustment, 1) res, err := e.redeemVoucher(eko, odd.ID, "odd") require.NoError(t, err) // 1.000 over three equal parts: 333 each, the rupiah left over to the first of // equals, in source order. b := budget.String() assert.ElementsMatch(t, []costRow{ {BudgetID: nil, SourceType: "ADJUSTMENT", Points: 1, Cost: 334}, {BudgetID: nil, SourceType: "EARN", Points: 1, Cost: 333}, {BudgetID: &b, SourceType: "GAME_REWARD", Points: 1, Cost: 333}, }, e.costs(res.ID)) }) } // newCustomer adds a customer with the test PIN to org. func (e *enakGameEnv) newCustomer(org uuid.UUID) uuid.UUID { e.t.Helper() id := uuid.New() hash, err := bcrypt.GenerateFromPassword([]byte(testPin), bcrypt.MinCost) require.NoError(e.t, err) require.NoError(e.t, e.db.Exec(`INSERT INTO customers (id, organization_id, name, pin_hash, pin_set_at) VALUES (?, ?, 'extra', ?, NOW())`, id, org, string(hash)).Error) return id } // EG-506, EG-507 func TestVoucherCatalogRedemptionsAndExpiry(t *testing.T) { e := newEnakGameEnv(t) ctx := e.ctx() e.points(e.alice, constants.WalletTxTypeAdjustment, 10_000) static := e.voucher(e.orgA, models.VoucherInput{Name: "A Static", FaceValue: 10_000, PointCost: 100, StockMode: "STATIC", Stock: ptr(int64(3))}) pool := e.voucher(e.orgA, models.VoucherInput{Name: "B Pool", FaceValue: 5_000, PointCost: 200, StockMode: "CODE_POOL"}) _, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, []byte("P1\nP2\nP3,2020-01-01")) require.NoError(t, err) e.voucher(e.orgA, models.VoucherInput{Name: "Draft", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1)), Status: "DRAFT"}) e.voucher(e.orgA, models.VoucherInput{Name: "External", FaceValue: 1, PointCost: 1, StockMode: "EXTERNAL", Provider: ptr("no-adapter")}) e.voucher(e.orgA, models.VoucherInput{Name: "Ended", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1)), ValidUntil: ptr(e.now.Add(-time.Hour))}) e.voucher(e.orgB, models.VoucherInput{Name: "Theirs", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))}) catalog, err := e.redeem.Catalog(ctx, e.alice) require.NoError(t, err) require.Len(t, catalog, 2, "only ACTIVE vouchers of the organization within their dates that can be redeemed now") byName := map[string]models.CustomerVoucher{} for _, v := range catalog { byName[v.Name] = v } assert.EqualValues(t, 3, *byName["A Static"].Available) assert.EqualValues(t, 2, *byName["B Pool"].Available, "the expired code is not available") raw, err := json.Marshal(catalog) require.NoError(t, err) for _, internal := range []string{"business_cost", "provider", "stock_mode", "REDEEMED", "EXPIRED", "counts"} { assert.NotContains(t, string(raw), internal) } _, err = e.redeemVoucher(e.alice, static.ID, "s1") require.NoError(t, err) got, err := e.redeemVoucher(e.alice, pool.ID, "p1") require.NoError(t, err) list, err := e.redeem.ListRedemptions(ctx, e.alice, 1, 10) require.NoError(t, err) require.Len(t, list.Data, 2) assert.Equal(t, got.ID, list.Data[0].ID, "newest first") assert.Equal(t, *got.Code, *list.Data[0].Code) assert.Contains(t, []string{"P1", "P2"}, *got.Code, "never the expired P3") assert.Equal(t, "B Pool", list.Data[0].VoucherName) assert.Nil(t, list.Data[1].Code, "a STATIC voucher has no code") other, err := e.redeem.ListRedemptions(ctx, e.bob, 1, 10) require.NoError(t, err) assert.Empty(t, other.Data) // The expiry job moves the expired code out of AVAILABLE, once. expired, err := e.vouchers.ExpireCodes(ctx, e.now) require.NoError(t, err) assert.GreaterOrEqual(t, expired, int64(1)) codes, err := e.vouchers.Codes(ctx, e.orgA, pool.ID, models.VoucherCodeListQuery{}) require.NoError(t, err) assert.Equal(t, map[string]int64{"AVAILABLE": 1, "REDEEMED": 1, "EXPIRED": 1}, codes.Counts) expired, err = e.vouchers.ExpireCodes(ctx, e.now) require.NoError(t, err) assert.Zero(t, e.count(`SELECT COUNT(*) FROM voucher_codes WHERE voucher_id = ? AND status = 'AVAILABLE' AND expires_at <= NOW()`, pool.ID)) _ = expired }