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