Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[API] Add Reactions #9220

Merged
merged 19 commits into from
Dec 7, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions integrations/api_issue_reaction_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 15 additions & 0 deletions models/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

// __________ .__ .__ __________ __
// \______ \__ __| | | |\______ \ ____ ________ __ ____ _______/ |_
// | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\
Expand Down
40 changes: 39 additions & 1 deletion models/fixtures/reaction.yml
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions models/issue_reaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
6543 marked this conversation as resolved.
Show resolved Hide resolved
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{
6543 marked this conversation as resolved.
Show resolved Hide resolved
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())
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
6543 marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, err
}
r.User = user
return user, nil
}

// ReactionList represents list of reactions
type ReactionList []*Reaction

Expand Down
24 changes: 12 additions & 12 deletions models/issue_reaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

err := issue1.loadReactions(x)
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 := 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())
Expand Down
6 changes: 6 additions & 0 deletions modules/setting/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ var (
DefaultTheme string
Themes []string
Reactions []string
ReactionsMap map[string]bool
SearchRepoDescription bool
UseServiceWorker bool

Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions modules/structs/issue_reaction.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Loading