feat(server): Redeem 换芯 GuardedRedeem——grant 回调同事务复刻订阅叠加+双审计(#codes-lib)
This commit is contained in:
@@ -2,6 +2,7 @@ package codes_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -55,3 +56,180 @@ func TestCreateBatchViaLib(t *testing.T) {
|
||||
t.Fatal("unknown plan should error")
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Redeem — via GuardedRedeem, grant callback in the same tx as the flip.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func newRedeemFixture(t *testing.T) (*sql.DB, *codes.Service, string) {
|
||||
t.Helper()
|
||||
db := openMigratedSQLite(t)
|
||||
seedUser(t, db, 1)
|
||||
seedUser(t, db, 2)
|
||||
store := codes.NewStore(db)
|
||||
svc := codes.NewService(store, nil, 5, time.Hour)
|
||||
res, err := svc.CreateBatch(context.Background(), codes.BatchRequest{
|
||||
PlanCode: codes.PlanPro, DurationDays: 30, Count: 1,
|
||||
Channel: codes.ChannelManual, CreatedBy: "t",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
return db, svc, res.Codes[0]
|
||||
}
|
||||
|
||||
func TestRedeemCreatesSubscription(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, svc, plain := newRedeemFixture(t)
|
||||
|
||||
r, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("redeem: %v", apiErr)
|
||||
}
|
||||
if r.Idempotent || r.PlanCode != codes.PlanPro || r.DurationDays != 30 || r.SubscriptionID == 0 {
|
||||
t.Fatalf("result = %+v", r)
|
||||
}
|
||||
wantMin := time.Now().UTC().AddDate(0, 0, 29)
|
||||
if r.ExpiresAt.Before(wantMin) {
|
||||
t.Errorf("expires %v, want ≥ ~30d out", r.ExpiresAt)
|
||||
}
|
||||
// 订阅真落库(source='code'),码翻转 + redeemed_by 是 "user:1"。
|
||||
var src string
|
||||
var exp time.Time
|
||||
if err := db.QueryRow(`SELECT source, expires_at FROM subscriptions WHERE id=?`, r.SubscriptionID).Scan(&src, &exp); err != nil || src != "code" {
|
||||
t.Fatalf("sub: src=%q err=%v", src, err)
|
||||
}
|
||||
var status, rby string
|
||||
if err := db.QueryRow(`SELECT status, redeemed_by FROM codes WHERE code_hash=?`, codes.Hash(mustCanonical(t, plain))).Scan(&status, &rby); err != nil {
|
||||
t.Fatalf("code: %v", err)
|
||||
}
|
||||
if status != "redeemed" || rby != "user:1" {
|
||||
t.Errorf("status=%q redeemed_by=%q", status, rby)
|
||||
}
|
||||
// 旧式审计行仍写 audit_log(admin 审计页数据源)。
|
||||
var audits int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action='redeem' AND actor='user:1'`).Scan(&audits); err != nil || audits != 1 {
|
||||
t.Errorf("audit_log redeem rows = %d (err=%v), want 1", audits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustCanonical(t *testing.T, code string) string {
|
||||
t.Helper()
|
||||
c, err := codes.Canonicalize(code)
|
||||
if err != nil {
|
||||
t.Fatalf("canonicalize: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestRedeemIdempotentSameUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, svc, plain := newRedeemFixture(t)
|
||||
if _, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain}); apiErr != nil {
|
||||
t.Fatalf("first: %v", apiErr)
|
||||
}
|
||||
r2, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("second: %v", apiErr)
|
||||
}
|
||||
if !r2.Idempotent || r2.PlanCode != codes.PlanPro || r2.DurationDays != 30 {
|
||||
t.Fatalf("r2 = %+v", r2)
|
||||
}
|
||||
if !r2.ExpiresAt.IsZero() {
|
||||
t.Errorf("idempotent replay must omit ExpiresAt (old contract), got %v", r2.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemOtherUserRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, svc, plain := newRedeemFixture(t)
|
||||
if _, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain}); apiErr != nil {
|
||||
t.Fatalf("first: %v", apiErr)
|
||||
}
|
||||
_, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 2, Code: plain})
|
||||
if apiErr == nil || apiErr.Code != "CODE_REDEEMED" {
|
||||
t.Fatalf("apiErr = %+v, want CODE_REDEEMED", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemSamePlanExtends(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, svc, plain := newRedeemFixture(t)
|
||||
// 预置同 plan 活跃订阅,到期在未来 10 天 → 兑换后应为 +10+30 天(max(expires,now)+days)。
|
||||
var proID int64
|
||||
if err := db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proID); err != nil {
|
||||
t.Fatalf("plan: %v", err)
|
||||
}
|
||||
base := time.Now().UTC().AddDate(0, 0, 10).Truncate(time.Second)
|
||||
var subID int64
|
||||
res, err := db.Exec(`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at) VALUES (1, ?, ?, 'code', ?)`,
|
||||
proID, base, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("seed sub: %v", err)
|
||||
}
|
||||
subID, _ = res.LastInsertId()
|
||||
|
||||
r, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("redeem: %v", apiErr)
|
||||
}
|
||||
if r.SubscriptionID != subID {
|
||||
t.Fatalf("extended sub id = %d, want %d (extend, not create)", r.SubscriptionID, subID)
|
||||
}
|
||||
want := base.AddDate(0, 0, 30)
|
||||
if !r.ExpiresAt.Equal(want) {
|
||||
t.Errorf("expires = %v, want %v", r.ExpiresAt, want)
|
||||
}
|
||||
var cnt int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1`).Scan(&cnt); err != nil || cnt != 1 {
|
||||
t.Errorf("subs = %d, want 1 (extended in place)", cnt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCrossPlanCreatesNew(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, svc, plain := newRedeemFixture(t) // 码是 pro
|
||||
var teamID int64
|
||||
if err := db.QueryRow(`SELECT id FROM plans WHERE code='team'`).Scan(&teamID); err != nil {
|
||||
t.Fatalf("plan: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at) VALUES (1, ?, ?, 'code', ?)`,
|
||||
teamID, time.Now().UTC().AddDate(0, 0, 90), time.Now().UTC()); err != nil {
|
||||
t.Fatalf("seed team sub: %v", err)
|
||||
}
|
||||
r, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("redeem: %v", apiErr)
|
||||
}
|
||||
var cnt int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1`).Scan(&cnt); err != nil || cnt != 2 {
|
||||
t.Fatalf("subs = %d, want 2 (new pro sub beside team)", cnt)
|
||||
}
|
||||
wantMin := time.Now().UTC().AddDate(0, 0, 29)
|
||||
if r.ExpiresAt.Before(wantMin) {
|
||||
t.Errorf("new pro sub expires %v, want ~30d (NOT stacked on team's 90d)", r.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemInvalidFormat(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, svc, _ := newRedeemFixture(t)
|
||||
_, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: "not-a-code"})
|
||||
if apiErr == nil || apiErr.Code != "INVALID_CODE" {
|
||||
t.Fatalf("apiErr = %+v, want INVALID_CODE", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, svc, _ := newRedeemFixture(t)
|
||||
// 结构合法但不存在的码:生成一个不入库的。
|
||||
plain, err := codes.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
|
||||
if apiErr == nil || apiErr.Code != "CODE_NOT_FOUND" {
|
||||
t.Fatalf("apiErr = %+v, want CODE_NOT_FOUND", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user