[API] Add Reactions (#9220)

* reject reactions wich ar not allowed

* dont duble check CreateReaction now throw ErrForbiddenIssueReaction

* add /repos/{owner}/{repo}/issues/comments/{id}/reactions endpoint

* add Find Functions

* fix some swagger stuff + add issue reaction endpoints + GET ReactionList now use FindReactions...

* explicite Issue Only Reaction for FindReactionsOptions with "-1" commentID

* load issue; load user ...

* return error again

* swagger def canged after LINT

* check if user has ben loaded

* add Tests

* better way of comparing results

* add suggestion

* use different issue for test
(dont interfear with integration test)

* test dont compare Location on timeCompare

* TEST: add forbidden dubble add

* add comments in code to explain

* add settings.UI.ReactionsMap
so if !setting.UI.ReactionsMap[opts.Type] works
This commit is contained in:
6543
2019-12-07 23:04:19 +01:00
committed by techknowlogick
parent ee7df7ba8c
commit 37e10d4543
12 changed files with 1048 additions and 31 deletions

View File

@ -0,0 +1,145 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package integrations
import (
"fmt"
"net/http"
"testing"
"time"
"code.gitea.io/gitea/models"
api "code.gitea.io/gitea/modules/structs"
"github.com/stretchr/testify/assert"
)
func TestAPIIssuesReactions(t *testing.T) {
defer prepareTestEnv(t)()
issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1}).(*models.Issue)
_ = issue.LoadRepo()
owner := models.AssertExistsAndLoadBean(t, &models.User{ID: issue.Repo.OwnerID}).(*models.User)
session := loginUser(t, owner.Name)
token := getTokenForLoggedInUser(t, session)
user1 := models.AssertExistsAndLoadBean(t, &models.User{ID: 1}).(*models.User)
user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions?token=%s",
owner.Name, issue.Repo.Name, issue.Index, token)
//Try to add not allowed reaction
req := NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
Reaction: "wrong",
})
resp := session.MakeRequest(t, req, http.StatusForbidden)
//Delete not allowed reaction
req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{
Reaction: "zzz",
})
resp = session.MakeRequest(t, req, http.StatusOK)
//Add allowed reaction
req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
Reaction: "rocket",
})
resp = session.MakeRequest(t, req, http.StatusCreated)
var apiNewReaction api.ReactionResponse
DecodeJSON(t, resp, &apiNewReaction)
//Add existing reaction
resp = session.MakeRequest(t, req, http.StatusForbidden)
//Get end result of reaction list of issue #1
req = NewRequestf(t, "GET", urlStr)
resp = session.MakeRequest(t, req, http.StatusOK)
var apiReactions []*api.ReactionResponse
DecodeJSON(t, resp, &apiReactions)
expectResponse := make(map[int]api.ReactionResponse)
expectResponse[0] = api.ReactionResponse{
User: user1.APIFormat(),
Reaction: "zzz",
Created: time.Unix(1573248002, 0),
}
expectResponse[1] = api.ReactionResponse{
User: user2.APIFormat(),
Reaction: "eyes",
Created: time.Unix(1573248003, 0),
}
expectResponse[2] = apiNewReaction
assert.Len(t, apiReactions, 3)
for i, r := range apiReactions {
assert.Equal(t, expectResponse[i].Reaction, r.Reaction)
assert.Equal(t, expectResponse[i].Created.Unix(), r.Created.Unix())
assert.Equal(t, expectResponse[i].User.ID, r.User.ID)
}
}
func TestAPICommentReactions(t *testing.T) {
defer prepareTestEnv(t)()
comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2}).(*models.Comment)
_ = comment.LoadIssue()
issue := comment.Issue
_ = issue.LoadRepo()
owner := models.AssertExistsAndLoadBean(t, &models.User{ID: issue.Repo.OwnerID}).(*models.User)
session := loginUser(t, owner.Name)
token := getTokenForLoggedInUser(t, session)
user1 := models.AssertExistsAndLoadBean(t, &models.User{ID: 1}).(*models.User)
user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/reactions?token=%s",
owner.Name, issue.Repo.Name, comment.ID, token)
//Try to add not allowed reaction
req := NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
Reaction: "wrong",
})
resp := session.MakeRequest(t, req, http.StatusForbidden)
//Delete none existing reaction
req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{
Reaction: "eyes",
})
resp = session.MakeRequest(t, req, http.StatusOK)
//Add allowed reaction
req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
Reaction: "+1",
})
resp = session.MakeRequest(t, req, http.StatusCreated)
var apiNewReaction api.ReactionResponse
DecodeJSON(t, resp, &apiNewReaction)
//Add existing reaction
resp = session.MakeRequest(t, req, http.StatusForbidden)
//Get end result of reaction list of issue #1
req = NewRequestf(t, "GET", urlStr)
resp = session.MakeRequest(t, req, http.StatusOK)
var apiReactions []*api.ReactionResponse
DecodeJSON(t, resp, &apiReactions)
expectResponse := make(map[int]api.ReactionResponse)
expectResponse[0] = api.ReactionResponse{
User: user2.APIFormat(),
Reaction: "laugh",
Created: time.Unix(1573248004, 0),
}
expectResponse[1] = api.ReactionResponse{
User: user1.APIFormat(),
Reaction: "laugh",
Created: time.Unix(1573248005, 0),
}
expectResponse[2] = apiNewReaction
assert.Len(t, apiReactions, 3)
for i, r := range apiReactions {
assert.Equal(t, expectResponse[i].Reaction, r.Reaction)
assert.Equal(t, expectResponse[i].Created.Unix(), r.Created.Unix())
assert.Equal(t, expectResponse[i].User.ID, r.User.ID)
}
}

