Skip to content
Open
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
81 changes: 79 additions & 2 deletions internal/repo/badge/badge_event_rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,17 +182,94 @@ func (br *eventRuleRepo) ReachAnswerAcceptedAmount(ctx context.Context,
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}

for _, b := range badges {
// get badge requirement
// Single award badges behind this handler form a ladder: every one of them stands for a level
// the user has reached, so only the highest level reached is awarded, and only when it is above
// the level the user already holds (an administrator can grant one by hand). Without this a user
// who was given the top badge received the lowest one as soon as their first answer was
// accepted, and crossing several thresholds at once granted the whole ladder at a time.
// Multi award badges, which every post can earn on its own, keep the previous behaviour.
tiers, others := splitSingleAwardBadges(badges)
for _, b := range others {
requirement := b.GetIntParam("amount")
if requirement == 0 || amount < requirement {
continue
}
awards = append(awards, br.createBadgeAward(event.AnswerUserID, event.AnswerID, b))
}
if best := highestTierReached(tiers, amount); best != nil {
held, err := br.highestHeldTier(ctx, event.AnswerUserID, tiers)
if err != nil {
return nil, err
}
if best.GetIntParam("amount") > held {
awards = append(awards, br.createBadgeAward(event.AnswerUserID, event.AnswerID, best))
}
}
return awards, nil
}

// splitSingleAwardBadges separates the tiered (single award) badges from the rest
func splitSingleAwardBadges(badges []*entity.Badge) (tiers, others []*entity.Badge) {
for _, b := range badges {
if b.Single == entity.BadgeSingleAward {
tiers = append(tiers, b)
} else {
others = append(others, b)
}
}
return tiers, others
}

// highestTierReached returns the highest tier whose requirement the user has reached
func highestTierReached(tiers []*entity.Badge, amount int64) (best *entity.Badge) {
for _, b := range tiers {
requirement := b.GetIntParam("amount")
if requirement == 0 || amount < requirement {
continue
}
if best == nil || requirement > best.GetIntParam("amount") {
best = b
}
}
return best
}

// highestHeldTierOf returns the requirement of the highest tier the user already holds
func highestHeldTierOf(tiers []*entity.Badge, awarded map[string]bool) (held int64) {
for _, b := range tiers {
if !awarded[b.ID] {
continue
}
if requirement := b.GetIntParam("amount"); requirement > held {
held = requirement
}
}
return held
}

// highestHeldTier reads which tiers the user already holds
func (br *eventRuleRepo) highestHeldTier(ctx context.Context, userID string, tiers []*entity.Badge) (int64, error) {
if userID == "" || len(tiers) == 0 {
return 0, nil
}
ids := make([]string, 0, len(tiers))
for _, b := range tiers {
ids = append(ids, b.ID)
}
held := make([]*entity.BadgeAward, 0)
err := br.data.DB.Context(ctx).Where("user_id = ?", userID).
And("is_badge_deleted = ?", entity.IsBadgeNotDeleted).
In("badge_id", ids).Find(&held)
if err != nil {
return 0, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
awarded := make(map[string]bool, len(held))
for _, a := range held {
awarded[a.BadgeID] = true
}
return highestHeldTierOf(tiers, awarded), nil
}

// ReachAnswerVote reach answer vote
func (br *eventRuleRepo) ReachAnswerVote(ctx context.Context,
event *schema.EventMsg) (awards []*entity.BadgeAward, err error) {
Expand Down
91 changes: 91 additions & 0 deletions internal/repo/badge/badge_event_rule_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package badge

import (
"testing"

"github.com/apache/answer/internal/entity"
"github.com/stretchr/testify/require"
)

// a ladder of single award badges: thresholds 1, 6, 15, 80 and 200 accepted answers
func tierBadges() []*entity.Badge {
return []*entity.Badge{
{ID: "10000000000000001", Name: "first", Single: entity.BadgeSingleAward, Param: `{"amount":"1"}`},
{ID: "10000000000000002", Name: "second", Single: entity.BadgeSingleAward, Param: `{"amount":"6"}`},
{ID: "10000000000000003", Name: "third", Single: entity.BadgeSingleAward, Param: `{"amount":"15"}`},
{ID: "10000000000000004", Name: "fourth", Single: entity.BadgeSingleAward, Param: `{"amount":"80"}`},
{ID: "10000000000000005", Name: "fifth", Single: entity.BadgeSingleAward, Param: `{"amount":"200"}`},
}
}

func TestHighestTierReached_OnlyTheTopTierIsHandedOut(t *testing.T) {
tiers := tierBadges()

require.Nil(t, highestTierReached(tiers, 0), "no accepted answers, no badge")
require.Equal(t, "first", highestTierReached(tiers, 1).Name)
require.Equal(t, "second", highestTierReached(tiers, 6).Name)
require.Equal(t, "second", highestTierReached(tiers, 14).Name)
require.Equal(t, "third", highestTierReached(tiers, 15).Name, "crossing several thresholds at once gives one badge")
require.Equal(t, "fourth", highestTierReached(tiers, 199).Name)
require.Equal(t, "fifth", highestTierReached(tiers, 200).Name)
}

func TestHighestHeldTierOf_KnowsTheTierTheUserAlreadyHas(t *testing.T) {
tiers := tierBadges()

require.Equal(t, int64(0), highestHeldTierOf(tiers, map[string]bool{}))
require.Equal(t, int64(80), highestHeldTierOf(tiers, map[string]bool{"10000000000000004": true}),
"granted by an administrator")
require.Equal(t, int64(80), highestHeldTierOf(tiers, map[string]bool{
"10000000000000004": true, "10000000000000001": true,
}), "the highest one held counts, not the last one granted")
}

// The bug itself: a user holding the fourth tier was awarded the first one as soon as an answer of
// theirs was accepted.
func TestTierNotAwardedBelowTheTierAlreadyHeld(t *testing.T) {
tiers := tierBadges()
held := highestHeldTierOf(tiers, map[string]bool{"10000000000000004": true})

best := highestTierReached(tiers, 1) // their first answer is accepted
require.Equal(t, "first", best.Name)
require.False(t, best.GetIntParam("amount") > held, "the lower badge must not be awarded")

best = highestTierReached(tiers, 200) // they reach the top threshold
require.Equal(t, "fifth", best.Name)
require.True(t, best.GetIntParam("amount") > held, "a higher tier is still awarded")

// and the top tier is not awarded twice
heldTop := highestHeldTierOf(tiers, map[string]bool{"10000000000000005": true})
require.False(t, highestTierReached(tiers, 200).GetIntParam("amount") > heldTop)
}

func TestSplitSingleAwardBadges_MultiAwardKeepsOldBehaviour(t *testing.T) {
badges := append(tierBadges(), &entity.Badge{
ID: "10000000000000006", Name: "per-post", Single: entity.BadgeMultiAward, Param: `{"amount":"3"}`,
})

tiers, others := splitSingleAwardBadges(badges)
require.Len(t, tiers, 5)
require.Len(t, others, 1)
require.Equal(t, "per-post", others[0].Name)
}