Files
sundynix-agentix/sundynix-gateway/internal/store/payment_invariant_test.go
T
Blizzard 1beef44625 ci: 加 -race + golangci-lint(new-issues) + govulncheck/gitleaks 安全扫描
补齐审计指出的「CI 缺 lint/安全扫描/-race」低成本质量门:

- go job:go test → go test -race(4 模块本地已验证 race-clean,ubuntu 自带 gcc)。
- lint job:golangci-lint(standard 集)仅 PR 跑 + only-new-issues —— 新代码必须干净,
  存量 ~42 处(多为未检 Close/死代码)单独消化,不拿存量红门。加 .golangci.yml(测试放过 errcheck)。
- security job:govulncheck advisory(stdlib/nats CVE 靠 toolchain/依赖升级,只做可见性不阻断)
  + gitleaks 扫提交防密钥泄漏。
- 顺手清掉本会话新代码的 lint:blob GetBytes 的 defer Close、退款测试未检 CreateOrder。

注:gitleaks-action 个人/公开仓库免费;组织仓库需 GITLEAKS_LICENSE。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:44:38 +08:00

270 lines
10 KiB
Go
Raw 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 store
import (
"context"
"testing"
"github.com/sundynix/sundynix-shared/contract"
)
// 这些是本会话 live 手验过的支付/计费不变量,固化成回归测试(P0-1)。
// 涉及钱的路径此前 0 测试——一个回归就是真金白银的错账。
// GrantCredits:记 grant 分录 + 增物化余额,且余额恒等于账本之和。
func TestGrantCredits_LedgerAndBalance(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
seedTenant(t, p, "t1")
if err := p.GrantCredits(ctx, "t1", LedgerGrant, 100_000_000, "ref-a", "充值A"); err != nil {
t.Fatalf("充值失败: %v", err)
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 100_000_000 {
t.Fatalf("首充后余额应为 100e6,得 %d", bal)
}
if err := p.GrantCredits(ctx, "t1", LedgerGrant, 50_000_000, "ref-b", "充值B"); err != nil {
t.Fatalf("二次充值失败: %v", err)
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 150_000_000 {
t.Fatalf("二次充值后余额应为 150e6,得 %d", bal)
}
assertBalanceInvariant(t, p, "t1")
}
// 兑换码核销:一次性(CAS)+ 原子入账(码占用/订单/分录/余额同事务)。
func TestRedeem_OnceAndAtomic(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
seedTenant(t, p, "t1")
codes, err := p.GenerateRedeemCodes(ctx, 1, 100_000_000, "测试")
if err != nil {
t.Fatalf("生成码失败: %v", err)
}
code := codes[0]
order, err := p.Redeem(ctx, code, "t1", "u1")
if err != nil {
t.Fatalf("首次核销应成功: %v", err)
}
if order.Status != OrderPaid || order.CreditsMicro != 100_000_000 {
t.Fatalf("订单应 paid/100e6,得 %s/%d", order.Status, order.CreditsMicro)
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 100_000_000 {
t.Fatalf("核销后余额应 100e6,得 %d", bal)
}
// 同码再核销 → 必须被 CAS 拦下,且余额纹丝不动(不重复入账)。
if _, err := p.Redeem(ctx, code, "t1", "u1"); err == nil {
t.Fatal("同一张码第二次核销应报错")
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 100_000_000 {
t.Fatalf("重复核销后余额不应变,得 %d", bal)
}
assertBalanceInvariant(t, p, "t1")
// 瞎编的码 → 报错。
if _, err := p.Redeem(ctx, "SDX-FAKE-FAKE-FAKE", "t1", "u1"); err == nil {
t.Fatal("不存在的码应报错")
}
}
// 账本 (kind,ref) 部分唯一索引:支付回调 at-least-once,重复的 grant 分录必须被兜底拦下。
func TestLedgerGrantRefUnique_Backstop(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
first := &CreditLedger{TenantID: "t1", Kind: LedgerGrant, CreditsMicro: 1_000_000, Ref: "order-x", Memo: "首次"}
if err := p.db.Create(first).Error; err != nil {
t.Fatalf("首条 grant 应成功: %v", err)
}
// 同 (kind=grant, ref=order-x) 再插 → 唯一索引拒绝。
dup := &CreditLedger{TenantID: "t1", Kind: LedgerGrant, CreditsMicro: 1_000_000, Ref: "order-x", Memo: "重复"}
if err := p.db.Create(dup).Error; err == nil {
t.Fatal("重复 grant/ref 应被唯一索引拒绝")
}
// usage 分录不受该索引约束(部分索引只管 kind='grant'):同 ref 的 usage 可正常写。
u := &CreditLedger{TenantID: "t1", Kind: LedgerUsage, CreditsMicro: -1, Ref: "order-x", Memo: "用量"}
if err := p.db.Create(u).Error; err != nil {
t.Fatalf("usage 分录不该被 grant 索引挡住: %v", err)
}
}
// MarkOrderPaidCAS 幂等——回调重复推送/回调与查单赛跑时只入账一次。
func TestMarkOrderPaid_IdempotentCAS(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
seedTenant(t, p, "t1")
o := &PaymentOrder{TenantID: "t1", UserID: "u1", AmountFen: 990, CreditsMicro: 100_000_000, Channel: ChannelWechat, Status: OrderPending}
if err := p.CreateOrder(ctx, o); err != nil {
t.Fatalf("建单失败: %v", err)
}
changed, err := p.MarkOrderPaid(ctx, o.ID, "wx-txn-1")
if err != nil || !changed {
t.Fatalf("首次入账应 changed=true, err=%v", err)
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 100_000_000 {
t.Fatalf("入账后余额应 100e6,得 %d", bal)
}
// 再次 MarkOrderPaid(模拟重复回调)→ changed=false,余额不变,无第二条分录。
changed2, err := p.MarkOrderPaid(ctx, o.ID, "wx-txn-1")
if err != nil {
t.Fatalf("重复入账不应报错: %v", err)
}
if changed2 {
t.Fatal("重复回调应 changed=false(幂等),不得重复入账")
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 100_000_000 {
t.Fatalf("重复回调后余额不应变,得 %d", bal)
}
assertBalanceInvariant(t, p, "t1")
}
// SaveUsageEventtask_id 幂等锚——同一任务的用量重投不重复扣费。
func TestSaveUsageEvent_IdempotentByTask(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
seedTenant(t, p, "t1")
p.GrantCredits(ctx, "t1", LedgerGrant, 100_000_000, "seed", "初始") // 先充值垫底
ev := &contract.UsageEvent{
TenantID: "t1", UserID: "u1", TaskID: "task-1", Model: "test-model",
PromptTok: 500, CompTok: 500, TotalTok: 1000, TS: 1_700_000_000_000,
}
// tokensPerCredit 默认 1000、weight 默认 1.0 → 1000 tok = 1 积分 = 1e6 micro。
inserted, err := p.SaveUsageEvent(ctx, ev)
if err != nil || !inserted {
t.Fatalf("首次计量应 inserted=true, err=%v", err)
}
after1 := p.TenantBalance(WithoutTenant(ctx), "t1")
if after1 != 99_000_000 { // 100e6 - 1e6
t.Fatalf("扣费后余额应 99e6,得 %d", after1)
}
// 同 task_id 重投 → inserted=false,余额纹丝不动(不重复扣)。
inserted2, err := p.SaveUsageEvent(ctx, ev)
if err != nil {
t.Fatalf("重投不应报错: %v", err)
}
if inserted2 {
t.Fatal("同 task_id 重投应 inserted=false(幂等)")
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != after1 {
t.Fatalf("重投后余额不应变,得 %d(应 %d)", bal, after1)
}
assertBalanceInvariant(t, p, "t1")
}
// RefundOrderpaid 单退款——置 refunded + adjust 负分录 + 回退余额,且 CAS 幂等(重复退不重复冲销)。
func TestRefundOrder_ReversesAndIdempotent(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
seedTenant(t, p, "t1")
// 先充一笔微信单入账。
o := &PaymentOrder{TenantID: "t1", UserID: "u1", AmountFen: 990, CreditsMicro: 100_000_000, Channel: ChannelWechat, Status: OrderPending}
if err := p.CreateOrder(ctx, o); err != nil {
t.Fatalf("建单失败: %v", err)
}
if _, err := p.MarkOrderPaid(ctx, o.ID, "wx-txn"); err != nil {
t.Fatalf("入账失败: %v", err)
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 100_000_000 {
t.Fatalf("入账后余额应 100e6,得 %d", bal)
}
// 退款 → changed=true,余额回退到 0,订单置 refunded。
changed, err := p.RefundOrder(ctx, o.ID, "admin1", "客户申请")
if err != nil || !changed {
t.Fatalf("首次退款应 changed=true, err=%v", err)
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 0 {
t.Fatalf("退款后余额应回 0,得 %d", bal)
}
got, _ := p.GetOrder(WithoutTenant(ctx), o.ID)
if got.Status != OrderRefunded {
t.Fatalf("订单应 refunded,得 %s", got.Status)
}
// 重复退款 → changed=false(CAS 拦),余额纹丝不动,不产生第二条负分录。
changed2, err := p.RefundOrder(ctx, o.ID, "admin1", "重复点")
if err != nil {
t.Fatalf("重复退款不应报错: %v", err)
}
if changed2 {
t.Fatal("重复退款应 changed=false(幂等)")
}
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 0 {
t.Fatalf("重复退款后余额不应变,得 %d", bal)
}
assertBalanceInvariant(t, p, "t1")
}
// RefundOrder:只退 paid 单——pending/未存在的单退款是幂等 no-opchanged=false),不误伤。
func TestRefundOrder_OnlyPaid(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
seedTenant(t, p, "t1")
// pending 单不可退。
o := &PaymentOrder{TenantID: "t1", UserID: "u1", AmountFen: 100, CreditsMicro: 1_000_000, Channel: ChannelWechat, Status: OrderPending}
if err := p.CreateOrder(ctx, o); err != nil {
t.Fatalf("建单失败: %v", err)
}
changed, err := p.RefundOrder(ctx, o.ID, "admin1", "")
if err != nil {
t.Fatalf("退 pending 单不应报错: %v", err)
}
if changed {
t.Fatal("pending 单不该被退(changed=false")
}
// 余额未动、订单仍 pending。
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 0 {
t.Fatalf("退 pending 后余额应仍 0,得 %d", bal)
}
got, _ := p.GetOrder(WithoutTenant(ctx), o.ID)
if got.Status != OrderPending {
t.Fatalf("pending 单状态不该变,得 %s", got.Status)
}
// 不存在的单 → 幂等 no-op。
if changed, err := p.RefundOrder(ctx, "no-such-order", "admin1", ""); err != nil || changed {
t.Fatalf("退不存在的单应 no-opchanged=false, err=nil),得 changed=%v err=%v", changed, err)
}
}
// ReconcileOrderspaid 订单缺对应 grant 分录 → 抓出 order_without_ledger(钱到了积分没给,最严重)。
func TestReconcileOrders_DetectsMissingLedger(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
seedTenant(t, p, "t1")
// 正常入账的单:对账应无差异。
o := &PaymentOrder{TenantID: "t1", UserID: "u1", AmountFen: 100, CreditsMicro: 1_000_000, Channel: ChannelWechat, Status: OrderPending}
p.CreateOrder(ctx, o)
p.MarkOrderPaid(ctx, o.ID, "txn")
if diffs, _ := p.ReconcileOrders(ctx, 100); len(diffs) != 0 {
t.Fatalf("正常入账单不该有对账差异,得 %d 条", len(diffs))
}
// 造一张 paid 但无账本分录的坏单 → 应被抓出。
bad := &PaymentOrder{BaseModel: BaseModel{ID: "bad-order"}, TenantID: "t1", UserID: "u1", CreditsMicro: 5_000_000, Channel: ChannelWechat, Status: OrderPaid}
p.db.Create(bad)
diffs, err := p.ReconcileOrders(ctx, 100)
if err != nil {
t.Fatalf("对账失败: %v", err)
}
found := false
for _, d := range diffs {
if d.OrderID == "bad-order" && d.Issue == "order_without_ledger" {
found = true
}
}
if !found {
t.Fatalf("应抓出 bad-order 的 order_without_ledger 差异,实得 %+v", diffs)
}
}