* change vote params to request

* limit voting for the same ip

* limit same ip vote duration

* add same ip vote check for directions

* wire RestrictVoteIP and duration

* add votes-ip and votes-ip-time description
This commit is contained in:
Umputun
2019-08-19 14:04:12 -05:00
committed by GitHub
parent 9a5684de71
commit 2e90b6172b
7 changed files with 220 additions and 63 deletions
+2
View File
@@ -161,6 +161,8 @@ _this is the recommended way to run remark42_
| ssl.acme-email | SSL_ACME_EMAIL | | admin email for receiving notifications from LE |
| max-comment | MAX_COMMENT_SIZE | `2048` | comment's size limit |
| max-votes | MAX_VOTES | `-1` | votes limit per comment, `-1` - unlimited |
| votes-ip | VOTES_IP |`false` | restrict votes from the same ip |
| votes-ip-time | VOTES_IP_TIME |`5m` | same ip vote restriction time, `0s` - unlimited |
| low-score | LOW_SCORE | `-5` | low score threshold |
| critical-score | CRITICAL_SCORE | `-10` | critical score threshold |
| positive-score | POSITIVE_SCORE | `false` | restricts comment's score to be only positive |
+4
View File
@@ -56,6 +56,8 @@ type ServerCommand struct {
ImageProxy bool `long:"img-proxy" env:"IMG_PROXY" description:"enable image proxy"`
MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"`
MaxVotes int `long:"max-votes" env:"MAX_VOTES" default:"-1" description:"maximum number of votes per comment"`
RestrictVoteIP bool `long:"votes-ip" env:"VOTES_IP" description:"restrict votes from the same ip"`
DurationVoteIP time.Duration `long:"votes-ip-time" env:"VOTES_IP_TIME" default:"5m" description:"same ip vote duration"`
LowScore int `long:"low-score" env:"LOW_SCORE" default:"-5" description:"low score threshold"`
CriticalScore int `long:"critical-score" env:"CRITICAL_SCORE" default:"-10" description:"critical score threshold"`
PositiveScore bool `long:"positive-score" env:"POSITIVE_SCORE" description:"enable positive score only"`
@@ -277,6 +279,8 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) {
TitleExtractor: service.NewTitleExtractor(http.Client{Timeout: time.Second * 5}),
RestrictedWordsMatcher: service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: s.RestrictedWords}),
}
dataService.RestrictSameIPVotes.Enabled = s.RestrictVoteIP
dataService.RestrictSameIPVotes.Duration = s.DurationVoteIP
loadingCache, err := s.makeCache()
if err != nil {
+9 -2
View File
@@ -40,7 +40,7 @@ type private struct {
type privStore interface {
Create(comment store.Comment) (commentID string, err error)
EditComment(locator store.Locator, commentID string, req service.EditRequest) (comment store.Comment, err error)
Vote(locator store.Locator, commentID string, userID string, val bool) (comment store.Comment, err error)
Vote(req service.VoteReq) (comment store.Comment, err error)
Get(locator store.Locator, commentID string, user store.User) (store.Comment, error)
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
ValidateComment(c *store.Comment) error
@@ -198,7 +198,14 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) {
return
}
comment, err := s.dataService.Vote(locator, id, user.ID, vote)
req := service.VoteReq{
Locator: locator,
CommentID: id,
UserID: user.ID,
UserIP: strings.Split(r.RemoteAddr, ":")[0],
Val: vote,
}
comment, err := s.dataService.Vote(req)
if err != nil {
code := parseError(err, rest.ErrVoteRejected)
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't vote for comment", code)
@@ -420,8 +420,6 @@ func TestRest_Vote(t *testing.T) {
assert.Equal(t, -1, cr.Score)
assert.Equal(t, 0, cr.Vote, "no vote info for different user")
assert.Equal(t, map[string]bool(nil), cr.Votes)
assert.Equal(t, map[string]bool(nil), cr.Votes)
}
func TestRest_UserAllData(t *testing.T) {
+22 -15
View File
@@ -11,21 +11,22 @@ import (
// Comment represents a single comment with optional reference to its parent
type Comment struct {
ID string `json:"id" bson:"_id"`
ParentID string `json:"pid"`
Text string `json:"text"`
Orig string `json:"orig,omitempty"`
User User `json:"user"`
Locator Locator `json:"locator"`
Score int `json:"score"`
Votes map[string]bool `json:"votes,omitempty"`
Vote int `json:"vote"` // vote for the current user, -1/1/0.
Controversy float64 `json:"controversy,omitempty"`
Timestamp time.Time `json:"time" bson:"time"`
Edit *Edit `json:"edit,omitempty" bson:"edit,omitempty"` // pointer to have empty default in json response
Pin bool `json:"pin,omitempty" bson:"pin,omitempty"`
Deleted bool `json:"delete,omitempty" bson:"delete"`
PostTitle string `json:"title,omitempty" bson:"title"`
ID string `json:"id" bson:"_id"`
ParentID string `json:"pid"`
Text string `json:"text"`
Orig string `json:"orig,omitempty"`
User User `json:"user"`
Locator Locator `json:"locator"`
Score int `json:"score"`
Votes map[string]bool `json:"votes,omitempty"`
VotedIPs map[string]VotedIPInfo `json:"voted_ips,omitempty"` // voted ips (hashes) with TS
Vote int `json:"vote"` // vote for the current user, -1/1/0.
Controversy float64 `json:"controversy,omitempty"`
Timestamp time.Time `json:"time" bson:"time"`
Edit *Edit `json:"edit,omitempty" bson:"edit,omitempty"` // pointer to have empty default in json response
Pin bool `json:"pin,omitempty" bson:"pin,omitempty"`
Deleted bool `json:"delete,omitempty" bson:"delete"`
PostTitle string `json:"title,omitempty" bson:"title"`
}
// Locator keeps site and url of the post
@@ -56,6 +57,12 @@ type BlockedUser struct {
Until time.Time `json:"time"`
}
// VotedIPInfo keeps timestamp and voting value (direction). Used as VotedIPs value
type VotedIPInfo struct {
Timestamp time.Time
Value bool
}
// DeleteMode defines how much comment info will be erased
type DeleteMode int
+69 -24
View File
@@ -24,11 +24,15 @@ import (
// DataStore wraps store.Interface with additional methods
type DataStore struct {
Engine engine.Interface
EditDuration time.Duration
AdminStore admin.Store
MaxCommentSize int
MaxVotes int
Engine engine.Interface
EditDuration time.Duration
AdminStore admin.Store
MaxCommentSize int
MaxVotes int
RestrictSameIPVotes struct {
Enabled bool
Duration time.Duration
}
PositiveScore bool
TitleExtractor *TitleExtractor
RestrictedWordsMatcher *RestrictedWordsMatcher
@@ -213,29 +217,47 @@ func (s *DataStore) SetPin(locator store.Locator, commentID string, status bool)
return s.Engine.Update(comment)
}
// Vote for comment by id and locator
func (s *DataStore) Vote(locator store.Locator, commentID string, userID string, val bool) (comment store.Comment, err error) {
// VoteReq is the request ot make a vote
type VoteReq struct {
Locator store.Locator
CommentID string
UserID string
UserIP string
Val bool
}
cLock := s.getScopedLocks(locator.URL) // get lock for URL scope
cLock.Lock() // prevents race on voting
// Vote for comment by id and locator
func (s *DataStore) Vote(req VoteReq) (comment store.Comment, err error) {
cLock := s.getScopedLocks(req.Locator.URL) // get lock for URL scope
cLock.Lock() // prevents race on voting
defer cLock.Unlock()
comment, err = s.Engine.Get(engine.GetRequest{Locator: locator, CommentID: commentID})
comment, err = s.Engine.Get(engine.GetRequest{Locator: req.Locator, CommentID: req.CommentID})
if err != nil {
return comment, err
}
if comment.User.ID == userID && userID != "dev" {
return comment, errors.Errorf("user %s can not vote for his own comment %s", userID, commentID)
if comment.User.ID == req.UserID && req.UserID != "dev" {
return comment, errors.Errorf("user %s can not vote for his own comment %s", req.UserID, req.CommentID)
}
if comment.Votes == nil {
comment.Votes = make(map[string]bool)
}
v, voted := comment.Votes[userID]
if voted && v == val {
return comment, errors.Errorf("user %s already voted for %s", userID, commentID)
v, voted := comment.Votes[req.UserID]
if voted && v == req.Val {
return comment, errors.Errorf("user %s already voted for %s", req.UserID, req.CommentID)
}
secret, err := s.AdminStore.Key()
if err != nil {
return store.Comment{}, errors.Wrapf(err, "can't get secret for site %s", comment.Locator.SiteID)
}
userIPHash := store.HashValue(req.UserIP, secret)
if s.isSameIPVote(req, userIPHash, comment) {
return comment, errors.Errorf("the same ip %s already voted for %s", userIPHash, req.CommentID)
}
maxVotes := s.MaxVotes // 0 value allowed and treated as "no comments allowed"
@@ -244,32 +266,39 @@ func (s *DataStore) Vote(locator store.Locator, commentID string, userID string,
}
if maxVotes >= 0 && len(comment.Votes) >= maxVotes {
return comment, errors.Errorf("maximum number of votes exceeded for comment %s", commentID)
return comment, errors.Errorf("maximum number of votes exceeded for comment %s", req.CommentID)
}
if s.PositiveScore && comment.Score <= 0 && !val {
return comment, errors.Errorf("minimal score reached for comment %s", commentID)
if s.PositiveScore && comment.Score <= 0 && !req.Val {
return comment, errors.Errorf("minimal score reached for comment %s", req.CommentID)
}
// reset vote if user changed to opposite
if voted && v != val {
delete(comment.Votes, userID)
if voted && v != req.Val {
delete(comment.Votes, req.UserID)
}
// add to voted map if first vote
if !voted {
comment.Votes[userID] = val
comment.Votes[req.UserID] = req.Val
}
// add ip hash to voted ip map
if comment.VotedIPs == nil {
comment.VotedIPs = map[string]store.VotedIPInfo{}
}
comment.VotedIPs[userIPHash] = store.VotedIPInfo{Timestamp: time.Now(), Value: req.Val}
// update score
if val {
if req.Val {
comment.Score++
} else {
comment.Score--
}
comment.Vote = 0
if vv, ok := comment.Votes[userID]; ok {
if vv, ok := comment.Votes[req.UserID]; ok {
if vv {
comment.Vote = 1
} else {
@@ -278,10 +307,26 @@ func (s *DataStore) Vote(locator store.Locator, commentID string, userID string,
}
comment.Controversy = s.controversy(s.upsAndDowns(comment))
comment.Locator = locator
comment.Locator = req.Locator
return comment, s.Engine.Update(comment)
}
func (s *DataStore) isSameIPVote(req VoteReq, userIPHash string, comment store.Comment) bool {
if req.UserIP == "" || !s.RestrictSameIPVotes.Enabled {
return false
}
if v, ipFound := comment.VotedIPs[userIPHash]; ipFound {
if v.Value != req.Val {
return false // opposite direction vote allowed
}
if s.RestrictSameIPVotes.Duration == 0 || v.Timestamp.Add(s.RestrictSameIPVotes.Duration).After(time.Now()) {
return true
}
}
return false
}
// controversy calculates controversial index of votes
// source - https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx#L60
func (s *DataStore) controversy(ups, downs int) float64 {
+114 -20
View File
@@ -183,7 +183,14 @@ func TestService_Vote(t *testing.T) {
assert.Equal(t, map[string]bool(nil), res[0].Votes, "no votes initially")
// vote +1 as user1
c, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", true)
req := VoteReq{
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
CommentID: res[0].ID,
UserID: "user1",
UserIP: "123",
Val: true,
}
c, err := b.Vote(req)
assert.NoError(t, err)
assert.Equal(t, 1, c.Score)
assert.Equal(t, 1, c.Vote)
@@ -201,10 +208,24 @@ func TestService_Vote(t *testing.T) {
assert.Equal(t, 0, c.Vote, "can't see other user vote result")
assert.Nil(t, c.Votes)
c, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user", true)
req = VoteReq{
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
CommentID: res[0].ID,
UserID: "user",
UserIP: "123",
Val: true,
}
c, err = b.Vote(req)
assert.NotNil(t, err, "self-voting not allowed")
_, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", true)
req = VoteReq{
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
CommentID: res[0].ID,
UserID: "user1",
UserIP: "123",
Val: true,
}
_, err = b.Vote(req)
assert.NotNil(t, err, "double-voting rejected")
assert.True(t, strings.HasPrefix(err.Error(), "user user1 already voted"))
@@ -226,7 +247,14 @@ func TestService_Vote(t *testing.T) {
assert.Equal(t, 0, res[0].Vote)
assert.Equal(t, 0.0, res[0].Controversy)
_, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", false)
req = VoteReq{
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
CommentID: res[0].ID,
UserID: "user1",
UserIP: "123",
Val: false,
}
_, err = b.Vote(req)
assert.NoError(t, err, "vote reset")
res, err = b.Last("radio-t", 0, time.Time{}, store.User{})
assert.NoError(t, err)
@@ -240,17 +268,21 @@ func TestService_VoteLimit(t *testing.T) {
defer teardown(t)
b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: 2}
_, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user2", true)
_, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1",
UserID: "user2", Val: true})
assert.NoError(t, err)
_, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user3", true)
_, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1",
UserID: "user3", Val: true})
assert.NoError(t, err)
_, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user4", true)
_, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1",
UserID: "user4", Val: true})
assert.NotNil(t, err, "vote limit reached")
assert.True(t, strings.HasPrefix(err.Error(), "maximum number of votes exceeded for comment id-1"))
_, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-2", "user4", true)
_, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
UserID: "user4", Val: true})
assert.NoError(t, err)
}
@@ -258,7 +290,8 @@ func TestService_VotesDisabled(t *testing.T) {
defer teardown(t)
b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: 0}
_, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user2", true)
_, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1",
UserID: "user2", Val: true})
assert.EqualError(t, err, "maximum number of votes exceeded for comment id-1")
}
@@ -282,7 +315,8 @@ func TestService_VoteAggressive(t *testing.T) {
assert.Equal(t, map[string]bool(nil), res[0].Votes, "no votes initially")
// add a vote as user2
_, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user2", true)
_, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: res[0].ID,
UserID: "user2", Val: true})
require.NoError(t, err)
// crazy vote +1 as user1
@@ -291,7 +325,8 @@ func TestService_VoteAggressive(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", true)
_, _ = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: res[0].ID,
UserID: "user1", Val: true})
}()
}
wg.Wait()
@@ -311,7 +346,8 @@ func TestService_VoteAggressive(t *testing.T) {
go func() {
defer wg.Done()
val := rand.Intn(2) > 0
_, _ = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", val)
_, _ = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: res[0].ID,
UserID: "user1", Val: val})
}()
}
wg.Wait()
@@ -344,8 +380,8 @@ func TestService_VoteConcurrent(t *testing.T) {
ii := i
go func() {
defer wg.Done()
_, _ = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID,
fmt.Sprintf("user1-%d", ii), true)
_, _ = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: res[0].ID,
UserID: fmt.Sprintf("user1-%d", ii), Val: true})
}()
}
wg.Wait()
@@ -361,15 +397,18 @@ func TestService_VotePositive(t *testing.T) {
b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"),
MaxVotes: -1, PositiveScore: true}
_, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user2", false)
_, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1",
UserID: "user2", Val: false})
assert.EqualError(t, err, "minimal score reached for comment id-1")
_, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user3", true)
_, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1",
UserID: "user3", Val: true})
assert.NoError(t, err, "minimal score doesn't affect positive vote")
b = DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"),
MaxVotes: -1, PositiveScore: false}
c, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user2", false)
c, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1",
UserID: "user2", Val: false})
assert.NoError(t, err, "minimal score ignored")
assert.Equal(t, -1, c.Score)
assert.Equal(t, 0.0, c.Controversy)
@@ -379,17 +418,20 @@ func TestService_VoteControversy(t *testing.T) {
defer teardown(t)
b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: -1}
c, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-2", "user2", false)
c, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
UserID: "user2", Val: false})
assert.NoError(t, err)
assert.Equal(t, -1, c.Score, "should have -1 score")
assert.InDelta(t, 0.00, c.Controversy, 0.01)
c, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-2", "user3", true)
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
UserID: "user3", Val: true})
assert.NoError(t, err)
assert.Equal(t, 0, c.Score, "should have 0 score")
assert.InDelta(t, 2.00, c.Controversy, 0.01)
c, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-2", "user4", true)
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
UserID: "user4", Val: true})
assert.NoError(t, err)
assert.Equal(t, 1, c.Score, "should have 1 score")
assert.InDelta(t, 1.73, c.Controversy, 0.01)
@@ -401,6 +443,58 @@ func TestService_VoteControversy(t *testing.T) {
assert.InDelta(t, 1.73, res[0].Controversy, 0.01)
}
func TestService_VoteSameIP(t *testing.T) {
defer teardown(t)
b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"),
MaxVotes: -1}
b.RestrictSameIPVotes.Enabled = true
c, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
UserID: "user2", UserIP: "123", Val: true})
assert.NoError(t, err)
assert.Equal(t, 1, c.Score, "should have 1 score")
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
UserID: "user3", UserIP: "123", Val: true})
assert.EqualError(t, err, "the same ip cce61be6e0a692420ae0de31dceca179123c3b8a already voted for id-2")
assert.Equal(t, 1, c.Score, "still have 1 score")
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
UserID: "user3", UserIP: "123", Val: false})
assert.NoError(t, err)
assert.Equal(t, 0, c.Score, "reset to 0 score, opposite vote allowed")
}
func TestService_VoteSameIPWithDuration(t *testing.T) {
defer teardown(t)
b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"),
MaxVotes: -1}
b.RestrictSameIPVotes.Enabled = true
b.RestrictSameIPVotes.Duration = 50 * time.Millisecond
c, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
UserID: "user2", UserIP: "123", Val: true})
assert.NoError(t, err)
assert.Equal(t, 1, c.Score, "should have 1 score")
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
UserID: "user3", UserIP: "123", Val: true})
assert.EqualError(t, err, "the same ip cce61be6e0a692420ae0de31dceca179123c3b8a already voted for id-2")
assert.Equal(t, 1, c.Score, "still have 1 score")
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
UserID: "user4", UserIP: "12345", Val: true})
assert.NoError(t, err)
assert.Equal(t, 2, c.Score, "have 2 score")
time.Sleep(51 * time.Millisecond)
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
UserID: "user3", UserIP: "123", Val: true})
assert.NoError(t, err)
assert.Equal(t, 3, c.Score, "have 3 score")
}
func TestService_Controversy(t *testing.T) {
tbl := []struct {
ups, downs int