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//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") } }