40760aa884
- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调 - desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导) - android:Compose 主 App + IME(键盘内录音直传) - ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写) - design/design-pipeline:设计系统 + token 导出 iOS/Android 主题 - doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
317 lines
9.3 KiB
Go
317 lines
9.3 KiB
Go
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)
|
||
}
|
||
}
|