Files
sundynix-agentix/sundynix-gateway/internal/store/payment_invariant_test.go
T
Blizzard bff6e5c7fd test(gateway): 补上「钱路径」与租户隔离的回归测试 —— P0 最高优先级缺口
完成度审计(见记忆 completion-audit)最尖的一条:涉及钱的路径此前 0 测试,
支付一上线 bug=真实错账;租户数据层隔离也只测了角色门禁没测数据层真隔离。
把本会话 live 手验过的断言固化成回归测试。

测试基建:纯 Go sqlite(glebarez,无 CGO)内存库,迁同款模型+建部分唯一索引+
挂租户作用域回调,复用生产 store 方法测真逻辑。CI ubuntu 无 Postgres 也能跑
(此前 store 测试全是纯逻辑,DB 事务逻辑从没进过关卡)。

钱路径不变量(6):
- GrantCredits 记分录+增余额,余额恒等于 SUM(ledger)
- 兑换码核销一次性(CAS)+原子入账,重复核销余额纹丝不动
- 账本(kind,ref)部分唯一索引:重复 grant 被兜底拦下、usage 不受约束
- MarkOrderPaid CAS 幂等:重复回调 changed=false 不重复入账
- SaveUsageEvent task_id 幂等:同任务重投不重复扣费
- ReconcileOrders 抓出 order_without_ledger(钱到了积分没给)

租户隔离(3):创建自动填 tenant_id、查询按 ctx 租户过滤、跨租户改/删命不中、
WithoutTenant 系统视角全可见。

go build/vet + 全 gateway 测试全绿;sqlite 仅测试引用,不进生产二进制。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:39:36 +08:00

192 lines
7.1 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")
}
// 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)
}
}