package pay import ( "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "github.com/wangjia/pangolin/server/internal/codes" ) // reconcileRig:假 pay(查单可配)+ sqlite 台账 + 真 WebhookHandler 作 settler。 // 验证 GetOrder 的 reconcile-on-read:pay 网关已 paid 时就地幂等开通,不干等 webhook。 func reconcileRig(t *testing.T, payFn http.HandlerFunc) (*chi.Mux, *Store, *sql.DB) { t.Helper() db := openMigratedSQLite(t) seedUser(t, db, 1, "uuid-1") srv := fakePay(t, payFn) st := NewStore(db) h := NewHandler(NewClient(srv.URL, "pangolin", testSecret), st, db) // 真 granter + webhook,settle 会真的开通订阅(与线上同一段逻辑)。 codesSvc := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) wh := NewWebhookHandler(st, codesSvc, db, nil, "pangolin", testSecret, 5*time.Minute, 15*time.Minute) h.SetSettler(wh) r := chi.NewRouter() r.Get("/v1/pay/orders/{orderNo}", h.GetOrder) return r, st, db } func getOrder(t *testing.T, router *chi.Mux, orderNo string, uid int64) (int, bool) { t.Helper() w := httptest.NewRecorder() router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/"+orderNo, nil), uid)) var resp struct { Activated bool `json:"activated"` } _ = json.Unmarshal(w.Body.Bytes(), &resp) return w.Code, resp.Activated } // 网关已 paid、本地还 created → 查单即就地开通(activated=true、台账翻 paid、订阅真授予); // 且重复轮询幂等:再查一次不二次开通(仍 1 条订阅)。 func TestGetOrder_ReconcileOnRead_ActivatesWhenGatewayPaid(t *testing.T) { router, st, db := reconcileRig(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"paid", "subject":"Pro","amount_minor":2999,"currency":"CNY"}}`)) }) ctx := context.Background() if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "alipay", 2999, "CNY"); err != nil { t.Fatal(err) } code, activated := getOrder(t, router, "pay001", 1) if code != http.StatusOK || !activated { t.Fatalf("首查应就地开通:code=%d activated=%v", code, activated) } row, err := st.GetForUser(ctx, 1, "pay001") if err != nil || row.Status != "paid" { t.Fatalf("本地台账应已翻 paid: row=%+v err=%v", row, err) } var n int if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1 AND source='pay'`).Scan(&n); err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("应授予 1 条 pay 订阅,得 %d", n) } // 幂等:再查一次(轮询会持续查),不得二次开通。 if _, activated2 := getOrder(t, router, "pay001", 1); !activated2 { t.Fatal("二次查询仍应 activated") } _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1 AND source='pay'`).Scan(&n) if n != 1 { t.Fatalf("重复轮询不得二次开通,订阅数 = %d, want 1", n) } } // 网关仍 pending → 不开通(activated=false、无订阅);证明只有 paid 才触发对账。 func TestGetOrder_ReconcileOnRead_SkipsWhenGatewayPending(t *testing.T) { router, st, db := reconcileRig(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending","currency":"CNY"}}`)) }) ctx := context.Background() if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "alipay", 2999, "CNY"); err != nil { t.Fatal(err) } if code, activated := getOrder(t, router, "pay001", 1); code != http.StatusOK || activated { t.Fatalf("pending 不应开通:code=%d activated=%v", code, activated) } row, _ := st.GetForUser(ctx, 1, "pay001") if row.Status != "created" { t.Fatalf("pending 时本地台账应仍 created, got %q", row.Status) } var n int _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1`).Scan(&n) if n != 0 { t.Fatalf("pending 不得开通订阅,得 %d", n) } }