Files
kevin 1fec77e81f 新增个人中心页面与用户资料自助修改接口
- 新增 GET/PUT /api/me:登录用户可查看并修改自己的 nickname/gender/birthday,空串清空、缺省保持不变,生日校验格式与未来日期
- users 表新增 gender/birthday 字段(迁移 v6)及 model.Date 日期类型,管理员用户接口同步支持
- 前端新增 /profile 个人中心页面、头像下拉入口与登录守卫,保存后同步会话用户信息
- 静态服务对未命中的无扩展名路径回退 index.html,避免刷新前端路由 404
- 重新生成 Swagger 文档,补充 /api/me 与资料字段相关测试
2026-09-21 16:05:23 +08:00

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)
}
}