package httpapi import ( "bytes" "encoding/json" "fmt" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/redis/go-redis/v9" "gorm.io/gorm" "dudu/server/internal/auth" "dudu/server/internal/config" "dudu/server/internal/quota" "dudu/server/internal/store" "dudu/server/pkg/protocol" ) func newAPI(t *testing.T) (*httptest.Server, *gorm.DB) { t.Helper() gin.SetMode(gin.TestMode) mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(store.AllModels()...); err != nil { t.Fatal(err) } if err := store.SeedPacks(db); err != nil { t.Fatal(err) } cfg := config.Config{} // 全部外部依赖走 mock jwt := auth.NewJWT("test-secret", time.Hour, rdb) r := gin.New() Register(r, &Deps{Cfg: cfg, DB: db, RDB: rdb, JWT: jwt, Quota: quota.New(rdb, db)}) srv := httptest.NewServer(r) t.Cleanup(srv.Close) return srv, db } func postJSON(t *testing.T, url, token string, body any) *http.Response { t.Helper() b, _ := json.Marshal(body) req, _ := http.NewRequest("POST", url, bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } return resp } func getJSON(t *testing.T, url, token string, out any) int { t.Helper() req, _ := http.NewRequest("GET", url, nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if out != nil { _ = json.NewDecoder(resp.Body).Decode(out) } return resp.StatusCode } func login(t *testing.T, srv *httptest.Server, code string) string { t.Helper() resp := postJSON(t, srv.URL+"/v1/auth/wechat", "", protocol.AuthWechatRequest{Code: code}) defer resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("login failed: %d", resp.StatusCode) } var tr protocol.AuthTokenResponse _ = json.NewDecoder(resp.Body).Decode(&tr) return tr.Token } func TestMobileLoginAndMe(t *testing.T) { srv, _ := newAPI(t) token := login(t, srv, "wangjia99") var me protocol.MeResponse if code := getJSON(t, srv.URL+"/v1/me", token, &me); code != 200 { t.Fatalf("me failed: %d", code) } if me.AccountState != protocol.AccountTrial || me.BalanceSeconds != 0 { t.Fatalf("new user should be trial: %+v", me) } if !strings.HasSuffix(me.NicknameMasked, "***") { t.Fatalf("nickname not masked: %s", me.NicknameMasked) } // 同 code 再登录 = 同一用户 token2 := login(t, srv, "wangjia99") var me2 protocol.MeResponse getJSON(t, srv.URL+"/v1/me", token2, &me2) if me2.UserID != me.UserID { t.Fatal("same code should map to same user") } // 未带 token 拒绝 if code := getJSON(t, srv.URL+"/v1/me", "", nil); code != 401 { t.Fatalf("want 401, got %d", code) } } func TestQrLoginFlow(t *testing.T) { srv, _ := newAPI(t) resp := postJSON(t, srv.URL+"/v1/auth/qr", "", nil) var qr protocol.AuthQrResponse _ = json.NewDecoder(resp.Body).Decode(&qr) resp.Body.Close() if qr.State == "" || !strings.Contains(qr.QrURL, qr.State) { t.Fatalf("bad qr resp: %+v", qr) } // pending var st protocol.AuthQrStatusResponse getJSON(t, srv.URL+"/v1/auth/qr/"+qr.State, "", &st) if st.Status != "pending" { t.Fatalf("want pending, got %s", st.Status) } // 手机授权回调 if code := getJSON(t, srv.URL+"/v1/auth/wechat/callback?code=abc&state="+qr.State, "", nil); code != 200 { t.Fatalf("callback failed: %d", code) } // confirmed + token 可用 getJSON(t, srv.URL+"/v1/auth/qr/"+qr.State, "", &st) if st.Status != "confirmed" || st.Token == "" { t.Fatalf("want confirmed with token, got %+v", st) } if code := getJSON(t, srv.URL+"/v1/me", st.Token, nil); code != 200 { t.Fatalf("token unusable: %d", code) } // 一次性:再次轮询已失效 getJSON(t, srv.URL+"/v1/auth/qr/"+qr.State, "", &st) if st.Status != "expired" { t.Fatalf("state should be one-shot, got %s", st.Status) } } func TestOrderAndPayNotifyIdempotent(t *testing.T) { srv, db := newAPI(t) token := login(t, srv, "buyer1") // packs var packs protocol.PacksResponse getJSON(t, srv.URL+"/v1/packs", "", &packs) if len(packs.Packs) != 3 { t.Fatalf("want 3 packs, got %d", len(packs.Packs)) } // 下单 500 分钟 resp := postJSON(t, srv.URL+"/v1/orders", token, protocol.CreateOrderRequest{PackID: "pack_500", Channel: "native"}) var order protocol.CreateOrderResponse _ = json.NewDecoder(resp.Body).Decode(&order) resp.Body.Close() if order.OrderID == "" || !strings.Contains(order.CodeURL, order.OrderID) { t.Fatalf("bad order resp: %+v", order) } // 金额不符 → 拒绝入账 bad := postJSON(t, srv.URL+"/v1/pay/notify", "", map[string]any{ "order_id": order.OrderID, "transaction_id": "txn-bad", "amount_cents": 100, }) bad.Body.Close() if bad.StatusCode != 500 { t.Fatalf("amount mismatch should fail, got %d", bad.StatusCode) } // 正确回调 → paid + 入账 30000s notify := map[string]any{"order_id": order.OrderID, "transaction_id": "txn-1", "amount_cents": 3900} ok := postJSON(t, srv.URL+"/v1/pay/notify", "", notify) ok.Body.Close() if ok.StatusCode != 200 { t.Fatalf("notify failed: %d", ok.StatusCode) } var ostat protocol.OrderStatusResponse getJSON(t, srv.URL+"/v1/orders/"+order.OrderID, token, &ostat) if ostat.Status != "paid" || ostat.BalanceSeconds != 30000 { t.Fatalf("want paid 30000s, got %+v", ostat) } // 重复回调幂等:余额不变 dup := postJSON(t, srv.URL+"/v1/pay/notify", "", notify) dup.Body.Close() var me protocol.MeResponse getJSON(t, srv.URL+"/v1/me", token, &me) if me.BalanceSeconds != 30000 || me.AccountState != protocol.AccountOK { t.Fatalf("idempotency broken: %+v", me) } var n int64 db.Model(&store.BalanceLedger{}).Where("reason = ?", store.LedgerPurchase).Count(&n) if n != 1 { t.Fatalf("want 1 purchase ledger, got %d", n) } } // minimalPNG 合法 PNG 头部的最小字节序列(仅用于 magic bytes 校验)。 var minimalPNG = []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0} func postFeedback(t *testing.T, srv *httptest.Server, token, content string, images ...[]byte) *http.Response { t.Helper() var buf bytes.Buffer w := multipart.NewWriter(&buf) _ = w.WriteField("content", content) _ = w.WriteField("diagnostics", `{"app_version":"0.1.0"}`) for i, img := range images { fw, _ := w.CreateFormFile("images[]", fmt.Sprintf("s%d.png", i)) _, _ = fw.Write(img) } w.Close() req, _ := http.NewRequest("POST", srv.URL+"/v1/feedback", &buf) req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } return resp } func TestFeedback(t *testing.T) { srv, db := newAPI(t) token := login(t, srv, "fbuser") t.Cleanup(func() { _ = removeUploads() }) // 正常提交(1 张合法 PNG) resp := postFeedback(t, srv, token, "浮层有时不消失", minimalPNG) if resp.StatusCode != 200 { t.Fatalf("feedback failed: %d", resp.StatusCode) } var fr protocol.FeedbackResponse _ = json.NewDecoder(resp.Body).Decode(&fr) resp.Body.Close() var fb store.Feedback if err := db.First(&fb, "id = ?", fr.FeedbackID).Error; err != nil { t.Fatal(err) } if !strings.Contains(string(fb.Images), fr.FeedbackID) { t.Fatalf("images keys missing: %s", fb.Images) } // 伪图片拒绝 resp = postFeedback(t, srv, token, "假图", []byte("not an image")) resp.Body.Close() if resp.StatusCode != 400 { t.Fatalf("fake image should 400, got %d", resp.StatusCode) } // 空内容拒绝 resp = postFeedback(t, srv, token, " ") resp.Body.Close() if resp.StatusCode != 400 { t.Fatalf("empty content should 400, got %d", resp.StatusCode) } // 限频:已用 3 次(含失败计数),再提交 7 次到第 10 次 ok,第 11 次 429 for i := 0; i < 7; i++ { r := postFeedback(t, srv, token, "继续反馈") r.Body.Close() if r.StatusCode != 200 { t.Fatalf("feedback %d failed: %d", i, r.StatusCode) } } r := postFeedback(t, srv, token, "第 11 条") r.Body.Close() if r.StatusCode != 429 { t.Fatalf("11th should 429, got %d", r.StatusCode) } } func removeUploads() error { return nil } // LocalStorage 写入 var/uploads,测试容忍残留 func TestMetricsBatchAndAggregate(t *testing.T) { srv, db := newAPI(t) body := protocol.MetricsBatchRequest{ DeviceID: "dev-m1", Platform: "mac", AppVersion: "0.1.0", Events: []protocol.MetricEvent{ {Event: "asr.first_partial_ms", Props: map[string]any{"ms": 320}, ClientTs: 1}, {Event: "asr.first_partial_ms", Props: map[string]any{"ms": 480}, ClientTs: 2}, {Event: "unknown.event", Props: map[string]any{"x": 1}, ClientTs: 3}, // 白名单外丢弃 }, } resp := postJSON(t, srv.URL+"/v1/metrics/batch", "", body) resp.Body.Close() if resp.StatusCode != 204 { t.Fatalf("want 204, got %d", resp.StatusCode) } // 等异步 writer 落库 var n int64 for i := 0; i < 60; i++ { db.Model(&store.MetricEvent{}).Count(&n) if n == 2 { break } time.Sleep(100 * time.Millisecond) } if n != 2 { t.Fatalf("want 2 events stored (whitelist), got %d", n) } }