Files
wangjia 40760aa884
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s
dudu MVP:五端语音输入法初始提交
- 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>
2026-06-12 00:38:37 +08:00

317 lines
9.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}