diff --git a/examples/gno.land/p/nt/commondao/commondao.gno b/examples/gno.land/p/nt/commondao/commondao.gno new file mode 100644 index 00000000000..ac57846c0d3 --- /dev/null +++ b/examples/gno.land/p/nt/commondao/commondao.gno @@ -0,0 +1,225 @@ +package commondao + +import ( + "errors" + "std" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/avl/rotree" + "gno.land/p/demo/seqid" +) + +var ( + ErrInvalidVoteChoice = errors.New("invalid vote choice") + ErrMemberExists = errors.New("member already exist") + ErrNotMember = errors.New("account is not a member of the DAO") + ErrOverflow = errors.New("next ID overflows uint64") + ErrProposalNotFound = errors.New("proposal not found") + ErrStatusIsNotActive = errors.New("proposal status is not active") + ErrVotingDeadlineNotMet = errors.New("voting deadline not met") +) + +type ( + // CommonDAO defines a DAO. + CommonDAO struct { + parent *CommonDAO + members *avl.Tree // string(std.Address) -> struct{} + genID seqid.ID + active *avl.Tree // string(proposal ID) -> *Proposal + finished *avl.Tree // string(proposal ID) -> *Proposal + } + + // Stats contains proposal voting stats. + Stats struct { + YayVotes int + NayVotes int + Abstained int + } +) + +// New creates a new common DAO. +func New(options ...Option) *CommonDAO { + dao := &CommonDAO{ + members: avl.NewTree(), + active: avl.NewTree(), + finished: avl.NewTree(), + } + for _, apply := range options { + apply(dao) + } + return dao +} + +// Parent returns the parent DAO. +// Null can be returned when DAO has no parent assigned. +func (dao CommonDAO) Parent() *CommonDAO { + return dao.parent +} + +// Members returns the list of DAO members. +func (dao CommonDAO) Members() []std.Address { + var members []std.Address + dao.members.Iterate("", "", func(key string, _ interface{}) bool { + members = append(members, std.Address(key)) + return false + }) + return members +} + +// AddMember adds a new member to the DAO. +func (dao *CommonDAO) AddMember(user std.Address) error { + if dao.IsMember(user) { + return ErrMemberExists + } + dao.members.Set(user.String(), struct{}{}) + return nil +} + +// RemoveMember removes a member from the DAO. +func (dao *CommonDAO) RemoveMember(user std.Address) (removed bool) { + _, removed = dao.members.Remove(user.String()) + return removed +} + +// IsMember checks if a user is a member of the DAO. +func (dao CommonDAO) IsMember(user std.Address) bool { + return dao.members.Has(user.String()) +} + +// ActiveProposals returns all active DAO proposals. +func (dao CommonDAO) ActiveProposals() rotree.IReadOnlyTree { + return dao.active +} + +// FinishedProposalsi returns all finished DAO proposals. +func (dao CommonDAO) FinishedProposals() rotree.IReadOnlyTree { + return dao.finished +} + +// Propose creates a new DAO proposal. +func (dao *CommonDAO) Propose(creator std.Address, d ProposalDefinition) (*Proposal, error) { + id, ok := dao.genID.TryNext() + if !ok { + return nil, ErrOverflow + } + + p, err := NewProposal(uint64(id), creator, d) + if err != nil { + return nil, err + } + + key := makeProposalKey(p.ID()) + dao.active.Set(key, p) + return p, nil +} + +// GetActiveProposal returns an active proposal. +func (dao CommonDAO) GetActiveProposal(proposalID uint64) (_ *Proposal, found bool) { + key := makeProposalKey(proposalID) + if v, ok := dao.active.Get(key); ok { + return v.(*Proposal), true + } + return nil, false +} + +// GetFinishedProposal returns a finished proposal. +func (dao CommonDAO) GetFinishedProposal(proposalID uint64) (_ *Proposal, found bool) { + key := makeProposalKey(proposalID) + if v, ok := dao.finished.Get(key); ok { + return v.(*Proposal), true + } + return nil, false +} + +// Vote submits a new vote for a proposal. +func (dao *CommonDAO) Vote(member std.Address, proposalID uint64, c VoteChoice) error { + if c != ChoiceYes && c != ChoiceNo && c != ChoiceAbstain { + return ErrInvalidVoteChoice + } + + if !dao.IsMember(member) { + return ErrNotMember + } + + p, found := dao.GetActiveProposal(proposalID) + if !found { + return ErrProposalNotFound + } + return p.record.AddVote(member, c) +} + +func (dao *CommonDAO) Tally(p *Proposal) Stats { + // Initialize stats considering only yes/no votes + record := p.VotingRecord() + stats := Stats{ + YayVotes: record.VoteCount(ChoiceYes), + NayVotes: record.VoteCount(ChoiceNo), + } + votesCount := stats.YayVotes + stats.NayVotes + membersCount := len(dao.Members()) + stats.Abstained = membersCount - votesCount + + percentage := float64(votesCount) / float64(membersCount) + if percentage < p.Quorum() { + p.status = StatusFailed + p.statusReason = "low participation" + return stats + } + + if !p.Definition().Tally(record, membersCount) { + p.status = StatusFailed + p.statusReason = "no consensus" + } + + return stats +} + +// Execute executes a proposal. +func (dao *CommonDAO) Execute(proposalID uint64) error { + p, found := dao.GetActiveProposal(proposalID) + if !found { + return ErrProposalNotFound + } + + if p.Status() != StatusActive { + return ErrStatusIsNotActive + } + + if time.Now().Before(p.VotingDeadline()) { + return ErrVotingDeadlineNotMet + } + + // Validate proposal before executing it + def := p.Definition() + err := def.Validate() + if err != nil { + p.status = StatusFailed + p.statusReason = err.Error() + } else { + // Tally votes and update proposal status + dao.Tally(p) + + // Execute proposal only when the majority vote wins + if p.Status() != StatusFailed { + err = def.Execute() + if err != nil { + p.status = StatusFailed + p.statusReason = err.Error() + } else { + p.status = StatusExecuted + } + } + } + + // Whichever the outcome of the validation, tallying + // and execution consider the proposal finished. + key := makeProposalKey(p.id) + dao.active.Remove(key) + dao.finished.Set(key, p) + return err +} + +func makeProposalKey(id uint64) string { + return seqid.ID(id).String() +} diff --git a/examples/gno.land/p/nt/commondao/commondao_test.gno b/examples/gno.land/p/nt/commondao/commondao_test.gno new file mode 100644 index 00000000000..b118841eb96 --- /dev/null +++ b/examples/gno.land/p/nt/commondao/commondao_test.gno @@ -0,0 +1,443 @@ +package commondao + +import ( + "errors" + "std" + "testing" + "time" + + "gno.land/p/demo/seqid" + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestNew(t *testing.T) { + cases := []struct { + name string + parent *CommonDAO + members []std.Address + }{ + { + name: "with parent", + parent: New(), + members: []std.Address{"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"}, + }, + { + name: "without parent", + members: []std.Address{"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"}, + }, + { + name: "multiple members", + members: []std.Address{ + "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", + "g1w4ek2u3jta047h6lta047h6lta047h6l9huexc", + }, + }, + { + name: "no members", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + options := []Option{WithParent(tc.parent)} + for _, m := range tc.members { + options = append(options, WithMember(m)) + } + + dao := New(options...) + + if tc.parent == nil { + uassert.Equal(t, nil, dao.Parent()) + } else { + uassert.NotEqual(t, nil, dao.Parent()) + } + + urequire.Equal(t, len(tc.members), len(dao.Members()), "dao members") + for i, m := range dao.Members() { + uassert.Equal(t, tc.members[i], m) + } + }) + } +} + +func TestCommonDAOAddMember(t *testing.T) { + member := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + dao := New(WithMember("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn")) + + err := dao.AddMember(member) + urequire.NoError(t, err) + uassert.Equal(t, 2, len(dao.Members())) + uassert.True(t, dao.IsMember(member)) + + err = dao.AddMember(member) + uassert.ErrorIs(t, err, ErrMemberExists) +} + +func TestCommonDAORemoveMember(t *testing.T) { + member := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + dao := New(WithMember(member)) + + removed := dao.RemoveMember(member) + urequire.True(t, removed) + + removed = dao.RemoveMember(member) + urequire.False(t, removed) +} + +func TestCommonDAOIsMember(t *testing.T) { + cases := []struct { + name string + member std.Address + dao *CommonDAO + want bool + }{ + { + name: "member", + member: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", + dao: New(WithMember("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn")), + want: true, + }, + { + name: "not a dao member", + member: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", + dao: New(WithMember("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc")), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.dao.IsMember(tc.member) + uassert.Equal(t, got, tc.want) + }) + } +} + +func TestCommonDAOPropose(t *testing.T) { + cases := []struct { + name string + setup func() *CommonDAO + creator std.Address + def ProposalDefinition + err error + }{ + { + name: "ok", + setup: func() *CommonDAO { return New() }, + creator: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", + def: testPropDef{}, + }, + { + name: "nil definition", + setup: func() *CommonDAO { return New() }, + err: ErrProposalDefinitionRequired, + }, + { + name: "invalid creator address", + setup: func() *CommonDAO { return New() }, + def: testPropDef{}, + err: ErrInvalidCreatorAddress, + }, + { + name: "proposal ID overflow", + setup: func() *CommonDAO { + dao := New() + dao.genID = seqid.ID(1<<64 - 1) + return dao + }, + creator: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", + def: testPropDef{}, + err: ErrOverflow, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dao := tc.setup() + + p, err := dao.Propose(tc.creator, tc.def) + + if tc.err != nil { + urequire.ErrorIs(t, err, tc.err) + return + } + + urequire.NoError(t, err) + + _, found := dao.GetActiveProposal(p.ID()) + urequire.True(t, found, "proposal not found") + uassert.Equal(t, p.Creator(), tc.creator) + }) + } +} + +func TestCommonDAOVote(t *testing.T) { + cases := []struct { + name string + setup func() *CommonDAO + member std.Address + choice VoteChoice + proposalID uint64 + err error + }{ + { + name: "ok", + setup: func() *CommonDAO { + member := std.Address("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn") + dao := New(WithMember(member)) + dao.Propose(member, testPropDef{}) + return dao + }, + member: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", + choice: ChoiceYes, + proposalID: 1, + }, + { + name: "invalid vote choice", + setup: func() *CommonDAO { return New() }, + choice: VoteChoice("invalid"), + err: ErrInvalidVoteChoice, + }, + { + name: "not a member", + setup: func() *CommonDAO { return New() }, + member: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", + err: ErrNotMember, + }, + { + name: "proposal not found", + setup: func() *CommonDAO { + return New(WithMember("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn")) + }, + member: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", + proposalID: 42, + err: ErrProposalNotFound, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dao := tc.setup() + + err := dao.Vote(tc.member, tc.proposalID, tc.choice) + + if tc.err != nil { + urequire.ErrorIs(t, err, tc.err) + return + } + + urequire.NoError(t, err) + + p, found := dao.GetActiveProposal(tc.proposalID) + urequire.True(t, found, "proposal not found") + + record := p.VotingRecord() + uassert.True(t, record.HasVoted(tc.member)) + uassert.Equal(t, record.VoteCount(tc.choice), 1) + }) + } +} + +func TestCommonDAOTally(t *testing.T) { + cases := []struct { + name string + dao *CommonDAO + votes []Vote + status ProposalStatus + statusReason string + stats Stats + }{ + { + name: "pass", + dao: New( + WithMember("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn"), + WithMember("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc"), + WithMember("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + ), + votes: []Vote{ + {Address: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", Choice: ChoiceYes}, + {Address: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", Choice: ChoiceYes}, + }, + status: StatusActive, + stats: Stats{YayVotes: 2, Abstained: 1}, + }, + { + name: "no votes", + dao: New(), + status: StatusFailed, + statusReason: "low participation", + }, + { + name: "no quorum", + dao: New( + WithMember("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn"), + WithMember("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc"), + WithMember("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + ), + votes: []Vote{ + {Address: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", Choice: ChoiceYes}, + }, + status: StatusFailed, + statusReason: "low participation", + stats: Stats{YayVotes: 1, Abstained: 2}, + }, + { + name: "no consensus", + dao: New( + WithMember("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn"), + WithMember("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc"), + WithMember("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + ), + votes: []Vote{ + {Address: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", Choice: ChoiceYes}, + {Address: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", Choice: ChoiceNo}, + }, + status: StatusFailed, + statusReason: "no consensus", + stats: Stats{YayVotes: 1, NayVotes: 1, Abstained: 1}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, _ := NewProposal(1, "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", majorityPropDef{}) + for _, v := range tc.votes { + p.record.AddVote(v.Address, v.Choice) + } + + stats := tc.dao.Tally(p) + + uassert.Equal(t, string(p.Status()), string(tc.status)) + uassert.Equal(t, p.StatusReason(), tc.statusReason) + uassert.Equal(t, stats.YayVotes, tc.stats.YayVotes) + uassert.Equal(t, stats.NayVotes, tc.stats.NayVotes) + uassert.Equal(t, stats.Abstained, tc.stats.Abstained) + }) + } +} + +func TestCommonDAOExecute(t *testing.T) { + errValidation := errors.New("validation error") + errExecution := errors.New("execution error") + cases := []struct { + name string + setup func() *CommonDAO + proposalID uint64 + status ProposalStatus + statusReason string + err error + }{ + { + name: "ok", + setup: func() *CommonDAO { + members := []std.Address{ + "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", + "g1w4ek2u3jta047h6lta047h6lta047h6l9huexc", + "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + } + dao := New(WithMember(members[0]), WithMember(members[1]), WithMember(members[2])) + p, _ := dao.Propose(members[0], testPropDef{tallyResult: true}) + p.record.AddVote(members[0], ChoiceYes) + p.record.AddVote(members[1], ChoiceYes) + return dao + }, + status: StatusExecuted, + proposalID: 1, + }, + { + name: "proposal not found", + setup: func() *CommonDAO { return New() }, + proposalID: 1, + err: ErrProposalNotFound, + }, + { + name: "proposal not active", + setup: func() *CommonDAO { + member := std.Address("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn") + dao := New(WithMember(member)) + p, _ := dao.Propose(member, testPropDef{}) + p.status = StatusExecuted + return dao + }, + proposalID: 1, + err: ErrStatusIsNotActive, + }, + { + name: "voting deadline not met", + setup: func() *CommonDAO { + member := std.Address("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn") + dao := New(WithMember(member)) + dao.Propose(member, testPropDef{votingPeriod: time.Minute * 5}) + return dao + }, + proposalID: 1, + err: ErrVotingDeadlineNotMet, + }, + { + name: "validation error", + setup: func() *CommonDAO { + member := std.Address("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn") + dao := New(WithMember(member)) + dao.Propose(member, testPropDef{validationErr: errValidation}) + return dao + }, + proposalID: 1, + status: StatusFailed, + statusReason: errValidation.Error(), + err: errValidation, + }, + { + name: "execution error", + setup: func() *CommonDAO { + members := []std.Address{ + "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", + "g1w4ek2u3jta047h6lta047h6lta047h6l9huexc", + "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + } + dao := New(WithMember(members[0]), WithMember(members[1]), WithMember(members[2])) + p, _ := dao.Propose(members[0], testPropDef{ + tallyResult: true, + executionErr: errExecution, + }) + p.record.AddVote(members[0], ChoiceYes) + p.record.AddVote(members[1], ChoiceYes) + return dao + }, + proposalID: 1, + status: StatusFailed, + statusReason: errExecution.Error(), + err: errExecution, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dao := tc.setup() + + err := dao.Execute(tc.proposalID) + + if tc.err != nil { + urequire.ErrorIs(t, err, tc.err) + return + } + + urequire.NoError(t, err) + + _, found := dao.GetActiveProposal(tc.proposalID) + urequire.False(t, found, "proposal should not be active") + + p, found := dao.GetFinishedProposal(tc.proposalID) + urequire.True(t, found, "proposal not found") + uassert.Equal(t, string(p.Status()), string(tc.status)) + uassert.Equal(t, string(p.StatusReason()), string(tc.statusReason)) + }) + } +} + +type majorityPropDef struct{ testPropDef } + +func (majorityPropDef) Tally(r ReadOnlyVotingRecord, membersCount int) bool { + _, success := SelectChoiceByAbsoluteMajority(r, membersCount) + return success +} diff --git a/examples/gno.land/p/nt/commondao/gno.mod b/examples/gno.land/p/nt/commondao/gno.mod new file mode 100644 index 00000000000..2d995d21ccb --- /dev/null +++ b/examples/gno.land/p/nt/commondao/gno.mod @@ -0,0 +1 @@ +module gno.land/p/nt/commondao diff --git a/examples/gno.land/p/nt/commondao/options.gno b/examples/gno.land/p/nt/commondao/options.gno new file mode 100644 index 00000000000..87dff3c0f79 --- /dev/null +++ b/examples/gno.land/p/nt/commondao/options.gno @@ -0,0 +1,20 @@ +package commondao + +import "std" + +// Option configures the CommonDAO. +type Option func(*CommonDAO) + +// WithParent assigns a parent DAO. +func WithParent(p *CommonDAO) Option { + return func(dao *CommonDAO) { + dao.parent = p + } +} + +// WithMember assigns a member to the DAO. +func WithMember(addr std.Address) Option { + return func(dao *CommonDAO) { + dao.members.Set(addr.String(), struct{}{}) + } +} diff --git a/examples/gno.land/p/nt/commondao/proposal.gno b/examples/gno.land/p/nt/commondao/proposal.gno new file mode 100644 index 00000000000..ec431f7d77d --- /dev/null +++ b/examples/gno.land/p/nt/commondao/proposal.gno @@ -0,0 +1,155 @@ +package commondao + +import ( + "errors" + "std" + "time" +) + +// DefaultQuorum defines the default quorum required to tally proposal votes. +const DefaultQuorum = 0.34 // 34% + +const ( + StatusActive ProposalStatus = "active" + StatusFailed = "failed" + StatusExecuted = "executed" +) + +const ( + ChoiceAbstain VoteChoice = "" + ChoiceYes = "yes" + ChoiceNo = "no" +) + +var ( + ErrInvalidCreatorAddress = errors.New("invalid proposal creator address") + ErrProposalDefinitionRequired = errors.New("proposal definition is required") +) + +type ( + // ProposalStatus defines a type for different proposal states. + ProposalStatus string + + // VoteChoice defines a type for proposal vote choices. + VoteChoice string + + // Proposal defines a DAO proposal. + Proposal struct { + id uint64 + status ProposalStatus + definition ProposalDefinition + creator std.Address + record *VotingRecord + statusReason string + votingDeadline time.Time + createdAt time.Time + } + + // ProposalDefinition defines an interface for custom proposal definitions. + // These definitions define proposal content and behavior, they esentially + // allow the definition for different proposal types. + ProposalDefinition interface { + // Title returns the proposal title. + Title() string + + // Body returns the proposal body. + // It usually contains the proposal description and other elements like proposal parameters. + Body() string + + // VotingPeriod returns the period where votes are allowed after proposal creation. + // No more votes should be allowed once this period is met. It is used to calculate + // the voting deadline from the proposal's creationd date. + VotingPeriod() time.Duration + + // Quorum returns the percentage of members that must vote to be able to pass a proposal. + // This is an optional value. DAOs use a default value when quorum is zero or invalid. + // Its value must be between 0 and 1, being 1 = 100% of member votes. + Quorum() float64 + + // Validate validates that the proposal is valid. + // Validations are optional and allow the validation of the current state before proposal execution. + Validate() error + + // Tally counts the number of votes and verifies that there is consensus. + // Tally fails when none of the vote choices wins over the others. + Tally(r ReadOnlyVotingRecord, memberCount int) (success bool) + + // Execute executes the proposal. + // Once proposal are executed they are archived and considered finished. + // Execution allows changing the state after a proposal passes. + Execute() error + } +) + +// NewProposal creates a new DAO proposal. +func NewProposal(id uint64, creator std.Address, d ProposalDefinition) (*Proposal, error) { + if d == nil { + return nil, ErrProposalDefinitionRequired + } + + if !creator.IsValid() { + return nil, ErrInvalidCreatorAddress + } + + now := time.Now() + return &Proposal{ + id: id, + status: StatusActive, + definition: d, + creator: creator, + record: &VotingRecord{}, + votingDeadline: now.Add(d.VotingPeriod()), + createdAt: now, + }, nil +} + +// ID returns the unique proposal identifies. +func (p Proposal) ID() uint64 { + return p.id +} + +// Definition returns the proposal definition. +// Proposal definitions define proposal content and behavior. +func (p Proposal) Definition() ProposalDefinition { + return p.definition +} + +// Quorum returns the percentage of members that must vote to be able to pass a proposal. +func (p Proposal) Quorum() float64 { + quorum := p.definition.Quorum() + if quorum <= 0 || quorum > 1 { + quorum = DefaultQuorum + } + return quorum +} + +// Status returns the current proposal status. +func (p Proposal) Status() ProposalStatus { + return p.status +} + +// Creator returns the address of the account that created the proposal. +func (p Proposal) Creator() std.Address { + return p.creator +} + +// CreatedAt returns the time that proposal was created. +func (p Proposal) CreatedAt() time.Time { + return p.createdAt +} + +// VotingRecord returns a record that contains all the votes submitted for the proposal. +func (p Proposal) VotingRecord() *VotingRecord { + return p.record +} + +// StatusReason returns an optional reason that lead to the current proposal status. +// Reason is mostyl useful when a proposal fails. +func (p Proposal) StatusReason() string { + return p.statusReason +} + +// VotingDeadline returns the deadline after which no more votes should be allowed. +func (p Proposal) VotingDeadline() time.Time { + return p.votingDeadline +} diff --git a/examples/gno.land/p/nt/commondao/proposal_test.gno b/examples/gno.land/p/nt/commondao/proposal_test.gno new file mode 100644 index 00000000000..656afa7b120 --- /dev/null +++ b/examples/gno.land/p/nt/commondao/proposal_test.gno @@ -0,0 +1,42 @@ +package commondao + +import ( + "std" + "testing" + "time" + + "gno.land/p/demo/uassert" +) + +func TestProposalDefaults(t *testing.T) { + id := uint64(1) + creator := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + votingPeriod := time.Minute * 10 + + p, err := NewProposal(id, creator, testPropDef{votingPeriod: votingPeriod}) + + uassert.NoError(t, err) + uassert.Equal(t, p.ID(), id) + uassert.NotEqual(t, p.Definition(), nil) + uassert.Equal(t, p.Quorum(), DefaultQuorum) + uassert.True(t, p.Status() == StatusActive) + uassert.Equal(t, p.Creator(), creator) + uassert.False(t, p.CreatedAt().IsZero()) + uassert.NotEqual(t, p.VotingRecord(), nil) + uassert.Empty(t, p.StatusReason()) + uassert.True(t, p.VotingDeadline() == p.CreatedAt().Add(votingPeriod)) +} + +type testPropDef struct { + votingPeriod time.Duration + tallyResult bool + validationErr, executionErr error +} + +func (testPropDef) Title() string { return "" } +func (testPropDef) Body() string { return "" } +func (testPropDef) Quorum() float64 { return 0 } +func (d testPropDef) VotingPeriod() time.Duration { return d.votingPeriod } +func (d testPropDef) Validate() error { return d.validationErr } +func (d testPropDef) Tally(ReadOnlyVotingRecord, int) bool { return d.tallyResult } +func (d testPropDef) Execute() error { return d.executionErr } diff --git a/examples/gno.land/p/nt/commondao/record.gno b/examples/gno.land/p/nt/commondao/record.gno new file mode 100644 index 00000000000..d1e9d5a092b --- /dev/null +++ b/examples/gno.land/p/nt/commondao/record.gno @@ -0,0 +1,176 @@ +package commondao + +import ( + "errors" + "std" + + "gno.land/p/demo/avl" +) + +// ErrVoteExists indicates that a user already voted. +var ErrVoteExists = errors.New("user already voted") + +type ( + // Vote defines a single vote. + Vote struct { + Address std.Address + Choice VoteChoice + } + + // ReadOnlyVotingRecord defines an interface for read only voting records. + ReadOnlyVotingRecord interface { + // VoteChoices returns the voting choices that has been voted. + VoteChoices() []VoteChoice + + // Votes returns the list of all votes. + Votes() []Vote + + // VoteCount returns the number of votes for a single voting choice. + VoteCount(VoteChoice) int + + // HasVoted checks if an account already voted. + HasVoted(std.Address) bool + + // GetProvableMajorityChoice returns the choice voted by the majority. + // The result is only valid if there is a majority. + // Caller must validate that the returned choice represents a majority. + GetProvableMajorityChoice() VoteChoice + } +) + +// VotingRecord stores accounts that voted and vote choices. +type VotingRecord struct { + votes avl.Tree // string(address) -> VoteChoice + count avl.Tree // string(choice) -> int +} + +// VoteChoices returns the voting choices that has been voted. +func (r VotingRecord) VoteChoices() []VoteChoice { + var choices []VoteChoice + r.count.Iterate("", "", func(k string, v interface{}) bool { + choices = append(choices, VoteChoice(k)) + return false + }) + return choices +} + +// Votes returns the list of all votes. +func (r VotingRecord) Votes() []Vote { + var votes []Vote + r.votes.Iterate("", "", func(k string, v interface{}) bool { + votes = append(votes, Vote{ + Address: std.Address(k), + Choice: v.(VoteChoice), + }) + return false + }) + return votes +} + +// VoteCount returns the number of votes for a single voting choice. +func (r VotingRecord) VoteCount(c VoteChoice) int { + if v, found := r.count.Get(string(c)); found { + return v.(int) + } + return 0 +} + +// HasVoted checks if an account already voted. +func (r VotingRecord) HasVoted(user std.Address) bool { + return r.votes.Has(user.String()) +} + +// AddVote adds a vote. +// Users are allowd to vote only once. +func (r *VotingRecord) AddVote(user std.Address, c VoteChoice) error { + if r.HasVoted(user) { + return ErrVoteExists + } + + r.votes.Set(user.String(), c) + r.count.Set(string(c), r.VoteCount(c)+1) + return nil +} + +// GetProvableMajorityChoice returns the choice voted by the majority. +// The result is only valid if there is a majority. +// Caller must validate that the returned choice represents a majority. +func (r VotingRecord) GetProvableMajorityChoice() VoteChoice { + var ( + choice VoteChoice + currentCount int + ) + + r.count.Iterate("", "", func(k string, v interface{}) bool { + count := v.(int) + if currentCount < count { + choice = VoteChoice(k) + currentCount = count + } + return false + }) + return choice +} + +// SelectChoiceByAbsoluteMajority select the vote choice by absolute majority. +// Vote choice is a majority when chosen by more than half of the votes. +// Absolute majority considers abstentions when counting votes. +func SelectChoiceByAbsoluteMajority(r ReadOnlyVotingRecord, memberCount int) (VoteChoice, bool) { + choice := r.GetProvableMajorityChoice() + if r.VoteCount(choice) > int(memberCount/2) { + return choice, true + } + return "", false +} + +// SelectChoiceBySuperMajority select the vote choice by super majority using a 2/3s threshold. +// Abstentions are not considered when calculating the super majority choice. +func SelectChoiceBySuperMajority(r ReadOnlyVotingRecord) (VoteChoice, bool) { + var count int + for _, v := range r.Votes() { + if v.Choice != ChoiceAbstain { + count++ + } + } + + if count < 3 { + return "", false + } + + choice := r.GetProvableMajorityChoice() + if r.VoteCount(choice) >= int((2*count)/3) { + return choice, true + } + return "", false +} + +// SelectChoiceByPlurality selects the vote choice by plurality. +// The choice will be considered a majority if it has votes and if there is no other +// choice with the same number of votes. A tie won't be considered majority. +func SelectChoiceByPlurality(r ReadOnlyVotingRecord) (VoteChoice, bool) { + var ( + choice VoteChoice + currentCount int + isMajority bool + ) + + for _, c := range r.VoteChoices() { + if c == ChoiceAbstain { + continue + } + + count := r.VoteCount(c) + if currentCount < count { + choice = c + currentCount = count + isMajority = true + } else if currentCount == count { + isMajority = false + } + } + + if isMajority { + return choice, true + } + return "", false +} diff --git a/examples/gno.land/p/nt/commondao/record_test.gno b/examples/gno.land/p/nt/commondao/record_test.gno new file mode 100644 index 00000000000..2e4a234ce4c --- /dev/null +++ b/examples/gno.land/p/nt/commondao/record_test.gno @@ -0,0 +1,348 @@ +package commondao + +import ( + "std" + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestVotingRecordDefaults(t *testing.T) { + var ( + record VotingRecord + user = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + ) + + uassert.Equal(t, record.Votes(), nil) + uassert.Equal(t, record.VoteCount(ChoiceYes), 0) + uassert.Equal(t, record.VoteCount(ChoiceNo), 0) + uassert.Equal(t, record.VoteCount(ChoiceAbstain), 0) + uassert.False(t, record.HasVoted(user)) +} + +func TestVotingRecordAddVote(t *testing.T) { + cases := []struct { + name string + setup func(*VotingRecord) + votes []Vote + yesCount, noCount, abstainCount int + err error + }{ + { + name: "single vote", + votes: []Vote{ + { + Address: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + Choice: ChoiceYes, + }, + }, + yesCount: 1, + }, + { + name: "multiple votes", + votes: []Vote{ + { + Address: "g125t352u4pmdrr57emc4pe04y40sknr5ztng5mt", + Choice: ChoiceNo, + }, + { + Address: "g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", + Choice: ChoiceYes, + }, + { + Address: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + Choice: ChoiceNo, + }, + { + Address: "g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", + Choice: ChoiceAbstain, + }, + }, + yesCount: 1, + noCount: 2, + abstainCount: 1, + }, + { + name: "vote exists", + votes: []Vote{ + { + Address: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + Choice: ChoiceYes, + }, + }, + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceAbstain) + }, + err: ErrVoteExists, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var ( + err error + record VotingRecord + ) + + if tc.setup != nil { + tc.setup(&record) + } + + for _, v := range tc.votes { + err = record.AddVote(v.Address, v.Choice) + if err != nil { + break + } + } + + if tc.err != nil { + urequire.ErrorIs(t, err, tc.err) + return + } + + urequire.NoError(t, err) + urequire.Equal(t, len(record.Votes()), len(tc.votes), "unexpected number of votes") + for i, v := range record.Votes() { + uassert.Equal(t, v.Address, tc.votes[i].Address) + uassert.Equal(t, string(v.Choice), string(tc.votes[i].Choice)) + uassert.True(t, record.HasVoted(v.Address)) + } + + uassert.Equal(t, record.VoteCount(ChoiceYes), tc.yesCount) + uassert.Equal(t, record.VoteCount(ChoiceNo), tc.noCount) + uassert.Equal(t, record.VoteCount(ChoiceAbstain), tc.abstainCount) + }) + } +} + +func TestVotingRecordGetProvableMajorityChoice(t *testing.T) { + cases := []struct { + name string + setup func(*VotingRecord) + choice VoteChoice + }{ + { + name: "no votes", + choice: ChoiceAbstain, + }, + { + name: "one vote", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + }, + choice: ChoiceYes, + }, + { + name: "majority", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + r.AddVote("g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", ChoiceYes) + r.AddVote("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", ChoiceNo) + }, + choice: ChoiceYes, + }, + { + name: "invalid because no majority", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + r.AddVote("g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", ChoiceNo) + }, + choice: ChoiceNo, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var record VotingRecord + + if tc.setup != nil { + tc.setup(&record) + } + + choice := record.GetProvableMajorityChoice() + + uassert.Equal(t, string(choice), string(tc.choice)) + }) + } +} + +func TestSelectChoiceByAbsoluteMajority(t *testing.T) { + memberCount := 3 + cases := []struct { + name string + setup func(*VotingRecord) + choice VoteChoice + success bool + }{ + { + name: "no votes", + choice: "", + success: false, + }, + { + name: "majority", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + r.AddVote("g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", ChoiceYes) + r.AddVote("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", ChoiceNo) + }, + choice: ChoiceYes, + success: true, + }, + { + name: "no majority", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + r.AddVote("g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", ChoiceNo) + }, + choice: "", + success: false, + }, + { + name: "majority with abstain vote", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + r.AddVote("g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", ChoiceYes) + r.AddVote("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", ChoiceAbstain) + }, + choice: ChoiceYes, + success: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var record VotingRecord + + if tc.setup != nil { + tc.setup(&record) + } + + choice, success := SelectChoiceByAbsoluteMajority(record, memberCount) + + uassert.Equal(t, string(tc.choice), string(choice), "choice") + uassert.Equal(t, tc.success, success, "success") + }) + } +} + +func TestSelectChoiceBySuperMajority(t *testing.T) { + cases := []struct { + name string + setup func(*VotingRecord) + choice VoteChoice + success bool + }{ + { + name: "no votes", + choice: "", + success: false, + }, + { + name: "majority", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + r.AddVote("g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", ChoiceYes) + r.AddVote("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", ChoiceNo) + }, + choice: ChoiceYes, + success: true, + }, + { + name: "no majority", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + r.AddVote("g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", ChoiceNo) + }, + choice: "", + success: false, + }, + { + name: "majority with abstain vote", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + r.AddVote("g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", ChoiceYes) + r.AddVote("g1vh7krmmzfua5xjmkatvmx09z37w34lsvd2mxa5", ChoiceNo) + r.AddVote("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", ChoiceAbstain) + }, + choice: ChoiceYes, + success: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var record VotingRecord + + if tc.setup != nil { + tc.setup(&record) + } + + choice, success := SelectChoiceBySuperMajority(record) + + uassert.Equal(t, string(tc.choice), string(choice), "choice") + uassert.Equal(t, tc.success, success, "success") + }) + } +} + +func TestSelectChoiceByPlurality(t *testing.T) { + cases := []struct { + name string + setup func(*VotingRecord) + choice VoteChoice + success bool + }{ + { + name: "no votes", + choice: ChoiceAbstain, + success: false, + }, + { + name: "plurality", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + r.AddVote("g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", ChoiceYes) + r.AddVote("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", ChoiceNo) + }, + choice: ChoiceYes, + success: true, + }, + { + name: "no plurality", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + r.AddVote("g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", ChoiceNo) + }, + choice: "", + success: false, + }, + { + name: "plurality with abstain vote", + setup: func(r *VotingRecord) { + r.AddVote("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", ChoiceYes) + r.AddVote("g12chzmwxw8sezcxe9h2csp0tck76r4ptwdlyyqk", ChoiceYes) + r.AddVote("g1vh7krmmzfua5xjmkatvmx09z37w34lsvd2mxa5", ChoiceNo) + r.AddVote("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", ChoiceAbstain) + }, + choice: ChoiceYes, + success: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var record VotingRecord + + if tc.setup != nil { + tc.setup(&record) + } + + choice, success := SelectChoiceByPlurality(record) + + uassert.Equal(t, string(tc.choice), string(choice), "choice") + uassert.Equal(t, tc.success, success, "success") + }) + } +} diff --git a/examples/gno.land/r/nt/boards2/v1/board.gno b/examples/gno.land/r/nt/boards2/v1/board.gno new file mode 100644 index 00000000000..85692fa3c91 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/board.gno @@ -0,0 +1,220 @@ +package boards2 + +import ( + "net/url" + "std" + "strconv" + "strings" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/moul/txlink" + "gno.land/p/nt/commondao" +) + +type BoardID uint64 + +func (id BoardID) String() string { + return strconv.Itoa(int(id)) +} + +func (id BoardID) Key() string { + return padZero(uint64(id), 10) +} + +type Board struct { + id BoardID // only set for public boards. + name string + aliases []string + creator std.Address + threads avl.Tree // Post.id -> *Post + postsCtr uint64 // increments Post.id + createdAt time.Time + deleted avl.Tree // TODO reserved for fast-delete. + perms Permissions + readOnly bool +} + +func newBoard(id BoardID, name string, creator std.Address, p Permissions) *Board { + return &Board{ + id: id, + name: name, + creator: creator, + threads: avl.Tree{}, + createdAt: time.Now(), + deleted: avl.Tree{}, + perms: p, + } +} + +func (board *Board) GetID() BoardID { + return board.id +} + +// GetName returns the name of the board. +func (board *Board) GetName() string { + return board.name +} + +// GetURL returns the relative URL of the board. +func (board *Board) GetURL() string { + return strings.TrimPrefix(std.CurrentRealm().PkgPath(), "gno.land") + ":" + url.PathEscape(board.name) +} + +// GetURL returns relative board path relative. +// +// Note: returned result is not escaped. Use GetURL to get URL-encoded path. +func (board *Board) GetPath() string { + return strings.TrimPrefix(std.CurrentRealm().PkgPath(), "gno.land") + ":" + board.name +} + +func (board *Board) GetPermissions() Permissions { + return board.perms +} + +// SetReadOnly updates board's read-only status. +func (board *Board) SetReadOnly(readOnly bool) { + board.readOnly = readOnly +} + +// IsReadOnly checks if the board is a read-only board. +func (board *Board) IsReadOnly() bool { + return board.readOnly +} + +func (board *Board) GetThread(threadID PostID) (_ *Post, found bool) { + v, found := board.threads.Get(threadID.Key()) + if !found { + return nil, false + } + return v.(*Post), true +} + +func (board *Board) AddThread(creator std.Address, title string, body string) *Post { + pid := board.incGetPostID() + thread := newPost(board, pid, creator, title, body, pid, 0, 0) + board.threads.Set(pid.Key(), thread) + return thread +} + +// NOTE: this can be potentially very expensive for threads with many replies. +// TODO: implement optional fast-delete where thread is simply moved. +func (board *Board) DeleteThread(pid PostID) { + _, removed := board.threads.Remove(pid.Key()) + if !removed { + panic("thread does not exist with id " + pid.String()) + } +} + +// Render renders a board into Markdown. +// +// Pager is used for pagination if it's not nil. +func (board *Board) Render(p *PaginationOpts) string { + if board.threads.Size() == 0 { + return "*This board doesn't have any threads.*" + } + + var sb strings.Builder + + page := p.Iterate(&board.threads, func(_ string, v interface{}) bool { + p := v.(*Post) + if p.isHidden { + return false + } + + sb.WriteString("----------------------------------------\n") + sb.WriteString(p.RenderSummary()) + sb.WriteString("\n") + return false + }) + + if page != nil { + sb.WriteString("\n---\n") + sb.WriteString(page.Picker()) + } + + return sb.String() +} + +func (board *Board) incGetPostID() PostID { + board.postsCtr++ + return PostID(board.postsCtr) +} + +func (board *Board) GetURLFromThreadID(threadID PostID) string { + return board.GetURL() + "/" + threadID.String() +} + +func (board *Board) GetURLFromReplyID(threadID, replyID PostID) string { + return board.GetURL() + "/" + threadID.String() + "/" + replyID.String() +} + +func (board *Board) GetRenameFormURL() string { + return txlink.Call("RenameBoard", "name", board.name) +} + +func (board *Board) GetFreezeFormURL() string { + return txlink.Call("FreezeBoard", "boardID", board.id.String()) +} + +func (board *Board) GetFlaggingThresholdFormURL() string { + return txlink.Call("SetFlaggingThreshold", "boardID", board.id.String()) +} + +func (board *Board) GetInviteMemberFormURL() string { + return txlink.Call("InviteMember", "boardID", board.id.String()) +} + +func (board *Board) GetRemoveMemberFormURL() string { + return txlink.Call("RemoveMember", "boardID", board.id.String()) +} + +func (board *Board) GetChangeMemberRoleFormURL() string { + return txlink.Call("ChangeMemberRole", "boardID", board.id.String()) +} + +func (board *Board) GetPostFormURL() string { + return txlink.Call("CreateThread", "boardID", board.id.String()) +} + +func (board *Board) GetMembersURL() string { + return board.GetURL() + "/members" +} + +func createDefaultBoardPermissions(owner std.Address) *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.SetSuperRole(RoleOwner) + perms.AddRole( + RoleAdmin, + PermissionBoardRename, + PermissionBoardFlaggingUpdate, + PermissionMemberInvite, + PermissionMemberRemove, + PermissionThreadCreate, + PermissionThreadEdit, + PermissionThreadDelete, + PermissionThreadRepost, + PermissionThreadFlag, + PermissionReplyCreate, + PermissionReplyDelete, + PermissionReplyFlag, + PermissionRoleChange, + ) + perms.AddRole( + RoleModerator, + PermissionThreadCreate, + PermissionThreadEdit, + PermissionThreadRepost, + PermissionThreadFlag, + PermissionReplyCreate, + PermissionReplyFlag, + ) + perms.AddRole( + RoleGuest, + PermissionThreadCreate, + PermissionThreadRepost, + PermissionReplyCreate, + ) + perms.AddUser(owner, RoleOwner) + return perms +} diff --git a/examples/gno.land/r/nt/boards2/v1/board_test.gno b/examples/gno.land/r/nt/boards2/v1/board_test.gno new file mode 100644 index 00000000000..a7738517c0b --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/board_test.gno @@ -0,0 +1,107 @@ +package boards2 + +import ( + "std" + "strings" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/p/moul/txlink" +) + +func TestBoardID_String(t *testing.T) { + input := BoardID(32) + + uassert.Equal(t, "32", input.String()) +} + +func TestBoardID_Key(t *testing.T) { + input := BoardID(128) + want := strings.Repeat("0", 7) + "128" + uassert.Equal(t, want, input.Key()) +} + +func TestBoard_GetID(t *testing.T) { + want := int(92) + b := new(Board) + b.id = BoardID(want) + got := int(b.GetID()) + + uassert.Equal(t, got, want) + uassert.NotEqual(t, got, want*want) +} + +func TestBoard_GetURL(t *testing.T) { + pkgPath := strings.TrimPrefix(std.CurrentRealm().PkgPath(), "gno.land") + name := "foobar_test_get_url123" + want := pkgPath + ":" + name + + addr := testutils.TestAddress("creator") + perms := createDefaultBoardPermissions(addr) + board := newBoard(1, name, addr, perms) + got := board.GetURL() + uassert.Equal(t, want, got) +} + +func TestBoard_GetThread(t *testing.T) { + addr := testutils.TestAddress("creator") + perms := createDefaultBoardPermissions(addr) + b := newBoard(1, "test123", addr, perms) + + _, ok := b.GetThread(12345) + uassert.False(t, ok) + + post := b.AddThread(addr, "foo", "bar") + _, ok = b.GetThread(post.GetPostID()) + uassert.True(t, ok) +} + +func TestBoard_DeleteThread(t *testing.T) { + addr := testutils.TestAddress("creator") + perms := createDefaultBoardPermissions(addr) + b := newBoard(1, "test123", addr, perms) + + post := b.AddThread(addr, "foo", "bar") + id := post.GetPostID() + + b.DeleteThread(id) + + _, ok := b.GetThread(id) + uassert.False(t, ok) +} + +var boardUrlPrefix = strings.TrimPrefix(std.CurrentRealm().PkgPath(), "gno.land") + +func TestBoard_GetURLFromThreadID(t *testing.T) { + boardName := "test12345" + addr := testutils.TestAddress("creator") + perms := createDefaultBoardPermissions(addr) + b := newBoard(BoardID(11), boardName, addr, perms) + want := boardUrlPrefix + ":" + boardName + "/10" + + got := b.GetURLFromThreadID(10) + uassert.Equal(t, want, got) +} + +func TestBoard_GetURLFromReplyID(t *testing.T) { + boardName := "test12345" + addr := testutils.TestAddress("creator") + perms := createDefaultBoardPermissions(addr) + b := newBoard(BoardID(11), boardName, addr, perms) + want := boardUrlPrefix + ":" + boardName + "/10/20" + + got := b.GetURLFromReplyID(10, 20) + uassert.Equal(t, want, got) +} + +func TestBoard_GetPostFormURL(t *testing.T) { + bid := BoardID(386) + addr := testutils.TestAddress("creator") + perms := createDefaultBoardPermissions(addr) + b := newBoard(bid, "foo1234", addr, perms) + expect := txlink.Call("CreateThread", "boardID", bid.String()) + + got := b.GetPostFormURL() + uassert.Equal(t, expect, got) +} diff --git a/examples/gno.land/r/nt/boards2/v1/boards.gno b/examples/gno.land/r/nt/boards2/v1/boards.gno new file mode 100644 index 00000000000..a1c737c4b91 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/boards.gno @@ -0,0 +1,73 @@ +package boards2 + +import ( + "std" + + "gno.land/p/demo/avl" +) + +var ( + gPerms Permissions + gLastBoardID BoardID + gBoardsByID avl.Tree // string(id) -> *Board + gBoardsByName avl.Tree // string(name) -> *Board +) + +func init() { + // TODO: Define and change the default realm owner (or owners) + owner := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + + // Initialize default realm permissions + gPerms = createDefaultPermissions(owner) +} + +// incGetBoardID returns a new board ID. +func incGetBoardID() BoardID { + gLastBoardID++ + return gLastBoardID +} + +// getBoard returns a board for a specific ID. +func getBoard(id BoardID) (_ *Board, found bool) { + v, exists := gBoardsByID.Get(id.Key()) + if !exists { + return nil, false + } + return v.(*Board), true +} + +// mustGetBoardByName returns a board or panics when it's not found. +func mustGetBoardByName(name string) *Board { + v, found := gBoardsByName.Get(name) + if !found { + panic("board does not exist with name: " + name) + } + return v.(*Board) +} + +// mustGetBoard returns a board or panics when it's not found. +func mustGetBoard(id BoardID) *Board { + board, found := getBoard(id) + if !found { + panic("board does not exist with ID: " + id.String()) + } + return board +} + +// mustGetThread returns a thread or panics when it's not found. +func mustGetThread(board *Board, threadID PostID) *Post { + thread, found := board.GetThread(threadID) + if !found { + panic("thread does not exist with ID: " + threadID.String()) + } + return thread +} + +// mustGetReply returns a reply or panics when it's not found. +func mustGetReply(thread *Post, replyID PostID) *Post { + reply, found := thread.GetReply(replyID) + if !found { + panic("reply does not exist with ID: " + replyID.String()) + } + return reply +} diff --git a/examples/gno.land/r/nt/boards2/v1/flag.gno b/examples/gno.land/r/nt/boards2/v1/flag.gno new file mode 100644 index 00000000000..4befac42a94 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/flag.gno @@ -0,0 +1,52 @@ +package boards2 + +import ( + "std" + "strconv" + + "gno.land/p/demo/avl" +) + +// DefaultFlaggingThreshold defines the default number of flags that hides flaggable items. +const DefaultFlaggingThreshold = 1 + +var gFlaggingThresholds avl.Tree // string(board ID) -> int + +type Flag struct { + User std.Address + Reason string +} + +type Flaggable interface { + // AddFlag adds a new flag to an item. + // + // Returns false if item was already flagged by user. + AddFlag(flag Flag) bool + + // FlagsCount returns number of times item was flagged. + FlagsCount() int +} + +// flagItem adds a flag to a flaggable item (post, thread, etc). +// +// Returns whether flag count threshold is reached and item can be hidden. +// +// Panics if flag count threshold was already reached. +func flagItem(item Flaggable, flag Flag, threshold int) bool { + if item.FlagsCount() >= threshold { + panic("item flag count threshold exceeded: " + strconv.Itoa(threshold)) + } + + if !item.AddFlag(flag) { + panic("item has been already flagged by a current user") + } + + return item.FlagsCount() == threshold +} + +func getFlaggingThreshold(bid BoardID) int { + if v, ok := gFlaggingThresholds.Get(bid.String()); ok { + return v.(int) + } + return DefaultFlaggingThreshold +} diff --git a/examples/gno.land/r/nt/boards2/v1/format.gno b/examples/gno.land/r/nt/boards2/v1/format.gno new file mode 100644 index 00000000000..9094139a656 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/format.gno @@ -0,0 +1,70 @@ +package boards2 + +import ( + "std" + "strconv" + "strings" + + "gno.land/r/demo/users" +) + +func padLeft(s string, length int) string { + if len(s) >= length { + return s + } + return strings.Repeat(" ", length-len(s)) + s +} + +func padZero(u64 uint64, length int) string { + s := strconv.Itoa(int(u64)) + if len(s) >= length { + return s + } + return strings.Repeat("0", length-len(s)) + s +} + +func indentBody(indent string, body string) string { + var ( + res string + lines = strings.Split(body, "\n") + ) + for i, line := range lines { + if i > 0 { + res += "\n" + } + res += indent + line + } + return res +} + +// NOTE: length must be greater than 3. +func summaryOf(text string, length int) string { + lines := strings.SplitN(text, "\n", 2) + line := lines[0] + if len(line) > length { + line = line[:(length-3)] + "..." + } else if len(lines) > 1 { + // len(line) <= 80 + line = line + "..." + } + return line +} + +// newLink returns a Markdown link. +func newLink(label, uri string) string { + return "[" + label + "](" + uri + ")" +} + +// newButtonLink returns a Makdown link with label wrapped in brackets. +func newButtonLink(label, uri string) string { + return `[\[` + label + `\]](` + uri + ")" +} + +// newUserLink returns a Markdown link for an account to the users realm. +func newUserLink(addr std.Address) string { + user := users.GetUserByAddress(addr) + if user == nil { + return newLink(addr.String(), "/r/demo/users:"+addr.String()) + } + return newLink("@"+user.Name, "/r/demo/users:"+user.Name) +} diff --git a/examples/gno.land/r/nt/boards2/v1/gno.mod b/examples/gno.land/r/nt/boards2/v1/gno.mod new file mode 100644 index 00000000000..fd4698c13b0 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/gno.mod @@ -0,0 +1 @@ +module gno.land/r/nt/boards2/v1 diff --git a/examples/gno.land/r/nt/boards2/v1/pagination.gno b/examples/gno.land/r/nt/boards2/v1/pagination.gno new file mode 100644 index 00000000000..edb47ca5d5b --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/pagination.gno @@ -0,0 +1,51 @@ +package boards2 + +import ( + "gno.land/p/demo/avl" + "gno.land/p/demo/avl/pager" +) + +type PaginationOpts struct { + pager pager.Pager + pageNumber int +} + +// Iterate loops over an a page. +// +// Loops though all avl tree contents if PaginationOpts is nil. +func (opts *PaginationOpts) Iterate(tree *avl.Tree, cb func(k string, val interface{}) bool) *pager.Page { + if opts == nil { + tree.Iterate("", "", cb) + return nil + } + + opts.pager.Tree = tree + page := opts.pager.GetPage(opts.pageNumber) + for _, item := range page.Items { + if cb(item.Key, item.Value) { + break + } + } + + if page.TotalPages > 1 { + return page + } + return nil +} + +func mustGetPagination(rawPath string, pageSize int) *PaginationOpts { + p := pager.Pager{ + PageQueryParam: "page", + DefaultPageSize: pageSize, + } + + pageNumber, _, err := p.ParseQuery(rawPath) + if err != nil { + panic(err) + } + + return &PaginationOpts{ + pager: p, + pageNumber: pageNumber, + } +} diff --git a/examples/gno.land/r/nt/boards2/v1/permissions.gno b/examples/gno.land/r/nt/boards2/v1/permissions.gno new file mode 100644 index 00000000000..2d6de0ec3f2 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/permissions.gno @@ -0,0 +1,80 @@ +package boards2 + +import "std" + +const ( + PermissionBoardCreate Permission = "board:create" + PermissionBoardRename = "board:rename" + PermissionBoardFlaggingUpdate = "board:flagging-update" + PermissionBoardFreeze = "board:freeze" + PermissionThreadCreate = "thread:create" + PermissionThreadEdit = "thread:edit" + PermissionThreadDelete = "thread:delete" + PermissionThreadFlag = "thread:flag" + PermissionThreadRepost = "thread:repost" + PermissionReplyCreate = "reply:create" + PermissionReplyDelete = "reply:delete" + PermissionReplyFlag = "reply:flag" + PermissionMemberInvite = "member:invite" + PermissionMemberRemove = "member:remove" + PermissionRoleChange = "role:change" + PermissionPermissionsUpdate = "permissions:update" +) + +const ( + RoleOwner Role = "owner" + RoleAdmin = "admin" + RoleModerator = "moderator" + RoleGuest = "" +) + +type ( + // Permission defines the type for permissions. + Permission string + + // Role defines the type for user roles. + Role string + + // Args is a list of generic arguments. + Args []interface{} + + // User contains user info. + User struct { + Address std.Address + Roles []Role + } + + // UsersIterFn defines a function type to iterate users. + UsersIterFn func(User) bool + + // Permissions define an interface to for permissioned execution. + Permissions interface { + // HasRole checks if a user has a specific role assigned. + HasRole(std.Address, Role) bool + + // HasPermission checks if a user has a specific permission. + HasPermission(std.Address, Permission) bool + + // WithPermission calls a callback when a user has a specific permission. + // It panics on error. + WithPermission(std.Address, Permission, Args, func(Args)) + + // AddUser adds a new user to the permissioner. + AddUser(std.Address, ...Role) error + + // SetUserRoles sets the roles of a user. + SetUserRoles(std.Address, ...Role) error + + // RemoveUser removes a user from the permissioner. + RemoveUser(std.Address) (removed bool) + + // HasUser checks if a user exists. + HasUser(std.Address) bool + + // UsersCount returns the total number of users the permissioner contains. + UsersCount() int + + // IterateUsers iterates permissions' users. + IterateUsers(start, count int, fn UsersIterFn) bool + } +) diff --git a/examples/gno.land/r/nt/boards2/v1/permissions_default.gno b/examples/gno.land/r/nt/boards2/v1/permissions_default.gno new file mode 100644 index 00000000000..9379b43e07f --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/permissions_default.gno @@ -0,0 +1,273 @@ +package boards2 + +import ( + "errors" + "std" + + "gno.land/p/demo/avl" + "gno.land/p/nt/commondao" + + "gno.land/r/demo/users" +) + +// DefaultPermissions manages users, roles and permissions. +type DefaultPermissions struct { + superRole Role + dao *commondao.CommonDAO + users *avl.Tree // string(std.Address) -> []Role + roles *avl.Tree // string(role) -> []Permission +} + +// NewDefaultPermissions creates a new permissions type. +// This type is a default implementation to handle users, roles and permissions. +func NewDefaultPermissions(dao *commondao.CommonDAO) *DefaultPermissions { + if dao == nil { + panic("default permissions require a DAO") + } + + return &DefaultPermissions{ + dao: dao, + roles: avl.NewTree(), + users: avl.NewTree(), + } +} + +// SetSuperRole assigns a super role. +// A super role is one that have all permissions. +// These type of role doesn't need to be mapped to any permission. +func (dp *DefaultPermissions) SetSuperRole(r Role) { + dp.superRole = r +} + +// AddRole add a role with one or more assigned permissions. +func (dp *DefaultPermissions) AddRole(r Role, p Permission, extra ...Permission) { + dp.roles.Set(string(r), append([]Permission{p}, extra...)) +} + +// RoleExists checks if a role exists. +func (dp DefaultPermissions) RoleExists(r Role) bool { + if dp.superRole != "" && r == dp.superRole { + return true + } + + return dp.roles.Iterate("", "", func(name string, _ interface{}) bool { + return Role(name) == r + }) +} + +// GetUserRoles returns the list of roles assigned to a user. +func (dp DefaultPermissions) GetUserRoles(user std.Address) []Role { + v, found := dp.users.Get(user.String()) + if !found { + return nil + } + return v.([]Role) +} + +// HasRole checks if a user has a specific role assigned. +func (dp DefaultPermissions) HasRole(user std.Address, r Role) bool { + for _, role := range dp.GetUserRoles(user) { + if role == r { + return true + } + } + return false +} + +// HasPermission checks if a user has a specific permission. +func (dp DefaultPermissions) HasPermission(user std.Address, perm Permission) bool { + for _, r := range dp.GetUserRoles(user) { + if dp.superRole == r { + return true + } + + v, found := dp.roles.Get(string(r)) + if !found { + continue + } + + for _, p := range v.([]Permission) { + if p == perm { + return true + } + } + } + return false +} + +// AddUser adds a new user to permissions. +func (dp *DefaultPermissions) AddUser(user std.Address, roles ...Role) error { + if dp.users.Has(user.String()) { + return errors.New("user already exists") + } + + if err := dp.dao.AddMember(user); err != nil { + return err + } + return dp.setUserRoles(user, roles...) +} + +// SetUserRoles sets the roles of a user. +func (dp *DefaultPermissions) SetUserRoles(user std.Address, roles ...Role) error { + if !dp.users.Has(user.String()) { + return errors.New("user not found") + } + return dp.setUserRoles(user, roles...) +} + +// RemoveUser removes a user from permissions. +func (dp *DefaultPermissions) RemoveUser(user std.Address) bool { + _, removed := dp.users.Remove(user.String()) + dp.dao.RemoveMember(user) + return removed +} + +// HasUser checks if a user exists. +func (dp DefaultPermissions) HasUser(user std.Address) bool { + return dp.users.Has(user.String()) +} + +// UsersCount returns the total number of users the permissioner contains. +func (dp DefaultPermissions) UsersCount() int { + return dp.users.Size() +} + +// IterateUsers iterates permissions' users. +func (dp DefaultPermissions) IterateUsers(start, count int, fn UsersIterFn) bool { + return dp.users.IterateByOffset(start, count, func(k string, v interface{}) bool { + return fn(User{ + Address: std.Address(k), + Roles: v.([]Role), + }) + }) +} + +// WithPermission calls a callback when a user has a specific permission. +// It panics on error or when a handler panics. +// Callbacks are by default called when there is no handle registered for the permission. +func (dp *DefaultPermissions) WithPermission(user std.Address, perm Permission, args Args, cb func(Args)) { + if !dp.HasPermission(user, perm) || !dp.dao.IsMember(user) { + panic("unauthorized") + } + + switch perm { + case PermissionBoardCreate: + dp.handleBoardCreate(args, cb) + case PermissionBoardRename: + dp.handleBoardRename(args, cb) + case PermissionMemberInvite: + dp.handleMemberInvite(args, cb) + case PermissionRoleChange: + dp.handleRoleChange(args, cb) + default: + cb(args) + } +} + +func (dp *DefaultPermissions) setUserRoles(user std.Address, roles ...Role) error { + for _, r := range roles { + if !dp.RoleExists(r) { + return errors.New("invalid role: " + string(r)) + } + } + + dp.users.Set(user.String(), append([]Role(nil), roles...)) + return nil +} + +func (DefaultPermissions) handleBoardCreate(args Args, cb func(Args)) { + name, ok := args[0].(string) + if !ok { + panic("expected board name to be a string") + } + + assertValidBoardNameLength(name) + assertBoardNameIsNotAddress(name) + assertBoardNameBelongsToCaller(name) + + cb(args) +} + +func (DefaultPermissions) handleBoardRename(args Args, cb func(Args)) { + newName, ok := args[2].(string) + if !ok { + panic("expected new board name to be a string") + } + + assertValidBoardNameLength(newName) + assertBoardNameIsNotAddress(newName) + assertBoardNameBelongsToCaller(newName) + + cb(args) +} + +func (dp DefaultPermissions) handleMemberInvite(args Args, cb func(Args)) { + // Make sure that only owners invite other owners + role, ok := args[1].(Role) + if !ok { + panic("expected a valid new member role") + } + + if role == RoleOwner { + if !dp.HasRole(std.OriginCaller(), RoleOwner) { + panic("only owners are allowed to invite other owners") + } + } + + cb(args) +} + +func (dp DefaultPermissions) handleRoleChange(args Args, cb func(Args)) { + // Owners and Admins can change roles. + // Admins should not be able to assign or remove the Owner role from members. + if dp.HasRole(std.OriginCaller(), RoleAdmin) { + role, ok := args[2].(Role) + if !ok { + panic("expected a valid member role") + } + + if role == RoleOwner { + panic("admins are not allowed to promote members to Owner") + } else { + member, ok := args[1].(std.Address) + if !ok { + panic("expected a valid member address") + } + + if dp.HasRole(member, RoleOwner) { + panic("admins are not allowed to remove the Owner role") + } + } + } + + cb(args) +} + +func createDefaultPermissions(owner std.Address) *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.SetSuperRole(RoleOwner) + perms.AddRole(RoleAdmin, PermissionBoardCreate) + perms.AddUser(owner, RoleOwner) + return perms +} + +func assertBoardNameIsNotAddress(s string) { + if std.Address(s).IsValid() { + panic("addresses are not allowed as board name") + } +} + +func assertValidBoardNameLength(name string) { + if len(name) < 6 { + panic("the minimum allowed board name length is 6 characters") + } +} + +func assertBoardNameBelongsToCaller(name string) { + // When the board name is the name of a registered user + // check that caller is the owner of the name. + user := users.GetUserByName(name) + if user != nil && user.Address != std.OriginCaller() { + panic("board name is a user name registered to a different user") + } +} diff --git a/examples/gno.land/r/nt/boards2/v1/permissions_default_test.gno b/examples/gno.land/r/nt/boards2/v1/permissions_default_test.gno new file mode 100644 index 00000000000..079de274e8d --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/permissions_default_test.gno @@ -0,0 +1,610 @@ +package boards2 + +import ( + "std" + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" + "gno.land/p/nt/commondao" +) + +var _ Permissions = (*DefaultPermissions)(nil) + +func TestDefaultPermissionsWithPermission(t *testing.T) { + cases := []struct { + name string + user std.Address + permission Permission + args Args + setup func() *DefaultPermissions + err string + called bool + }{ + { + name: "ok", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("foo", "bar") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") + return perms + }, + called: true, + }, + { + name: "ok with arguments", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + args: Args{"a", "b"}, + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("foo", "bar") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") + return perms + }, + called: true, + }, + { + name: "no permission", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("foo", "bar") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + return perms + }, + err: "unauthorized", + }, + { + name: "is not a DAO member", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + setup: func() *DefaultPermissions { + return NewDefaultPermissions(commondao.New()) + }, + err: "unauthorized", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var ( + called bool + args Args + ) + + perms := tc.setup() + callback := func(a Args) { + args = a + called = true + } + + testCaseFn := func() { + perms.WithPermission(tc.user, tc.permission, tc.args, callback) + } + + if tc.err != "" { + urequire.PanicsWithMessage(t, tc.err, testCaseFn, "panic") + return + } else { + urequire.NotPanics(t, testCaseFn, "no panic") + } + + urequire.Equal(t, tc.called, called, "callback called") + urequire.Equal(t, len(tc.args), len(args), "args count") + for i, a := range args { + uassert.Equal(t, tc.args[i].(string), a.(string)) + } + }) + } +} + +func TestDefaultPermissionsGetUserRoles(t *testing.T) { + cases := []struct { + name string + user std.Address + roles []string + setup func() *DefaultPermissions + }{ + { + name: "single role", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + roles: []string{"admin"}, + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("admin", "x") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin") + return perms + }, + }, + { + name: "multiple roles", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + roles: []string{"admin", "foo", "bar"}, + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("admin", "x") + perms.AddRole("foo", "x") + perms.AddRole("bar", "x") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin", "foo", "bar") + return perms + }, + }, + { + name: "without roles", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + return perms + }, + }, + { + name: "not a user", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + setup: func() *DefaultPermissions { + return NewDefaultPermissions(commondao.New()) + }, + }, + { + name: "multiple users", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + roles: []string{"admin"}, + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("admin", "x") + perms.AddRole("bar", "x") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin") + perms.AddUser("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", "admin") + perms.AddUser("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc", "admin", "bar") + return perms + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + perms := tc.setup() + roles := perms.GetUserRoles(tc.user) + + urequire.Equal(t, len(tc.roles), len(roles), "user role count") + for i, r := range roles { + uassert.Equal(t, tc.roles[i], string(r)) + } + }) + } +} + +func TestDefaultPermissionsHasRole(t *testing.T) { + cases := []struct { + name string + user std.Address + role Role + setup func() *DefaultPermissions + want bool + }{ + { + name: "ok", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + role: "admin", + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("admin", "x") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin") + return perms + }, + want: true, + }, + { + name: "ok with multiple roles", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + role: "foo", + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("admin", "x") + perms.AddRole("foo", "x") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin", "foo") + return perms + }, + want: true, + }, + { + name: "user without roles", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + return perms + }, + }, + { + name: "has no role", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + role: "bar", + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("foo", "x") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") + return perms + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + perms := tc.setup() + got := perms.HasRole(tc.user, tc.role) + uassert.Equal(t, got, tc.want) + }) + } +} + +func TestDefaultPermissionsHasPermission(t *testing.T) { + cases := []struct { + name string + user std.Address + permission Permission + setup func() *DefaultPermissions + want bool + }{ + { + name: "ok", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("foo", "bar") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") + return perms + }, + want: true, + }, + { + name: "ok with multiple users", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("foo", "bar") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") + perms.AddUser("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", "foo") + return perms + }, + want: true, + }, + { + name: "ok with multiple roles", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "other", + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("foo", "bar") + perms.AddRole("baz", "other") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo", "baz") + return perms + }, + want: true, + }, + { + name: "no permission", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "other", + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("foo", "bar") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") + return perms + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + perms := tc.setup() + got := perms.HasPermission(tc.user, tc.permission) + uassert.Equal(t, got, tc.want) + }) + } +} + +func TestDefaultPermissionsAddUser(t *testing.T) { + cases := []struct { + name string + user std.Address + roles []Role + setup func() *DefaultPermissions + err string + }{ + { + name: "single user", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + roles: []Role{"a", "b"}, + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("a", "permission1") + perms.AddRole("b", "permission2") + return perms + }, + }, + { + name: "multiple users", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + roles: []Role{"a"}, + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("a", "permission1") + perms.AddUser("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", "a") + perms.AddUser("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc") + return perms + }, + }, + { + name: "duplicated user", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + return perms + }, + err: "user already exists", + }, + { + name: "duplicated user", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + roles: []Role{"a", "foo"}, + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("a", "permission1") + return perms + }, + err: "invalid role: foo", + }, + { + name: "already a DAO member", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + setup: func() *DefaultPermissions { + return NewDefaultPermissions( + commondao.New(commondao.WithMember("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5")), + ) + }, + err: "member already exist", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + perms := tc.setup() + + err := perms.AddUser(tc.user, tc.roles...) + + if tc.err != "" { + urequire.True(t, err != nil, "expected an error") + uassert.Equal(t, tc.err, err.Error()) + return + } else { + urequire.NoError(t, err) + } + + roles := perms.GetUserRoles(tc.user) + uassert.Equal(t, len(tc.roles), len(roles)) + for i, r := range roles { + urequire.Equal(t, string(tc.roles[i]), string(r)) + } + }) + } +} + +func TestDefaultPermissionsSetUserRoles(t *testing.T) { + cases := []struct { + name string + user std.Address + roles []Role + setup func() *DefaultPermissions + err string + }{ + { + name: "single role", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + roles: []Role{"b"}, + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("a", "permission1") + perms.AddRole("b", "permission2") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "a") + return perms + }, + }, + { + name: "multiple roles", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + roles: []Role{"b", "c"}, + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("a", "permission1") + perms.AddRole("b", "permission2") + perms.AddRole("c", "permission2") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "a") + return perms + }, + }, + { + name: "duplicated role", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + roles: []Role{"a", "c"}, + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("a", "permission1") + perms.AddRole("b", "permission2") + perms.AddRole("c", "permission2") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "a", "c") + return perms + }, + }, + { + name: "remove roles", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("a", "permission1") + perms.AddRole("b", "permission2") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "a", "b") + return perms + }, + }, + { + name: "invalid role", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + roles: []Role{"x", "a"}, + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("a", "permission1") + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "a") + return perms + }, + err: "invalid role: x", + }, + { + name: "user not found", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + setup: func() *DefaultPermissions { + return NewDefaultPermissions(commondao.New()) + }, + err: "user not found", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + perms := tc.setup() + + err := perms.SetUserRoles(tc.user, tc.roles...) + + if tc.err != "" { + urequire.True(t, err != nil, "expected an error") + uassert.Equal(t, tc.err, err.Error()) + return + } else { + urequire.NoError(t, err) + } + + roles := perms.GetUserRoles(tc.user) + uassert.Equal(t, len(tc.roles), len(roles)) + for i, r := range roles { + urequire.Equal(t, string(tc.roles[i]), string(r)) + } + }) + } +} + +func TestDefaultPermissionsRemoveUser(t *testing.T) { + cases := []struct { + name string + user std.Address + setup func() *DefaultPermissions + want bool + }{ + { + name: "ok", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + setup: func() *DefaultPermissions { + perms := NewDefaultPermissions(commondao.New()) + perms.AddUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + return perms + }, + want: true, + }, + { + name: "user not found", + user: std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + setup: func() *DefaultPermissions { + return NewDefaultPermissions(commondao.New()) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + perms := tc.setup() + got := perms.RemoveUser(tc.user) + uassert.Equal(t, tc.want, got) + }) + } +} + +func TestDefaultPermissionsIterateUsers(t *testing.T) { + users := []User{ + { + Address: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + Roles: []Role{"foo"}, + }, + { + Address: "g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", + Roles: []Role{"foo", "bar"}, + }, + { + Address: "g1vh7krmmzfua5xjmkatvmx09z37w34lsvd2mxa5", + Roles: []Role{"bar"}, + }, + } + + perms := NewDefaultPermissions(commondao.New()) + perms.AddRole("foo", "perm1") + perms.AddRole("bar", "perm2") + for _, u := range users { + perms.AddUser(u.Address, u.Roles...) + } + + cases := []struct { + name string + start, count, want int + }{ + { + name: "exceed users count", + count: 50, + want: 3, + }, + { + name: "exact users count", + count: 3, + want: 3, + }, + { + name: "two users", + start: 1, + count: 2, + want: 2, + }, + { + name: "one user", + start: 1, + count: 1, + want: 1, + }, + { + name: "no iteration", + start: 50, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var i int + perms.IterateUsers(0, len(users), func(u User) bool { + urequire.True(t, i < len(users), "expect iterator to respect number of users") + uassert.Equal(t, users[i].Address, u.Address) + + urequire.Equal(t, len(users[i].Roles), len(u.Roles), "expect number of roles to match") + for j, r := range u.Roles { + uassert.Equal(t, string(users[i].Roles[j]), string(u.Roles[j])) + } + + i++ + }) + + uassert.Equal(t, i, len(users), "expect iterator to iterate all users") + }) + } +} diff --git a/examples/gno.land/r/nt/boards2/v1/post.gno b/examples/gno.land/r/nt/boards2/v1/post.gno new file mode 100644 index 00000000000..2341dbfe147 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/post.gno @@ -0,0 +1,475 @@ +package boards2 + +import ( + "errors" + "std" + "strconv" + "strings" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/moul/txlink" +) + +const dateFormat = "2006-01-02 3:04pm MST" + +type PostID uint64 + +func (id PostID) String() string { + return strconv.Itoa(int(id)) +} + +func (id PostID) Key() string { + return padZero(uint64(id), 10) +} + +// A Post is a "thread" or a "reply" depending on context. +// A thread is a Post of a Board that holds other replies. +type Post struct { + board *Board + id PostID + creator std.Address + title string // optional + body string + isHidden bool + replies avl.Tree // Post.id -> *Post + repliesAll avl.Tree // Post.id -> *Post (all replies, for top-level posts) + reposts avl.Tree // Board.id -> Post.id + flags []Flag + threadID PostID // original Post.id + parentID PostID // parent Post.id (if reply or repost) + repostBoardID BoardID // original Board.id (if repost) + repostsCount uint64 + createdAt time.Time + updatedAt time.Time +} + +func newPost(board *Board, id PostID, creator std.Address, title, body string, threadID, parentID PostID, repostBoardID BoardID) *Post { + return &Post{ + board: board, + id: id, + creator: creator, + title: title, + body: body, + replies: avl.Tree{}, + repliesAll: avl.Tree{}, + reposts: avl.Tree{}, + threadID: threadID, + parentID: parentID, + repostBoardID: repostBoardID, + createdAt: time.Now(), + } +} + +func (post *Post) IsThread() bool { + // repost threads also have parent ID + return post.parentID == 0 || post.repostBoardID != 0 +} + +func (post *Post) GetBoard() *Board { + return post.board +} + +func (post *Post) GetPostID() PostID { + return post.id +} + +func (post *Post) GetParentID() PostID { + return post.parentID +} + +func (post *Post) GetRepostBoardID() BoardID { + return post.repostBoardID +} + +func (post *Post) GetCreator() std.Address { + return post.creator +} + +func (post *Post) GetTitle() string { + return post.title +} + +func (post *Post) GetBody() string { + return post.body +} + +func (post *Post) GetCreatedAt() time.Time { + return post.createdAt +} + +func (post *Post) GetUpdatedAt() time.Time { + return post.updatedAt +} + +func (post *Post) AddFlag(flag Flag) bool { + // TODO: sort flags for fast search in case of big thresholds + for _, v := range post.flags { + if v.User == flag.User { + return false + } + } + + post.flags = append(post.flags, flag) + return true +} + +func (post *Post) FlagsCount() int { + return len(post.flags) +} + +func (post *Post) SetVisible(isVisible bool) { + post.isHidden = !isVisible +} + +func (post *Post) IsHidden() bool { + return post.isHidden +} + +func (post *Post) AddReply(creator std.Address, body string) *Post { + board := post.board + pid := board.incGetPostID() + pKey := pid.Key() + reply := newPost(board, pid, creator, "", body, post.threadID, post.id, 0) + // TODO: Figure out how to remove this redundancy of data "replies==repliesAll" in threads + post.replies.Set(pKey, reply) + if post.threadID == post.id { + post.repliesAll.Set(pKey, reply) + } else { + thread, _ := board.GetThread(post.threadID) + thread.repliesAll.Set(pKey, reply) + } + return reply +} + +func (post *Post) Update(title string, body string) { + post.title = title + post.body = body + post.updatedAt = time.Now() +} + +func (post *Post) HasReplies() bool { + return post.replies.Size() > 0 +} + +func (thread *Post) GetReply(pid PostID) (_ *Post, found bool) { + v, found := thread.repliesAll.Get(pid.Key()) + if !found { + return nil, false + } + return v.(*Post), true +} + +func (post *Post) AddRepostTo(creator std.Address, repost *Post, dst *Board) { + if !post.IsThread() { + panic("cannot repost non-thread post") + } + + if post.isHidden { + panic("thread has been flagged as inappropriate") + } + + post.repostsCount++ + dst.threads.Set(repost.id.Key(), repost) + post.reposts.Set(dst.id.Key(), repost.id) +} + +func (post *Post) DeleteReply(replyID PostID) error { + if !post.IsThread() { + // TODO: Allow removing replies from parent replies too + panic("cannot delete reply from a non-thread post") + } + + if post.id == replyID { + return errors.New("expected an ID of an inner reply") + } + + key := replyID.Key() + v, removed := post.repliesAll.Remove(key) + if !removed { + return errors.New("reply not found in thread") + } + + // TODO: Shouldn't reply be hidden instead of deleted? Maybe replace reply by a deleted message. + reply := v.(*Post) + if reply.parentID != post.id { + parent, _ := post.GetReply(reply.parentID) + parent.replies.Remove(key) + } else { + post.replies.Remove(key) + } + return nil +} + +func (post *Post) GetSummary() string { + return summaryOf(post.body, 80) +} + +func (post *Post) GetURL() string { + if post.IsThread() { + return post.board.GetURLFromThreadID(post.id) + } + return post.board.GetURLFromReplyID(post.threadID, post.id) +} + +func (post *Post) GetReplyFormURL() string { + if post.IsThread() { + return txlink.Call("CreateReply", + "boardID", post.board.id.String(), + "threadID", post.threadID.String(), + "replyID", "0", + ) + } + return txlink.Call("CreateReply", + "boardID", post.board.id.String(), + "threadID", post.threadID.String(), + "replyID", post.id.String(), + ) +} + +func (post *Post) GetRepostFormURL() string { + return txlink.Call("CreateRepost", + "boardID", post.board.id.String(), + "threadID", post.id.String(), + ) +} + +func (post *Post) GetDeleteFormURL() string { + if post.IsThread() { + return txlink.Call("DeleteThread", + "boardID", post.board.id.String(), + "threadID", post.threadID.String(), + ) + } + return txlink.Call("DeleteReply", + "boardID", post.board.id.String(), + "threadID", post.threadID.String(), + "replyID", post.id.String(), + ) +} + +func (post *Post) GetEditFormURL() string { + if post.IsThread() { + return txlink.Call("EditThread", + "boardID", post.board.id.String(), + "threadID", post.threadID.String(), + "title", post.GetTitle(), + "body", post.GetBody(), + ) + } + + return txlink.Call("EditReply", + "boardID", post.board.id.String(), + "threadID", post.threadID.String(), + "replyID", post.id.String(), + "body", post.GetBody(), + ) +} + +func (post *Post) GetFlagFormURL() string { + if post.IsThread() { + return txlink.Call("FlagThread", + "boardID", post.board.id.String(), + "threadID", post.threadID.String(), + ) + } + + return txlink.Call("FlagReply", + "boardID", post.board.id.String(), + "threadID", post.threadID.String(), + "replyID", post.id.String(), + ) +} + +func (post *Post) RenderSummary() string { + var ( + s string + postURL = post.GetURL() + ) + + if post.title != "" { + s += "## " + newLink(summaryOf(post.title, 80), postURL) + "\n\n" + } + + s += post.GetSummary() + "\n" + + repostBody, _ := post.renderSourcePost("") + s += repostBody + + s += "\\- " + newUserLink(post.creator) + "," + s += " " + newLink(post.createdAt.Format(dateFormat), postURL) + s += " " + newButtonLink("x", post.GetDeleteFormURL()) + s += " (" + strconv.Itoa(post.replies.Size()) + " replies)" + s += " (" + strconv.Itoa(post.reposts.Size()) + " reposts)" + "\n" + return s +} + +func (post *Post) renderSourcePost(indent string) (string, *Post) { + if post.repostBoardID == 0 { + return "", nil + } + + indent += "> " + + // TODO: figure out a way to decouple posts from a global storage. + board, ok := getBoard(post.repostBoardID) + if !ok { + return indentBody(indent, "*Source board is not available*\n\n"), nil + } + + srcPost, ok := board.GetThread(post.parentID) + if !ok { + return indentBody(indent, "*Source post is not available*\n\n"), nil + } + + if srcPost.isHidden { + return indentBody(indent, "*Source post has been flagged as inappropriate*\n\n"), nil + } + + return indentBody(indent, srcPost.GetSummary()) + "\n\n", srcPost +} + +// renderPostContent renders post text content (including repost body). +// Function will dump a predefined message instead of a body if post is hidden. +func (post *Post) renderPostContent(sb *strings.Builder, indent string) { + if post.isHidden { + // Flagged comment should be hidden, but replies still visible (see: #3480) + // Flagged threads will be hidden by render function caller. + sb.WriteString(indentBody(indent, "_Reply is hidden as it has been flagged as inappropriate_")) + sb.WriteString("\n") + return + } + + srcContent, srcPost := post.renderSourcePost(indent) + sb.WriteString(srcContent) + sb.WriteString(indentBody(indent, post.body)) + sb.WriteString("\n") + + if post.IsThread() { + // Split content and controls for threads. + sb.WriteString("\n") + } + + // Buttons & counters + sb.WriteString(indent) + if !post.IsThread() { + sb.WriteString(" \n" + indent) + } + + sb.WriteString(newUserLink(post.creator)) + sb.WriteString(", ") + sb.WriteString(post.createdAt.Format(dateFormat)) + + if post.repostsCount > 0 { + sb.WriteString(", ") + sb.WriteString(strconv.FormatUint(post.repostsCount, 10)) + sb.WriteString(" reposts") + } + + sb.WriteString(" - ") + + if srcPost != nil { + sb.WriteString(" ") + sb.WriteString(newButtonLink("see source post", srcPost.GetURL())) + } + + sb.WriteString(" ") + sb.WriteString(newButtonLink("reply", post.GetReplyFormURL())) + + if post.IsThread() { + sb.WriteString(" ") + sb.WriteString(newButtonLink("repost", post.GetRepostFormURL())) + } + + sb.WriteString(" ") + sb.WriteString(newButtonLink("edit", post.GetEditFormURL())) + + sb.WriteString(" ") + sb.WriteString(newButtonLink("flag", post.GetFlagFormURL())) + + sb.WriteString(" ") + sb.WriteString(newButtonLink("x", post.GetDeleteFormURL())) + sb.WriteString("\n") +} + +func (post *Post) Render(p *PaginationOpts, indent string, levels int) string { + if post == nil { + return "nil post" + } + + // TODO: pass a builder as arg into Render. + var sb strings.Builder + + if post.title != "" { + sb.WriteString(indent) + sb.WriteString("# ") + sb.WriteString(post.title) + sb.WriteString("\n") + sb.WriteString(indent) + sb.WriteString("\n") + } + + post.renderPostContent(&sb, indent) + + if post.replies.Size() == 0 { + return sb.String() + } + + if levels == 0 { + sb.WriteString(indent + "\n") + sb.WriteString(indent) + sb.WriteString("_") + sb.WriteString(newLink("see all "+strconv.Itoa(post.replies.Size())+" replies", post.GetURL())) + sb.WriteString("_\n") + return sb.String() + } + + commentsIndent := indent + "> " + page := p.Iterate(&post.replies, func(_ string, value interface{}) bool { + reply := value.(*Post) + + sb.WriteString(indent) + sb.WriteString("\n") + sb.WriteString(reply.Render(nil, commentsIndent, levels-1)) + return false + }) + + if page != nil { + sb.WriteString("\n---\n") + sb.WriteString(page.Picker()) + } + + return sb.String() +} + +func (post *Post) RenderInner() string { + if post.IsThread() { + panic("unexpected thread") + } + + var ( + threadID = post.threadID + thread, _ = post.board.GetThread(threadID) // TODO: This seems redundant (post == thread) + ) + + s := "_" + newLink("see thread", post.board.GetURLFromThreadID(threadID)) + "_\n\n" + + // Fully render parent if it's not a repost. + if post.repostBoardID == 0 { + var ( + parent *Post + parentID = post.parentID + ) + + if thread.id == parentID { + parent = thread + } else { + parent, _ = thread.GetReply(parentID) + } + + s += parent.Render(nil, "", 0) + "\n" + } + + s += post.Render(nil, "> ", 5) + return s +} diff --git a/examples/gno.land/r/nt/boards2/v1/post_test.gno b/examples/gno.land/r/nt/boards2/v1/post_test.gno new file mode 100644 index 00000000000..1f4926a7436 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/post_test.gno @@ -0,0 +1,419 @@ +package boards2 + +import ( + "strings" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/p/demo/ufmt" +) + +func TestPostUpdate(t *testing.T) { + addr := testutils.TestAddress("creator") + perms := createDefaultBoardPermissions(addr) + board := newBoard(1, "test123", addr, perms) + creator := testutils.TestAddress("creator") + post := newPost(board, 1, creator, "Title", "Body", 1, 0, 0) + title := "New Title" + body := "New body" + + post.Update(title, body) + + uassert.Equal(t, title, post.GetTitle()) + uassert.Equal(t, body, post.GetBody()) + uassert.False(t, post.GetUpdatedAt().IsZero()) +} + +func TestPostAddFlag(t *testing.T) { + addr := testutils.TestAddress("creator") + post := createTestThread(t) + + flag := Flag{ + User: addr, + Reason: "foobar", + } + uassert.True(t, post.AddFlag(flag)) + uassert.False(t, post.AddFlag(flag), "should reject flag from duplicate user") + uassert.Equal(t, post.FlagsCount(), 1) +} + +func TestPostSetVisible(t *testing.T) { + post := createTestThread(t) + uassert.False(t, post.IsHidden(), "post should be visible by default") + + post.SetVisible(false) + uassert.True(t, post.IsHidden(), "post should be hidden") + + post.SetVisible(true) + uassert.False(t, post.IsHidden(), "post should be visible") +} + +func TestPostAddRepostTo(t *testing.T) { + // TODO: Improve this unit test + addr := testutils.TestAddress("creatorDstBoard") + perms := createDefaultBoardPermissions(addr) + cases := []struct { + name, title, body string + dstBoard *Board + thread *Post + setup func() *Post + err string + }{ + { + name: "repost thread", + title: "Repost Title", + body: "Repost body", + dstBoard: newBoard(42, "dst123", addr, perms), + setup: func() *Post { return createTestThread(t) }, + }, + { + name: "invalid repost from reply", + setup: func() *Post { return createTestReply(t) }, + err: "cannot repost non-thread post", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var ( + repost *Post + creator = testutils.TestAddress("repostCreator") + thread = tc.setup() + ) + + createRepost := func() { + var repostId PostID + if tc.dstBoard != nil { + repostId = tc.dstBoard.incGetPostID() + } + + repost = newPost(tc.dstBoard, repostId, creator, tc.title, tc.body, repostId, thread.GetPostID(), thread.GetBoard().GetID()) + thread.AddRepostTo(creator, repost, tc.dstBoard) + } + + if tc.err != "" { + uassert.PanicsWithMessage(t, tc.err, createRepost) + return + } else { + uassert.NotPanics(t, createRepost) + } + + r, found := tc.dstBoard.GetThread(repost.GetPostID()) + uassert.True(t, found) + uassert.True(t, repost == r) + uassert.Equal(t, tc.title, repost.GetTitle()) + uassert.Equal(t, tc.body, repost.GetBody()) + uassert.Equal(t, uint(thread.GetBoard().GetID()), uint(repost.GetRepostBoardID())) + }) + } +} + +func TestNewThread(t *testing.T) { + creator := testutils.TestAddress("creator") + member := testutils.TestAddress("member") + title := "Test Title" + body := strings.Repeat("A", 82) + boardID := BoardID(1) + threadID := PostID(42) + boardName := "test123" + perms := createDefaultBoardPermissions(creator) + board := newBoard(boardID, boardName, creator, perms) + url := ufmt.Sprintf( + "/r/nt/boards2/v1:%s/%d", + boardName, + uint(threadID), + ) + replyURL := ufmt.Sprintf( + "/r/nt/boards2/v1$help&func=CreateReply&boardID=%d&replyID=0&threadID=%d", + uint(boardID), + uint(threadID), + ) + editURL := ufmt.Sprintf( + "/r/nt/boards2/v1$help&func=EditThread&boardID=%d&body=%s&threadID=%d&title=%s", + uint(boardID), + body, + uint(threadID), + strings.ReplaceAll(title, " ", "+"), + ) + repostURL := ufmt.Sprintf( + "/r/nt/boards2/v1$help&func=CreateRepost&boardID=%d&threadID=%d", + uint(boardID), + uint(threadID), + ) + deleteURL := ufmt.Sprintf( + "/r/nt/boards2/v1$help&func=DeleteThread&boardID=%d&threadID=%d", + uint(boardID), + uint(threadID), + ) + flagURL := ufmt.Sprintf( + "/r/nt/boards2/v1$help&func=FlagThread&boardID=%d&threadID=%d", + uint(boardID), + uint(threadID), + ) + + thread := newPost(board, threadID, creator, title, body, threadID, 0, 0) + + uassert.True(t, thread.IsThread()) + uassert.Equal(t, uint(threadID), uint(thread.GetPostID())) + uassert.False(t, thread.GetCreatedAt().IsZero()) + uassert.True(t, thread.GetUpdatedAt().IsZero()) + uassert.Equal(t, title, thread.GetTitle()) + uassert.Equal(t, body[:77]+"...", thread.GetSummary()) + uassert.False(t, thread.HasReplies()) + uassert.Equal(t, url, thread.GetURL()) + uassert.Equal(t, replyURL, thread.GetReplyFormURL()) + uassert.Equal(t, editURL, thread.GetEditFormURL()) + uassert.Equal(t, repostURL, thread.GetRepostFormURL()) + uassert.Equal(t, deleteURL, thread.GetDeleteFormURL()) + uassert.Equal(t, flagURL, thread.GetFlagFormURL()) +} + +func TestThreadAddReply(t *testing.T) { + replier := testutils.TestAddress("replier") + thread := createTestThread(t) + threadID := uint(thread.GetPostID()) + body := "A reply" + + reply := thread.AddReply(replier, body) + + r, found := thread.GetReply(reply.GetPostID()) + uassert.True(t, found) + uassert.True(t, reply == r) + uassert.Equal(t, threadID+1, uint(reply.GetPostID())) + uassert.Equal(t, reply.GetCreator(), replier) + uassert.Equal(t, reply.GetBody(), body) + uassert.True(t, thread.HasReplies()) +} + +func TestThreadGetReply(t *testing.T) { + cases := []struct { + name string + thread *Post + setup func(thread *Post) (replyID PostID) + found bool + }{ + { + name: "found", + thread: createTestThread(t), + setup: func(thread *Post) PostID { + reply := thread.AddReply(testutils.TestAddress("replier"), "") + return reply.GetPostID() + }, + found: true, + }, + { + name: "not found", + thread: createTestThread(t), + setup: func(*Post) PostID { return 42 }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + replyID := tc.setup(tc.thread) + + reply, found := tc.thread.GetReply(replyID) + + uassert.Equal(t, tc.found, found) + if reply != nil { + uassert.Equal(t, uint(replyID), uint(reply.GetPostID())) + } + }) + } +} + +func TestThreadDeleteReply(t *testing.T) { + thread := createTestThread(t) + cases := []struct { + name string + setup func() PostID + err string + }{ + { + name: "ok", + setup: func() PostID { + reply := thread.AddReply(testutils.TestAddress("replier"), "") + return reply.GetPostID() + }, + }, + { + name: "ok nested", + setup: func() PostID { + reply := thread.AddReply(testutils.TestAddress("replier"), "") + return reply.AddReply(testutils.TestAddress("replier2"), "").GetPostID() + }, + }, + { + name: "invalid", + setup: func() PostID { return thread.GetPostID() }, + err: "expected an ID of an inner reply", + }, + { + name: "not found", + setup: func() PostID { return 42 }, + err: "reply not found in thread", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + replyID := tc.setup() + + err := thread.DeleteReply(replyID) + + if tc.err != "" { + uassert.ErrorContains(t, err, tc.err) + return + } + + uassert.NoError(t, err) + _, found := thread.GetReply(replyID) + uassert.False(t, found) + }) + } +} + +func TestThreadRenderSummary(t *testing.T) { + t.Skip("TODO: implement") +} + +func TestThreadRender(t *testing.T) { + t.Skip("TODO: implement") +} + +func TestThreadRenderInner(t *testing.T) { + t.Skip("TODO: implement") +} + +func TestNewReply(t *testing.T) { + creator := testutils.TestAddress("creator") + member := testutils.TestAddress("member") + body := strings.Repeat("A", 82) + boardID := BoardID(1) + threadID := PostID(42) + parentID := PostID(1) + replyID := PostID(2) + boardName := "test123" + perms := createDefaultBoardPermissions(creator) + board := newBoard(boardID, boardName, creator, perms) + url := ufmt.Sprintf( + "/r/nt/boards2/v1:%s/%d/%d", + boardName, + uint(threadID), + uint(replyID), + ) + replyURL := ufmt.Sprintf( + "/r/nt/boards2/v1$help&func=CreateReply&boardID=%d&replyID=%d&threadID=%d", + uint(boardID), + uint(replyID), + uint(threadID), + ) + deleteURL := ufmt.Sprintf( + "/r/nt/boards2/v1$help&func=DeleteReply&boardID=%d&replyID=%d&threadID=%d", + uint(boardID), + uint(replyID), + uint(threadID), + ) + + reply := newPost(board, replyID, creator, "", body, threadID, parentID, 0) + + uassert.False(t, reply.IsThread()) + uassert.Equal(t, uint(replyID), uint(reply.GetPostID())) + uassert.False(t, reply.GetCreatedAt().IsZero()) + uassert.True(t, reply.GetUpdatedAt().IsZero()) + uassert.False(t, reply.HasReplies()) + uassert.Equal(t, body[:77]+"...", reply.GetSummary()) + uassert.Equal(t, url, reply.GetURL()) + uassert.Equal(t, replyURL, reply.GetReplyFormURL()) + uassert.Equal(t, deleteURL, reply.GetDeleteFormURL()) +} + +func TestReplyAddReply(t *testing.T) { + replier := testutils.TestAddress("replier") + thread := createTestThread(t) + parentReply := thread.AddReply(testutils.TestAddress("parentReplier"), "") + threadID := uint(thread.GetPostID()) + parentReplyID := uint(parentReply.GetPostID()) + body := "A child reply" + + reply := parentReply.AddReply(replier, body) + + r, found := thread.GetReply(reply.GetPostID()) + uassert.True(t, found) + uassert.True(t, reply == r) + uassert.Equal(t, parentReplyID, uint(reply.GetParentID())) + uassert.Equal(t, parentReplyID+1, uint(reply.GetPostID())) + uassert.Equal(t, reply.GetCreator(), replier) + uassert.Equal(t, reply.GetBody(), body) + uassert.False(t, reply.HasReplies()) + uassert.True(t, parentReply.HasReplies()) +} + +func TestReplyGetReply(t *testing.T) { + thread := createTestThread(t) + parentReply := thread.AddReply(testutils.TestAddress("parentReplier"), "") + cases := []struct { + name string + setup func() PostID + found bool + }{ + { + name: "found", + setup: func() PostID { + reply := parentReply.AddReply(testutils.TestAddress("replier"), "") + return reply.GetPostID() + }, + found: true, + }, + { + name: "not found", + setup: func() PostID { return 42 }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + replyID := tc.setup() + + reply, found := thread.GetReply(replyID) + + uassert.Equal(t, tc.found, found) + if reply != nil { + uassert.Equal(t, uint(replyID), uint(reply.GetPostID())) + } + }) + } +} + +func TestReplyDeleteReply(t *testing.T) { + thread := createTestThread(t) + parentReply := thread.AddReply(testutils.TestAddress("replier"), "") + reply := parentReply.AddReply(testutils.TestAddress("replier2"), "") + + // NOTE: Deleting a reply from a parent reply should eventually be suported + uassert.PanicsWithMessage(t, "cannot delete reply from a non-thread post", func() { + parentReply.DeleteReply(reply.GetPostID()) + }) +} + +func TestReplyRender(t *testing.T) { + t.Skip("TODO: implement") +} + +func createTestThread(t *testing.T) *Post { + t.Helper() + + creator := testutils.TestAddress("creator") + perms := createDefaultBoardPermissions(creator) + board := newBoard(1, "test_board_123", creator, perms) + return board.AddThread(creator, "Title", "Body") +} + +func createTestReply(t *testing.T) *Post { + t.Helper() + + creator := testutils.TestAddress("replier") + thread := createTestThread(t) + return thread.AddReply(creator, "Test message") +} diff --git a/examples/gno.land/r/nt/boards2/v1/public.gno b/examples/gno.land/r/nt/boards2/v1/public.gno new file mode 100644 index 00000000000..c42aa0e7a6e --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/public.gno @@ -0,0 +1,491 @@ +package boards2 + +import ( + "std" + "strings" +) + +// SetPermissions sets a permissions implementation for boards2 realm or a board. +func SetPermissions(bid BoardID, p Permissions) { + if p == nil { + panic("permissions is required") + } + + if bid != 0 { + assertBoardExists(bid) + } + + caller := std.OriginCaller() + args := Args{bid} + gPerms.WithPermission(caller, PermissionPermissionsUpdate, args, func(Args) { + // When board ID is zero it means that realm permissions are being updated + if bid == 0 { + gPerms = p + return + } + + // Otherwise update the permissions of a single board + board := mustGetBoard(bid) + board.perms = p + }) +} + +// GetBoardIDFromName searches a board by name and returns it's ID. +func GetBoardIDFromName(name string) (_ BoardID, found bool) { + v, found := gBoardsByName.Get(name) + if !found { + return 0, false + } + return v.(*Board).id, true +} + +// CreateBoard creates a new board. +func CreateBoard(name string) BoardID { + name = strings.TrimSpace(name) + assertNameIsNotEmpty(name) + assertBoardNameNotExists(name) + + caller := std.OriginCaller() + id := incGetBoardID() + args := Args{name, id} + gPerms.WithPermission(caller, PermissionBoardCreate, args, func(Args) { + assertBoardNameNotExists(name) + + perms := createDefaultBoardPermissions(caller) + board := newBoard(id, name, caller, perms) + gBoardsByID.Set(id.Key(), board) + gBoardsByName.Set(name, board) + }) + return id +} + +// RenameBoard changes the name of an existing board. +// +// A history of previous board names is kept when boards are renamed. +// Because of that boards are also accesible using previous name(s). +func RenameBoard(name, newName string) { + newName = strings.TrimSpace(newName) + assertNameIsNotEmpty(newName) + assertBoardNameNotExists(newName) + + board := mustGetBoardByName(name) + assertBoardIsNotFrozen(board) + + bid := board.GetID() + caller := std.OriginCaller() + args := Args{bid, name, newName} + board.perms.WithPermission(caller, PermissionBoardRename, args, func(Args) { + assertBoardNameNotExists(newName) + + board := mustGetBoard(bid) + board.aliases = append(board.aliases, board.name) + board.name = newName + + // Index board for the new name keeping previous indexes for older names + gBoardsByName.Set(newName, board) + }) +} + +// FreezeBoard freezes a board so no more threads and comments can be created or modified. +func FreezeBoard(boardID BoardID) { + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + + caller := std.OriginCaller() + args := Args{boardID} + board.perms.WithPermission(caller, PermissionBoardFreeze, args, func(Args) { + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + + board.SetReadOnly(true) + }) +} + +// IsBoardFrozen checks if a board has been frozen. +func IsBoardFrozen(boardID BoardID) bool { + board := mustGetBoard(boardID) + return board.IsReadOnly() +} + +// SetFlaggingThreshold sets the number of flags required to hide a thread or comment. +// +// Threshold is only applicable within the board where it's setted. +func SetFlaggingThreshold(boardID BoardID, threshold int) { + if threshold < 1 { + panic("invalid flagging threshold") + } + + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + + caller := std.OriginCaller() + args := Args{boardID, threshold} + board.perms.WithPermission(caller, PermissionBoardFlaggingUpdate, args, func(Args) { + assertBoardExists(boardID) + + gFlaggingThresholds.Set(boardID.String(), threshold) + }) +} + +// GetFlaggingThreshold returns the number of flags required to hide a thread or comment within a board. +func GetFlaggingThreshold(boardID BoardID) int { + assertBoardExists(boardID) + return getFlaggingThreshold(boardID) +} + +// FlagThread adds a new flag to a thread. +// +// Flagging requires special permissions and hides the thread when +// the number of flags reaches a pre-defined flagging threshold. +func FlagThread(boardID BoardID, threadID PostID, reason string) { + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + + caller := std.OriginCaller() + assertHasBoardPermission(board, caller, PermissionThreadFlag) + + t, ok := board.GetThread(threadID) + if !ok { + panic("post doesn't exist") + } + + f := Flag{ + User: caller, + Reason: reason, + } + hide := flagItem(t, f, getFlaggingThreshold(boardID)) + if hide { + t.SetVisible(false) + } +} + +// CreateThread creates a new thread within a board. +func CreateThread(boardID BoardID, title, body string) PostID { + title = strings.TrimSpace(title) + assertTitleIsNotEmpty(title) + + body = strings.TrimSpace(body) + assertBodyIsNotEmpty(body) + + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + + caller := std.OriginCaller() + assertHasBoardPermission(board, caller, PermissionThreadCreate) + + thread := board.AddThread(caller, title, body) + return thread.id +} + +// CreateReply creates a new comment or reply within a thread. +// +// The value of `replyID` is only required when creating a reply of another reply. +func CreateReply(boardID BoardID, threadID, replyID PostID, body string) PostID { + body = strings.TrimSpace(body) + assertBodyIsNotEmpty(body) + + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + + caller := std.OriginCaller() + assertHasBoardPermission(board, caller, PermissionReplyCreate) + + thread := mustGetThread(board, threadID) + assertThreadVisible(thread) + + var reply *Post + if replyID == 0 { + // When the parent reply is the thread just add reply to thread + reply = thread.AddReply(caller, body) + } else { + // Try to get parent reply and add a new child reply + post := mustGetReply(thread, replyID) + assertReplyVisible(post) + + reply = post.AddReply(caller, body) + } + return reply.id +} + +// FlagReply adds a new flag to a comment or reply. +// +// Flagging requires special permissions and hides the comment or reply +// when the number of flags reaches a pre-defined flagging threshold. +func FlagReply(boardID BoardID, threadID, replyID PostID, reason string) { + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + + caller := std.OriginCaller() + assertHasBoardPermission(board, caller, PermissionThreadFlag) + + thread := mustGetThread(board, threadID) + reply := mustGetReply(thread, replyID) + + f := Flag{ + User: caller, + Reason: reason, + } + hide := flagItem(reply, f, getFlaggingThreshold(boardID)) + if hide { + reply.SetVisible(false) + } +} + +// CreateRepost reposts a thread into another board. +func CreateRepost(boardID BoardID, threadID PostID, title, body string, dstBoardID BoardID) PostID { + caller := std.OriginCaller() + dst := mustGetBoard(dstBoardID) + assertBoardIsNotFrozen(dst) + assertHasBoardPermission(dst, caller, PermissionThreadRepost) + + board := mustGetBoard(boardID) + thread := mustGetThread(board, threadID) + repostId := dst.incGetPostID() + repost := newPost(dst, repostId, caller, title, body, repostId, thread.GetPostID(), thread.GetBoard().GetID()) + thread.AddRepostTo(caller, repost, dst) + return repostId +} + +// DeleteThread deletes a thread from a board. +// +// Threads can be deleted by the users who created them or otherwise by users with special permissions. +func DeleteThread(boardID BoardID, threadID PostID) { + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + + caller := std.OriginCaller() + thread := mustGetThread(board, threadID) + if caller != thread.GetCreator() { + assertHasBoardPermission(board, caller, PermissionThreadDelete) + } + + // TODO: Discuss how to deal with thread deletion (should we hide instead?) + board.DeleteThread(threadID) +} + +// DeleteReply deletes a reply from a thread. +// +// Replies can be deleted by the users who created them or otherwise by users with special permissions. +// Soft deletion is used when the deleted reply contains sub replies, in which case the reply content +// is replaced by a text informing that reply has been deleted to avoid deleting sub-replies. +func DeleteReply(boardID BoardID, threadID, replyID PostID) { + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + + thread := mustGetThread(board, threadID) + reply := mustGetReply(thread, replyID) + assertReplyVisible(reply) + + caller := std.OriginCaller() + if caller != reply.GetCreator() { + assertHasBoardPermission(board, caller, PermissionReplyDelete) + } + + // Soft delete reply by changing its body when it contains + // sub-replies, otherwise hard delete it. + if reply.HasReplies() { + reply.Update(reply.GetTitle(), "This reply has been deleted") + } else { + thread.DeleteReply(replyID) + } +} + +// EditThread updates the title and body of thread. +// +// Threads can be updated by the users who created them or otherwise by users with special permissions. +func EditThread(boardID BoardID, threadID PostID, title, body string) { + title = strings.TrimSpace(title) + assertTitleIsNotEmpty(title) + + body = strings.TrimSpace(body) + assertBodyIsNotEmpty(body) + + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + + thread := mustGetThread(board, threadID) + caller := std.OriginCaller() + if caller != thread.GetCreator() { + assertHasBoardPermission(board, caller, PermissionThreadEdit) + } + + thread.Update(title, body) +} + +// EditReply updates the body of comment or reply. +// +// Replies can be updated only by the users who created them. +func EditReply(boardID BoardID, threadID, replyID PostID, body string) { + body = strings.TrimSpace(body) + assertBodyIsNotEmpty(body) + + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + + thread := mustGetThread(board, threadID) + reply := mustGetReply(thread, replyID) + assertReplyVisible(reply) + + if std.OriginCaller() != reply.GetCreator() { + panic("only the reply creator is allowed to edit it") + } + + reply.Update("", body) +} + +// InviteMember adds a member to the realm or to a boards. +// +// A role can optionally be specified to be assigned to the new member. +// Board ID is only required when inviting a member to a board. +func InviteMember(boardID BoardID, user std.Address, role Role) { + if boardID != 0 { + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + } + + perms := mustGetPermissions(boardID) + caller := std.OriginCaller() + args := Args{user, role} + perms.WithPermission(caller, PermissionMemberInvite, args, func(Args) { + if err := perms.AddUser(user, role); err != nil { + panic(err) + } + }) +} + +// RemoveMember removes a member from the realm or a boards. +// +// Board ID is only required when removing a member from board. +func RemoveMember(boardID BoardID, user std.Address) { + if boardID != 0 { + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + } + + perms := mustGetPermissions(boardID) + caller := std.OriginCaller() + perms.WithPermission(caller, PermissionMemberRemove, Args{user}, func(Args) { + if !perms.RemoveUser(user) { + panic("member not found") + } + }) +} + +// IsMember checks if an user is a member of the realm or a board. +// +// Board ID is only required when checking if a user is a member of a board. +func IsMember(boardID BoardID, user std.Address) bool { + if boardID != 0 { + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + } + + perms := mustGetPermissions(boardID) + return perms.HasUser(user) +} + +// HasMemberRole checks if a realm or board member has a specific role assigned. +// +// Board ID is only required when checking a member of a board. +func HasMemberRole(boardID BoardID, member std.Address, role Role) bool { + if boardID != 0 { + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + } + + perms := mustGetPermissions(boardID) + return perms.HasRole(member, role) +} + +// ChangeMemberRole changes the role of a realm or board member. +// +// Board ID is only required when changing the role for a member of a board. +func ChangeMemberRole(boardID BoardID, member std.Address, role Role) { + if boardID != 0 { + board := mustGetBoard(boardID) + assertBoardIsNotFrozen(board) + } + + perms := mustGetPermissions(boardID) + caller := std.OriginCaller() + args := Args{boardID, member, role} + perms.WithPermission(caller, PermissionRoleChange, args, func(Args) { + if err := perms.SetUserRoles(member, role); err != nil { + panic(err) + } + }) +} + +func assertHasBoardPermission(b *Board, user std.Address, p Permission) { + if !b.perms.HasPermission(user, p) { + panic("unauthorized") + } +} + +func assertBoardExists(id BoardID) { + if _, found := getBoard(id); !found { + panic("board not found: " + id.String()) + } +} + +func assertBoardIsNotFrozen(b *Board) { + if b.IsReadOnly() { + panic("board is frozen") + } +} + +func assertNameIsNotEmpty(name string) { + if name == "" { + panic("name is empty") + } +} + +func assertTitleIsNotEmpty(title string) { + if title == "" { + panic("title is empty") + } +} + +func assertBodyIsNotEmpty(body string) { + if body == "" { + panic("body is empty") + } +} + +func assertBoardNameNotExists(name string) { + if gBoardsByName.Has(name) { + panic("board already exists") + } +} + +func assertThreadExists(b *Board, threadID PostID) { + if _, found := b.GetThread(threadID); !found { + panic("thread not found: " + threadID.String()) + } +} + +func assertReplyExists(thread *Post, replyID PostID) { + if _, found := thread.GetReply(replyID); !found { + panic("reply not found: " + replyID.String()) + } +} + +func assertThreadVisible(thread *Post) { + if thread.IsHidden() { + panic("thread with ID: " + thread.GetPostID().String() + " was hidden") + } +} + +func assertReplyVisible(thread *Post) { + if thread.IsHidden() { + panic("reply with ID: " + thread.GetPostID().String() + " was hidden") + } +} + +func mustGetPermissions(bid BoardID) Permissions { + if bid != 0 { + board := mustGetBoard(bid) + return board.perms + } + return gPerms +} diff --git a/examples/gno.land/r/nt/boards2/v1/render.gno b/examples/gno.land/r/nt/boards2/v1/render.gno new file mode 100644 index 00000000000..59fb2f97406 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/render.gno @@ -0,0 +1,260 @@ +package boards2 + +import ( + "net/url" + "std" + "strconv" + "strings" + + "gno.land/p/demo/mux" + "gno.land/p/jeronimoalbi/pager" + "gno.land/p/moul/txlink" +) + +const ( + boardsPageSize = 20 + threadsPageSize = 30 + repliesPageSize = 30 +) + +const ( + menuAdmin = "admin" + menuMembership = "membership" +) + +func Render(path string) string { + router := mux.NewRouter() + router.HandleFunc("", renderBoardsList) + router.HandleFunc("members", renderMembers) + router.HandleFunc("{board}", renderBoard) + router.HandleFunc("{board}/members", renderMembers) + router.HandleFunc("{board}/{thread}", renderThread) + router.HandleFunc("{board}/{thread}/{reply}", renderReply) + + router.NotFoundHandler = func(res *mux.ResponseWriter, _ *mux.Request) { + res.Write("Path not found") + } + + return router.Render(path) +} + +func renderBoardsList(res *mux.ResponseWriter, req *mux.Request) { + renderBoardListMenu(res, req) + + res.Write("These are all the boards of this realm:\n\n") + p := mustGetPagination(req.RawPath, boardsPageSize) + page := p.Iterate(&gBoardsByID, func(_ string, value interface{}) bool { + board := value.(*Board) + path := board.GetPath() + url := board.GetURL() + res.Write(" * " + newLink(path, url) + "\n") + return false + }) + + if page != nil { + res.Write("\n---\n") + res.Write(page.Picker()) + } +} + +func renderBoardListMenu(res *mux.ResponseWriter, req *mux.Request) { + res.Write(newButtonLink("create board", txlink.Call("CreateBoard")) + " - ") + + menu := getCurrentSubmenu(req.RawPath) + if menu == menuMembership { + res.Write("**membership**") + } else { + res.Write(newButtonLink("membership", submenuURL(menuMembership))) + } + + res.Write("\n\n") + + if menu == menuMembership { + path := strings.TrimPrefix(std.CurrentRealm().PkgPath(), "gno.land") + + res.Write("↳") + res.Write(newButtonLink("invite", txlink.Call("InviteMember", "boardID", "0")) + " ") + res.Write(newButtonLink("members", path+":members") + " ") + res.Write(newButtonLink("remove member", txlink.Call("RemoveMember", "boardID", "0")) + " ") + res.Write(newButtonLink("change member role", txlink.Call("ChangeMemberRole", "boardID", "0")) + "\n\n") + } +} + +func renderBoard(res *mux.ResponseWriter, req *mux.Request) { + name := req.GetVar("board") + v, found := gBoardsByName.Get(name) + if !found { + res.Write("Board does not exist: " + name) + return + } + + board := v.(*Board) + renderBoardMenu(board, res, req) + + p := mustGetPagination(req.RawPath, threadsPageSize) + res.Write(board.Render(p)) +} + +func renderBoardMenu(board *Board, res *mux.ResponseWriter, req *mux.Request) { + res.Write(newButtonLink("post", board.GetPostFormURL()) + " - ") + + menu := getCurrentSubmenu(req.RawPath) + if menu == menuMembership { + res.Write("**membership** - ") + } else { + res.Write(newButtonLink("membership", submenuURL(menuMembership)) + " - ") + } + + if menu == menuAdmin { + res.Write("**admin**") + } else { + res.Write(newButtonLink("admin", submenuURL(menuAdmin))) + } + + res.Write("\n\n") + + if menu != "" { + res.Write("↳") + } + + switch menu { + case menuAdmin: + res.Write(newButtonLink("rename board", board.GetRenameFormURL()) + " ") + res.Write(newButtonLink("freeze", board.GetFreezeFormURL()) + " ") + res.Write(newButtonLink("change flagging threshold", board.GetFlaggingThresholdFormURL()) + "\n\n") + case menuMembership: + res.Write(newButtonLink("invite", board.GetInviteMemberFormURL()) + " ") + res.Write(newButtonLink("members", board.GetPath()+"/members") + " ") + res.Write(newButtonLink("remove member", board.GetRemoveMemberFormURL()) + " ") + res.Write(newButtonLink("change member role", board.GetChangeMemberRoleFormURL()) + "\n\n") + } +} + +func renderThread(res *mux.ResponseWriter, req *mux.Request) { + name := req.GetVar("board") + v, found := gBoardsByName.Get(name) + if !found { + res.Write("Board does not exist: " + name) + return + } + + rawID := req.GetVar("thread") + tID, err := strconv.Atoi(rawID) + if err != nil { + res.Write("Invalid thread ID: " + rawID) + return + } + + board := v.(*Board) + thread, found := board.GetThread(PostID(tID)) + if !found { + res.Write("Thread does not exist with ID: " + rawID) + } else if thread.IsHidden() { + res.Write("Thread with ID: " + rawID + " has been flagged as inappropriate") + } else { + p := mustGetPagination(req.RawPath, repliesPageSize) + res.Write(thread.Render(p, "", 5)) + } +} + +func renderReply(res *mux.ResponseWriter, req *mux.Request) { + name := req.GetVar("board") + v, found := gBoardsByName.Get(name) + if !found { + res.Write("Board does not exist: " + name) + return + } + + rawID := req.GetVar("thread") + tID, err := strconv.Atoi(rawID) + if err != nil { + res.Write("Invalid thread ID: " + rawID) + return + } + + rawID = req.GetVar("reply") + rID, err := strconv.Atoi(rawID) + if err != nil { + res.Write("Invalid reply ID: " + rawID) + return + } + + board := v.(*Board) + thread, found := board.GetThread(PostID(tID)) + if !found { + res.Write("Thread does not exist with ID: " + req.GetVar("thread")) + return + } + + reply, found := thread.GetReply(PostID(rID)) + if !found { + res.Write("Reply does not exist with ID: " + rawID) + return + } + + // Call render even for hidden replies to display children. + // Original comment content will be hidden under the hood. + // See: #3480 + res.Write(reply.RenderInner()) +} + +func renderMembers(res *mux.ResponseWriter, req *mux.Request) { + perms := gPerms + name := req.GetVar("board") + if name != "" { + v, found := gBoardsByName.Get(name) + if !found { + res.Write("Board does not exist: " + name) + return + } + + board := v.(*Board) + perms = board.perms + + res.Write("# Board Members: " + board.GetName() + "\n\n") + } else { + res.Write("# Boards Members\n\n") + } + + p, err := pager.New(req.RawPath, perms.UsersCount()) + if err != nil { + res.Write(err.Error()) + return + } + + perms.IterateUsers(p.Offset(), p.PageSize(), func(u User) bool { + res.Write("- " + u.Address.String() + " " + rolesToString(u.Roles) + "\n") + return false + }) + + if p.HasPages() { + res.Write("\n\n" + pager.Picker(p)) + } +} + +func rolesToString(roles []Role) string { + if len(roles) == 0 { + return "" + } + + names := make([]string, len(roles)) + for i, r := range roles { + names[i] = string(r) + } + return strings.Join(names, ", ") +} + +func submenuURL(name string) string { + // TODO: Submenu URL works because no other GET arguments are being used + return "?submenu=" + name +} + +func getCurrentSubmenu(rawURL string) string { + _, rawQuery, found := strings.Cut(rawURL, "?") + if !found { + return "" + } + + query, _ := url.ParseQuery(rawQuery) + return query.Get("submenu") +} diff --git a/examples/gno.land/r/nt/boards2/v1/z_0_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_0_a_filetest.gno new file mode 100644 index 00000000000..106ce1c4fe3 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_0_a_filetest.gno @@ -0,0 +1,21 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + bid := boards2.CreateBoard("test123") + println("ID =", bid) +} + +// Output: +// ID = 1 diff --git a/examples/gno.land/r/nt/boards2/v1/z_0_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_0_b_filetest.gno new file mode 100644 index 00000000000..b2f9c674492 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_0_b_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.CreateBoard("") +} + +// Error: +// name is empty diff --git a/examples/gno.land/r/nt/boards2/v1/z_0_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_0_c_filetest.gno new file mode 100644 index 00000000000..4be9c4be8be --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_0_c_filetest.gno @@ -0,0 +1,24 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + boardName = "test123" +) + +func init() { + std.TestSetOriginCaller(owner) + boards2.CreateBoard(boardName) +} + +func main() { + boards2.CreateBoard(boardName) +} + +// Error: +// board already exists diff --git a/examples/gno.land/r/nt/boards2/v1/z_0_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_0_e_filetest.gno new file mode 100644 index 00000000000..98992a17ef8 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_0_e_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.CreateBoard("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") +} + +// Error: +// addresses are not allowed as board name diff --git a/examples/gno.land/r/nt/boards2/v1/z_0_f_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_0_f_filetest.gno new file mode 100644 index 00000000000..c08abd08d87 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_0_f_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.CreateBoard("gnoland") +} + +// Error: +// board name is a user name registered to a different user diff --git a/examples/gno.land/r/nt/boards2/v1/z_0_g_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_0_g_filetest.gno new file mode 100644 index 00000000000..658b3cef215 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_0_g_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.CreateBoard("short") +} + +// Error: +// the minimum allowed board name length is 6 characters diff --git a/examples/gno.land/r/nt/boards2/v1/z_0_h_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_0_h_filetest.gno new file mode 100644 index 00000000000..82856e8677c --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_0_h_filetest.gno @@ -0,0 +1,34 @@ +package main + +// SEND: 200000000ugnot + +import ( + "std" + + "gno.land/r/demo/users" + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + member = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + name = "test123" +) + +func init() { + std.TestSetOriginCaller(owner) + + // Test1 is the boards owner and its address has a user already registered + // so a new member must register a user with the new board name. + boards2.InviteMember(0, member, boards2.RoleOwner) // Operate on realm DAO members instead of individual boards + std.TestSetOriginCaller(member) + users.Register("", name, "") +} + +func main() { + bid := boards2.CreateBoard(name) + println("ID =", bid) +} + +// Output: +// ID = 1 diff --git a/examples/gno.land/r/nt/boards2/v1/z_0_i_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_0_i_filetest.gno new file mode 100644 index 00000000000..aac2127c552 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_0_i_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.CreateBoard("test123") +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_10_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_10_a_filetest.gno new file mode 100644 index 00000000000..f699f7866e9 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_10_a_filetest.gno @@ -0,0 +1,30 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") +} + +func main() { + boards2.FlagThread(bid, pid, "") + + // Ensure that original thread content not visible + println(boards2.Render("test-board/1")) +} + +// Output: +// Thread with ID: 1 has been flagged as inappropriate diff --git a/examples/gno.land/r/nt/boards2/v1/z_10_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_10_b_filetest.gno new file mode 100644 index 00000000000..905bf3cc824 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_10_b_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.FlagThread(404, 1, "") +} + +// Error: +// board does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_10_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_10_c_filetest.gno new file mode 100644 index 00000000000..96aa028a8b8 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_10_c_filetest.gno @@ -0,0 +1,32 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") + + std.TestSetOriginCaller(user) +} + +func main() { + boards2.FlagThread(bid, pid, "") +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_10_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_10_d_filetest.gno new file mode 100644 index 00000000000..b3545957105 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_10_d_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") +} + +func main() { + boards2.FlagThread(bid, 404, "") +} + +// Error: +// post doesn't exist diff --git a/examples/gno.land/r/nt/boards2/v1/z_10_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_10_e_filetest.gno new file mode 100644 index 00000000000..efe836a4a3b --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_10_e_filetest.gno @@ -0,0 +1,28 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") + boards2.FlagThread(bid, pid, "") +} + +func main() { + boards2.FlagThread(bid, pid, "") +} + +// Error: +// item flag count threshold exceeded: 1 diff --git a/examples/gno.land/r/nt/boards2/v1/z_10_f_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_10_f_filetest.gno new file mode 100644 index 00000000000..3a730a9713b --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_10_f_filetest.gno @@ -0,0 +1,37 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + moderator = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") + + // Invite a member using a role with permission to flag threads + boards2.InviteMember(bid, moderator, boards2.RoleModerator) + std.TestSetOriginCaller(moderator) +} + +func main() { + boards2.FlagThread(bid, pid, "") + + // Ensure that original thread content not visible + println(boards2.Render("test-board/1")) +} + +// Output: +// Thread with ID: 1 has been flagged as inappropriate diff --git a/examples/gno.land/r/nt/boards2/v1/z_10_g_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_10_g_filetest.gno new file mode 100644 index 00000000000..bb0c0aceb67 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_10_g_filetest.gno @@ -0,0 +1,30 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") + + boards2.SetFlaggingThreshold(bid, 2) + boards2.FlagThread(bid, pid, "") +} + +func main() { + boards2.FlagThread(bid, pid, "") +} + +// Error: +// item has been already flagged by a current user diff --git a/examples/gno.land/r/nt/boards2/v1/z_11_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_11_a_filetest.gno new file mode 100644 index 00000000000..b2c7cdb693e --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_11_a_filetest.gno @@ -0,0 +1,39 @@ +package main + +import ( + "std" + "strings" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + title = "Test Thread" + body = "Test body" + path = "test-board/1" +) + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") +} + +func main() { + boards2.EditThread(bid, pid, title, body) + + // Render content must contains thread's title and body + content := boards2.Render(path) + println(strings.HasPrefix(content, "# "+title)) + println(strings.Contains(content, body)) +} + +// Output: +// true +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_11_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_11_b_filetest.gno new file mode 100644 index 00000000000..b30c15d1da1 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_11_b_filetest.gno @@ -0,0 +1,27 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") +} + +func main() { + boards2.EditThread(bid, pid, "", "bar") +} + +// Error: +// title is empty diff --git a/examples/gno.land/r/nt/boards2/v1/z_11_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_11_c_filetest.gno new file mode 100644 index 00000000000..570a6b19e4e --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_11_c_filetest.gno @@ -0,0 +1,27 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") +} + +func main() { + boards2.EditThread(bid, pid, "Foo", "") +} + +// Error: +// body is empty diff --git a/examples/gno.land/r/nt/boards2/v1/z_11_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_11_d_filetest.gno new file mode 100644 index 00000000000..6ad751348d0 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_11_d_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.EditThread(404, 1, "Foo", "bar") +} + +// Error: +// board does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_11_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_11_e_filetest.gno new file mode 100644 index 00000000000..294c7d7c282 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_11_e_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") +} + +func main() { + boards2.EditThread(bid, 404, "Foo", "") +} + +// Error: +// body is empty diff --git a/examples/gno.land/r/nt/boards2/v1/z_11_f_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_11_f_filetest.gno new file mode 100644 index 00000000000..e2b24da8295 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_11_f_filetest.gno @@ -0,0 +1,32 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") + + std.TestSetOriginCaller(user) +} + +func main() { + boards2.EditThread(bid, pid, "Foo", "bar") +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_11_g_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_11_g_filetest.gno new file mode 100644 index 00000000000..159ba2ec79f --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_11_g_filetest.gno @@ -0,0 +1,44 @@ +package main + +import ( + "std" + "strings" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + admin = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + title = "Test Thread" + body = "Test body" + path = "test-board/1" +) + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") + + // Invite a member using a role with permission to edit threads + boards2.InviteMember(bid, admin, boards2.RoleAdmin) + std.TestSetOriginCaller(admin) +} + +func main() { + boards2.EditThread(bid, pid, title, body) + + // Render content must contains thread's title and body + content := boards2.Render(path) + println(strings.HasPrefix(content, "# "+title)) + println(strings.Contains(content, body)) +} + +// Output: +// true +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_12_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_12_a_filetest.gno new file mode 100644 index 00000000000..b1f5798889d --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_12_a_filetest.gno @@ -0,0 +1,37 @@ +package main + +import ( + "std" + "strings" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + body = "Test reply" + path = "test-board/1/2" +) + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "body") +} + +func main() { + boards2.EditReply(bid, tid, rid, body) + + // Render content must contain the modified reply + content := boards2.Render(path) + println(strings.Contains(content, "\n> "+body+"\n")) +} + +// Output: +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_12_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_12_b_filetest.gno new file mode 100644 index 00000000000..7b9d0ee0cb7 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_12_b_filetest.gno @@ -0,0 +1,40 @@ +package main + +import ( + "std" + "strings" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + body = "Test reply" + path = "test-board/1/2" +) + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + + // Create a reply and a sub reply + parentRID := boards2.CreateReply(bid, tid, 0, "Parent") + rid = boards2.CreateReply(bid, tid, parentRID, "Child") +} + +func main() { + boards2.EditReply(bid, tid, rid, body) + + // Render content must contain the modified reply + content := boards2.Render(path) + println(strings.Contains(content, "\n> > "+body+"\n")) +} + +// Output: +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_12_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_12_c_filetest.gno new file mode 100644 index 00000000000..e70c55d5413 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_12_c_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.EditReply(404, 1, 0, "body") +} + +// Error: +// board does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_12_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_12_d_filetest.gno new file mode 100644 index 00000000000..bd99705359f --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_12_d_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") +} + +func main() { + boards2.EditReply(bid, 404, 0, "body") +} + +// Error: +// thread does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_12_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_12_e_filetest.gno new file mode 100644 index 00000000000..06aa752773c --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_12_e_filetest.gno @@ -0,0 +1,27 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") +} + +func main() { + boards2.EditReply(bid, tid, 404, "body") +} + +// Error: +// reply does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_12_f_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_12_f_filetest.gno new file mode 100644 index 00000000000..824e7505344 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_12_f_filetest.gno @@ -0,0 +1,32 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "body") + std.TestSetOriginCaller(user) +} + +func main() { + boards2.EditReply(bid, tid, rid, "new body") +} + +// Error: +// only the reply creator is allowed to edit it diff --git a/examples/gno.land/r/nt/boards2/v1/z_12_g_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_12_g_filetest.gno new file mode 100644 index 00000000000..f35984e2c84 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_12_g_filetest.gno @@ -0,0 +1,34 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "body") + + // Flag the reply so it's hidden + boards2.FlagReply(bid, tid, rid, "reason") +} + +func main() { + boards2.EditReply(bid, tid, rid, "body") +} + +// Error: +// reply with ID: 2 was hidden diff --git a/examples/gno.land/r/nt/boards2/v1/z_12_h_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_12_h_filetest.gno new file mode 100644 index 00000000000..f36e50d7ba6 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_12_h_filetest.gno @@ -0,0 +1,28 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "body") +} + +func main() { + boards2.EditReply(bid, tid, rid, "") +} + +// Error: +// body is empty diff --git a/examples/gno.land/r/nt/boards2/v1/z_13_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_13_a_filetest.gno new file mode 100644 index 00000000000..420d722c862 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_13_a_filetest.gno @@ -0,0 +1,33 @@ +package main + +import ( + "std" + "strings" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + rid, tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "body") +} + +func main() { + boards2.FlagReply(bid, tid, rid, "") + + // Render content must contain a message about the hidden reply + content := boards2.Render("test-board/1/2") + println(strings.Contains(content, "\n> _Reply is hidden as it has been flagged as inappropriate_\n")) +} + +// Output: +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_13_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_13_b_filetest.gno new file mode 100644 index 00000000000..a11c57af144 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_13_b_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.FlagReply(404, 1, 1, "") +} + +// Error: +// board does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_13_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_13_c_filetest.gno new file mode 100644 index 00000000000..2065e3c87de --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_13_c_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") +} + +func main() { + boards2.FlagReply(bid, 404, 1, "") +} + +// Error: +// thread does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_13_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_13_d_filetest.gno new file mode 100644 index 00000000000..d16a89e3998 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_13_d_filetest.gno @@ -0,0 +1,27 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") +} + +func main() { + boards2.FlagReply(bid, tid, 404, "") +} + +// Error: +// reply does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_13_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_13_e_filetest.gno new file mode 100644 index 00000000000..56d58618e37 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_13_e_filetest.gno @@ -0,0 +1,29 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + rid, tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "body") + boards2.FlagReply(bid, tid, rid, "") +} + +func main() { + boards2.FlagReply(bid, tid, rid, "") +} + +// Error: +// item flag count threshold exceeded: 1 diff --git a/examples/gno.land/r/nt/boards2/v1/z_13_f_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_13_f_filetest.gno new file mode 100644 index 00000000000..387898783a5 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_13_f_filetest.gno @@ -0,0 +1,33 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + rid, tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "body") + + std.TestSetOriginCaller(user) +} + +func main() { + boards2.FlagReply(bid, tid, rid, "") +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_13_g_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_13_g_filetest.gno new file mode 100644 index 00000000000..3f0c762107f --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_13_g_filetest.gno @@ -0,0 +1,40 @@ +package main + +import ( + "std" + "strings" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + moderator = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + rid, tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "body") + + // Invite a member using a role with permission to flag replies + boards2.InviteMember(bid, moderator, boards2.RoleModerator) + std.TestSetOriginCaller(moderator) +} + +func main() { + boards2.FlagReply(bid, tid, rid, "") + + // Render content must contain a message about the hidden reply + content := boards2.Render("test-board/1/2") + println(strings.Contains(content, "\n> _Reply is hidden as it has been flagged as inappropriate_\n")) +} + +// Output: +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_14_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_14_a_filetest.gno new file mode 100644 index 00000000000..d167f2776ed --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_14_a_filetest.gno @@ -0,0 +1,31 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "body") +} + +func main() { + boards2.DeleteReply(bid, tid, rid) + + // Ensure reply doesn't exist + println(boards2.Render("test-board/1/2")) +} + +// Output: +// Reply does not exist with ID: 2 diff --git a/examples/gno.land/r/nt/boards2/v1/z_14_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_14_b_filetest.gno new file mode 100644 index 00000000000..6373a604fc1 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_14_b_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.DeleteReply(404, 1, 1) +} + +// Error: +// board does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_14_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_14_c_filetest.gno new file mode 100644 index 00000000000..82045269705 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_14_c_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") +} + +func main() { + boards2.DeleteReply(bid, 404, 1) +} + +// Error: +// thread does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_14_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_14_d_filetest.gno new file mode 100644 index 00000000000..cdde26d4f7f --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_14_d_filetest.gno @@ -0,0 +1,27 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") +} + +func main() { + boards2.DeleteReply(bid, tid, 404) +} + +// Error: +// reply does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_14_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_14_e_filetest.gno new file mode 100644 index 00000000000..c1314f65429 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_14_e_filetest.gno @@ -0,0 +1,37 @@ +package main + +import ( + "std" + "strings" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + + rid = boards2.CreateReply(bid, tid, 0, "Parent") + boards2.CreateReply(bid, tid, rid, "Child reply") +} + +func main() { + boards2.DeleteReply(bid, tid, rid) + + // Render content must contain the releted message instead of reply's body + content := boards2.Render("test-board/1/2") + println(strings.Contains(content, "\n> This reply has been deleted\n")) + println(strings.Contains(content, "\n> > Child reply\n")) +} + +// Output: +// true +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_14_f_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_14_f_filetest.gno new file mode 100644 index 00000000000..1cbafc26ced --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_14_f_filetest.gno @@ -0,0 +1,34 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "body") + + // Call using a user that has not permission to delete replies + std.TestSetOriginCaller(user) +} + +func main() { + boards2.DeleteReply(bid, tid, rid) +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_14_g_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_14_g_filetest.gno new file mode 100644 index 00000000000..5e913e02994 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_14_g_filetest.gno @@ -0,0 +1,38 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + member = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "body") + + // Invite a member using a role with permission to delete replies + boards2.InviteMember(bid, member, boards2.RoleAdmin) + std.TestSetOriginCaller(member) +} + +func main() { + boards2.DeleteReply(bid, tid, rid) + + // Ensure reply doesn't exist + println(boards2.Render("test-board/1/2")) +} + +// Output: +// Reply does not exist with ID: 2 diff --git a/examples/gno.land/r/nt/boards2/v1/z_15_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_15_a_filetest.gno new file mode 100644 index 00000000000..dfb70648364 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_15_a_filetest.gno @@ -0,0 +1,32 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + srcBid boards2.BoardID + dstBid boards2.BoardID + srcTid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + srcBid = boards2.CreateBoard("src-board") + dstBid = boards2.CreateBoard("dst-board") + + srcTid = boards2.CreateThread(srcBid, "Foo", "bar") + boards2.FlagThread(srcBid, srcTid, "idk") +} + +func main() { + // Repost should fail if source thread is flagged + _ = boards2.CreateRepost(srcBid, srcTid, "foo", "bar", dstBid) +} + +// Error: +// thread has been flagged as inappropriate diff --git a/examples/gno.land/r/nt/boards2/v1/z_15_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_15_b_filetest.gno new file mode 100644 index 00000000000..3846cd70d93 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_15_b_filetest.gno @@ -0,0 +1,29 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + srcBid boards2.BoardID + dstBid boards2.BoardID + srcTid boards2.PostID = 1024 +) + +func init() { + std.TestSetOriginCaller(owner) + srcBid = boards2.CreateBoard("src-board") + dstBid = boards2.CreateBoard("dst-board") +} + +func main() { + // Repost should fail if source thread doesn't exist + _ = boards2.CreateRepost(srcBid, srcTid, "foo", "bar", dstBid) +} + +// Error: +// thread does not exist with ID: 1024 diff --git a/examples/gno.land/r/nt/boards2/v1/z_15_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_15_c_filetest.gno new file mode 100644 index 00000000000..daab5017321 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_15_c_filetest.gno @@ -0,0 +1,34 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + srcBid boards2.BoardID + dstBid boards2.BoardID + srcTid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + srcBid = boards2.CreateBoard("src-board") + dstBid = boards2.CreateBoard("dst-board") + + srcTid = boards2.CreateThread(srcBid, "Foo", "bar") + std.TestSetOriginCaller(user) +} + +func main() { + _ = boards2.CreateRepost(srcBid, srcTid, "foo", "bar", dstBid) +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_15_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_15_d_filetest.gno new file mode 100644 index 00000000000..850632c5763 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_15_d_filetest.gno @@ -0,0 +1,41 @@ +package main + +import ( + "std" + "strings" + + "gno.land/p/demo/ufmt" + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + srcBid boards2.BoardID + dstBid boards2.BoardID + srcTid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + srcBid = boards2.CreateBoard("src-board") + dstBid = boards2.CreateBoard("dst-board") + + srcTid = boards2.CreateThread(srcBid, "original title", "original text") +} + +func main() { + // Success case + tid := boards2.CreateRepost(srcBid, srcTid, "repost title", "repost text", dstBid) + p := ufmt.Sprintf("dst-board/%s", tid) + out := boards2.Render(p) + + println(strings.Contains(out, "original text")) + println(strings.Contains(out, "repost title")) + println(strings.Contains(out, "repost text")) +} + +// Output: +// true +// true +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_16_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_16_a_filetest.gno new file mode 100644 index 00000000000..8e151d16e0c --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_16_a_filetest.gno @@ -0,0 +1,26 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") +} + +func main() { + boards2.SetFlaggingThreshold(bid, 4) + + // Ensure that flagging threshold changed + println(boards2.GetFlaggingThreshold(bid)) +} + +// Output: +// 4 diff --git a/examples/gno.land/r/nt/boards2/v1/z_16_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_16_b_filetest.gno new file mode 100644 index 00000000000..14593cb8310 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_16_b_filetest.gno @@ -0,0 +1,22 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +const bid boards2.BoardID = 404 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.SetFlaggingThreshold(bid, 1) +} + +// Error: +// board does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_16_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_16_c_filetest.gno new file mode 100644 index 00000000000..b827d964953 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_16_c_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.SetFlaggingThreshold(1, 0) +} + +// Error: +// invalid flagging threshold diff --git a/examples/gno.land/r/nt/boards2/v1/z_17_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_17_a_filetest.gno new file mode 100644 index 00000000000..f8aad471ea9 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_17_a_filetest.gno @@ -0,0 +1,24 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") +} + +func main() { + boards2.FreezeBoard(bid) + println(boards2.IsBoardFrozen(bid)) +} + +// Output: +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_17_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_17_b_filetest.gno new file mode 100644 index 00000000000..c2ac61ace64 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_17_b_filetest.gno @@ -0,0 +1,24 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + boards2.FreezeBoard(bid) +} + +func main() { + boards2.FreezeBoard(bid) +} + +// Error: +// board is frozen diff --git a/examples/gno.land/r/nt/boards2/v1/z_18_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_18_a_filetest.gno new file mode 100644 index 00000000000..4c16b2ea07f --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_18_a_filetest.gno @@ -0,0 +1,33 @@ +package main + +import ( + "std" + + "gno.land/p/nt/commondao" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + bid = boards2.BoardID(0) // Operate on realm instead of individual boards +) + +var perms boards2.Permissions + +func init() { + // Create a new permissions instance without users + perms = boards2.NewDefaultPermissions(commondao.New()) + + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.SetPermissions(bid, perms) + + // Owner that setted new permissions is not a member of the new permissions + println(boards2.IsMember(bid, owner)) +} + +// Output: +// false diff --git a/examples/gno.land/r/nt/boards2/v1/z_18_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_18_b_filetest.gno new file mode 100644 index 00000000000..3d65b049b5b --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_18_b_filetest.gno @@ -0,0 +1,30 @@ +package main + +import ( + "std" + + "gno.land/p/nt/commondao" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + bid = boards2.BoardID(0) // Operate on realm instead of individual boards +) + +var perms boards2.Permissions + +func init() { + // Create a new permissions instance + perms = boards2.NewDefaultPermissions(commondao.New()) + + std.TestSetOriginCaller(user) +} + +func main() { + boards2.SetPermissions(bid, perms) +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_18_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_18_c_filetest.gno new file mode 100644 index 00000000000..84c4cad0fe3 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_18_c_filetest.gno @@ -0,0 +1,34 @@ +package main + +import ( + "std" + + "gno.land/p/nt/commondao" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + perms boards2.Permissions + bid boards2.BoardID +) + +func init() { + // Create a new permissions instance without users + perms = boards2.NewDefaultPermissions(commondao.New()) + + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("foobar") +} + +func main() { + boards2.SetPermissions(bid, perms) + + // Owner that setted new board permissions is not a member of the new permissions + println(boards2.IsMember(bid, owner)) +} + +// Output: +// false diff --git a/examples/gno.land/r/nt/boards2/v1/z_1_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_1_a_filetest.gno new file mode 100644 index 00000000000..54c84f8c186 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_1_a_filetest.gno @@ -0,0 +1,28 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + bid = boards2.BoardID(0) // Operate on realm DAO instead of individual boards + role = boards2.RoleOwner +) + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.InviteMember(bid, user, role) + + // Check that user is invited + println(boards2.HasMemberRole(bid, user, role)) +} + +// Output: +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_1_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_1_b_filetest.gno new file mode 100644 index 00000000000..1f53c6b6f6e --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_1_b_filetest.gno @@ -0,0 +1,33 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + admin = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + user = std.Address("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn") +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + + // Add an admin member + boards2.InviteMember(bid, admin, boards2.RoleAdmin) + + // Next call will be done by the admin member + std.TestSetOriginCaller(admin) +} + +func main() { + boards2.InviteMember(bid, user, boards2.RoleOwner) +} + +// Error: +// only owners are allowed to invite other owners diff --git a/examples/gno.land/r/nt/boards2/v1/z_1_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_1_c_filetest.gno new file mode 100644 index 00000000000..cdb9b2d9bed --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_1_c_filetest.gno @@ -0,0 +1,37 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + admin = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + user = std.Address("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn") + role = boards2.RoleAdmin +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + + // Add an admin member + boards2.InviteMember(bid, admin, boards2.RoleAdmin) + + // Next call will be done by the admin member + std.TestSetOriginCaller(admin) +} + +func main() { + boards2.InviteMember(bid, user, role) + + // Check that user is invited + println(boards2.HasMemberRole(bid, user, role)) +} + +// Output: +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_1_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_1_d_filetest.gno new file mode 100644 index 00000000000..d4588dc9fad --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_1_d_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.InviteMember(0, user, boards2.Role("foobar")) // Operate on realm DAO instead of individual boards +} + +// Error: +// invalid role: foobar diff --git a/examples/gno.land/r/nt/boards2/v1/z_1_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_1_e_filetest.gno new file mode 100644 index 00000000000..49dc4f82d89 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_1_e_filetest.gno @@ -0,0 +1,32 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + role = boards2.RoleOwner +) + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("foo123") // Operate on board DAO members +} + +func main() { + boards2.InviteMember(bid, user, role) + + // Check that user is invited + println(boards2.HasMemberRole(0, user, role)) // Operate on realm DAO + println(boards2.HasMemberRole(bid, user, role)) +} + +// Output: +// false +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_1_f_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_1_f_filetest.gno new file mode 100644 index 00000000000..e3cdfc0535a --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_1_f_filetest.gno @@ -0,0 +1,26 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + bid = boards2.BoardID(0) // Operate on realm DAO instead of individual boards + role = boards2.RoleOwner +) + +func init() { + std.TestSetOriginCaller(owner) + boards2.InviteMember(bid, user, role) +} + +func main() { + boards2.InviteMember(bid, user, role) +} + +// Error: +// user already exists diff --git a/examples/gno.land/r/nt/boards2/v1/z_1_g_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_1_g_filetest.gno new file mode 100644 index 00000000000..97f1b780738 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_1_g_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.InviteMember(0, "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", boards2.RoleGuest) +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_2_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_2_a_filetest.gno new file mode 100644 index 00000000000..7195e29eb26 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_2_a_filetest.gno @@ -0,0 +1,40 @@ +package main + +import ( + "std" + "strings" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + path = "test-board/1/2" + comment = "Test comment" +) + +var ( + bid boards2.BoardID + tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") +} + +func main() { + rid := boards2.CreateReply(bid, tid, 0, comment) + + // Ensure that returned ID is right + println(rid == 2) + + // Render content must contain the reply + content := boards2.Render(path) + println(strings.Contains(content, "\n> "+comment+"\n")) +} + +// Output: +// true +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_2_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_2_b_filetest.gno new file mode 100644 index 00000000000..7126223358c --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_2_b_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.CreateReply(404, 1, 0, "comment") +} + +// Error: +// board does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_2_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_2_c_filetest.gno new file mode 100644 index 00000000000..ae36bc63140 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_2_c_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") +} + +func main() { + boards2.CreateReply(bid, 404, 0, "comment") +} + +// Error: +// thread does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_2_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_2_d_filetest.gno new file mode 100644 index 00000000000..44e634f0794 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_2_d_filetest.gno @@ -0,0 +1,27 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") +} + +func main() { + boards2.CreateReply(bid, tid, 404, "comment") +} + +// Error: +// reply does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_2_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_2_e_filetest.gno new file mode 100644 index 00000000000..02c93888f9e --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_2_e_filetest.gno @@ -0,0 +1,30 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + tid = boards2.CreateThread(bid, "Foo", "bar") + + // Hide thread by flagging it so reply can't be submitted + boards2.FlagThread(bid, tid, "reason") +} + +func main() { + boards2.CreateReply(bid, tid, 0, "Test reply") +} + +// Error: +// thread with ID: 1 was hidden diff --git a/examples/gno.land/r/nt/boards2/v1/z_2_f_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_2_f_filetest.gno new file mode 100644 index 00000000000..d98acf7a235 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_2_f_filetest.gno @@ -0,0 +1,31 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "reply1") + + // Hide thread by flagging it so reply of a reply can't be submitted + boards2.FlagThread(bid, tid, "reason") +} + +func main() { + boards2.CreateReply(bid, tid, rid, "reply1.1") +} + +// Error: +// thread with ID: 1 was hidden diff --git a/examples/gno.land/r/nt/boards2/v1/z_2_g_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_2_g_filetest.gno new file mode 100644 index 00000000000..bc7cf601d15 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_2_g_filetest.gno @@ -0,0 +1,31 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + tid = boards2.CreateThread(bid, "thread", "thread") + rid = boards2.CreateReply(bid, tid, 0, "reply1") + + // Hide reply by flagging it so sub reply can't be submitted + boards2.FlagReply(bid, tid, rid, "reason") +} + +func main() { + boards2.CreateReply(bid, tid, rid, "reply1.1") +} + +// Error: +// reply with ID: 2 was hidden diff --git a/examples/gno.land/r/nt/boards2/v1/z_2_h_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_2_h_filetest.gno new file mode 100644 index 00000000000..a94caf1dd23 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_2_h_filetest.gno @@ -0,0 +1,32 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + tid = boards2.CreateThread(bid, "Foo", "bar") + + std.TestSetOriginCaller(user) +} + +func main() { + boards2.CreateReply(bid, tid, 0, "Test reply") +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_2_i_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_2_i_filetest.gno new file mode 100644 index 00000000000..5bc871355ce --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_2_i_filetest.gno @@ -0,0 +1,27 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var ( + bid boards2.BoardID + tid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") +} + +func main() { + boards2.CreateReply(bid, tid, 0, "") +} + +// Error: +// body is empty diff --git a/examples/gno.land/r/nt/boards2/v1/z_2_j_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_2_j_filetest.gno new file mode 100644 index 00000000000..4b0585c3688 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_2_j_filetest.gno @@ -0,0 +1,41 @@ +package main + +import ( + "std" + "strings" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + path = "test-board/1/2" + comment = "Second comment" +) + +var ( + bid boards2.BoardID + tid, rid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + tid = boards2.CreateThread(bid, "Foo", "bar") + rid = boards2.CreateReply(bid, tid, 0, "First comment") +} + +func main() { + rid2 := boards2.CreateReply(bid, tid, rid, comment) + + // Ensure that returned ID is right + println(rid2 == 3) + + // Render content must contain the sub-reply + content := boards2.Render(path) + println(strings.Contains(content, "\n> > "+comment+"\n")) +} + +// Output: +// true +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_3_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_3_a_filetest.gno new file mode 100644 index 00000000000..c41125affc3 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_3_a_filetest.gno @@ -0,0 +1,31 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + name = "foo123" + newName = "bar123" +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard(name) +} + +func main() { + boards2.RenameBoard(name, newName) + + // Ensure board is renamed by the default board owner + bid2, _ := boards2.GetBoardIDFromName(newName) + println("IDs match =", bid == bid2) +} + +// Output: +// IDs match = true diff --git a/examples/gno.land/r/nt/boards2/v1/z_3_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_3_b_filetest.gno new file mode 100644 index 00000000000..a23b4c54936 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_3_b_filetest.gno @@ -0,0 +1,24 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + name = "foo123" +) + +func init() { + std.TestSetOriginCaller(owner) + boards2.CreateBoard(name) +} + +func main() { + boards2.RenameBoard(name, "") +} + +// Error: +// name is empty diff --git a/examples/gno.land/r/nt/boards2/v1/z_3_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_3_c_filetest.gno new file mode 100644 index 00000000000..0b6722a46de --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_3_c_filetest.gno @@ -0,0 +1,24 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + name = "foo123" +) + +func init() { + std.TestSetOriginCaller(owner) + boards2.CreateBoard(name) +} + +func main() { + boards2.RenameBoard(name, name) +} + +// Error: +// board already exists diff --git a/examples/gno.land/r/nt/boards2/v1/z_3_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_3_d_filetest.gno new file mode 100644 index 00000000000..5e78150657e --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_3_d_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.RenameBoard("unexisting", "foo") +} + +// Error: +// board does not exist with name: unexisting diff --git a/examples/gno.land/r/nt/boards2/v1/z_3_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_3_e_filetest.gno new file mode 100644 index 00000000000..b407326ee51 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_3_e_filetest.gno @@ -0,0 +1,24 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + name = "foo123" +) + +func init() { + std.TestSetOriginCaller(owner) + boards2.CreateBoard(name) +} + +func main() { + boards2.RenameBoard(name, "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") +} + +// Error: +// addresses are not allowed as board name diff --git a/examples/gno.land/r/nt/boards2/v1/z_3_f_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_3_f_filetest.gno new file mode 100644 index 00000000000..b074504acd2 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_3_f_filetest.gno @@ -0,0 +1,42 @@ +package main + +// SEND: 200000000ugnot + +import ( + "std" + + "gno.land/r/demo/users" + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + member = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + name = "foo123" + newName = "barbaz" +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + + bid = boards2.CreateBoard(name) + boards2.InviteMember(bid, member, boards2.RoleOwner) + + // Test1 is the boards owner and its address has a user already registered + // so a new member must register a user with the new board name. + std.TestSetOriginCaller(member) + users.Register("", newName, "") +} + +func main() { + boards2.RenameBoard(name, newName) + + // Ensure board is renamed by another board owner + bid2, _ := boards2.GetBoardIDFromName(newName) + println("IDs match =", bid == bid2) +} + +// Output: +// IDs match = true diff --git a/examples/gno.land/r/nt/boards2/v1/z_3_g_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_3_g_filetest.gno new file mode 100644 index 00000000000..f3d1ba69ead --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_3_g_filetest.gno @@ -0,0 +1,43 @@ +package main + +// SEND: 200000000ugnot + +import ( + "std" + + "gno.land/r/demo/users" + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + member = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + member2 = std.Address("g1vh7krmmzfua5xjmkatvmx09z37w34lsvd2mxa5") + name = "foo123" + newName = "barbaz" +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + + bid = boards2.CreateBoard(name) + boards2.InviteMember(bid, member, boards2.RoleOwner) + + // Test1 is the boards owner and its address has a user already registered + // so a new member must register a user with the new board name. + std.TestSetOriginCaller(member) + users.Register("", newName, "") + + // Invite a new member that doesn't own the user that matches the new board name + boards2.InviteMember(bid, member2, boards2.RoleOwner) + std.TestSetOriginCaller(member2) +} + +func main() { + boards2.RenameBoard(name, newName) +} + +// Error: +// board name is a user name registered to a different user diff --git a/examples/gno.land/r/nt/boards2/v1/z_3_h_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_3_h_filetest.gno new file mode 100644 index 00000000000..7ccd271b401 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_3_h_filetest.gno @@ -0,0 +1,24 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + name = "foo123" +) + +func init() { + std.TestSetOriginCaller(owner) + boards2.CreateBoard(name) +} + +func main() { + boards2.RenameBoard(name, "short") +} + +// Error: +// the minimum allowed board name length is 6 characters diff --git a/examples/gno.land/r/nt/boards2/v1/z_3_i_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_3_i_filetest.gno new file mode 100644 index 00000000000..f3d1ba69ead --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_3_i_filetest.gno @@ -0,0 +1,43 @@ +package main + +// SEND: 200000000ugnot + +import ( + "std" + + "gno.land/r/demo/users" + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + member = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + member2 = std.Address("g1vh7krmmzfua5xjmkatvmx09z37w34lsvd2mxa5") + name = "foo123" + newName = "barbaz" +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + + bid = boards2.CreateBoard(name) + boards2.InviteMember(bid, member, boards2.RoleOwner) + + // Test1 is the boards owner and its address has a user already registered + // so a new member must register a user with the new board name. + std.TestSetOriginCaller(member) + users.Register("", newName, "") + + // Invite a new member that doesn't own the user that matches the new board name + boards2.InviteMember(bid, member2, boards2.RoleOwner) + std.TestSetOriginCaller(member2) +} + +func main() { + boards2.RenameBoard(name, newName) +} + +// Error: +// board name is a user name registered to a different user diff --git a/examples/gno.land/r/nt/boards2/v1/z_3_j_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_3_j_filetest.gno new file mode 100644 index 00000000000..e2598f53572 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_3_j_filetest.gno @@ -0,0 +1,27 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + name = "foo123" +) + +func init() { + std.TestSetOriginCaller(owner) + boards2.CreateBoard(name) + + std.TestSetOriginCaller(user) +} + +func main() { + boards2.RenameBoard(name, "barbaz") +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_4_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_4_a_filetest.gno new file mode 100644 index 00000000000..671a012a8bf --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_4_a_filetest.gno @@ -0,0 +1,29 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + member = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + newRole = boards2.RoleOwner + bid = boards2.BoardID(0) // Operate on realm DAO instead of individual boards +) + +func init() { + std.TestSetOriginCaller(owner) + boards2.InviteMember(bid, member, boards2.RoleAdmin) +} + +func main() { + boards2.ChangeMemberRole(bid, member, newRole) + + // Ensure that new role has been changed + println(boards2.HasMemberRole(bid, member, newRole)) +} + +// Output: +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_4_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_4_b_filetest.gno new file mode 100644 index 00000000000..ff910620ca5 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_4_b_filetest.gno @@ -0,0 +1,31 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + member = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + newRole = boards2.RoleAdmin +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("foo123") + boards2.InviteMember(bid, member, boards2.RoleGuest) +} + +func main() { + boards2.ChangeMemberRole(bid, member, newRole) + + // Ensure that new role has been changed + println(boards2.HasMemberRole(bid, member, newRole)) +} + +// Output: +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_4_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_4_c_filetest.gno new file mode 100644 index 00000000000..c4f174cd8e2 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_4_c_filetest.gno @@ -0,0 +1,32 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + owner2 = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + admin = std.Address("g1vh7krmmzfua5xjmkatvmx09z37w34lsvd2mxa5") +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + + boards2.InviteMember(bid, owner2, boards2.RoleOwner) + boards2.InviteMember(bid, admin, boards2.RoleAdmin) + + std.TestSetOriginCaller(admin) +} + +func main() { + boards2.ChangeMemberRole(bid, owner2, boards2.RoleAdmin) +} + +// Error: +// admins are not allowed to remove the Owner role diff --git a/examples/gno.land/r/nt/boards2/v1/z_4_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_4_d_filetest.gno new file mode 100644 index 00000000000..d0834af98d1 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_4_d_filetest.gno @@ -0,0 +1,32 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + admin = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + admin2 = std.Address("g1vh7krmmzfua5xjmkatvmx09z37w34lsvd2mxa5") +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + + boards2.InviteMember(bid, admin, boards2.RoleAdmin) + boards2.InviteMember(bid, admin2, boards2.RoleAdmin) + + std.TestSetOriginCaller(admin) +} + +func main() { + boards2.ChangeMemberRole(bid, admin2, boards2.RoleOwner) +} + +// Error: +// admins are not allowed to promote members to Owner diff --git a/examples/gno.land/r/nt/boards2/v1/z_4_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_4_e_filetest.gno new file mode 100644 index 00000000000..94c9e572eb9 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_4_e_filetest.gno @@ -0,0 +1,29 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + member = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + bid = boards2.BoardID(0) // Operate on realm DAO members instead of individual boards + newRole = boards2.RoleOwner +) + +func init() { + std.TestSetOriginCaller(owner) + boards2.InviteMember(bid, member, boards2.RoleAdmin) +} + +func main() { + boards2.ChangeMemberRole(bid, member, newRole) // Owner can promote other members to Owner + + // Ensure that new role has been changed to owner + println(boards2.HasMemberRole(bid, member, newRole)) +} + +// Output: +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_4_f_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_4_f_filetest.gno new file mode 100644 index 00000000000..17e67cd6ef3 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_4_f_filetest.gno @@ -0,0 +1,28 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + admin = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + + boards2.InviteMember(bid, admin, boards2.RoleGuest) +} + +func main() { + boards2.ChangeMemberRole(bid, admin, boards2.Role("foo")) +} + +// Error: +// invalid role: foo diff --git a/examples/gno.land/r/nt/boards2/v1/z_4_g_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_4_g_filetest.gno new file mode 100644 index 00000000000..1ed67f84f6d --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_4_g_filetest.gno @@ -0,0 +1,28 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + admin = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + + boards2.InviteMember(bid, admin, boards2.RoleGuest) +} + +func main() { + boards2.ChangeMemberRole(bid, "invalid address", boards2.RoleModerator) +} + +// Error: +// user not found diff --git a/examples/gno.land/r/nt/boards2/v1/z_4_h_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_4_h_filetest.gno new file mode 100644 index 00000000000..aa7d0be5d27 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_4_h_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.ChangeMemberRole(0, "g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", boards2.RoleGuest) +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_5_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_5_a_filetest.gno new file mode 100644 index 00000000000..761fb359209 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_5_a_filetest.gno @@ -0,0 +1,31 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + + boards2.InviteMember(bid, user, boards2.RoleGuest) +} + +func main() { + boards2.RemoveMember(bid, user) + + // Check that user is not a member + println(boards2.IsMember(bid, user)) +} + +// Output: +// false diff --git a/examples/gno.land/r/nt/boards2/v1/z_5_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_5_b_filetest.gno new file mode 100644 index 00000000000..4b1a1b0f57e --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_5_b_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.RemoveMember(0, "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn") // Operate on realm DAO instead of individual boards +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_5_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_5_c_filetest.gno new file mode 100644 index 00000000000..87c82f57494 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_5_c_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.RemoveMember(0, "g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // Operate on realm DAO instead of individual boards +} + +// Error: +// member not found diff --git a/examples/gno.land/r/nt/boards2/v1/z_6_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_6_a_filetest.gno new file mode 100644 index 00000000000..258c679abf1 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_6_a_filetest.gno @@ -0,0 +1,33 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + member = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + role = boards2.RoleGuest +) + +var bid boards2.BoardID // Operate on board DAO + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + + boards2.InviteMember(bid, member, role) +} + +func main() { + println(boards2.HasMemberRole(bid, member, role)) + println(boards2.HasMemberRole(bid, member, "invalid")) + println(boards2.IsMember(bid, member)) +} + +// Output: +// true +// false +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_6_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_6_b_filetest.gno new file mode 100644 index 00000000000..e179de32839 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_6_b_filetest.gno @@ -0,0 +1,27 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 + bid = boards2.BoardID(0) // Operate on realm DAO instead of individual boards + role = boards2.RoleGuest +) + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + println(boards2.HasMemberRole(bid, user, role)) + println(boards2.IsMember(bid, user)) +} + +// Output: +// false +// false diff --git a/examples/gno.land/r/nt/boards2/v1/z_7_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_7_a_filetest.gno new file mode 100644 index 00000000000..d738e1e4591 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_7_a_filetest.gno @@ -0,0 +1,29 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + name = "test123" +) + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard(name) +} + +func main() { + bid2, found := boards2.GetBoardIDFromName(name) + println(found) + println(bid2 == bid) +} + +// Output: +// true +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_7_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_7_b_filetest.gno new file mode 100644 index 00000000000..970abeab621 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_7_b_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + bid, found := boards2.GetBoardIDFromName("foobar") + println(found) + println(bid == 0) +} + +// Output: +// false +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_8_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_8_a_filetest.gno new file mode 100644 index 00000000000..31adb1431ca --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_8_a_filetest.gno @@ -0,0 +1,39 @@ +package main + +import ( + "std" + "strings" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + title = "Test Thread" + body = "Test body" + path = "test-board/1" +) + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") +} + +func main() { + pid := boards2.CreateThread(bid, title, body) + + // Ensure that returned ID is right + println(pid == 1) + + // Render content must contains thread's title and body + content := boards2.Render(path) + println(strings.HasPrefix(content, "# "+title)) + println(strings.Contains(content, body)) +} + +// Output: +// true +// true +// true diff --git a/examples/gno.land/r/nt/boards2/v1/z_8_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_8_b_filetest.gno new file mode 100644 index 00000000000..6ba9ff167de --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_8_b_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.CreateThread(404, "Foo", "bar") +} + +// Error: +// board does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_8_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_8_c_filetest.gno new file mode 100644 index 00000000000..610c4422f2d --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_8_c_filetest.gno @@ -0,0 +1,28 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") + + std.TestSetOriginCaller(user) +} + +func main() { + boards2.CreateThread(bid, "Foo", "bar") +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_8_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_8_d_filetest.gno new file mode 100644 index 00000000000..615b8eaf2c0 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_8_d_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") +} + +func main() { + boards2.CreateThread(bid, "", "bar") +} + +// Error: +// title is empty diff --git a/examples/gno.land/r/nt/boards2/v1/z_8_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_8_e_filetest.gno new file mode 100644 index 00000000000..339efa5bed1 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_8_e_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test123") +} + +func main() { + boards2.CreateThread(bid, "Foo", "") +} + +// Error: +// body is empty diff --git a/examples/gno.land/r/nt/boards2/v1/z_9_a_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_9_a_filetest.gno new file mode 100644 index 00000000000..4f8cf6c3fdb --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_9_a_filetest.gno @@ -0,0 +1,34 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + title = "Test Thread" + body = "Test body" +) + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, title, body) +} + +func main() { + boards2.DeleteThread(bid, pid) + + // Ensure thread doesn't exist + println(boards2.Render("test-board/1")) +} + +// Output: +// Thread does not exist with ID: 1 diff --git a/examples/gno.land/r/nt/boards2/v1/z_9_b_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_9_b_filetest.gno new file mode 100644 index 00000000000..9243ed05ee3 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_9_b_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOriginCaller(owner) +} + +func main() { + boards2.DeleteThread(404, 1) +} + +// Error: +// board does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_9_c_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_9_c_filetest.gno new file mode 100644 index 00000000000..a979ae2785e --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_9_c_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +var bid boards2.BoardID + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") +} + +func main() { + boards2.DeleteThread(bid, 404) +} + +// Error: +// thread does not exist with ID: 404 diff --git a/examples/gno.land/r/nt/boards2/v1/z_9_d_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_9_d_filetest.gno new file mode 100644 index 00000000000..a0021b7e302 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_9_d_filetest.gno @@ -0,0 +1,33 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + user = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") + + // Call using a user that has not permission to delete threads + std.TestSetOriginCaller(user) +} + +func main() { + boards2.DeleteThread(bid, pid) +} + +// Error: +// unauthorized diff --git a/examples/gno.land/r/nt/boards2/v1/z_9_e_filetest.gno b/examples/gno.land/r/nt/boards2/v1/z_9_e_filetest.gno new file mode 100644 index 00000000000..3a84b4fb817 --- /dev/null +++ b/examples/gno.land/r/nt/boards2/v1/z_9_e_filetest.gno @@ -0,0 +1,37 @@ +package main + +import ( + "std" + + boards2 "gno.land/r/nt/boards2/v1" +) + +const ( + owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + member = std.Address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // @test2 +) + +var ( + bid boards2.BoardID + pid boards2.PostID +) + +func init() { + std.TestSetOriginCaller(owner) + bid = boards2.CreateBoard("test-board") + pid = boards2.CreateThread(bid, "Foo", "bar") + + // Invite a member using a role with permission to delete threads + boards2.InviteMember(bid, member, boards2.RoleAdmin) + std.TestSetOriginCaller(member) +} + +func main() { + boards2.DeleteThread(bid, pid) + + // Ensure thread doesn't exist + println(boards2.Render("test-board/1")) +} + +// Output: +// Thread does not exist with ID: 1