[API] Fix 9544 | return 200 when reaction already exist (#9550)

* add ErrReactionAlreadyExist

* extend CreateReaction

* reaction already exist = 200

* extend FindReactionsOptions

* refactor swagger options/definitions

* fix swagger-validate

* Update models/error.go

Co-Authored-By: zeripath <art27@cantab.net>

* fix test PART1

* extend FindReactionsOptions with UserID option

* catch error on test

* fix test PART2

* format ...

Co-authored-by: zeripath <art27@cantab.net>
Co-authored-by: techknowlogick <matti@mdranta.net>
This commit is contained in:
6543 2019-12-31 09:21:21 +01:00 committed by techknowlogick
parent 655aea13a5
commit 9600c27085
9 changed files with 119 additions and 79 deletions

View File

@ -47,7 +47,7 @@ func TestAPIIssuesReactions(t *testing.T) {
Reaction: "rocket", Reaction: "rocket",
}) })
resp = session.MakeRequest(t, req, http.StatusCreated) resp = session.MakeRequest(t, req, http.StatusCreated)
var apiNewReaction api.ReactionResponse var apiNewReaction api.Reaction
DecodeJSON(t, resp, &apiNewReaction) DecodeJSON(t, resp, &apiNewReaction)
//Add existing reaction //Add existing reaction
@ -56,10 +56,10 @@ func TestAPIIssuesReactions(t *testing.T) {
//Get end result of reaction list of issue #1 //Get end result of reaction list of issue #1
req = NewRequestf(t, "GET", urlStr) req = NewRequestf(t, "GET", urlStr)
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
var apiReactions []*api.ReactionResponse var apiReactions []*api.Reaction
DecodeJSON(t, resp, &apiReactions) DecodeJSON(t, resp, &apiReactions)
expectResponse := make(map[int]api.ReactionResponse) expectResponse := make(map[int]api.Reaction)
expectResponse[0] = api.ReactionResponse{ expectResponse[0] = api.Reaction{
User: user2.APIFormat(), User: user2.APIFormat(),
Reaction: "eyes", Reaction: "eyes",
Created: time.Unix(1573248003, 0), Created: time.Unix(1573248003, 0),
@ -107,7 +107,7 @@ func TestAPICommentReactions(t *testing.T) {
Reaction: "+1", Reaction: "+1",
}) })
resp = session.MakeRequest(t, req, http.StatusCreated) resp = session.MakeRequest(t, req, http.StatusCreated)
var apiNewReaction api.ReactionResponse var apiNewReaction api.Reaction
DecodeJSON(t, resp, &apiNewReaction) DecodeJSON(t, resp, &apiNewReaction)
//Add existing reaction //Add existing reaction
@ -116,15 +116,15 @@ func TestAPICommentReactions(t *testing.T) {
//Get end result of reaction list of issue #1 //Get end result of reaction list of issue #1
req = NewRequestf(t, "GET", urlStr) req = NewRequestf(t, "GET", urlStr)
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
var apiReactions []*api.ReactionResponse var apiReactions []*api.Reaction
DecodeJSON(t, resp, &apiReactions) DecodeJSON(t, resp, &apiReactions)
expectResponse := make(map[int]api.ReactionResponse) expectResponse := make(map[int]api.Reaction)
expectResponse[0] = api.ReactionResponse{ expectResponse[0] = api.Reaction{
User: user2.APIFormat(), User: user2.APIFormat(),
Reaction: "laugh", Reaction: "laugh",
Created: time.Unix(1573248004, 0), Created: time.Unix(1573248004, 0),
} }
expectResponse[1] = api.ReactionResponse{ expectResponse[1] = api.Reaction{
User: user1.APIFormat(), User: user1.APIFormat(),
Reaction: "laugh", Reaction: "laugh",
Created: time.Unix(1573248005, 0), Created: time.Unix(1573248005, 0),

View File

@ -1201,6 +1201,21 @@ func (err ErrForbiddenIssueReaction) Error() string {
return fmt.Sprintf("'%s' is not an allowed reaction", err.Reaction) return fmt.Sprintf("'%s' is not an allowed reaction", err.Reaction)
} }
// ErrReactionAlreadyExist is used when a existing reaction was try to created
type ErrReactionAlreadyExist struct {
Reaction string
}
// IsErrReactionAlreadyExist checks if an error is a ErrReactionAlreadyExist.
func IsErrReactionAlreadyExist(err error) bool {
_, ok := err.(ErrReactionAlreadyExist)
return ok
}
func (err ErrReactionAlreadyExist) Error() string {
return fmt.Sprintf("reaction '%s' already exists", err.Reaction)
}
// __________ .__ .__ __________ __ // __________ .__ .__ __________ __
// \______ \__ __| | | |\______ \ ____ ________ __ ____ _______/ |_ // \______ \__ __| | | |\______ \ ____ ________ __ ____ _______/ |_
// | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\ // | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\

View File

@ -30,6 +30,8 @@ type Reaction struct {
type FindReactionsOptions struct { type FindReactionsOptions struct {
IssueID int64 IssueID int64
CommentID int64 CommentID int64
UserID int64
Reaction string
} }
func (opts *FindReactionsOptions) toConds() builder.Cond { func (opts *FindReactionsOptions) toConds() builder.Cond {
@ -46,6 +48,12 @@ func (opts *FindReactionsOptions) toConds() builder.Cond {
} else if opts.CommentID == -1 { } else if opts.CommentID == -1 {
cond = cond.And(builder.Eq{"reaction.comment_id": 0}) cond = cond.And(builder.Eq{"reaction.comment_id": 0})
} }
if opts.UserID > 0 {
cond = cond.And(builder.Eq{"reaction.user_id": opts.UserID})
}
if opts.Reaction != "" {
cond = cond.And(builder.Eq{"reaction.type": opts.Reaction})
}
return cond return cond
} }
@ -80,9 +88,25 @@ func createReaction(e *xorm.Session, opts *ReactionOptions) (*Reaction, error) {
UserID: opts.Doer.ID, UserID: opts.Doer.ID,
IssueID: opts.Issue.ID, IssueID: opts.Issue.ID,
} }
findOpts := FindReactionsOptions{
IssueID: opts.Issue.ID,
CommentID: -1, // reaction to issue only
Reaction: opts.Type,
UserID: opts.Doer.ID,
}
if opts.Comment != nil { if opts.Comment != nil {
reaction.CommentID = opts.Comment.ID reaction.CommentID = opts.Comment.ID
findOpts.CommentID = opts.Comment.ID
} }
existingR, err := findReactions(e, findOpts)
if err != nil {
return nil, err
}
if len(existingR) > 0 {
return existingR[0], ErrReactionAlreadyExist{Reaction: opts.Type}
}
if _, err := e.Insert(reaction); err != nil { if _, err := e.Insert(reaction); err != nil {
return nil, err return nil, err
} }
@ -99,23 +123,23 @@ type ReactionOptions struct {
} }
// CreateReaction creates reaction for issue or comment. // CreateReaction creates reaction for issue or comment.
func CreateReaction(opts *ReactionOptions) (reaction *Reaction, err error) { func CreateReaction(opts *ReactionOptions) (*Reaction, error) {
if !setting.UI.ReactionsMap[opts.Type] { if !setting.UI.ReactionsMap[opts.Type] {
return nil, ErrForbiddenIssueReaction{opts.Type} return nil, ErrForbiddenIssueReaction{opts.Type}
} }
sess := x.NewSession() sess := x.NewSession()
defer sess.Close() defer sess.Close()
if err = sess.Begin(); err != nil { if err := sess.Begin(); err != nil {
return nil, err return nil, err
} }
reaction, err = createReaction(sess, opts) reaction, err := createReaction(sess, opts)
if err != nil { if err != nil {
return nil, err return reaction, err
} }
if err = sess.Commit(); err != nil { if err := sess.Commit(); err != nil {
return nil, err return nil, err
} }
return reaction, nil return reaction, nil

View File

@ -50,9 +50,10 @@ func TestIssueAddDuplicateReaction(t *testing.T) {
Type: "heart", Type: "heart",
}) })
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, reaction) assert.Equal(t, ErrReactionAlreadyExist{Reaction: "heart"}, err)
AssertExistsAndLoadBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1.ID}) existingR := AssertExistsAndLoadBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1.ID}).(*Reaction)
assert.Equal(t, existingR.ID, reaction.ID)
} }
func TestIssueDeleteReaction(t *testing.T) { func TestIssueDeleteReaction(t *testing.T) {
@ -129,7 +130,6 @@ func TestIssueCommentDeleteReaction(t *testing.T) {
user2 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) user2 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
user3 := AssertExistsAndLoadBean(t, &User{ID: 3}).(*User) user3 := AssertExistsAndLoadBean(t, &User{ID: 3}).(*User)
user4 := AssertExistsAndLoadBean(t, &User{ID: 4}).(*User) user4 := AssertExistsAndLoadBean(t, &User{ID: 4}).(*User)
ghost := NewGhostUser()
issue1 := AssertExistsAndLoadBean(t, &Issue{ID: 1}).(*Issue) issue1 := AssertExistsAndLoadBean(t, &Issue{ID: 1}).(*Issue)
@ -139,14 +139,13 @@ func TestIssueCommentDeleteReaction(t *testing.T) {
addReaction(t, user2, issue1, comment1, "heart") addReaction(t, user2, issue1, comment1, "heart")
addReaction(t, user3, issue1, comment1, "heart") addReaction(t, user3, issue1, comment1, "heart")
addReaction(t, user4, issue1, comment1, "+1") addReaction(t, user4, issue1, comment1, "+1")
addReaction(t, ghost, issue1, comment1, "heart")
err := comment1.LoadReactions() err := comment1.LoadReactions()
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, comment1.Reactions, 5) assert.Len(t, comment1.Reactions, 4)
reactions := comment1.Reactions.GroupByType() reactions := comment1.Reactions.GroupByType()
assert.Len(t, reactions["heart"], 4) assert.Len(t, reactions["heart"], 3)
assert.Len(t, reactions["+1"], 1) assert.Len(t, reactions["+1"], 1)
} }
@ -160,7 +159,7 @@ func TestIssueCommentReactionCount(t *testing.T) {
comment1 := AssertExistsAndLoadBean(t, &Comment{ID: 1}).(*Comment) comment1 := AssertExistsAndLoadBean(t, &Comment{ID: 1}).(*Comment)
addReaction(t, user1, issue1, comment1, "heart") addReaction(t, user1, issue1, comment1, "heart")
DeleteCommentReaction(user1, issue1, comment1, "heart") assert.NoError(t, DeleteCommentReaction(user1, issue1, comment1, "heart"))
AssertNotExistsBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1.ID, CommentID: comment1.ID}) AssertNotExistsBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1.ID, CommentID: comment1.ID})
} }

