Skip to content
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
14 changes: 3 additions & 11 deletions frontend/src/components/editCard/EditExpiration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,30 +35,22 @@ const ExpirationNotification: FC<Props> = ({ edit }) => {
if (
!config?.vote_cron_interval ||
edit.status !== VoteStatusEnum.PENDING ||
!edit.expires
!edit.expires ||
edit.passing == null
)
return null;

// Pending edits that have reached the voting threshold have shorter voting periods.
// This will happen for destructive edits, or when votes are not unanimous.
const shortVotingPeriod =
config.vote_application_threshold > 0 &&
edit.vote_count >= config.vote_application_threshold;

const expirationTime = parseInstant(edit.expires);
const expirationDistance =
expirationTime && isInstantInFuture(expirationTime)
? formatDistance(expirationTime)
: "in a moment";

const threshold = edit.destructive ? 1 : 0;
const pass = shortVotingPeriod || edit.vote_count >= threshold;

return (
<div>
<Tooltip
delay={0}
text={<TooltipMessage pass={pass} time={expirationTime} />}
text={<TooltipMessage pass={edit.passing} time={expirationTime} />}
>
<span>
Voting closes{" "}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/editCard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface EditCardEdit {
updated?: string | null;
closed?: string | null;
expires?: string | null;
passing?: boolean | null;
update_count: number;
vote_count: number;
user?: EditCardUser | null;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/graphql/fragments/EditFragment.gql
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ fragment EditFragment on Edit {
updated
closed
expires
passing
update_count
updatable
vote_count
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ fragment NotificationEditFragment on Edit {
updated
closed
expires
passing
update_count
vote_count
destructive
Expand Down
94 changes: 48 additions & 46 deletions frontend/src/graphql/types.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions graphql/schema/types/edit.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ type Edit {
updated: Time
closed: Time
expires: Time
"""Whether the current tally passes. Null unless pending."""
passing: Boolean
}

input EditInput {
Expand Down
146 changes: 145 additions & 1 deletion internal/api/edit_close_completed_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package api_test
import (
"context"
"testing"
"time"

"github.com/gofrs/uuid"
"github.com/stashapp/stash-box/internal/auth"
Expand All @@ -17,6 +18,12 @@ import (
// Long enough that no edit reaches the end of its voting period during a test.
const neverElapses = 86400 * 365

// Distinct values so an expiry assertion shows which period was applied.
const (
testMinPeriod = 3600
testVotingPeriod = 7200
)

// A fresh user per vote, since users cannot vote twice or vote on their own edits.
func (s *editTestRunner) voteAs(editID uuid.UUID, vote models.VoteTypeEnum) {
s.t.Helper()
Expand Down Expand Up @@ -101,7 +108,7 @@ func (s *editTestRunner) testContestedEditClosesOnFullPeriod() {
s.verifyEditStatus(models.VoteStatusEnumAccepted.String(), s.findEdit(edit.ID))
}

func (s *editTestRunner) createDestructiveEdit(vote models.VoteTypeEnum) *models.Edit {
func (s *editTestRunner) createTagDestroyEdit() *models.Edit {
s.t.Helper()

createdTag, err := s.createTestTag(nil)
Expand All @@ -114,6 +121,14 @@ func (s *editTestRunner) createDestructiveEdit(vote models.VoteTypeEnum) *models
})
assert.NoError(s.t, err)

return createdEdit
}

func (s *editTestRunner) createDestructiveEdit(vote models.VoteTypeEnum) *models.Edit {
s.t.Helper()

createdEdit := s.createTagDestroyEdit()

for range config.GetVoteApplicationThreshold() {
s.voteAs(createdEdit.ID, vote)
}
Expand All @@ -140,6 +155,105 @@ func (s *editTestRunner) testUnanimousRejectClosesAfterMinPeriod() {
s.verifyEditStatus(models.VoteStatusEnumRejected.String(), s.findEdit(edit.ID))
}

func (s *editTestRunner) createContestedDestructiveEdit() *models.Edit {
s.t.Helper()

edit := s.createDestructiveEdit(models.VoteTypeEnumAccept)
s.voteAs(edit.ID, models.VoteTypeEnumReject)
s.verifyEditPending(s.findEdit(edit.ID))

return edit
}

// Overriding the periods keeps the assertions independent of the configured values.
func (s *editTestRunner) expiryOf(edit *models.Edit) time.Time {
s.t.Helper()

originalMin := config.C.MinDestructiveVotingPeriod
originalVoting := config.C.VotingPeriod
config.C.MinDestructiveVotingPeriod = testMinPeriod
config.C.VotingPeriod = testVotingPeriod
defer func() {
config.C.MinDestructiveVotingPeriod = originalMin
config.C.VotingPeriod = originalVoting
}()

expires, err := s.resolver.Edit().Expires(s.ctx, edit)
assert.NoError(s.t, err)
assert.NotNil(s.t, expires)

return *expires
}

// The minimum period only shortens the deadline for an edit the unanimous branch can close.
func (s *editTestRunner) testContestedEditExpiresOnFullPeriod() {
edit := s.createContestedEdit()

expected := edit.CreatedAt.Add(testVotingPeriod * time.Second)
assert.Equal(s.t, expected, s.expiryOf(edit))
}

func (s *editTestRunner) testContestedDestructiveEditExpiresOnFullPeriod() {
edit := s.createContestedDestructiveEdit()

expected := edit.CreatedAt.Add(testVotingPeriod * time.Second)
assert.Equal(s.t, expected, s.expiryOf(edit))
}

func (s *editTestRunner) testUnanimousDestructiveEditExpiresOnMinPeriod() {
edit := s.createDestructiveEdit(models.VoteTypeEnumAccept)

expected := edit.CreatedAt.Add(testMinPeriod * time.Second)
assert.Equal(s.t, expected, s.expiryOf(edit))
}

func (s *editTestRunner) passingOf(edit *models.Edit) *bool {
s.t.Helper()

passing, err := s.resolver.Edit().Passing(s.ctx, edit)
assert.NoError(s.t, err)

return passing
}

// The projection is only useful if it names the status the sweep goes on to produce.
func (s *editTestRunner) testPassingMatchesClosedStatus() {
edit := s.createContestedEdit()

passing := s.passingOf(edit)
assert.NotNil(s.t, passing)

s.sweep(0, 0)
closed := s.findEdit(edit.ID)

assert.Equal(s.t, *passing, closed.Status == models.VoteStatusEnumAccepted.String())
assert.Nil(s.t, s.passingOf(closed), "a closed edit has no tally left to project")
}

// A tally that carries a non-destructive edit leaves a destructive one short.
func (s *editTestRunner) testDestructiveEditNeedsPositiveNetScore() {
edit := s.createTagDestroyEdit()
s.voteAs(edit.ID, models.VoteTypeEnumAccept)
s.voteAs(edit.ID, models.VoteTypeEnumReject)

edit = s.findEdit(edit.ID)
assert.Equal(s.t, 0, edit.VoteCount, "the test needs a neutral net score")
assert.Equal(s.t, false, *s.passingOf(edit))

s.sweep(0, 0)
s.verifyEditStatus(models.VoteStatusEnumRejected.String(), s.findEdit(edit.ID))
}

func (s *editTestRunner) testClosedEditHasNoExpiry() {
edit := s.createContestedEdit()

s.sweep(0, 0)

expires, err := s.resolver.Edit().Expires(s.ctx, s.findEdit(edit.ID))
assert.NoError(s.t, err)
assert.Nil(s.t, expires)
}

func TestContestedEditNotClosedEarly(t *testing.T) {
pt := createEditTestRunner(t)
pt.testContestedEditNotClosedEarly()
Expand All @@ -159,3 +273,33 @@ func TestUnanimousRejectClosesAfterMinPeriod(t *testing.T) {
pt := createEditTestRunner(t)
pt.testUnanimousRejectClosesAfterMinPeriod()
}

func TestContestedEditExpiresOnFullPeriod(t *testing.T) {
pt := createEditTestRunner(t)
pt.testContestedEditExpiresOnFullPeriod()
}

func TestContestedDestructiveEditExpiresOnFullPeriod(t *testing.T) {
pt := createEditTestRunner(t)
pt.testContestedDestructiveEditExpiresOnFullPeriod()
}

func TestUnanimousDestructiveEditExpiresOnMinPeriod(t *testing.T) {
pt := createEditTestRunner(t)
pt.testUnanimousDestructiveEditExpiresOnMinPeriod()
}

func TestClosedEditHasNoExpiry(t *testing.T) {
pt := createEditTestRunner(t)
pt.testClosedEditHasNoExpiry()
}

func TestPassingMatchesClosedStatus(t *testing.T) {
pt := createEditTestRunner(t)
pt.testPassingMatchesClosedStatus()
}

func TestDestructiveEditNeedsPositiveNetScore(t *testing.T) {
pt := createEditTestRunner(t)
pt.testDestructiveEditNeedsPositiveNetScore()
}
20 changes: 7 additions & 13 deletions internal/api/resolver_model_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,16 @@ func (r *editResolver) Expires(ctx context.Context, obj *models.Edit) (*time.Tim
return nil, nil
}

// Count expiration time from creation, or time when edit was amended
startTime := obj.CreatedAt
if obj.UpdatedAt != nil {
startTime = *obj.UpdatedAt
}
return r.services.Edit().ExpiryTime(ctx, obj)
}

// Pending edits that have reached the voting threshold have shorter voting periods.
// This will happen for destructive edits, or when votes are not unanimous.
short := config.GetVoteApplicationThreshold() > 0 && obj.VoteCount >= config.GetVoteApplicationThreshold()
duration := config.GetVotingPeriod()
if short {
duration = config.GetMinDestructiveVotingPeriod()
func (r *editResolver) Passing(ctx context.Context, obj *models.Edit) (*bool, error) {
if obj.Status != models.VoteStatusEnumPending.String() {
return nil, nil
}

expiration := startTime.Add(time.Second * time.Duration(duration))
return &expiration, nil
passing := r.services.Edit().Passing(obj)
return &passing, nil
}

func (r *editResolver) Target(ctx context.Context, obj *models.Edit) (models.EditTarget, error) {
Expand Down
68 changes: 68 additions & 0 deletions internal/models/generated_exec.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading