6c34650a0d
v0.5 Bearers Runtime — 7 runtime REQs (REQ-033..039) shipped as feature. 8 modules promoted to runtime (MsgServer + simtest). cosmos-sdk v0.50.8 + ibc-go v8.2.1 added (G-006 controlled exception). G-003 + locked-const firewalls intact. 8 keeper packages ≥80% coverage. 5 GRILL decisions ratified; 8 binding fixes landed; 5 P1+ flagged for v0.6+. ---ci--- project: oy phase: 8 milestone: v0.5 status: complete requirements: covered: [REQ-033, REQ-034, REQ-035, REQ-036, REQ-037, REQ-038, REQ-039] partial: [] ---/ci---
967 lines
40 KiB
Go
967 lines
40 KiB
Go
package keeper_test
|
|
|
|
// msg_server_simtest_test.go is the x/council keeper simtest (P7-04-01,
|
|
// REQ-039, D-060).
|
|
//
|
|
// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no
|
|
// real watcher/stand/guild keepers. The simtest wires the expected-keeper
|
|
// shims (WatcherKeeper, StandKeeper, GuildKeeper) to in-test stubs
|
|
// (G-003 test exemption: the test imports x/council/keeper + defines stub
|
|
// types that satisfy the interfaces; no production struct imports across
|
|
// x/<module>/types).
|
|
//
|
|
// Coverage (REQ-039 lifecycle Pending → Active → Vote → Tally →
|
|
// Succeeded/Failed):
|
|
// - Full success lifecycle: Submit (Pending) → Active → Vote (Yes) →
|
|
// Tally → Succeeded.
|
|
// - MissionLockAmendment-Rejected kind REJECTED at ValidateBasic
|
|
// (D-064/A-572 — the message never reaches the handler; the keeper
|
|
// Proposal store stays empty).
|
|
// - Veto semantics (D-065/A-574):
|
|
// - Single Veto does NOT block (anti-greed, vision §19): a single
|
|
// Veto + majority Yes → Succeeded.
|
|
// - Veto quorum (default 6) → Failed: 6 Vetos → Failed.
|
|
// - Quorum boundary: quorum-1 = 5 Vetos (below default 6) + majority
|
|
// Yes → Succeeded; quorum-6 = 6 Vetos → Failed.
|
|
// - Watcher authz for Veto: a non-Watcher casting Veto is REJECTED
|
|
// (the Vote is NOT recorded).
|
|
// - Vote-on-non-Active REJECTED (vote on a Pending proposal → error).
|
|
// - Vote-after-deadline REJECTED (now >= VotingDeadline → error).
|
|
// - Tally-before-deadline REJECTED (now < VotingDeadline → error).
|
|
// - Tally-on-non-Active REJECTED (tally on a Pending proposal → error).
|
|
// - Idempotency: duplicate proposal-id + duplicate vote-id → error.
|
|
// - NotFound: Vote/Tally on a missing proposal-id → error.
|
|
// - Proposal-target validation: Stand-kind Proposal on a non-Stand
|
|
// Council REJECTED; Guild-kind Proposal on a non-Guild Council
|
|
// REJECTED; Stand-kind Proposal with a non-existent stand-id-ref
|
|
// REJECTED (via the StandKeeper stub).
|
|
// - ValidateBasic: each Msg* ValidateBasic error path.
|
|
//
|
|
// Coverage target: ≥80% on x/council/keeper.
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"cosmossdk.io/log"
|
|
"cosmossdk.io/store"
|
|
storetypes "cosmossdk.io/store/types"
|
|
cmtproto "github.com/cometbft/cometbft/proto/tendermint/types"
|
|
dbm "github.com/cosmos/cosmos-db"
|
|
"github.com/cosmos/cosmos-sdk/codec"
|
|
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
|
|
sdk "github.com/cosmos/cosmos-sdk/types"
|
|
|
|
"github.com/oy/openyield/x/council/keeper"
|
|
"github.com/oy/openyield/x/council/types"
|
|
)
|
|
|
|
// --- Stub expected-keepers (G-003 test exemption) ---------------------------
|
|
|
|
// stubWatcherKeeper satisfies types.WatcherKeeper for the simtest. It
|
|
// records IsWatcher + CountWatchers calls for assertion and returns the
|
|
// configured watcher-set + per-reach-id watcher membership.
|
|
type stubWatcherKeeper struct {
|
|
isWatcher map[string]bool // reach-id → is-watcher
|
|
watcherCount int // total Watcher set size (default 9 per REQ-004)
|
|
calls []string // recorded IsWatcher reach-ids
|
|
}
|
|
|
|
func (s *stubWatcherKeeper) IsWatcher(reachID string) bool {
|
|
s.calls = append(s.calls, reachID)
|
|
if s.isWatcher != nil {
|
|
return s.isWatcher[reachID]
|
|
}
|
|
return true // default: all are Watchers (simtest wiring)
|
|
}
|
|
|
|
func (s *stubWatcherKeeper) CountWatchers() int {
|
|
if s.watcherCount == 0 {
|
|
return 9 // REQ-004: 9 Watchers
|
|
}
|
|
return s.watcherCount
|
|
}
|
|
|
|
// stubStandKeeper satisfies types.StandKeeper for the simtest. Returns
|
|
// the configured existence per stand-id (default: exists=true).
|
|
type stubStandKeeper struct {
|
|
exists map[string]bool
|
|
}
|
|
|
|
func (s *stubStandKeeper) StandExists(standID string) bool {
|
|
if s.exists != nil {
|
|
return s.exists[standID]
|
|
}
|
|
return true // default: exists (simtest wiring)
|
|
}
|
|
|
|
// stubGuildKeeper satisfies types.GuildKeeper for the simtest.
|
|
type stubGuildKeeper struct {
|
|
exists map[string]bool
|
|
}
|
|
|
|
func (s *stubGuildKeeper) GuildExists(guildID string) bool {
|
|
if s.exists != nil {
|
|
return s.exists[guildID]
|
|
}
|
|
return true // default: exists (simtest wiring)
|
|
}
|
|
|
|
// --- Simtest context helper --------------------------------------------------
|
|
|
|
// newSimtestContext constructs an in-memory sdk.Context with a KVStore
|
|
// mounted at the council store key. D-054: in-memory, no real
|
|
// watcher/stand/guild keepers. Returns the ctx, the stub WatcherKeeper,
|
|
// the stub StandKeeper, the stub GuildKeeper, and the Keeper.
|
|
func newSimtestContext(t *testing.T) (sdk.Context, *stubWatcherKeeper, *stubStandKeeper, *stubGuildKeeper, keeper.Keeper) {
|
|
t.Helper()
|
|
db := dbm.NewMemDB()
|
|
cdc := newTestCodec()
|
|
storeKey := storetypes.NewKVStoreKey(types.StoreKey)
|
|
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
|
|
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
|
|
if err := cms.LoadLatestVersion(); err != nil {
|
|
t.Fatalf("load latest version: %v", err)
|
|
}
|
|
// Block time set to a fixed unix second so lifecycle timestamps are
|
|
// deterministic (now = 1000).
|
|
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
|
|
|
|
wk := &stubWatcherKeeper{}
|
|
sk := &stubStandKeeper{}
|
|
gk := &stubGuildKeeper{}
|
|
k := keeper.NewKeeper(cdc, storeKey, wk, sk, gk)
|
|
return ctx, wk, sk, gk, k
|
|
}
|
|
|
|
// newTestCodec constructs a minimal codec for the simtest.
|
|
func newTestCodec() codec.Codec {
|
|
registry := codectypes.NewInterfaceRegistry()
|
|
return codec.NewProtoCodec(registry)
|
|
}
|
|
|
|
// hasEvent reports whether ctx emitted an event of the given type.
|
|
func hasEvent(ctx sdk.Context, eventType string) bool {
|
|
for _, ev := range ctx.EventManager().Events() {
|
|
if ev.Type == eventType {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// eventAttr returns the value of an attribute on the last event of the
|
|
// given type, or "" if not found.
|
|
func eventAttr(ctx sdk.Context, eventType, attrKey string) string {
|
|
for _, ev := range ctx.EventManager().Events() {
|
|
if ev.Type == eventType {
|
|
for _, a := range ev.Attributes {
|
|
if string(a.Key) == attrKey {
|
|
return string(a.Value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// seedCouncil seeds a Council into the runtime store for the SubmitProposal
|
|
// target validation. Returns the Council.
|
|
func seedCouncil(k keeper.Keeper, ctx sdk.Context, councilID string, kind types.CouncilKind, standRef, guildRef string) types.Council {
|
|
c := types.Council{
|
|
CouncilID: councilID,
|
|
Kind: kind,
|
|
StandIDRef: standRef,
|
|
GuildIDRef: guildRef,
|
|
Members: []types.CouncilMember{{ReachID: "reach:member-1", VoiceWeight: 1, JoinedAt: 0}},
|
|
VoiceThreshold: 1,
|
|
}
|
|
k.SetCouncil(ctx, c)
|
|
return c
|
|
}
|
|
|
|
// activateProposal transitions a Pending Proposal to Active (the simtest
|
|
// helper — the v0.5 keeper does not expose an Activate message; the
|
|
// handler creates Pending and the tally closes Active; the Pending →
|
|
// Active transition is the voting-window-open transition, which in a
|
|
// real chain would be triggered by the block height crossing the
|
|
// submit-time. For the simtest, the helper flips the status directly to
|
|
// enable voting).
|
|
func activateProposal(k keeper.Keeper, ctx sdk.Context, proposalID string) types.Proposal {
|
|
p, ok := k.GetProposal(ctx, proposalID)
|
|
if !ok {
|
|
panic("activateProposal: proposal not found: " + proposalID)
|
|
}
|
|
p.Status = types.ProposalStatusActive
|
|
k.SetProposal(ctx, p)
|
|
return p
|
|
}
|
|
|
|
// newSubmitMsg returns a valid MsgSubmitProposal for a Mesh Council.
|
|
func newSubmitMsg(proposalID, councilID string, kind types.ProposalKind, deadline int64) *types.MsgSubmitProposal {
|
|
return &types.MsgSubmitProposal{
|
|
ProposalID: proposalID,
|
|
CouncilID: councilID,
|
|
Kind: kind,
|
|
ProposerReach: "reach:prop",
|
|
SubmitTime: 500,
|
|
VotingDeadline: deadline,
|
|
Signer: "reach:prop",
|
|
}
|
|
}
|
|
|
|
// --- Full success lifecycle: Pending → Active → Vote → Tally → Succeeded -------
|
|
|
|
// TestProposalLifecycleFullSuccess asserts the full success lifecycle:
|
|
// Submit (Pending) → Active → Vote (Yes majority) → Tally → Succeeded.
|
|
func TestProposalLifecycleFullSuccess(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
|
|
// Submit → Pending.
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p1", "cm", types.ProposalKindMesh, 2000)); err != nil {
|
|
t.Fatalf("SubmitProposal: %v", err)
|
|
}
|
|
p, ok := k.GetProposal(ctx, "p1")
|
|
if !ok {
|
|
t.Fatal("proposal not found after submit")
|
|
}
|
|
if p.Status != types.ProposalStatusPending {
|
|
t.Errorf("status = %q, want Pending", p.Status)
|
|
}
|
|
if p.Kind != types.ProposalKindMesh {
|
|
t.Errorf("kind = %q, want Mesh", p.Kind)
|
|
}
|
|
if !hasEvent(ctx, "council.proposal_submitted") {
|
|
t.Error("proposal_submitted event not emitted")
|
|
}
|
|
|
|
// Pending → Active (simtest helper).
|
|
activateProposal(k, ctx, "p1")
|
|
|
|
// Vote (3 Yes, 1 No → Yes majority → Succeeded on tally).
|
|
for i, voter := range []string{"reach:a", "reach:b", "reach:c"} {
|
|
if _, err := srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "v-yes-" + string(rune('A'+i)),
|
|
ProposalID: "p1", VoterReach: voter, Option: types.VoteOptionYes, Signer: voter,
|
|
}); err != nil {
|
|
t.Fatalf("Vote[%d]: %v", i, err)
|
|
}
|
|
}
|
|
if _, err := srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "v-no-1", ProposalID: "p1", VoterReach: "reach:d", Option: types.VoteOptionNo, Signer: "reach:d",
|
|
}); err != nil {
|
|
t.Fatalf("Vote No: %v", err)
|
|
}
|
|
if !hasEvent(ctx, "council.vote_cast") {
|
|
t.Error("vote_cast event not emitted")
|
|
}
|
|
|
|
// Advance block time past the voting deadline (now=1000 < 2000; need
|
|
// now >= 2000 to tally). Re-create the ctx with a later block time.
|
|
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
|
|
|
|
// Tally → Succeeded (Yes=3 > No=1, no Vetos).
|
|
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p1", Signer: "reach:tally"}); err != nil {
|
|
t.Fatalf("TallyProposal: %v", err)
|
|
}
|
|
p, _ = k.GetProposal(ctx, "p1")
|
|
if p.Status != types.ProposalStatusSucceeded {
|
|
t.Errorf("status = %q, want Succeeded (Yes=3 > No=1)", p.Status)
|
|
}
|
|
if p.Tally.Yes != 3 || p.Tally.No != 1 || p.Tally.Abstain != 0 || p.Tally.NoWithVeto != 0 || p.Tally.Total != 4 {
|
|
t.Errorf("tally = %+v, want Yes=3 No=1 Abstain=0 NoWithVeto=0 Total=4", p.Tally)
|
|
}
|
|
if !p.Tally.QuorumMet {
|
|
t.Error("QuorumMet should be true (Total > 0)")
|
|
}
|
|
if !hasEvent(ctx, "council.proposal_tallied") {
|
|
t.Error("proposal_tallied event not emitted")
|
|
}
|
|
if eventAttr(ctx, "council.proposal_tallied", "status") != string(types.ProposalStatusSucceeded) {
|
|
t.Errorf("tally event status = %q, want Succeeded", eventAttr(ctx, "council.proposal_tallied", "status"))
|
|
}
|
|
}
|
|
|
|
// --- MissionLockAmendment-Rejected REJECTED at ValidateBasic (D-064) --------
|
|
|
|
// TestMissionLockAmendmentRejectedAtValidateBasic asserts the
|
|
// MissionLockAmendment-Rejected kind is REJECTED at ValidateBasic
|
|
// (D-064/A-572 — the message never reaches the handler; the keeper
|
|
// Proposal store stays empty).
|
|
func TestMissionLockAmendmentRejectedAtValidateBasic(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
|
|
msg := newSubmitMsg("p-mla", "cm", types.ProposalMissionLockAmendmentRejected, 2000)
|
|
_, err := srv.SubmitProposal(ctx, msg)
|
|
if err == nil {
|
|
t.Fatal("SubmitProposal with MissionLockAmendment-Rejected kind should be rejected at ValidateBasic (D-064)")
|
|
}
|
|
// The keeper Proposal store stays empty (the handler was never
|
|
// invoked with this kind — ValidateBasic rejected it).
|
|
if _, ok := k.GetProposal(ctx, "p-mla"); ok {
|
|
t.Error("Proposal store should be empty — the MissionLockAmendment-Rejected message never reaches the handler (D-064)")
|
|
}
|
|
if !hasEvent(ctx, "council.proposal_submitted") {
|
|
// no event emitted (the rejection is at ValidateBasic, before
|
|
// the handler emits any event) — this is correct.
|
|
}
|
|
}
|
|
|
|
// --- Veto semantics (D-065/A-574) --------------------------------------------
|
|
|
|
// TestVetoSingleDoesNotBlock asserts a single Veto does NOT block
|
|
// (anti-greed, vision §19, D-065): a single Veto + majority Yes →
|
|
// Succeeded. The Veto quorum (default 6) must be met to FAIL.
|
|
func TestVetoSingleDoesNotBlock(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-veto-1", "cm", types.ProposalKindMesh, 2000)); err != nil {
|
|
t.Fatalf("SubmitProposal: %v", err)
|
|
}
|
|
activateProposal(k, ctx, "p-veto-1")
|
|
|
|
// 3 Yes + 1 Veto → Yes majority, single Veto does NOT block → Succeeded.
|
|
for i, voter := range []string{"reach:a", "reach:b", "reach:c"} {
|
|
srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "vy" + string(rune('A'+i)), ProposalID: "p-veto-1", VoterReach: voter, Option: types.VoteOptionYes, Signer: voter,
|
|
})
|
|
}
|
|
// 1 Veto (watcher-1 is a Watcher via the default stub).
|
|
srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "vv1", ProposalID: "p-veto-1", VoterReach: "reach:watcher-1", Option: types.VoteOptionVeto, Signer: "reach:watcher-1",
|
|
})
|
|
|
|
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
|
|
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-veto-1", Signer: "reach:tally"}); err != nil {
|
|
t.Fatalf("TallyProposal: %v", err)
|
|
}
|
|
p, _ := k.GetProposal(ctx, "p-veto-1")
|
|
if p.Status != types.ProposalStatusSucceeded {
|
|
t.Errorf("status = %q, want Succeeded (single Veto does NOT block — D-065 anti-greed; Yes=3 > No=0)", p.Status)
|
|
}
|
|
if p.Tally.NoWithVeto != 1 {
|
|
t.Errorf("NoWithVeto = %d, want 1 (single Veto recorded but does NOT block)", p.Tally.NoWithVeto)
|
|
}
|
|
}
|
|
|
|
// TestVetoQuorumBlocks asserts the Veto quorum (default 6) FAILS the
|
|
// proposal: 6 Vetos → Failed (D-065/A-574).
|
|
func TestVetoQuorumBlocks(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-veto-q", "cm", types.ProposalKindMesh, 2000)); err != nil {
|
|
t.Fatalf("SubmitProposal: %v", err)
|
|
}
|
|
activateProposal(k, ctx, "p-veto-q")
|
|
|
|
// 2 Yes + 6 Vetos → Veto quorum met → Failed.
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-veto-q", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vy2", ProposalID: "p-veto-q", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"})
|
|
for i := 0; i < 6; i++ {
|
|
voter := "reach:watcher-" + string(rune('A'+i))
|
|
srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "vv" + string(rune('A'+i)), ProposalID: "p-veto-q", VoterReach: voter, Option: types.VoteOptionVeto, Signer: voter,
|
|
})
|
|
}
|
|
|
|
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
|
|
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-veto-q", Signer: "reach:tally"}); err != nil {
|
|
t.Fatalf("TallyProposal: %v", err)
|
|
}
|
|
p, _ := k.GetProposal(ctx, "p-veto-q")
|
|
if p.Status != types.ProposalStatusFailed {
|
|
t.Errorf("status = %q, want Failed (Veto quorum met — 6 Vetos >= default 6 per D-065/A-574)", p.Status)
|
|
}
|
|
if p.Tally.NoWithVeto != 6 {
|
|
t.Errorf("NoWithVeto = %d, want 6 (quorum)", p.Tally.NoWithVeto)
|
|
}
|
|
}
|
|
|
|
// TestVetoQuorumBoundary asserts the quorum boundary: 5 Vetos (below the
|
|
// default 6) + majority Yes → Succeeded; 6 Vetos → Failed.
|
|
func TestVetoQuorumBoundary(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-bnd", "cm", types.ProposalKindMesh, 2000)); err != nil {
|
|
t.Fatalf("SubmitProposal: %v", err)
|
|
}
|
|
activateProposal(k, ctx, "p-bnd")
|
|
|
|
// 3 Yes + 5 Vetos (below default quorum 6) → Succeeded.
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-bnd", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vy2", ProposalID: "p-bnd", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"})
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vy3", ProposalID: "p-bnd", VoterReach: "reach:c", Option: types.VoteOptionYes, Signer: "reach:c"})
|
|
for i := 0; i < 5; i++ {
|
|
voter := "reach:watcher-" + string(rune('A'+i))
|
|
srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "vv" + string(rune('A'+i)), ProposalID: "p-bnd", VoterReach: voter, Option: types.VoteOptionVeto, Signer: voter,
|
|
})
|
|
}
|
|
|
|
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
|
|
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-bnd", Signer: "reach:tally"}); err != nil {
|
|
t.Fatalf("TallyProposal (5 Vetos, below quorum): %v", err)
|
|
}
|
|
p, _ := k.GetProposal(ctx, "p-bnd")
|
|
if p.Status != types.ProposalStatusSucceeded {
|
|
t.Errorf("status = %q, want Succeeded (5 Vetos < default quorum 6 — single-Veto-no-block quorum rule; Yes=3 > No=0)", p.Status)
|
|
}
|
|
if p.Tally.NoWithVeto != 5 {
|
|
t.Errorf("NoWithVeto = %d, want 5 (below quorum)", p.Tally.NoWithVeto)
|
|
}
|
|
}
|
|
|
|
// TestVetoQuorumCustom asserts the WatcherVetoQuorum Params field is
|
|
// honored: setting the quorum to 3 makes 3 Vetos FAIL the proposal. The
|
|
// Params must be set BEFORE constructing the MsgServer (the server embeds
|
|
// the Keeper by value).
|
|
func TestVetoQuorumCustom(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
// Override the quorum to 3 BEFORE constructing the MsgServer.
|
|
k.SetParams(types.Params{WatcherVetoQuorum: 3})
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-cq", "cm", types.ProposalKindMesh, 2000)); err != nil {
|
|
t.Fatalf("SubmitProposal: %v", err)
|
|
}
|
|
activateProposal(k, ctx, "p-cq")
|
|
|
|
// 2 Yes + 3 Vetos → quorum 3 met → Failed.
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-cq", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vy2", ProposalID: "p-cq", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"})
|
|
for i := 0; i < 3; i++ {
|
|
voter := "reach:watcher-" + string(rune('A'+i))
|
|
srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "vv" + string(rune('A'+i)), ProposalID: "p-cq", VoterReach: voter, Option: types.VoteOptionVeto, Signer: voter,
|
|
})
|
|
}
|
|
|
|
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
|
|
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-cq", Signer: "reach:tally"}); err != nil {
|
|
t.Fatalf("TallyProposal: %v", err)
|
|
}
|
|
p, _ := k.GetProposal(ctx, "p-cq")
|
|
if p.Status != types.ProposalStatusFailed {
|
|
t.Errorf("status = %q, want Failed (custom quorum 3 met — 3 Vetos >= 3)", p.Status)
|
|
}
|
|
}
|
|
|
|
// --- Watcher authz for Veto --------------------------------------------------
|
|
|
|
// TestVetoNonWatcherRejected asserts a non-Watcher casting Veto is
|
|
// REJECTED at the handler (the Vote is NOT recorded). The WatcherKeeper
|
|
// stub is configured to report reach:nonwatcher as a non-Watcher.
|
|
func TestVetoNonWatcherRejected(t *testing.T) {
|
|
ctx, wk, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-nw", "cm", types.ProposalKindMesh, 2000)); err != nil {
|
|
t.Fatalf("SubmitProposal: %v", err)
|
|
}
|
|
activateProposal(k, ctx, "p-nw")
|
|
|
|
// Configure the stub: reach:nonwatcher is NOT a Watcher.
|
|
wk.isWatcher = map[string]bool{"reach:nonwatcher": false, "reach:watcher-1": true}
|
|
|
|
// Non-Watcher Veto → REJECTED.
|
|
_, err := srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "v-nw", ProposalID: "p-nw", VoterReach: "reach:nonwatcher", Option: types.VoteOptionVeto, Signer: "reach:nonwatcher",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Veto from non-Watcher should be REJECTED (D-065/A-574 Watcher authz)")
|
|
}
|
|
// The Vote is NOT recorded.
|
|
if _, ok := k.GetVote(ctx, "v-nw"); ok {
|
|
t.Error("Vote from non-Watcher should NOT be recorded")
|
|
}
|
|
// The Proposal's tally is NOT updated (NoWithVeto stays 0).
|
|
p, _ := k.GetProposal(ctx, "p-nw")
|
|
if p.Tally.NoWithVeto != 0 {
|
|
t.Errorf("NoWithVeto = %d, want 0 (non-Watcher Veto rejected, not recorded)", p.Tally.NoWithVeto)
|
|
}
|
|
|
|
// Watcher Veto → accepted.
|
|
if _, err := srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "v-w", ProposalID: "p-nw", VoterReach: "reach:watcher-1", Option: types.VoteOptionVeto, Signer: "reach:watcher-1",
|
|
}); err != nil {
|
|
t.Fatalf("Veto from Watcher should be accepted; got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVetoNilWatcherKeeperPath exercises the nil-WatcherKeeper-shim path
|
|
// directly: construct a fresh Keeper with nil shims and assert a Veto is
|
|
// recorded (the nil guard skips the authz). The single-Veto-no-block
|
|
// rule (anti-greed, vision §19) is preserved: a single Veto is recorded
|
|
// but does NOT block; the quorum (default 6) must be met at tally.
|
|
func TestVetoNilWatcherKeeperPath(t *testing.T) {
|
|
db := dbm.NewMemDB()
|
|
storeKey := storetypes.NewKVStoreKey(types.StoreKey)
|
|
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
|
|
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
|
|
if err := cms.LoadLatestVersion(); err != nil {
|
|
t.Fatalf("load latest version: %v", err)
|
|
}
|
|
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
|
|
// nil WatcherKeeper, nil StandKeeper, nil GuildKeeper.
|
|
k := keeper.NewKeeper(nil, storeKey, nil, nil, nil)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-nil-wk", "cm", types.ProposalKindMesh, 2000)); err != nil {
|
|
t.Fatalf("SubmitProposal: %v", err)
|
|
}
|
|
activateProposal(k, ctx, "p-nil-wk")
|
|
|
|
// Veto from any reach-id — nil shim skips authz → accepted.
|
|
if _, err := srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "v-nil-wk", ProposalID: "p-nil-wk", VoterReach: "reach:nonwatcher", Option: types.VoteOptionVeto, Signer: "reach:nonwatcher",
|
|
}); err != nil {
|
|
t.Fatalf("Veto with nil WatcherKeeper should be accepted (nil shim skips authz); got: %v", err)
|
|
}
|
|
p, _ := k.GetProposal(ctx, "p-nil-wk")
|
|
if p.Tally.NoWithVeto != 1 {
|
|
t.Errorf("NoWithVeto = %d, want 1 (nil shim skips authz, Veto recorded)", p.Tally.NoWithVeto)
|
|
}
|
|
}
|
|
|
|
// --- Vote-on-non-Active REJECTED ---------------------------------------------
|
|
|
|
// TestVoteRejectsNonActive asserts a Vote on a non-Active proposal is
|
|
// REJECTED. Covers Pending (not yet Active) and Succeeded (already
|
|
// tallied).
|
|
func TestVoteRejectsNonActive(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-na", "cm", types.ProposalKindMesh, 2000)); err != nil {
|
|
t.Fatalf("SubmitProposal: %v", err)
|
|
}
|
|
// Proposal is Pending (not Active) → Vote rejected.
|
|
_, err := srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "v-na", ProposalID: "p-na", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a",
|
|
})
|
|
if err == nil {
|
|
t.Error("Vote on Pending proposal should be rejected (vote-on-non-Active)")
|
|
}
|
|
|
|
// Active the proposal; tally it to Succeeded; then Vote should be
|
|
// rejected again.
|
|
activateProposal(k, ctx, "p-na")
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-na", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
|
|
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
|
|
srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-na", Signer: "reach:tally"})
|
|
_, err = srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "v-na-2", ProposalID: "p-na", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b",
|
|
})
|
|
if err == nil {
|
|
t.Error("Vote on Succeeded proposal should be rejected (vote-on-non-Active)")
|
|
}
|
|
}
|
|
|
|
// --- Vote-after-deadline REJECTED --------------------------------------------
|
|
|
|
// TestVoteRejectsAfterDeadline asserts a Vote after the voting deadline
|
|
// is REJECTED (now >= VotingDeadline).
|
|
func TestVoteRejectsAfterDeadline(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
// Voting deadline = 1500; block time now = 1000 (< 1500).
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-ad", "cm", types.ProposalKindMesh, 1500)); err != nil {
|
|
t.Fatalf("SubmitProposal: %v", err)
|
|
}
|
|
activateProposal(k, ctx, "p-ad")
|
|
// Advance block time past the deadline (now=1600 >= 1500).
|
|
ctx = ctx.WithBlockTime(time.Unix(1600, 0))
|
|
_, err := srv.Vote(ctx, &types.MsgVote{
|
|
VoteID: "v-ad", ProposalID: "p-ad", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a",
|
|
})
|
|
if err == nil {
|
|
t.Error("Vote after voting deadline should be rejected")
|
|
}
|
|
}
|
|
|
|
// --- Tally-before-deadline REJECTED ------------------------------------------
|
|
|
|
// TestTallyRejectsBeforeDeadline asserts a Tally before the voting
|
|
// deadline is REJECTED (now < VotingDeadline).
|
|
func TestTallyRejectsBeforeDeadline(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
// Voting deadline = 5000; block time now = 1000 (< 5000).
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-bd", "cm", types.ProposalKindMesh, 5000)); err != nil {
|
|
t.Fatalf("SubmitProposal: %v", err)
|
|
}
|
|
activateProposal(k, ctx, "p-bd")
|
|
// now=1000 < VotingDeadline=5000 → tally rejected.
|
|
_, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-bd", Signer: "reach:tally"})
|
|
if err == nil {
|
|
t.Error("Tally before voting deadline should be rejected")
|
|
}
|
|
}
|
|
|
|
// --- Tally-on-non-Active REJECTED --------------------------------------------
|
|
|
|
// TestTallyRejectsNonActive asserts a Tally on a non-Active proposal is
|
|
// REJECTED (a Pending proposal has not opened voting; a Succeeded
|
|
// proposal is already tallied).
|
|
func TestTallyRejectsNonActive(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-tna", "cm", types.ProposalKindMesh, 1500)); err != nil {
|
|
t.Fatalf("SubmitProposal: %v", err)
|
|
}
|
|
// Proposal is Pending → tally rejected.
|
|
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
|
|
_, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-tna", Signer: "reach:tally"})
|
|
if err == nil {
|
|
t.Error("Tally on Pending proposal should be rejected (tally-on-non-Active)")
|
|
}
|
|
}
|
|
|
|
// --- Idempotency + NotFound --------------------------------------------------
|
|
|
|
// TestSubmitProposalRejectsDuplicate asserts a duplicate proposal-id is
|
|
// rejected.
|
|
func TestSubmitProposalRejectsDuplicate(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-dup", "cm", types.ProposalKindMesh, 2000)); err != nil {
|
|
t.Fatalf("SubmitProposal[1]: %v", err)
|
|
}
|
|
_, err := srv.SubmitProposal(ctx, newSubmitMsg("p-dup", "cm", types.ProposalKindMesh, 2000))
|
|
if err == nil {
|
|
t.Error("duplicate proposal-id should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestSubmitProposalRejectsUnknownCouncil asserts a Submit to a missing
|
|
// council-id is rejected.
|
|
func TestSubmitProposalRejectsUnknownCouncil(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
_, err := srv.SubmitProposal(ctx, newSubmitMsg("p-uc", "no-such-council", types.ProposalKindMesh, 2000))
|
|
if err == nil {
|
|
t.Error("Submit to unknown council-id should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestVoteRejectsDuplicate asserts a duplicate vote-id is rejected.
|
|
func TestVoteRejectsDuplicate(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
srv.SubmitProposal(ctx, newSubmitMsg("p-vd", "cm", types.ProposalKindMesh, 2000))
|
|
activateProposal(k, ctx, "p-vd")
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "v-dup", ProposalID: "p-vd", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
|
|
_, err := srv.Vote(ctx, &types.MsgVote{VoteID: "v-dup", ProposalID: "p-vd", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"})
|
|
if err == nil {
|
|
t.Error("duplicate vote-id should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestVoteRejectsUnknownProposal asserts a Vote on a missing proposal-id
|
|
// is rejected.
|
|
func TestVoteRejectsUnknownProposal(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
_, err := srv.Vote(ctx, &types.MsgVote{VoteID: "v-np", ProposalID: "no-such", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
|
|
if err == nil {
|
|
t.Error("Vote on unknown proposal-id should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestTallyRejectsUnknownProposal asserts a Tally on a missing proposal-id
|
|
// is rejected.
|
|
func TestTallyRejectsUnknownProposal(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
_, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "no-such", Signer: "reach:tally"})
|
|
if err == nil {
|
|
t.Error("Tally on unknown proposal-id should be rejected")
|
|
}
|
|
}
|
|
|
|
// --- Proposal-target validation (Stand/Guild shims) -------------------------
|
|
|
|
// TestSubmitProposalStandTargetValidation asserts a Stand-kind Proposal
|
|
// targets a Stand Council whose stand-id-ref references a real Stand.
|
|
func TestSubmitProposalStandTargetValidation(t *testing.T) {
|
|
ctx, _, sk, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cs", types.CouncilStand, "stand-xyz", "")
|
|
|
|
// Stand exists (default stub) → accepted.
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-stand-ok", "cs", types.ProposalKindStand, 2000)); err != nil {
|
|
t.Fatalf("SubmitProposal Stand with valid stand-id-ref should be accepted; got: %v", err)
|
|
}
|
|
|
|
// Stand does NOT exist → rejected.
|
|
sk.exists = map[string]bool{"stand-xyz": false}
|
|
_, err := srv.SubmitProposal(ctx, newSubmitMsg("p-stand-bad", "cs", types.ProposalKindStand, 2000))
|
|
if err == nil {
|
|
t.Error("SubmitProposal Stand with non-existent stand-id-ref should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestSubmitProposalGuildTargetValidation asserts a Guild-kind Proposal
|
|
// targets a Guild Council whose guild-id-ref references a real Guild.
|
|
func TestSubmitProposalGuildTargetValidation(t *testing.T) {
|
|
ctx, _, _, gk, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cg", types.CouncilGuild, "", "guild-xyz")
|
|
|
|
// Guild exists (default stub) → accepted.
|
|
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-guild-ok", "cg", types.ProposalKindGuild, 2000)); err != nil {
|
|
t.Fatalf("SubmitProposal Guild with valid guild-id-ref should be accepted; got: %v", err)
|
|
}
|
|
|
|
// Guild does NOT exist → rejected.
|
|
gk.exists = map[string]bool{"guild-xyz": false}
|
|
_, err := srv.SubmitProposal(ctx, newSubmitMsg("p-guild-bad", "cg", types.ProposalKindGuild, 2000))
|
|
if err == nil {
|
|
t.Error("SubmitProposal Guild with non-existent guild-id-ref should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestSubmitProposalKindMustMatchCouncil asserts the ProposalKind must
|
|
// match the CouncilKind (a Stand-kind Proposal on a Mesh Council is
|
|
// rejected; a Guild-kind Proposal on a Stand Council is rejected).
|
|
func TestSubmitProposalKindMustMatchCouncil(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
|
|
// Stand-kind Proposal on a Mesh Council → rejected.
|
|
_, err := srv.SubmitProposal(ctx, newSubmitMsg("p-stand-on-mesh", "cm", types.ProposalKindStand, 2000))
|
|
if err == nil {
|
|
t.Error("Stand-kind Proposal on a Mesh Council should be rejected")
|
|
}
|
|
// Guild-kind Proposal on a Mesh Council → rejected.
|
|
_, err = srv.SubmitProposal(ctx, newSubmitMsg("p-guild-on-mesh", "cm", types.ProposalKindGuild, 2000))
|
|
if err == nil {
|
|
t.Error("Guild-kind Proposal on a Mesh Council should be rejected")
|
|
}
|
|
}
|
|
|
|
// --- Tally outcome: No majority → Failed ------------------------------------
|
|
|
|
// TestTallyNoMajorityFails asserts a tally with Yes <= No (no majority)
|
|
// transitions to Failed.
|
|
func TestTallyNoMajorityFails(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
srv.SubmitProposal(ctx, newSubmitMsg("p-nm", "cm", types.ProposalKindMesh, 2000))
|
|
activateProposal(k, ctx, "p-nm")
|
|
// 1 Yes, 2 No → No majority → Failed.
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-nm", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vn1", ProposalID: "p-nm", VoterReach: "reach:b", Option: types.VoteOptionNo, Signer: "reach:b"})
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vn2", ProposalID: "p-nm", VoterReach: "reach:c", Option: types.VoteOptionNo, Signer: "reach:c"})
|
|
|
|
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
|
|
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-nm", Signer: "reach:tally"}); err != nil {
|
|
t.Fatalf("TallyProposal: %v", err)
|
|
}
|
|
p, _ := k.GetProposal(ctx, "p-nm")
|
|
if p.Status != types.ProposalStatusFailed {
|
|
t.Errorf("status = %q, want Failed (Yes=1 not > No=2 — no majority)", p.Status)
|
|
}
|
|
}
|
|
|
|
// TestTallyTieFails asserts a tally tie (Yes == No) → Failed (the proposal
|
|
// does not pass on a tie).
|
|
func TestTallyTieFails(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
srv.SubmitProposal(ctx, newSubmitMsg("p-tie", "cm", types.ProposalKindMesh, 2000))
|
|
activateProposal(k, ctx, "p-tie")
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-tie", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "vn1", ProposalID: "p-tie", VoterReach: "reach:b", Option: types.VoteOptionNo, Signer: "reach:b"})
|
|
|
|
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
|
|
srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-tie", Signer: "reach:tally"})
|
|
p, _ := k.GetProposal(ctx, "p-tie")
|
|
if p.Status != types.ProposalStatusFailed {
|
|
t.Errorf("status = %q, want Failed (tie Yes=No → does not pass)", p.Status)
|
|
}
|
|
}
|
|
|
|
// TestTallyAbstainOnly asserts a tally with only Abstains → Failed (no
|
|
// Yes majority).
|
|
func TestTallyAbstainOnly(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
|
|
srv.SubmitProposal(ctx, newSubmitMsg("p-ab", "cm", types.ProposalKindMesh, 2000))
|
|
activateProposal(k, ctx, "p-ab")
|
|
srv.Vote(ctx, &types.MsgVote{VoteID: "va1", ProposalID: "p-ab", VoterReach: "reach:a", Option: types.VoteOptionAbstain, Signer: "reach:a"})
|
|
|
|
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
|
|
srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-ab", Signer: "reach:tally"})
|
|
p, _ := k.GetProposal(ctx, "p-ab")
|
|
if p.Status != types.ProposalStatusFailed {
|
|
t.Errorf("status = %q, want Failed (Abstain only — no Yes majority)", p.Status)
|
|
}
|
|
if p.Tally.Abstain != 1 || p.Tally.Yes != 0 || p.Tally.No != 0 {
|
|
t.Errorf("tally = %+v, want Abstain=1 only", p.Tally)
|
|
}
|
|
}
|
|
|
|
// --- Keeper store helpers ----------------------------------------------------
|
|
|
|
// TestSetGetProposal asserts the Proposal store round-trips.
|
|
func TestSetGetProposal(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
p := types.Proposal{ProposalID: "p-rt", CouncilID: "cm", Kind: types.ProposalKindMesh, Status: types.ProposalStatusPending}
|
|
k.SetProposal(ctx, p)
|
|
got, ok := k.GetProposal(ctx, "p-rt")
|
|
if !ok {
|
|
t.Fatal("GetProposal: not found")
|
|
}
|
|
if got.Status != types.ProposalStatusPending {
|
|
t.Errorf("status = %q", got.Status)
|
|
}
|
|
if _, ok := k.GetProposal(ctx, "missing"); ok {
|
|
t.Error("GetProposal should return false for missing id")
|
|
}
|
|
}
|
|
|
|
// TestAllProposals asserts AllProposals iteration.
|
|
func TestAllProposals(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
k.SetProposal(ctx, types.Proposal{ProposalID: "p1", Status: types.ProposalStatusPending})
|
|
k.SetProposal(ctx, types.Proposal{ProposalID: "p2", Status: types.ProposalStatusActive})
|
|
if len(k.AllProposals(ctx)) != 2 {
|
|
t.Errorf("expected 2 proposals, got %d", len(k.AllProposals(ctx)))
|
|
}
|
|
}
|
|
|
|
// TestSetGetVote asserts the Vote store round-trips.
|
|
func TestSetGetVote(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
v := types.Vote{VoteID: "v-rt", ProposalID: "p", VoterReach: "reach:a", Option: types.VoteOptionYes}
|
|
k.SetVote(ctx, v)
|
|
got, ok := k.GetVote(ctx, "v-rt")
|
|
if !ok {
|
|
t.Fatal("GetVote: not found")
|
|
}
|
|
if got.Option != types.VoteOptionYes {
|
|
t.Errorf("option = %q", got.Option)
|
|
}
|
|
if _, ok := k.GetVote(ctx, "missing"); ok {
|
|
t.Error("GetVote should return false for missing id")
|
|
}
|
|
}
|
|
|
|
// TestVotesForProposal asserts the VotesForProposal filter.
|
|
func TestVotesForProposal(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
k.SetVote(ctx, types.Vote{VoteID: "v1", ProposalID: "p1", Option: types.VoteOptionYes})
|
|
k.SetVote(ctx, types.Vote{VoteID: "v2", ProposalID: "p1", Option: types.VoteOptionNo})
|
|
k.SetVote(ctx, types.Vote{VoteID: "v3", ProposalID: "p2", Option: types.VoteOptionYes})
|
|
if len(k.VotesForProposal(ctx, "p1")) != 2 {
|
|
t.Errorf("VotesForProposal(p1) = %d, want 2", len(k.VotesForProposal(ctx, "p1")))
|
|
}
|
|
if len(k.VotesForProposal(ctx, "p2")) != 1 {
|
|
t.Errorf("VotesForProposal(p2) = %d, want 1", len(k.VotesForProposal(ctx, "p2")))
|
|
}
|
|
if len(k.VotesForProposal(ctx, "no-such")) != 0 {
|
|
t.Errorf("VotesForProposal(no-such) = %d, want 0", len(k.VotesForProposal(ctx, "no-such")))
|
|
}
|
|
}
|
|
|
|
// TestSetGetCouncil asserts the Council store round-trips.
|
|
func TestSetGetCouncil(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
c := types.Council{CouncilID: "cm", Kind: types.CouncilMesh}
|
|
k.SetCouncil(ctx, c)
|
|
got, ok := k.GetCouncil(ctx, "cm")
|
|
if !ok {
|
|
t.Fatal("GetCouncil: not found")
|
|
}
|
|
if got.Kind != types.CouncilMesh {
|
|
t.Errorf("kind = %q", got.Kind)
|
|
}
|
|
if _, ok := k.GetCouncil(ctx, "missing"); ok {
|
|
t.Error("GetCouncil should return false for missing id")
|
|
}
|
|
}
|
|
|
|
// --- Params helper ----------------------------------------------------------
|
|
|
|
// TestKeeperGetSetParams asserts the Keeper holds + returns the Params.
|
|
func TestKeeperGetSetParams(t *testing.T) {
|
|
_, _, _, _, k := newSimtestContext(t)
|
|
if k.GetParams().WatcherVetoQuorum != types.WatcherVetoQuorumDefault {
|
|
t.Errorf("default WatcherVetoQuorum = %d, want %d", k.GetParams().WatcherVetoQuorum, types.WatcherVetoQuorumDefault)
|
|
}
|
|
k.SetParams(types.Params{WatcherVetoQuorum: 4})
|
|
if k.GetParams().WatcherVetoQuorum != 4 {
|
|
t.Errorf("WatcherVetoQuorum = %d, want 4", k.GetParams().WatcherVetoQuorum)
|
|
}
|
|
}
|
|
|
|
// --- Expected-keeper stubs --------------------------------------------------
|
|
|
|
// TestStubWatcherKeeper asserts the stub records calls and returns
|
|
// configured results.
|
|
func TestStubWatcherKeeper(t *testing.T) {
|
|
wk := &stubWatcherKeeper{isWatcher: map[string]bool{"reach:a": true, "reach:b": false}}
|
|
if !wk.IsWatcher("reach:a") {
|
|
t.Error("reach:a should be a Watcher")
|
|
}
|
|
if wk.IsWatcher("reach:b") {
|
|
t.Error("reach:b should NOT be a Watcher")
|
|
}
|
|
if len(wk.calls) != 2 {
|
|
t.Errorf("calls = %d, want 2", len(wk.calls))
|
|
}
|
|
if wk.CountWatchers() != 9 {
|
|
t.Errorf("CountWatchers = %d, want 9 (REQ-004)", wk.CountWatchers())
|
|
}
|
|
wk2 := &stubWatcherKeeper{watcherCount: 7}
|
|
if wk2.CountWatchers() != 7 {
|
|
t.Errorf("CountWatchers = %d, want 7", wk2.CountWatchers())
|
|
}
|
|
}
|
|
|
|
// --- G-003 import-invariant (test exemption documentation) -------------------
|
|
|
|
// TestG003NoWatcherOrStandOrGuildTypesImport asserts the council
|
|
// production files do NOT import x/watcher/types, x/stand/types, or
|
|
// x/guild/types by struct (G-003 — the WatcherKeeper, StandKeeper, and
|
|
// GuildKeeper interfaces are the only coupling; no struct import). This
|
|
// is a tested invariant. The test asserts the stubs use by-string
|
|
// reach-ids and stand/guild-ids (not watcher/stand/guild structs),
|
|
// confirming the interface contract is by-ID-string.
|
|
func TestG003NoWatcherOrStandOrGuildTypesImport(t *testing.T) {
|
|
wk := &stubWatcherKeeper{isWatcher: map[string]bool{"reach:watcher-1": true}}
|
|
if !wk.IsWatcher("reach:watcher-1") {
|
|
t.Error("stub IsWatcher by-ID-string should return true")
|
|
}
|
|
if len(wk.calls) != 1 {
|
|
t.Errorf("expected 1 watcher call recorded, got %d", len(wk.calls))
|
|
}
|
|
sk := &stubStandKeeper{}
|
|
if !sk.StandExists("stand-1") {
|
|
t.Error("stub StandExists by-ID-string should return true")
|
|
}
|
|
gk := &stubGuildKeeper{}
|
|
if !gk.GuildExists("guild-1") {
|
|
t.Error("stub GuildExists by-ID-string should return true")
|
|
}
|
|
}
|