View File

@ -13,8 +13,8 @@ type EditReactionOption struct {
Reaction string `json:"content"` Reaction string `json:"content"`
} }
// ReactionResponse contain one reaction // Reaction contain one reaction
type ReactionResponse struct { type Reaction struct {
User *User `json:"user"` User *User `json:"user"`
Reaction string `json:"content"` Reaction string `json:"content"`
// swagger:strfmt date-time // swagger:strfmt date-time

View File

@ -41,7 +41,7 @@ func GetIssueCommentReactions(ctx *context.APIContext) {
// required: true // required: true
// responses: // responses:
// "200": // "200":
// "$ref": "#/responses/ReactionResponseList" // "$ref": "#/responses/ReactionList"
// "403": // "403":
// "$ref": "#/responses/forbidden" // "$ref": "#/responses/forbidden"
@ -71,9 +71,9 @@ func GetIssueCommentReactions(ctx *context.APIContext) {
return return
} }
var result []api.ReactionResponse var result []api.Reaction
for _, r := range reactions { for _, r := range reactions {
result = append(result, api.ReactionResponse{ result = append(result, api.Reaction{
User: r.User.APIFormat(), User: r.User.APIFormat(),
Reaction: r.Type, Reaction: r.Type,
Created: r.CreatedUnix.AsTime(), Created: r.CreatedUnix.AsTime(),
@ -114,8 +114,10 @@ func PostIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOpti
// schema: // schema:
// "$ref": "#/definitions/EditReactionOption" // "$ref": "#/definitions/EditReactionOption"
// responses: // responses:
// "200":
// "$ref": "#/responses/Reaction"
// "201": // "201":
// "$ref": "#/responses/ReactionResponse" // "$ref": "#/responses/Reaction"
// "403": // "403":
// "$ref": "#/responses/forbidden" // "$ref": "#/responses/forbidden"
@ -188,19 +190,20 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp
if err != nil { if err != nil {
if models.IsErrForbiddenIssueReaction(err) { if models.IsErrForbiddenIssueReaction(err) {
ctx.Error(http.StatusForbidden, err.Error(), err) ctx.Error(http.StatusForbidden, err.Error(), err)
} else if models.IsErrReactionAlreadyExist(err) {
ctx.JSON(http.StatusOK, api.Reaction{
User: ctx.User.APIFormat(),
Reaction: reaction.Type,
Created: reaction.CreatedUnix.AsTime(),
})
} else { } else {
ctx.Error(http.StatusInternalServerError, "CreateCommentReaction", err) ctx.Error(http.StatusInternalServerError, "CreateCommentReaction", err)
} }
return return
} }
_, err = reaction.LoadUser()
if err != nil {
ctx.Error(http.StatusInternalServerError, "Reaction.LoadUser()", err)
return
}
ctx.JSON(http.StatusCreated, api.ReactionResponse{ ctx.JSON(http.StatusCreated, api.Reaction{
User: reaction.User.APIFormat(), User: ctx.User.APIFormat(),
Reaction: reaction.Type, Reaction: reaction.Type,
Created: reaction.CreatedUnix.AsTime(), Created: reaction.CreatedUnix.AsTime(),
}) })
@ -244,7 +247,7 @@ func GetIssueReactions(ctx *context.APIContext) {
// required: true // required: true
// responses: // responses:
// "200": // "200":
// "$ref": "#/responses/ReactionResponseList" // "$ref": "#/responses/ReactionList"
// "403": // "403":
// "$ref": "#/responses/forbidden" // "$ref": "#/responses/forbidden"
@ -274,9 +277,9 @@ func GetIssueReactions(ctx *context.APIContext) {
return return
} }
var result []api.ReactionResponse var result []api.Reaction
for _, r := range reactions { for _, r := range reactions {
result = append(result, api.ReactionResponse{ result = append(result, api.Reaction{
User: r.User.APIFormat(), User: r.User.APIFormat(),
Reaction: r.Type, Reaction: r.Type,
Created: r.CreatedUnix.AsTime(), Created: r.CreatedUnix.AsTime(),
@ -317,8 +320,10 @@ func PostIssueReaction(ctx *context.APIContext, form api.EditReactionOption) {
// schema: // schema:
// "$ref": "#/definitions/EditReactionOption" // "$ref": "#/definitions/EditReactionOption"
// responses: // responses:
// "200":
// "$ref": "#/responses/Reaction"
// "201": // "201":
// "$ref": "#/responses/ReactionResponse" // "$ref": "#/responses/Reaction"
// "403": // "403":
// "$ref": "#/responses/forbidden" // "$ref": "#/responses/forbidden"
@ -386,19 +391,20 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i
if err != nil { if err != nil {
if models.IsErrForbiddenIssueReaction(err) { if models.IsErrForbiddenIssueReaction(err) {
ctx.Error(http.StatusForbidden, err.Error(), err) ctx.Error(http.StatusForbidden, err.Error(), err)
} else if models.IsErrReactionAlreadyExist(err) {
ctx.JSON(http.StatusOK, api.Reaction{
User: ctx.User.APIFormat(),
Reaction: reaction.Type,
Created: reaction.CreatedUnix.AsTime(),
})
} else { } else {
ctx.Error(http.StatusInternalServerError, "CreateCommentReaction", err) ctx.Error(http.StatusInternalServerError, "CreateCommentReaction", err)
} }
return return
} }
_, err = reaction.LoadUser()
if err != nil {
ctx.Error(http.StatusInternalServerError, "Reaction.LoadUser()", err)
return
}
ctx.JSON(http.StatusCreated, api.ReactionResponse{ ctx.JSON(http.StatusCreated, api.Reaction{
User: reaction.User.APIFormat(), User: ctx.User.APIFormat(),
Reaction: reaction.Type, Reaction: reaction.Type,
Created: reaction.CreatedUnix.AsTime(), Created: reaction.CreatedUnix.AsTime(),
}) })

View File

@ -99,23 +99,16 @@ type swaggerResponseStopWatchList struct {
Body []api.StopWatch `json:"body"` Body []api.StopWatch `json:"body"`
} }
// EditReactionOption // Reaction
// swagger:response EditReactionOption // swagger:response Reaction
type swaggerEditReactionOption struct { type swaggerReaction struct {
// in:body // in:body
Body api.EditReactionOption `json:"body"` Body api.Reaction `json:"body"`
} }
// ReactionResponse // ReactionList
// swagger:response ReactionResponse // swagger:response ReactionList
type swaggerReactionResponse struct { type swaggerReactionList struct {
// in:body // in:body
Body api.ReactionResponse `json:"body"` Body []api.Reaction `json:"body"`
}
// ReactionResponseList
// swagger:response ReactionResponseList
type swaggerReactionResponseList struct {
// in:body
Body []api.ReactionResponse `json:"body"`
} }

View File

@ -123,4 +123,7 @@ type swaggerParameterBodies struct {
// in:body // in:body
RepoTopicOptions api.RepoTopicOptions RepoTopicOptions api.RepoTopicOptions
// in:body
EditReactionOption api.EditReactionOption
} }

View File

@ -3130,7 +3130,7 @@
], ],
"responses": { "responses": {
"200": { "200": {
"$ref": "#/responses/ReactionResponseList" "$ref": "#/responses/ReactionList"
}, },
"403": { "403": {
"$ref": "#/responses/forbidden" "$ref": "#/responses/forbidden"
@ -3181,8 +3181,11 @@
} }
], ],
"responses": { "responses": {
"200": {
"$ref": "#/responses/Reaction"
},
"201": { "201": {
"$ref": "#/responses/ReactionResponse" "$ref": "#/responses/Reaction"
}, },
"403": { "403": {
"$ref": "#/responses/forbidden" "$ref": "#/responses/forbidden"
@ -3896,7 +3899,7 @@
], ],
"responses": { "responses": {
"200": { "200": {
"$ref": "#/responses/ReactionResponseList" "$ref": "#/responses/ReactionList"
}, },
"403": { "403": {
"$ref": "#/responses/forbidden" "$ref": "#/responses/forbidden"
@ -3947,8 +3950,11 @@
} }
], ],
"responses": { "responses": {
"200": {
"$ref": "#/responses/Reaction"
},
"201": { "201": {
"$ref": "#/responses/ReactionResponse" "$ref": "#/responses/Reaction"
}, },
"403": { "403": {
"$ref": "#/responses/forbidden" "$ref": "#/responses/forbidden"
@ -10822,8 +10828,8 @@
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
}, },
"ReactionResponse": { "Reaction": {
"description": "ReactionResponse contain one reaction", "description": "Reaction contain one reaction",
"type": "object", "type": "object",
"properties": { "properties": {
"content": { "content": {
@ -11735,12 +11741,6 @@
} }
} }
}, },
"EditReactionOption": {
"description": "EditReactionOption",
"schema": {
"$ref": "#/definitions/EditReactionOption"
}
},
"EmailList": { "EmailList": {
"description": "EmailList", "description": "EmailList",
"schema": { "schema": {
@ -11927,18 +11927,18 @@
} }
} }
}, },
"ReactionResponse": { "Reaction": {
"description": "ReactionResponse", "description": "Reaction",
"schema": { "schema": {
"$ref": "#/definitions/ReactionResponse" "$ref": "#/definitions/Reaction"
} }
}, },
"ReactionResponseList": { "ReactionList": {
"description": "ReactionResponseList", "description": "ReactionList",
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/ReactionResponse" "$ref": "#/definitions/Reaction"
} }
} }
}, },
@ -12164,7 +12164,7 @@
"parameterBodies": { "parameterBodies": {
"description": "parameterBodies", "description": "parameterBodies",
"schema": { "schema": {
"$ref": "#/definitions/RepoTopicOptions" "$ref": "#/definitions/EditReactionOption"
} }
}, },
"redirect": { "redirect": {