package service import ( "context" "testing" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" "github.com/DATA-DOG/go-sqlmock" "gorm.io/gorm" ) // setupQuotaTest 固定免费额度配置为内置默认值(Cos 20 / AI 3 / 分享 10 / 衣柜 5),保证断言确定。 func setupQuotaTest(t *testing.T) sqlmock.Sqlmock { t.Helper() old := global.UserDefaultQuotaSetting global.UserDefaultQuotaSetting = nil t.Cleanup(func() { global.UserDefaultQuotaSetting = old }) return setupOrderQuotaTest(t) } func TestEffectiveQuotaMemberGrantsNotStacked(t *testing.T) { mock := setupQuotaTest(t) // Cos 永久额度:免费 20 已用 0;会员包同额两份只计一份,加量包/体验额度仍叠加。 mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0)) grants := []mysqlmodel.QuotaGrant{ memberGrantFixture(1, 100, 10), // 月度包:生效份(同额时保留正在使用的一份) memberGrantFixture(2, 100, 0), // 年度包:不叠加,不计入 otherGrantFixture(3, QuotaGrantSourceAddon, 5, 2), // 加量包:叠加 otherGrantFixture(4, QuotaGrantSourceTrial, 7, 0), // 体验额度:叠加 } eff := effectiveQuotaFromGrants(context.Background(), 7, QuotaTypeCos, grants) // 免费 20 + 会员包 100 + 加量包 5 + 体验 7 = 132;已用 0 + 10 + 2 + 0 = 12。 if eff.Total != 132 || eff.Used != 12 || eff.Remain != 120 { t.Fatalf("cos quota = %+v, want total=132 used=12 remain=120", eff) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestEffectiveQuotaDailyGrantsNotStacked(t *testing.T) { mock := setupQuotaTest(t) // AI 每日额度:免费 3,当日已用 1,会员包同额两份只计一份,广告激励仍叠加。 mock.ExpectQuery("SELECT .* FROM `user_daily_usages`").WillReturnRows(sqlmock.NewRows([]string{"ai_recommend_count"}).AddRow(1)) grants := []mysqlmodel.QuotaGrant{ memberGrantFixture(1, 50, 0), memberGrantFixture(2, 50, 0), otherGrantFixture(3, QuotaGrantSourceAdReward, 10, 0), } eff := effectiveQuotaFromGrants(context.Background(), 7, QuotaTypeAI, grants) // 免费 3 + 会员包 50 + 广告 10 = 63;当日已用 1。 if eff.Total != 63 || eff.Used != 1 || eff.Remain != 62 { t.Fatalf("ai quota = %+v, want total=63 used=1 remain=62", eff) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestConsumeCosQuotaOnlyConsumesEffectiveMemberGrant(t *testing.T) { mock := setupQuotaTest(t) future := nowUnix() + 86400 // 有效凭证:加量包(先到期先用)→ 月度包(生效份)→ 年度包(未生效,不得扣减)。 mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400)) mock.ExpectBegin() mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE"). WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}). AddRow(3, QuotaGrantSourceAddon, 10, 0, future). AddRow(1, QuotaGrantSourceMember, 100, 30, 0). AddRow(2, QuotaGrantSourceMember, 100, 0, 0)) mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 加量包 +10 mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 月度包 +5(年度包不参与扣减) mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0)) mock.ExpectQuery("SELECT .* FROM `quota_grants`"). WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}). AddRow(3, QuotaGrantSourceAddon, 10, 10, future). AddRow(1, QuotaGrantSourceMember, 100, 35, 0). AddRow(2, QuotaGrantSourceMember, 100, 0, 0)) mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(9, 1)) mock.ExpectCommit() if err := consumeCosQuota(context.Background(), 7, 15, 123, "上传衣物"); err != nil { t.Fatal(err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestRefundCosQuotaOnlyRefundsEffectiveMemberGrant(t *testing.T) { mock := setupQuotaTest(t) mock.ExpectBegin() // 免费额度已用 5,不足整体回退 10。 mock.ExpectExec("UPDATE .*`user_quotas`.*").WillReturnResult(sqlmock.NewResult(0, 0)) // 非会员包凭证无已用。 mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE"). WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"})) // 会员包凭证:月度包为生效份(used=30),年度包未生效。 mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE"). WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}). AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0). AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0)) mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 月度包 -10 mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(5)) mock.ExpectQuery("SELECT .* FROM `quota_grants`"). WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}). AddRow(1, QuotaGrantSourceMember, 100, 20, 0). AddRow(2, QuotaGrantSourceMember, 100, 0, 0)) mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(9, 1)) mock.ExpectCommit() vipExpire := nowUnix() + 86400 if err := global.MysqlEngine.WithContext(context.Background()).Transaction(func(tx *gorm.DB) error { return refundCosQuotaTx(context.Background(), tx, 7, vipExpire, 10, 123, "上传失败回退") }); err != nil { t.Fatal(err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestBuildQuotaRevokeFlowsSkipsNonEffectiveMemberGrant(t *testing.T) { mock := setupQuotaTest(t) // 订单凭证是年度包(未生效),月度包(生效份)来自其他订单: // 吊销年度包不改变有效剩余,不应写回收流水。 mock.ExpectQuery("SELECT .* FROM `quota_grants`"). WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}). AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0)) mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400)) mock.ExpectQuery("SELECT .* FROM `quota_grants`"). WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}). AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0). AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0)) mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0)) mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400)) mock.ExpectQuery("SELECT .* FROM `quota_grants`"). WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}). AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0). AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0)) mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0)) flows, err := NewOrderService().buildQuotaRevokeFlows(context.Background(), &mysqlmodel.Order{OrderNo: "O1", UserID: 7}) if err != nil { t.Fatal(err) } if len(flows) != 0 { t.Fatalf("want no revoke flows, got %d", len(flows)) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestBuildQuotaRevokeFlowsRecordsEffectiveGrantRevoke(t *testing.T) { mock := setupQuotaTest(t) // 订单凭证即生效份(月度包 100/30):吊销前有效剩余 20+70=90,吊销后仅剩免费 20, // 回收流水变更量应为 -70。 mock.ExpectQuery("SELECT .* FROM `quota_grants`"). WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}). AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0)) mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400)) mock.ExpectQuery("SELECT .* FROM `quota_grants`"). WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}). AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0)) mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0)) mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400)) mock.ExpectQuery("SELECT .* FROM `quota_grants`"). WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}). AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0)) mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0)) flows, err := NewOrderService().buildQuotaRevokeFlows(context.Background(), &mysqlmodel.Order{OrderNo: "O2", UserID: 7}) if err != nil { t.Fatal(err) } if len(flows) != 1 { t.Fatalf("want 1 revoke flow, got %d", len(flows)) } f := flows[0] if f.ChangeNum != -70 || f.OldValue != 90 || f.NewValue != 20 || f.QuotaType != QuotaTypeCos { t.Fatalf("flow = %+v, want change=-70 old=90 new=20", f) } }