View File

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

View File

@ -1 +1,39 @@
[] # empty
-
id: 1 #issue reaction
type: zzz # not allowed reaction (added before allowed reaction list has changed)
issue_id: 1
comment_id: 0
user_id: 2
created_unix: 1573248001
-
id: 2 #issue reaction
type: zzz # not allowed reaction (added before allowed reaction list has changed)
issue_id: 1
comment_id: 0
user_id: 1
created_unix: 1573248002
-
id: 3 #issue reaction
type: eyes # allowed reaction
issue_id: 1
comment_id: 0
user_id: 2
created_unix: 1573248003
-
id: 4 #comment reaction
type: laugh # allowed reaction
issue_id: 1
comment_id: 2
user_id: 2
created_unix: 1573248004
-
id: 5 #comment reaction
type: laugh # allowed reaction
issue_id: 1
comment_id: 2
user_id: 1
created_unix: 1573248005

View File

@ -33,16 +33,38 @@ type FindReactionsOptions struct {
}
func (opts *FindReactionsOptions) toConds() builder.Cond {
//If Issue ID is set add to Query
var cond = builder.NewCond()
if opts.IssueID > 0 {
cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueID})
}
//If CommentID is > 0 add to Query
//If it is 0 Query ignore CommentID to select
//If it is -1 it explicit search of Issue Reactions where CommentID = 0
if opts.CommentID > 0 {
cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID})
} else if opts.CommentID == -1 {
cond = cond.And(builder.Eq{"reaction.comment_id": 0})
}
return cond
}
// FindCommentReactions returns a ReactionList of all reactions from an comment
func FindCommentReactions(comment *Comment) (ReactionList, error) {
return findReactions(x, FindReactionsOptions{
IssueID: comment.IssueID,
CommentID: comment.ID})
}
// FindIssueReactions returns a ReactionList of all reactions from an issue
func FindIssueReactions(issue *Issue) (ReactionList, error) {
return findReactions(x, FindReactionsOptions{
IssueID: issue.ID,
CommentID: -1,
})
}
func findReactions(e Engine, opts FindReactionsOptions) ([]*Reaction, error) {
reactions := make([]*Reaction, 0, 10)
sess := e.Where(opts.toConds())
@ -77,6 +99,10 @@ type ReactionOptions struct {
// CreateReaction creates reaction for issue or comment.
func CreateReaction(opts *ReactionOptions) (reaction *Reaction, err error) {
if !setting.UI.ReactionsMap[opts.Type] {
return nil, ErrForbiddenIssueReaction{opts.Type}
}
sess := x.NewSession()
defer sess.Close()
if err = sess.Begin(); err != nil {
@ -160,6 +186,19 @@ func DeleteCommentReaction(doer *User, issue *Issue, comment *Comment, content s
})
}
// LoadUser load user of reaction
func (r *Reaction) LoadUser() (*User, error) {
if r.User != nil {
return r.User, nil
}
user, err := getUserByID(x, r.UserID)
if err != nil {
return nil, err
}
r.User = user
return user, nil
}
// ReactionList represents list of reactions
type ReactionList []*Reaction

View File

@ -81,22 +81,22 @@ func TestIssueReactionCount(t *testing.T) {
user4 := AssertExistsAndLoadBean(t, &User{ID: 4}).(*User)
ghost := NewGhostUser()
issue1 := AssertExistsAndLoadBean(t, &Issue{ID: 1}).(*Issue)
issue := AssertExistsAndLoadBean(t, &Issue{ID: 2}).(*Issue)
addReaction(t, user1, issue1, nil, "heart")
addReaction(t, user2, issue1, nil, "heart")
addReaction(t, user3, issue1, nil, "heart")
addReaction(t, user3, issue1, nil, "+1")
addReaction(t, user4, issue1, nil, "+1")
addReaction(t, user4, issue1, nil, "heart")
addReaction(t, ghost, issue1, nil, "-1")
addReaction(t, user1, issue, nil, "heart")
addReaction(t, user2, issue, nil, "heart")
addReaction(t, user3, issue, nil, "heart")
addReaction(t, user3, issue, nil, "+1")
addReaction(t, user4, issue, nil, "+1")
addReaction(t, user4, issue, nil, "heart")
addReaction(t, ghost, issue, nil, "-1")
err := issue1.loadReactions(x)
err := issue.loadReactions(x)
assert.NoError(t, err)
assert.Len(t, issue1.Reactions, 7)
assert.Len(t, issue.Reactions, 7)
reactions := issue1.Reactions.GroupByType()
reactions := issue.Reactions.GroupByType()
assert.Len(t, reactions["heart"], 4)
assert.Equal(t, 2, reactions["heart"].GetMoreUserCount())
assert.Equal(t, user1.DisplayName()+", "+user2.DisplayName(), reactions["heart"].GetFirstUsers())

View File

@ -171,6 +171,7 @@ var (
DefaultTheme string
Themes []string
Reactions []string
ReactionsMap map[string]bool
SearchRepoDescription bool
UseServiceWorker bool
@ -985,6 +986,11 @@ func NewContext() {
U2F.AppID = sec.Key("APP_ID").MustString(strings.TrimRight(AppURL, "/"))
zip.Verbose = false
UI.ReactionsMap = make(map[string]bool)
for _, reaction := range UI.Reactions {
UI.ReactionsMap[reaction] = true
}
}
func loadInternalToken(sec *ini.Section) string {

View File

@ -0,0 +1,22 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package structs
import (
"time"
)
// EditReactionOption contain the reaction type
type EditReactionOption struct {
Reaction string `json:"content"`
}
// ReactionResponse contain one reaction
type ReactionResponse struct {
User *User `json:"user"`
Reaction string `json:"content"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
}

View File

@ -657,21 +657,25 @@ func RegisterRoutes(m *macaron.Macaron) {
Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}), repo.CreateIssue)
m.Group("/comments", func() {
m.Get("", repo.ListRepoIssueComments)
m.Combo("/:id", reqToken()).
m.Group("/:id", func() {
m.Combo("", reqToken()).
Patch(mustNotBeArchived, bind(api.EditIssueCommentOption{}), repo.EditIssueComment).
Delete(repo.DeleteIssueComment)
m.Combo("/reactions", reqToken()).
Get(repo.GetIssueCommentReactions).
Post(bind(api.EditReactionOption{}), repo.PostIssueCommentReaction).
Delete(bind(api.EditReactionOption{}), repo.DeleteIssueCommentReaction)
})
})
m.Group("/:index", func() {
m.Combo("").Get(repo.GetIssue).
Patch(reqToken(), bind(api.EditIssueOption{}), repo.EditIssue)
m.Group("/comments", func() {
m.Combo("").Get(repo.ListIssueComments).
Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueCommentOption{}), repo.CreateIssueComment)
m.Combo("/:id", reqToken()).Patch(bind(api.EditIssueCommentOption{}), repo.EditIssueCommentDeprecated).
Delete(repo.DeleteIssueCommentDeprecated)
})
m.Group("/labels", func() {
m.Combo("").Get(repo.ListIssueLabels).
Post(reqToken(), bind(api.IssueLabelsOption{}), repo.AddIssueLabels).
@ -679,12 +683,10 @@ func RegisterRoutes(m *macaron.Macaron) {
Delete(reqToken(), repo.ClearIssueLabels)
m.Delete("/:id", reqToken(), repo.DeleteIssueLabel)
})
m.Group("/times", func() {
m.Combo("").Get(repo.ListTrackedTimes).
Post(reqToken(), bind(api.AddTimeOption{}), repo.AddTime)
})
m.Combo("/deadline").Post(reqToken(), bind(api.EditDeadlineOption{}), repo.UpdateIssueDeadline)
m.Group("/stopwatch", func() {
m.Post("/start", reqToken(), repo.StartIssueStopwatch)
@ -695,6 +697,10 @@ func RegisterRoutes(m *macaron.Macaron) {
m.Put("/:user", reqToken(), repo.AddIssueSubscription)
m.Delete("/:user", reqToken(), repo.DelIssueSubscription)
})
m.Combo("/reactions", reqToken()).
Get(repo.GetIssueReactions).
Post(bind(api.EditReactionOption{}), repo.PostIssueReaction).
Delete(bind(api.EditReactionOption{}), repo.DeleteIssueReaction)
})
}, mustEnableIssuesOrPulls)
m.Group("/labels", func() {

File diff suppressed because it is too large Load Diff

View File

@ -84,3 +84,24 @@ type swaggerIssueDeadline struct {
// in:body
Body api.IssueDeadline `json:"body"`
}
// EditReactionOption
// swagger:response EditReactionOption
type swaggerEditReactionOption struct {
// in:body
Body api.EditReactionOption `json:"body"`
}
// ReactionResponse
// swagger:response ReactionResponse
type swaggerReactionResponse struct {
// in:body
Body api.ReactionResponse `json:"body"`
}
// ReactionResponseList
// swagger:response ReactionResponseList
type swaggerReactionResponseList struct {
// in:body
Body []api.ReactionResponse `json:"body"`
}

View File

@ -1463,14 +1463,12 @@ func ChangeIssueReaction(ctx *context.Context, form auth.ReactionForm) {
switch ctx.Params(":action") {
case "react":
if !util.IsStringInSlice(form.Content, setting.UI.Reactions) {
err := fmt.Errorf("ChangeIssueReaction: '%s' is not an allowed reaction", form.Content)
ctx.ServerError(err.Error(), err)
return
}
reaction, err := models.CreateIssueReaction(ctx.User, issue, form.Content)
if err != nil {
if models.IsErrForbiddenIssueReaction(err) {
ctx.ServerError("ChangeIssueReaction", err)
return
}
log.Info("CreateIssueReaction: %s", err)
break
}
@ -1564,14 +1562,12 @@ func ChangeCommentReaction(ctx *context.Context, form auth.ReactionForm) {
switch ctx.Params(":action") {
case "react":
if !util.IsStringInSlice(form.Content, setting.UI.Reactions) {
err := fmt.Errorf("ChangeIssueReaction: '%s' is not an allowed reaction", form.Content)
ctx.ServerError(err.Error(), err)
return
}
reaction, err := models.CreateCommentReaction(ctx.User, comment.Issue, comment, form.Content)
if err != nil {
if models.IsErrForbiddenIssueReaction(err) {
ctx.ServerError("ChangeIssueReaction", err)
return
}
log.Info("CreateCommentReaction: %s", err)
break
}

File diff suppressed because it is too large Load Diff