- 新增 GET/PUT /api/me:登录用户可查看并修改自己的 nickname/gender/birthday,空串清空、缺省保持不变,生日校验格式与未来日期 - users 表新增 gender/birthday 字段(迁移 v6)及 model.Date 日期类型,管理员用户接口同步支持 - 前端新增 /profile 个人中心页面、头像下拉入口与登录守卫,保存后同步会话用户信息 - 静态服务对未命中的无扩展名路径回退 index.html,避免刷新前端路由 404 - 重新生成 Swagger 文档,补充 /api/me 与资料字段相关测试
242 lines
8.4 KiB
Go
242 lines
8.4 KiB
Go
package user_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"rill/internal/model"
|
|
"rill/internal/testutil"
|
|
)
|
|
|
|
func hasGroup(user model.User, groupID uint) bool {
|
|
for _, group := range user.Groups {
|
|
if group.ID == groupID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestUserCRUD(t *testing.T) {
|
|
env := testutil.Setup(t)
|
|
r := env.AdminRouter()
|
|
|
|
w := testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{
|
|
"username": "alice",
|
|
"email": "alice@example.com",
|
|
"password": "secret123",
|
|
})
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
|
|
}
|
|
if body := w.Body.String(); strings.Contains(body, "password") || strings.Contains(body, "secret123") {
|
|
t.Fatalf("响应泄露密码信息: %s", body)
|
|
}
|
|
created := testutil.DecodeUser(t, w)
|
|
if created.ID == 0 || created.Username != "alice" || created.Status != 1 {
|
|
t.Fatalf("创建结果异常: %+v", created)
|
|
}
|
|
if !hasGroup(created, model.GroupIDUser) {
|
|
t.Fatalf("新用户默认组应为 %d, groups=%+v", model.GroupIDUser, created.Groups)
|
|
}
|
|
|
|
var stored model.User
|
|
if err := env.DB.First(&stored, created.ID).Error; err != nil {
|
|
t.Fatalf("查询数据库失败: %v", err)
|
|
}
|
|
if stored.PasswordHash == "secret123" {
|
|
t.Fatal("密码未加密存储")
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(stored.PasswordHash), []byte("secret123")); err != nil {
|
|
t.Fatalf("密码哈希校验失败: %v", err)
|
|
}
|
|
|
|
detailPath := fmt.Sprintf("/api/users/%d", created.ID)
|
|
w = testutil.Call(t, r, http.MethodGet, detailPath, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("详情状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
|
}
|
|
if got := testutil.DecodeUser(t, w); got.ID != created.ID {
|
|
t.Errorf("详情 ID = %d, 期望 %d", got.ID, created.ID)
|
|
}
|
|
|
|
w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]any{
|
|
"nickname": "Alice",
|
|
"status": 0,
|
|
"group_ids": []uint{model.GroupIDAdmin},
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
updated := testutil.DecodeUser(t, w)
|
|
if updated.Nickname != "Alice" || updated.Status != 0 {
|
|
t.Errorf("更新结果异常: %+v", updated)
|
|
}
|
|
if len(updated.Groups) != 1 || updated.Groups[0].ID != model.GroupIDAdmin {
|
|
t.Errorf("组替换异常: %+v", updated.Groups)
|
|
}
|
|
|
|
w = testutil.Call(t, r, http.MethodGet, "/api/users?page=1&page_size=10", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("列表状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
|
}
|
|
var list struct {
|
|
Items []model.User `json:"items"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
|
|
t.Fatalf("解析列表响应失败: %v", err)
|
|
}
|
|
if list.Total != 2 || len(list.Items) != 2 {
|
|
t.Fatalf("列表结果异常: total=%d, items=%d", list.Total, len(list.Items))
|
|
}
|
|
if !hasGroup(list.Items[0], model.GroupIDAdmin) {
|
|
t.Errorf("列表未预加载组: %+v", list.Items[0].Groups)
|
|
}
|
|
|
|
w = testutil.Call(t, r, http.MethodDelete, detailPath, nil)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("删除状态码 = %d, 期望 %d", w.Code, http.StatusNoContent)
|
|
}
|
|
|
|
w = testutil.Call(t, r, http.MethodGet, detailPath, nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("删除后详情状态码 = %d, 期望 %d", w.Code, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func TestUserValidation(t *testing.T) {
|
|
env := testutil.Setup(t)
|
|
r := env.AdminRouter()
|
|
|
|
cases := []struct {
|
|
name string
|
|
body map[string]any
|
|
}{
|
|
{"缺少用户名", map[string]any{"email": "a@example.com", "password": "secret123"}},
|
|
{"邮箱格式非法", map[string]any{"username": "a", "email": "not-email", "password": "secret123"}},
|
|
{"密码过短", map[string]any{"username": "a", "email": "a@example.com", "password": "123"}},
|
|
{"组不存在", map[string]any{"username": "a", "email": "a@example.com", "password": "secret123", "group_ids": []uint{99}}},
|
|
}
|
|
for _, tc := range cases {
|
|
w := testutil.Call(t, r, http.MethodPost, "/api/users", tc.body)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
}
|
|
|
|
w := testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{
|
|
"username": "bob", "email": "bob@example.com", "password": "secret123",
|
|
})
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("创建用户失败: %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
w = testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{
|
|
"username": "bob", "email": "other@example.com", "password": "secret123",
|
|
})
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("重复用户名状态码 = %d, 期望 %d", w.Code, http.StatusConflict)
|
|
}
|
|
|
|
w = testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{
|
|
"username": "bob2", "email": "bob@example.com", "password": "secret123",
|
|
})
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("重复邮箱状态码 = %d, 期望 %d", w.Code, http.StatusConflict)
|
|
}
|
|
|
|
w = testutil.Call(t, r, http.MethodGet, "/api/users/abc", nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func TestUserProfileFields(t *testing.T) {
|
|
env := testutil.Setup(t)
|
|
r := env.AdminRouter()
|
|
|
|
w := testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{
|
|
"username": "dave",
|
|
"email": "dave@example.com",
|
|
"password": "secret123",
|
|
"gender": "male",
|
|
"birthday": "1995-06-15",
|
|
})
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
|
|
}
|
|
created := testutil.DecodeUser(t, w)
|
|
if created.Gender != model.GenderMale {
|
|
t.Errorf("gender = %q, 期望 %q", created.Gender, model.GenderMale)
|
|
}
|
|
if created.Birthday.IsZero() || created.Birthday.Format("2006-01-02") != "1995-06-15" {
|
|
t.Errorf("birthday 异常: %v", created.Birthday.Time)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
body map[string]any
|
|
}{
|
|
{"非法性别", map[string]any{"username": "e1", "email": "e1@example.com", "password": "secret123", "gender": "unknown"}},
|
|
{"生日格式非法", map[string]any{"username": "e2", "email": "e2@example.com", "password": "secret123", "birthday": "15-06-1995"}},
|
|
{"生日在未来", map[string]any{"username": "e3", "email": "e3@example.com", "password": "secret123", "birthday": time.Now().AddDate(1, 0, 0).Format("2006-01-02")}},
|
|
}
|
|
for _, tc := range cases {
|
|
w := testutil.Call(t, r, http.MethodPost, "/api/users", tc.body)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
}
|
|
|
|
detailPath := fmt.Sprintf("/api/users/%d", created.ID)
|
|
|
|
w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]any{
|
|
"nickname": "Dave",
|
|
"gender": model.GenderFemale,
|
|
"birthday": "1990-01-01",
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
updated := testutil.DecodeUser(t, w)
|
|
if updated.Gender != model.GenderFemale || updated.Birthday.IsZero() || updated.Birthday.Format("2006-01-02") != "1990-01-01" {
|
|
t.Fatalf("更新结果异常: gender=%q birthday=%v", updated.Gender, updated.Birthday.Time)
|
|
}
|
|
|
|
w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]any{"gender": model.GenderFemale})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("更新状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
|
}
|
|
kept := testutil.DecodeUser(t, w)
|
|
if kept.Birthday.IsZero() {
|
|
t.Error("未提供 birthday 时不应变为 null")
|
|
} else if got := kept.Birthday.Format("2006-01-02"); got != "1990-01-01" {
|
|
t.Errorf("未提供 birthday 不应修改: %q", got)
|
|
}
|
|
|
|
w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]any{"gender": "", "birthday": ""})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("清空状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
if cleared := testutil.DecodeUser(t, w); cleared.Gender != "" || !cleared.Birthday.IsZero() {
|
|
t.Errorf("清空失败: gender=%q birthday=%v", cleared.Gender, cleared.Birthday.Time)
|
|
}
|
|
|
|
var stored model.User
|
|
if err := env.DB.First(&stored, created.ID).Error; err != nil {
|
|
t.Fatalf("查询数据库失败: %v", err)
|
|
}
|
|
if stored.Gender != "" || !stored.Birthday.IsZero() {
|
|
t.Errorf("数据库未清空: gender=%q birthday=%v", stored.Gender, stored.Birthday.Time)
|
|
}
|
|
}
|