Files
dudu/server/internal/httpapi/api_test.go
T
wangjia 19281d9c42
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 10s
feat(auth+desktop): 邮箱验证码登录 + 登录窗改版(微信/邮箱双方式,无边框)
后端(协议 → 存储 → 接口,E2E 测试全绿):
- protocol:AuthEmailCodeRequest / AuthEmailRequest DTO
- store:EmailIdentity 表(邮箱唯一索引)+ authmail:* Redis key
- auth/email.go:POST /v1/auth/email/code 发 6 位码(crypto/rand,
  60s 冷却 SetNX,10 分钟 TTL);POST /v1/auth/email 常量时间比对、
  码一次性、邮箱建号(昵称取前缀)→ JWT(与微信登录同响应形态)
- Mailer 接口 + MockMailer(验证码打日志供联调;SMTP 未配置自动降级,
  装配结果入启动日志,上线前须换真实实现)
- TestEmailLogin E2E:发码/冷却 429/错码拒绝/登录/token 可用/码防复用

桌面(Rust 命令 + UI 改版):
- api.rs:login_email_code / login_email(成功保存 token + ws 重连,
  与扫码登录同路径)
- 登录窗改版(原型 LoginPurchase 先行,dark 截图验收):logo + 分段
  切换(微信扫码 / 邮箱登录,与设置页同控件语言)+ 邮箱表单
  (60s 倒计时、错误文案、未注册自动建号提示)
- 二维码真渲染:qrcode 库画 canvas(前景/背景取主题 token),
  替换原 URL 文本占位;过期态半透明化
- 登录窗无边框化:transparent + Overlay 标题栏 + 原生贴形阴影;
  抽公共 WindowFrame 组件(设置窗同步重构复用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:40:16 +08:00

369 lines
11 KiB
Go
Raw 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, *redis.Client) {
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, rdb
}
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)
}
}
// 邮箱验证码登录 E2E:发码(60s 冷却)→ 取码登录 → token 可用 → 码一次性。
func TestEmailLogin(t *testing.T) {
srv, _, rdb := newAPI(t)
// 发码
resp := postJSON(t, srv.URL+"/v1/auth/email/code", "", map[string]string{"email": "Dev@Example.com"})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("send code: want 204, got %d", resp.StatusCode)
}
// 冷却期内再发 → 429
resp = postJSON(t, srv.URL+"/v1/auth/email/code", "", map[string]string{"email": "dev@example.com"})
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("cooldown: want 429, got %d", resp.StatusCode)
}
code, err := rdb.Get(t.Context(), store.KeyAuthEmail("dev@example.com")).Result()
if err != nil {
t.Fatal(err)
}
// 错码拒绝
resp = postJSON(t, srv.URL+"/v1/auth/email", "", map[string]string{"email": "dev@example.com", "code": "000000"})
if code != "000000" && resp.StatusCode != http.StatusBadRequest {
t.Fatalf("wrong code: want 400, got %d", resp.StatusCode)
}
// 正确码登录
resp = postJSON(t, srv.URL+"/v1/auth/email", "", map[string]string{"email": "DEV@example.com", "code": code})
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: want 200, got %d", resp.StatusCode)
}
var tok protocol.AuthTokenResponse
_ = json.NewDecoder(resp.Body).Decode(&tok)
if tok.Token == "" || tok.User.UserID == "" {
t.Fatalf("empty token/user: %+v", tok)
}
// token 可访问 /v1/me
req, _ := http.NewRequest("GET", srv.URL+"/v1/me", nil)
req.Header.Set("Authorization", "Bearer "+tok.Token)
meResp, err := http.DefaultClient.Do(req)
if err != nil || meResp.StatusCode != http.StatusOK {
t.Fatalf("me with email token: %v %d", err, meResp.StatusCode)
}
// 码一次性:复用被拒
resp = postJSON(t, srv.URL+"/v1/auth/email", "", map[string]string{"email": "dev@example.com", "code": code})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("code reuse: want 400, got %d", resp.StatusCode)
}
}