Files
descrybe/apps/api/internal/billing/credits_integrity_test.go
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

123 lines
3.3 KiB
Go

package billing
import (
"errors"
"testing"
)
func TestRemainingCreditsClamped(t *testing.T) {
cases := []struct {
total, used, want int
}{
{100, 40, 60},
{100, 100, 0},
{100, 150, 0}, // used > total must not report negative
{0, 0, 0},
{0, 5, 0},
}
for _, c := range cases {
got := RemainingCreditsClamped(c.total, c.used)
if got != c.want {
t.Fatalf("total=%d used=%d got=%d want=%d", c.total, c.used, got, c.want)
}
}
}
func TestApplyCreditDeltaPreventsNegativeRemaining(t *testing.T) {
cases := []struct {
total, used, amount, want int
}{
{100, 20, 50, 150},
{100, 80, -50, 80}, // clawback stops at used
{100, 80, -200, 80},
{10, 0, -5, 5},
{10, 0, -20, 0},
{0, 0, 25, 25},
}
for _, c := range cases {
got := ApplyCreditDelta(c.total, c.used, c.amount)
if got != c.want {
t.Fatalf("total=%d used=%d amount=%d got=%d want=%d", c.total, c.used, c.amount, got, c.want)
}
if got < c.used {
t.Fatalf("total after delta below used: got=%d used=%d", got, c.used)
}
}
}
func TestComputeEntitlementsClampsNegativeRemaining(t *testing.T) {
ent := ComputeEntitlements("Free", 0, -10, false)
if ent.RemainingCredits != 0 {
t.Fatalf("remaining=%d want 0", ent.RemainingCredits)
}
if ent.CanUseAI {
t.Fatal("negative remaining on Free must not unlock AI")
}
}
func TestConsumeCreditsContractErrors(t *testing.T) {
// Document sentinel used by processOne / ProcessJob abort path.
if !errors.Is(ErrInsufficientCredits, ErrInsufficientCredits) {
t.Fatal("sentinel self-match")
}
wrapped := errors.New("x")
if errors.Is(wrapped, ErrInsufficientCredits) {
t.Fatal("unrelated error must not match")
}
}
func TestDebitFloorAndNegativeTokens(t *testing.T) {
if got := DebitAmount(1, 1, -5); got != 1 {
t.Fatalf("DebitAmount negative tokens=%d want 1", got)
}
}
func TestDebitAmount(t *testing.T) {
cases := []struct {
feature, tokenK, tokens, want int
}{
{1, 1, 0, 1},
{1, 1, 1, 2},
{1, 1, 1000, 2},
{1, 1, 1001, 3},
{2, 3, 2500, 2 + 3*3}, // base 2 + 3 packs * 3
{0, 0, 0, 1}, // floors
}
for _, c := range cases {
got := DebitAmount(c.feature, c.tokenK, c.tokens)
if got != c.want {
t.Fatalf("DebitAmount(%d,%d,%d)=%d want %d", c.feature, c.tokenK, c.tokens, got, c.want)
}
}
}
func TestDebitAmountNVsPerProduct(t *testing.T) {
// Exact parity when each item's tokens don't leave partial packs that merge.
sum := DebitAmount(1, 1, 1000) + DebitAmount(1, 1, 1000)
batchedExact := DebitAmountN(1, 1, 2000, 2)
if sum != batchedExact {
t.Fatalf("aligned packs: sum=%d batch=%d", sum, batchedExact)
}
// Combined packs can undercharge vs per-item ceil.
perItem := DebitAmount(1, 1, 500) + DebitAmount(1, 1, 500) // 2+2=4
batched := DebitAmountN(1, 1, 1000, 2) // 2*1 + 1 = 3
if perItem <= batched {
t.Fatalf("expected batch undercharge: perItem=%d batched=%d", perItem, batched)
}
}
func TestConsumeCreditsSkipsEntitlementsOnAITokens(t *testing.T) {
// Document hot-path contract: tokenCount > 0 skips EntitlementsForCompany.
// Flat (0-token) Free-plan burn still gates via !CanUseAI.
tokenCount := 1200
needEntitlements := tokenCount == 0
if needEntitlements {
t.Fatal("AI token debit must not require entitlements preflight")
}
tokenCount = 0
if !(tokenCount == 0) {
t.Fatal("flat debit still gates entitlements")
}
}