init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
12
webapp-back/users/doc.go
Normal file
12
webapp-back/users/doc.go
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
The user module containing the user CRU operation.
|
||||
|
||||
model.go: definition of orm based data model
|
||||
|
||||
routers.go: router binding and core logic
|
||||
|
||||
serializers.go: definition the schema of return data
|
||||
|
||||
validators.go: definition the validator of form data
|
||||
*/
|
||||
package users
|
||||
75
webapp-back/users/middlewares.go
Normal file
75
webapp-back/users/middlewares.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
)
|
||||
|
||||
// Extract token from Authorization header or query parameter
|
||||
func extractToken(c *gin.Context) string {
|
||||
// Check Authorization header first
|
||||
bearerToken := c.GetHeader("Authorization")
|
||||
if len(bearerToken) > 6 && strings.ToUpper(bearerToken[0:6]) == "TOKEN " {
|
||||
return bearerToken[6:]
|
||||
}
|
||||
|
||||
// Check query parameter
|
||||
token := c.Query("access_token")
|
||||
if token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// A helper to write user_id and user_model to the context
|
||||
func UpdateContextUserModel(c *gin.Context, my_user_id uint) {
|
||||
var myUserModel UserModel
|
||||
if my_user_id != 0 {
|
||||
db := common.GetDB()
|
||||
db.First(&myUserModel, my_user_id)
|
||||
}
|
||||
c.Set("my_user_id", my_user_id)
|
||||
c.Set("my_user_model", myUserModel)
|
||||
}
|
||||
|
||||
// You can custom middlewares yourself as the doc: https://github.com/gin-gonic/gin#custom-middleware
|
||||
//
|
||||
// r.Use(AuthMiddleware(true))
|
||||
func AuthMiddleware(auto401 bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
UpdateContextUserModel(c, 0)
|
||||
tokenString := extractToken(c)
|
||||
|
||||
if tokenString == "" {
|
||||
if auto401 {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Validate the signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(common.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if auto401 {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
my_user_id := uint(claims["id"].(float64))
|
||||
UpdateContextUserModel(c, my_user_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
154
webapp-back/users/models.go
Normal file
154
webapp-back/users/models.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Models should only be concerned with database schema, more strict checking should be put in validator.
|
||||
//
|
||||
// More detail you can find here: http://jinzhu.me/gorm/models.html#model-definition
|
||||
//
|
||||
// HINT: If you want to split null and "", you should use *string instead of string.
|
||||
type UserModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"column:username"`
|
||||
Email string `gorm:"column:email;uniqueIndex"`
|
||||
Bio string `gorm:"column:bio;size:1024"`
|
||||
Image *string `gorm:"column:image"`
|
||||
PasswordHash string `gorm:"column:password;not null"`
|
||||
}
|
||||
|
||||
// A hack way to save ManyToMany relationship,
|
||||
// gorm will build the alias as FollowingBy <-> FollowingByID <-> "following_by_id".
|
||||
//
|
||||
// DB schema looks like: id, created_at, updated_at, deleted_at, following_id, followed_by_id.
|
||||
//
|
||||
// Retrieve them by:
|
||||
//
|
||||
// db.Where(FollowModel{ FollowingID: v.ID, FollowedByID: u.ID, }).First(&follow)
|
||||
// db.Where(FollowModel{ FollowedByID: u.ID, }).Find(&follows)
|
||||
//
|
||||
// More details about gorm.Model: http://jinzhu.me/gorm/models.html#conventions
|
||||
type FollowModel struct {
|
||||
gorm.Model
|
||||
Following UserModel
|
||||
FollowingID uint
|
||||
FollowedBy UserModel
|
||||
FollowedByID uint
|
||||
}
|
||||
|
||||
// Migrate the schema of database if needed
|
||||
func AutoMigrate() {
|
||||
db := common.GetDB()
|
||||
|
||||
db.AutoMigrate(&UserModel{})
|
||||
db.AutoMigrate(&FollowModel{})
|
||||
}
|
||||
|
||||
// What's bcrypt? https://en.wikipedia.org/wiki/Bcrypt
|
||||
// Golang bcrypt doc: https://godoc.org/golang.org/x/crypto/bcrypt
|
||||
// You can change the value in bcrypt.DefaultCost to adjust the security index.
|
||||
//
|
||||
// err := userModel.setPassword("password0")
|
||||
func (u *UserModel) setPassword(password string) error {
|
||||
if len(password) == 0 {
|
||||
return errors.New("password should not be empty!")
|
||||
}
|
||||
bytePassword := []byte(password)
|
||||
// Make sure the second param `bcrypt generator cost` between [4, 32)
|
||||
passwordHash, _ := bcrypt.GenerateFromPassword(bytePassword, bcrypt.DefaultCost)
|
||||
u.PasswordHash = string(passwordHash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Database will only save the hashed string, you should check it by util function.
|
||||
//
|
||||
// if err := serModel.checkPassword("password0"); err != nil { password error }
|
||||
func (u *UserModel) checkPassword(password string) error {
|
||||
bytePassword := []byte(password)
|
||||
byteHashedPassword := []byte(u.PasswordHash)
|
||||
return bcrypt.CompareHashAndPassword(byteHashedPassword, bytePassword)
|
||||
}
|
||||
|
||||
// You could input the conditions and it will return an UserModel in database with error info.
|
||||
//
|
||||
// userModel, err := FindOneUser(&UserModel{Username: "username0"})
|
||||
func FindOneUser(condition interface{}) (UserModel, error) {
|
||||
db := common.GetDB()
|
||||
var model UserModel
|
||||
err := db.Where(condition).First(&model).Error
|
||||
return model, err
|
||||
}
|
||||
|
||||
// You could input an UserModel which will be saved in database returning with error info
|
||||
//
|
||||
// if err := SaveOne(&userModel); err != nil { ... }
|
||||
func SaveOne(data interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Save(data).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// You could update properties of an UserModel to database returning with error info.
|
||||
//
|
||||
// err := db.Model(userModel).Updates(UserModel{Username: "wangzitian0"}).Error
|
||||
func (model *UserModel) Update(data interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Model(model).Updates(data).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// You could add a following relationship as userModel1 following userModel2
|
||||
//
|
||||
// err = userModel1.following(userModel2)
|
||||
func (u UserModel) following(v UserModel) error {
|
||||
db := common.GetDB()
|
||||
var follow FollowModel
|
||||
err := db.FirstOrCreate(&follow, &FollowModel{
|
||||
FollowingID: v.ID,
|
||||
FollowedByID: u.ID,
|
||||
}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// You could check whether userModel1 following userModel2
|
||||
//
|
||||
// followingBool = myUserModel.isFollowing(self.UserModel)
|
||||
func (u UserModel) isFollowing(v UserModel) bool {
|
||||
db := common.GetDB()
|
||||
var follow FollowModel
|
||||
db.Where(FollowModel{
|
||||
FollowingID: v.ID,
|
||||
FollowedByID: u.ID,
|
||||
}).First(&follow)
|
||||
return follow.ID != 0
|
||||
}
|
||||
|
||||
// You could delete a following relationship as userModel1 following userModel2
|
||||
//
|
||||
// err = userModel1.unFollowing(userModel2)
|
||||
func (u UserModel) unFollowing(v UserModel) error {
|
||||
db := common.GetDB()
|
||||
err := db.Where("following_id = ? AND followed_by_id = ?", v.ID, u.ID).Delete(&FollowModel{}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// You could get a following list of userModel
|
||||
//
|
||||
// followings := userModel.GetFollowings()
|
||||
func (u UserModel) GetFollowings() []UserModel {
|
||||
db := common.GetDB()
|
||||
var follows []FollowModel
|
||||
var followings []UserModel
|
||||
db.Preload("Following").Where(FollowModel{
|
||||
FollowedByID: u.ID,
|
||||
}).Find(&follows)
|
||||
for _, follow := range follows {
|
||||
followings = append(followings, follow.Following)
|
||||
}
|
||||
return followings
|
||||
}
|
||||
137
webapp-back/users/routers.go
Normal file
137
webapp-back/users/routers.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func UsersRegister(router *gin.RouterGroup) {
|
||||
router.POST("", UsersRegistration)
|
||||
router.POST("/", UsersRegistration)
|
||||
router.POST("/login", UsersLogin)
|
||||
}
|
||||
|
||||
func UserRegister(router *gin.RouterGroup) {
|
||||
router.GET("", UserRetrieve)
|
||||
router.GET("/", UserRetrieve)
|
||||
router.PUT("", UserUpdate)
|
||||
router.PUT("/", UserUpdate)
|
||||
}
|
||||
|
||||
func ProfileRetrieveRegister(router *gin.RouterGroup) {
|
||||
router.GET("/:username", ProfileRetrieve)
|
||||
}
|
||||
|
||||
func ProfileRegister(router *gin.RouterGroup) {
|
||||
router.POST("/:username/follow", ProfileFollow)
|
||||
router.DELETE("/:username/follow", ProfileUnfollow)
|
||||
}
|
||||
|
||||
func ProfileRetrieve(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
userModel, err := FindOneUser(&UserModel{Username: username})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("profile", errors.New("Invalid username")))
|
||||
return
|
||||
}
|
||||
profileSerializer := ProfileSerializer{c, userModel}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": profileSerializer.Response()})
|
||||
}
|
||||
|
||||
func ProfileFollow(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
userModel, err := FindOneUser(&UserModel{Username: username})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("profile", errors.New("Invalid username")))
|
||||
return
|
||||
}
|
||||
myUserModel := c.MustGet("my_user_model").(UserModel)
|
||||
err = myUserModel.following(userModel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ProfileSerializer{c, userModel}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": serializer.Response()})
|
||||
}
|
||||
|
||||
func ProfileUnfollow(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
userModel, err := FindOneUser(&UserModel{Username: username})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("profile", errors.New("Invalid username")))
|
||||
return
|
||||
}
|
||||
myUserModel := c.MustGet("my_user_model").(UserModel)
|
||||
|
||||
err = myUserModel.unFollowing(userModel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ProfileSerializer{c, userModel}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": serializer.Response()})
|
||||
}
|
||||
|
||||
func UsersRegistration(c *gin.Context) {
|
||||
userModelValidator := NewUserModelValidator()
|
||||
if err := userModelValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := SaveOne(&userModelValidator.userModel); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
c.Set("my_user_model", userModelValidator.userModel)
|
||||
serializer := UserSerializer{c}
|
||||
c.JSON(http.StatusCreated, gin.H{"user": serializer.Response()})
|
||||
}
|
||||
|
||||
func UsersLogin(c *gin.Context) {
|
||||
loginValidator := NewLoginValidator()
|
||||
if err := loginValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
userModel, err := FindOneUser(&UserModel{Email: loginValidator.userModel.Email})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, common.NewError("login", errors.New("Not Registered email or invalid password")))
|
||||
return
|
||||
}
|
||||
|
||||
if userModel.checkPassword(loginValidator.User.Password) != nil {
|
||||
c.JSON(http.StatusUnauthorized, common.NewError("login", errors.New("Not Registered email or invalid password")))
|
||||
return
|
||||
}
|
||||
UpdateContextUserModel(c, userModel.ID)
|
||||
serializer := UserSerializer{c}
|
||||
c.JSON(http.StatusOK, gin.H{"user": serializer.Response()})
|
||||
}
|
||||
|
||||
func UserRetrieve(c *gin.Context) {
|
||||
serializer := UserSerializer{c}
|
||||
c.JSON(http.StatusOK, gin.H{"user": serializer.Response()})
|
||||
}
|
||||
|
||||
func UserUpdate(c *gin.Context) {
|
||||
myUserModel := c.MustGet("my_user_model").(UserModel)
|
||||
userModelValidator := NewUserModelValidatorFillWith(myUserModel)
|
||||
if err := userModelValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
|
||||
userModelValidator.userModel.ID = myUserModel.ID
|
||||
if err := myUserModel.Update(userModelValidator.userModel); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
UpdateContextUserModel(c, myUserModel.ID)
|
||||
serializer := UserSerializer{c}
|
||||
c.JSON(http.StatusOK, gin.H{"user": serializer.Response()})
|
||||
}
|
||||
66
webapp-back/users/serializers.go
Normal file
66
webapp-back/users/serializers.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
)
|
||||
|
||||
type ProfileSerializer struct {
|
||||
C *gin.Context
|
||||
UserModel
|
||||
}
|
||||
|
||||
// Declare your response schema here
|
||||
type ProfileResponse struct {
|
||||
ID uint `json:"-"`
|
||||
Username string `json:"username"`
|
||||
Bio string `json:"bio"`
|
||||
Image string `json:"image"`
|
||||
Following bool `json:"following"`
|
||||
}
|
||||
|
||||
// Put your response logic including wrap the userModel here.
|
||||
func (self *ProfileSerializer) Response() ProfileResponse {
|
||||
myUserModel := self.C.MustGet("my_user_model").(UserModel)
|
||||
image := ""
|
||||
if self.Image != nil {
|
||||
image = *self.Image
|
||||
}
|
||||
profile := ProfileResponse{
|
||||
ID: self.ID,
|
||||
Username: self.Username,
|
||||
Bio: self.Bio,
|
||||
Image: image,
|
||||
Following: myUserModel.isFollowing(self.UserModel),
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
type UserSerializer struct {
|
||||
c *gin.Context
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Bio string `json:"bio"`
|
||||
Image string `json:"image"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (self *UserSerializer) Response() UserResponse {
|
||||
myUserModel := self.c.MustGet("my_user_model").(UserModel)
|
||||
image := ""
|
||||
if myUserModel.Image != nil {
|
||||
image = *myUserModel.Image
|
||||
}
|
||||
user := UserResponse{
|
||||
Username: myUserModel.Username,
|
||||
Email: myUserModel.Email,
|
||||
Bio: myUserModel.Bio,
|
||||
Image: image,
|
||||
Token: common.GenToken(myUserModel.ID),
|
||||
}
|
||||
return user
|
||||
}
|
||||
554
webapp-back/users/unit_test.go
Normal file
554
webapp-back/users/unit_test.go
Normal file
@@ -0,0 +1,554 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var image_url = "https://golang.org/doc/gopher/frontpage.png"
|
||||
var test_db *gorm.DB
|
||||
|
||||
func newUserModel() UserModel {
|
||||
return UserModel{
|
||||
ID: 2,
|
||||
Username: "asd123!@#ASD",
|
||||
Email: "wzt@g.cn",
|
||||
Bio: "heheda",
|
||||
Image: &image_url,
|
||||
PasswordHash: "",
|
||||
}
|
||||
}
|
||||
|
||||
func userModelMocker(n int) []UserModel {
|
||||
var offset int64
|
||||
test_db.Model(&UserModel{}).Count(&offset)
|
||||
var ret []UserModel
|
||||
for i := int(offset) + 1; i <= int(offset)+n; i++ {
|
||||
image := fmt.Sprintf("http://image/%v.jpg", i)
|
||||
userModel := UserModel{
|
||||
Username: fmt.Sprintf("user%v", i),
|
||||
Email: fmt.Sprintf("user%v@linkedin.com", i),
|
||||
Bio: fmt.Sprintf("bio%v", i),
|
||||
Image: &image,
|
||||
}
|
||||
userModel.setPassword("password123")
|
||||
test_db.Create(&userModel)
|
||||
ret = append(ret, userModel)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func TestUserModel(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
//Testing UserModel's password feature
|
||||
userModel := newUserModel()
|
||||
err := userModel.checkPassword("")
|
||||
asserts.Error(err, "empty password should return err")
|
||||
|
||||
userModel = newUserModel()
|
||||
err = userModel.setPassword("")
|
||||
asserts.Error(err, "empty password can not be set null")
|
||||
|
||||
userModel = newUserModel()
|
||||
err = userModel.setPassword("asd123!@#ASD")
|
||||
asserts.NoError(err, "password should be set successful")
|
||||
asserts.Len(userModel.PasswordHash, 60, "password hash length should be 60")
|
||||
|
||||
err = userModel.checkPassword("sd123!@#ASD")
|
||||
asserts.Error(err, "password should be checked and not validated")
|
||||
|
||||
err = userModel.checkPassword("asd123!@#ASD")
|
||||
asserts.NoError(err, "password should be checked and validated")
|
||||
|
||||
//Testing the following relationship between users
|
||||
users := userModelMocker(3)
|
||||
a := users[0]
|
||||
b := users[1]
|
||||
c := users[2]
|
||||
asserts.Equal(0, len(a.GetFollowings()), "GetFollowings should be right before following")
|
||||
asserts.Equal(false, a.isFollowing(b), "isFollowing relationship should be right at init")
|
||||
a.following(b)
|
||||
asserts.Equal(1, len(a.GetFollowings()), "GetFollowings should be right after a following b")
|
||||
asserts.Equal(true, a.isFollowing(b), "isFollowing should be right after a following b")
|
||||
a.following(c)
|
||||
asserts.Equal(2, len(a.GetFollowings()), "GetFollowings be right after a following c")
|
||||
asserts.EqualValues(b, a.GetFollowings()[0], "GetFollowings should be right")
|
||||
asserts.EqualValues(c, a.GetFollowings()[1], "GetFollowings should be right")
|
||||
a.unFollowing(b)
|
||||
asserts.Equal(1, len(a.GetFollowings()), "GetFollowings should be right after a unFollowing b")
|
||||
asserts.EqualValues(c, a.GetFollowings()[0], "GetFollowings should be right after a unFollowing b")
|
||||
asserts.Equal(false, a.isFollowing(b), "isFollowing should be right after a unFollowing b")
|
||||
}
|
||||
|
||||
// Reset test DB and create new one with mock data
|
||||
func resetDBWithMock() {
|
||||
common.TestDBFree(test_db)
|
||||
test_db = common.TestDBInit()
|
||||
AutoMigrate()
|
||||
userModelMocker(3)
|
||||
}
|
||||
|
||||
// You could write the init logic like reset database code here
|
||||
var unauthRequestTests = []struct {
|
||||
init func(*http.Request)
|
||||
url string
|
||||
method string
|
||||
bodyData string
|
||||
expectedCode int
|
||||
responseRegexg string
|
||||
msg string
|
||||
}{
|
||||
//Testing will run one by one, so you can combine it to a user story till another init().
|
||||
//And you can modified the header or body in the func(req *http.Request) {}
|
||||
|
||||
//--------------------- Testing for user register ---------------------
|
||||
{
|
||||
func(req *http.Request) {
|
||||
resetDBWithMock()
|
||||
},
|
||||
"/users/",
|
||||
"POST",
|
||||
`{"user":{"username": "wangzitian0","email": "wzt@gg.cn","password": "jakejxke"}}`,
|
||||
http.StatusCreated,
|
||||
`{"user":{"username":"wangzitian0","email":"wzt@gg.cn","bio":"","image":"","token":"([a-zA-Z0-9-_.]{115})"}}`,
|
||||
"valid data and should return StatusCreated",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {},
|
||||
"/users/",
|
||||
"POST",
|
||||
`{"user":{"username": "wangzitian0","email": "wzt@gg.cn","password": "jakejxke"}}`,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"database":"UNIQUE constraint failed: user_models.email"}}`,
|
||||
"duplicated data and should return StatusUnprocessableEntity",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {},
|
||||
"/users/",
|
||||
"POST",
|
||||
`{"user":{"username": "u","email": "wzt@gg.cn","password": "jakejxke"}}`,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"Username":"{min: 4}"}}`,
|
||||
"too short username should return error",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {},
|
||||
"/users/",
|
||||
"POST",
|
||||
`{"user":{"username": "wangzitian0","email": "wzt@gg.cn","password": "j"}}`,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"Password":"{min: 8}"}}`,
|
||||
"too short password should return error",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {},
|
||||
"/users/",
|
||||
"POST",
|
||||
`{"user":{"username": "wangzitian0","email": "wztgg.cn","password": "jakejxke"}}`,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"Email":"{key: email}"}}`,
|
||||
"email invalid should return error",
|
||||
},
|
||||
|
||||
//--------------------- Testing for user login ---------------------
|
||||
{
|
||||
func(req *http.Request) {
|
||||
resetDBWithMock()
|
||||
},
|
||||
"/users/login",
|
||||
"POST",
|
||||
`{"user":{"email": "user1@linkedin.com","password": "password123"}}`,
|
||||
http.StatusOK,
|
||||
`{"user":{"username":"user1","email":"user1@linkedin.com","bio":"bio1","image":"http://image/1.jpg","token":"([a-zA-Z0-9-_.]{115})"}}`,
|
||||
"right info login should return user",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {},
|
||||
"/users/login",
|
||||
"POST",
|
||||
`{"user":{"email": "user112312312@linkedin.com","password": "password123"}}`,
|
||||
http.StatusUnauthorized,
|
||||
`{"errors":{"login":"Not Registered email or invalid password"}}`,
|
||||
"email not exist should return error info",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {},
|
||||
"/users/login",
|
||||
"POST",
|
||||
`{"user":{"email": "user1@linkedin.com","password": "password126"}}`,
|
||||
http.StatusUnauthorized,
|
||||
`{"errors":{"login":"Not Registered email or invalid password"}}`,
|
||||
"password error should return error info",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {},
|
||||
"/users/login",
|
||||
"POST",
|
||||
`{"user":{"email": "user1@linkedin.com","password": "passw"}}`,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"Password":"{min: 8}"}}`,
|
||||
"password too short should return error info",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {},
|
||||
"/users/login",
|
||||
"POST",
|
||||
`{"user":{"email": "user1@linkedin.com","password": "passw"}}`,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"Password":"{min: 8}"}}`,
|
||||
"password too short should return error info",
|
||||
},
|
||||
|
||||
//--------------------- Testing for self info get & auth module ---------------------
|
||||
{
|
||||
func(req *http.Request) {
|
||||
resetDBWithMock()
|
||||
},
|
||||
"/user/",
|
||||
"GET",
|
||||
``,
|
||||
http.StatusUnauthorized,
|
||||
``,
|
||||
"request should return 401 without token",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Tokee %v", common.GenToken(1)))
|
||||
},
|
||||
"/user/",
|
||||
"GET",
|
||||
``,
|
||||
http.StatusUnauthorized,
|
||||
``,
|
||||
"wrong token should return 401",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.HeaderTokenMock(req, 1)
|
||||
},
|
||||
"/user/",
|
||||
"GET",
|
||||
``,
|
||||
http.StatusOK,
|
||||
`{"user":{"username":"user1","email":"user1@linkedin.com","bio":"bio1","image":"http://image/1.jpg","token":"([a-zA-Z0-9-_.]{115})"}}`,
|
||||
"request should return current user with token",
|
||||
},
|
||||
|
||||
//--------------------- Testing for users' profile get ---------------------
|
||||
{
|
||||
func(req *http.Request) {
|
||||
resetDBWithMock()
|
||||
},
|
||||
"/profiles/user1",
|
||||
"GET",
|
||||
``,
|
||||
http.StatusOK,
|
||||
`{"profile":{"username":"user1","bio":"bio1","image":"http://image/1.jpg","following":false}}`,
|
||||
"anonymous request should return profile with following=false",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
resetDBWithMock()
|
||||
common.HeaderTokenMock(req, 1)
|
||||
},
|
||||
"/profiles/user1",
|
||||
"GET",
|
||||
``,
|
||||
http.StatusOK,
|
||||
`{"profile":{"username":"user1","bio":"bio1","image":"http://image/1.jpg","following":false}}`,
|
||||
"request should return self profile",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.HeaderTokenMock(req, 2)
|
||||
},
|
||||
"/profiles/user1",
|
||||
"GET",
|
||||
``,
|
||||
http.StatusOK,
|
||||
`{"profile":{"username":"user1","bio":"bio1","image":"http://image/1.jpg","following":false}}`,
|
||||
"request should return correct other's profile",
|
||||
},
|
||||
|
||||
//--------------------- Testing for users' profile update ---------------------
|
||||
{
|
||||
func(req *http.Request) {
|
||||
resetDBWithMock()
|
||||
common.HeaderTokenMock(req, 1)
|
||||
},
|
||||
"/profiles/user123",
|
||||
"GET",
|
||||
``,
|
||||
http.StatusNotFound,
|
||||
``,
|
||||
"user should not exist profile before changed",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.HeaderTokenMock(req, 1)
|
||||
},
|
||||
"/user/",
|
||||
"PUT",
|
||||
`{"user":{"username":"user123","password": "password126","email":"user123@linkedin.com","bio":"bio123","image":"http://hehe/123.jpg"}}`,
|
||||
http.StatusOK,
|
||||
`{"user":{"username":"user123","email":"user123@linkedin.com","bio":"bio123","image":"http://hehe/123.jpg","token":"([a-zA-Z0-9-_.]{115})"}}`,
|
||||
"current user profile should be changed",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.HeaderTokenMock(req, 1)
|
||||
},
|
||||
"/profiles/user123",
|
||||
"GET",
|
||||
``,
|
||||
http.StatusOK,
|
||||
`{"profile":{"username":"user123","bio":"bio123","image":"http://hehe/123.jpg","following":false}}`,
|
||||
"request should return self profile after changed",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {},
|
||||
"/users/login",
|
||||
"POST",
|
||||
`{"user":{"email": "user123@linkedin.com","password": "password126"}}`,
|
||||
http.StatusOK,
|
||||
`{"user":{"username":"user123","email":"user123@linkedin.com","bio":"bio123","image":"http://hehe/123.jpg","token":"([a-zA-Z0-9-_.]{115})"}}`,
|
||||
"user should login using new password after changed",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.HeaderTokenMock(req, 2)
|
||||
},
|
||||
"/user/",
|
||||
"PUT",
|
||||
`{"user":{"password": "pas"}}`,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"Password":"{min: 8}"}}`,
|
||||
"current user profile should not be changed with error user info",
|
||||
},
|
||||
|
||||
//--------------------- Testing for db errors ---------------------
|
||||
{
|
||||
func(req *http.Request) {
|
||||
resetDBWithMock()
|
||||
common.HeaderTokenMock(req, 4)
|
||||
},
|
||||
"/user/",
|
||||
"PUT",
|
||||
`{"password": "password321"}}`,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"Email":"{key: required}","Username":"{key: required}"}}`,
|
||||
"test database pk error for user update",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.HeaderTokenMock(req, 0)
|
||||
},
|
||||
"/user/",
|
||||
"PUT",
|
||||
`{"user":{"username": "wangzitian0","email": "wzt@gg.cn","password": "jakejxke"}}`,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"database":"WHERE conditions required"}}`,
|
||||
"cheat validator and test database connecting error for user update",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.TestDBFree(test_db)
|
||||
test_db = common.TestDBInit()
|
||||
|
||||
test_db.AutoMigrate(&UserModel{})
|
||||
userModelMocker(3)
|
||||
common.HeaderTokenMock(req, 2)
|
||||
},
|
||||
"/profiles/user1/follow",
|
||||
"POST",
|
||||
``,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"database":"no such table: follow_models"}}`,
|
||||
"test database error for following",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.HeaderTokenMock(req, 2)
|
||||
},
|
||||
"/profiles/user1/follow",
|
||||
"DELETE",
|
||||
``,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"database":"no such table: follow_models"}}`,
|
||||
"test database error for canceling following",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
resetDBWithMock()
|
||||
common.HeaderTokenMock(req, 2)
|
||||
},
|
||||
"/profiles/user666/follow",
|
||||
"POST",
|
||||
``,
|
||||
http.StatusNotFound,
|
||||
`{"errors":{"profile":"Invalid username"}}`,
|
||||
"following wrong user name should return errors",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.HeaderTokenMock(req, 2)
|
||||
},
|
||||
"/profiles/user666/follow",
|
||||
"DELETE",
|
||||
``,
|
||||
http.StatusNotFound,
|
||||
`{"errors":{"profile":"Invalid username"}}`,
|
||||
"cancel following wrong user name should return errors",
|
||||
},
|
||||
|
||||
//--------------------- Testing for user following ---------------------
|
||||
{
|
||||
func(req *http.Request) {
|
||||
resetDBWithMock()
|
||||
common.HeaderTokenMock(req, 2)
|
||||
},
|
||||
"/profiles/user1/follow",
|
||||
"POST",
|
||||
``,
|
||||
http.StatusOK,
|
||||
`{"profile":{"username":"user1","bio":"bio1","image":"http://image/1.jpg","following":true}}`,
|
||||
"user follow another should work",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.HeaderTokenMock(req, 2)
|
||||
},
|
||||
"/profiles/user1",
|
||||
"GET",
|
||||
``,
|
||||
http.StatusOK,
|
||||
`{"profile":{"username":"user1","bio":"bio1","image":"http://image/1.jpg","following":true}}`,
|
||||
"user follow another should make sure database changed",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.HeaderTokenMock(req, 2)
|
||||
},
|
||||
"/profiles/user1/follow",
|
||||
"DELETE",
|
||||
``,
|
||||
http.StatusOK,
|
||||
`{"profile":{"username":"user1","bio":"bio1","image":"http://image/1.jpg","following":false}}`,
|
||||
"user cancel follow another should work",
|
||||
},
|
||||
{
|
||||
func(req *http.Request) {
|
||||
common.HeaderTokenMock(req, 2)
|
||||
},
|
||||
"/profiles/user1",
|
||||
"GET",
|
||||
``,
|
||||
http.StatusOK,
|
||||
`{"profile":{"username":"user1","bio":"bio1","image":"http://image/1.jpg","following":false}}`,
|
||||
"user cancel follow another should make sure database changed",
|
||||
},
|
||||
}
|
||||
|
||||
func TestWithoutAuth(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
//You could write the reset database code here if you want to create a database for this block
|
||||
//resetDB()
|
||||
|
||||
r := gin.New()
|
||||
UsersRegister(r.Group("/users"))
|
||||
r.Use(AuthMiddleware(false))
|
||||
ProfileRetrieveRegister(r.Group("/profiles"))
|
||||
r.Use(AuthMiddleware(true))
|
||||
UserRegister(r.Group("/user"))
|
||||
ProfileRegister(r.Group("/profiles"))
|
||||
for _, testData := range unauthRequestTests {
|
||||
bodyData := testData.bodyData
|
||||
req, err := http.NewRequest(testData.method, testData.url, bytes.NewBufferString(bodyData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
asserts.NoError(err)
|
||||
|
||||
testData.init(req)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
asserts.Equal(testData.expectedCode, w.Code, "Response Status - "+testData.msg)
|
||||
asserts.Regexp(testData.responseRegexg, w.Body.String(), "Response Content - "+testData.msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTokenFromQueryParameter(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(AuthMiddleware(false))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
userID := c.MustGet("my_user_id").(uint)
|
||||
c.JSON(http.StatusOK, gin.H{"user_id": userID})
|
||||
})
|
||||
|
||||
resetDBWithMock()
|
||||
|
||||
// Test with access_token query parameter
|
||||
token := common.GenToken(1)
|
||||
req, _ := http.NewRequest("GET", "/test?access_token="+token, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
asserts.Equal(http.StatusOK, w.Code, "Request with query token should succeed")
|
||||
asserts.Contains(w.Body.String(), `"user_id":1`, "User ID should be 1")
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareInvalidToken(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(AuthMiddleware(true))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Test with invalid JWT token
|
||||
req, _ := http.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Authorization", "Token invalid.jwt.token")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
asserts.Equal(http.StatusUnauthorized, w.Code, "Invalid token should return 401")
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareNoToken(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(AuthMiddleware(false))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
userID := c.MustGet("my_user_id").(uint)
|
||||
c.JSON(http.StatusOK, gin.H{"user_id": userID})
|
||||
})
|
||||
|
||||
// Test with no token (auto401=false should still proceed)
|
||||
req, _ := http.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
asserts.Equal(http.StatusOK, w.Code, "No token with auto401=false should proceed")
|
||||
asserts.Contains(w.Body.String(), `"user_id":0`, "User ID should be 0")
|
||||
}
|
||||
|
||||
// This is a hack way to add test database for each case, as whole test will just share one database.
|
||||
// You can read TestWithoutAuth's comment to know how to not share database each case.
|
||||
func TestMain(m *testing.M) {
|
||||
test_db = common.TestDBInit()
|
||||
AutoMigrate()
|
||||
exitVal := m.Run()
|
||||
common.TestDBFree(test_db)
|
||||
os.Exit(exitVal)
|
||||
}
|
||||
85
webapp-back/users/validators.go
Normal file
85
webapp-back/users/validators.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
)
|
||||
|
||||
// *ModelValidator containing two parts:
|
||||
// - Validator: write the form/json checking rule according to the doc https://github.com/go-playground/validator
|
||||
// - DataModel: fill with data from Validator after invoking common.Bind(c, self)
|
||||
// Then, you can just call model.save() after the data is ready in DataModel.
|
||||
type UserModelValidator struct {
|
||||
User struct {
|
||||
Username string `form:"username" json:"username" binding:"required,min=4,max=255"`
|
||||
Email string `form:"email" json:"email" binding:"required,email"`
|
||||
Password string `form:"password" json:"password" binding:"required,min=8,max=255"`
|
||||
Bio string `form:"bio" json:"bio" binding:"max=1024"`
|
||||
Image string `form:"image" json:"image" binding:"omitempty,url"`
|
||||
} `json:"user"`
|
||||
userModel UserModel `json:"-"`
|
||||
}
|
||||
|
||||
// There are some difference when you create or update a model, you need to fill the DataModel before
|
||||
// update so that you can use your origin data to cheat the validator.
|
||||
// BTW, you can put your general binding logic here such as setting password.
|
||||
func (self *UserModelValidator) Bind(c *gin.Context) error {
|
||||
err := common.Bind(c, self)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.userModel.Username = self.User.Username
|
||||
self.userModel.Email = self.User.Email
|
||||
self.userModel.Bio = self.User.Bio
|
||||
|
||||
if self.User.Password != common.RandomPassword {
|
||||
self.userModel.setPassword(self.User.Password)
|
||||
}
|
||||
if self.User.Image != "" {
|
||||
self.userModel.Image = &self.User.Image
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// You can put the default value of a Validator here
|
||||
func NewUserModelValidator() UserModelValidator {
|
||||
userModelValidator := UserModelValidator{}
|
||||
return userModelValidator
|
||||
}
|
||||
|
||||
func NewUserModelValidatorFillWith(userModel UserModel) UserModelValidator {
|
||||
userModelValidator := NewUserModelValidator()
|
||||
userModelValidator.User.Username = userModel.Username
|
||||
userModelValidator.User.Email = userModel.Email
|
||||
userModelValidator.User.Bio = userModel.Bio
|
||||
userModelValidator.User.Password = common.RandomPassword
|
||||
|
||||
if userModel.Image != nil {
|
||||
userModelValidator.User.Image = *userModel.Image
|
||||
}
|
||||
return userModelValidator
|
||||
}
|
||||
|
||||
type LoginValidator struct {
|
||||
User struct {
|
||||
Email string `form:"email" json:"email" binding:"required,email"`
|
||||
Password string `form:"password" json:"password" binding:"required,min=8,max=255"`
|
||||
} `json:"user"`
|
||||
userModel UserModel `json:"-"`
|
||||
}
|
||||
|
||||
func (self *LoginValidator) Bind(c *gin.Context) error {
|
||||
err := common.Bind(c, self)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.userModel.Email = self.User.Email
|
||||
return nil
|
||||
}
|
||||
|
||||
// You can put the default value of a Validator here
|
||||
func NewLoginValidator() LoginValidator {
|
||||
loginValidator := LoginValidator{}
|
||||
return loginValidator
|
||||
}
|
||||
Reference in New Issue
Block a user