- users/user_groups 模型与 CRUD 接口,组成员关系手动维护(规避 GORM 零值主键问题) - 迁移 v2~v4:用户组、用户表、初始 admin 用户 - 初始密码随机生成,仅终端打印一次并写入 data/admin_password.txt
168 lines
5.3 KiB
Go
168 lines
5.3 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"rill/internal/model"
|
|
)
|
|
|
|
func decodeUser(t *testing.T, w *httptest.ResponseRecorder) model.User {
|
|
t.Helper()
|
|
var user model.User
|
|
if err := json.Unmarshal(w.Body.Bytes(), &user); err != nil {
|
|
t.Fatalf("解析响应失败: %v, body=%s", err, w.Body.String())
|
|
}
|
|
return user
|
|
}
|
|
|
|
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) {
|
|
r, db := setupRouterWithDB(t)
|
|
|
|
w := 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 := 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 := 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 = call(t, r, http.MethodGet, detailPath, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("详情状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
|
}
|
|
if got := decodeUser(t, w); got.ID != created.ID {
|
|
t.Errorf("详情 ID = %d, 期望 %d", got.ID, created.ID)
|
|
}
|
|
|
|
w = 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 := 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 = 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 = call(t, r, http.MethodDelete, detailPath, nil)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("删除状态码 = %d, 期望 %d", w.Code, http.StatusNoContent)
|
|
}
|
|
|
|
w = 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) {
|
|
r := setupRouter(t)
|
|
|
|
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 := 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 := 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 = 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 = 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 = call(t, r, http.MethodGet, "/api/users/abc", nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
|
|
}
|
|
}
|