Files
openyield/x/guild/keeper/msg_server_simtest_test.go
T
cloudinit-bot b6d7b1a9ec feat(guild): P3 Guild Charter + Chapter Federation + Household/Confederation
Extends x/guild with P3 (v0.7) Guild Charter + Chapter Federation runtime
+ Household one-tap exit + Confederation Voice delegation + D-087
PierCarriesVoice locked const + REQ-064 cooling consts.

- x/guild/types: extend Guild (CommonBondHash, PublicProfile,
  ParentGuildID, IsChapter, SecessionTermsHash, GoodStandingLiens); add
  GuildPublicProfile, Lien, SecessionTerms, ConfederationVoice structs;
  add PierCarriesVoice=false (D-087), CoolingSecessionCoverActiveDays=21,
  CoolingSecessionNonCoverDays=14 consts; extend Params + GenesisState
  (Chapters slice + Chapter->ParentGuildID ref check).
- x/guild/types/msg_guild.go: 5 Msg* (CreateGuild, CreateChapter,
  OneTapExitStand, DelegateConfederationVoice, AddLien) + MsgServer
  interface + Response types (Disclaimer surfaced per REQ-061).
- x/guild/types/expected_keepers.go: StandKeeper + StashKeeper G-003 shims.
- x/guild/keeper: NEW store-backed Keeper (guild/lien/delegation stores)
  + MsgServer handlers + simtest (8 cases).
- x/guild/module.go: AppModule (D-054 simtest-grade).
- x/stand/types: IsHousehold + IsConfederation helpers + ConfederationVoice
  type-level scaffold (REQ-057/REQ-058).

Coverage: x/guild 85.7%, keeper 94.3%, types 97.1%.
G-006/G-028 intact. go.mod/go.sum diff EMPTY. go vet clean.

REQs: REQ-051, REQ-053, REQ-057, REQ-058, REQ-061

---ci---
project: oy
phase: 3
milestone: v0.7
status: execute
---/ci---
2026-08-19 02:16:42 +00:00

979 lines
33 KiB
Go

package keeper_test
// msg_server_simtest_test.go is the x/guild keeper simtest (P3, REQ-051,
// REQ-053, REQ-057, REQ-058, REQ-061).
//
// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no
// real Stand keeper (the StandKeeper shim is a stub; G-003 test exemption),
// no real Stash keeper (the StashKeeper shim is a simtest-local stub that
// records ReturnAssetsToHolder calls for assertion). The simtest exercises:
//
// CreateGuild (REQ-051 + REQ-061 disclaimer):
// - (a) successful Guild creation with Common Bond hash + Public Profile
// (MasonCount disclosed).
// - (b) successful Guild creation with MasonCountPrivate=true (count not
// disclosed — MasonCount is 0).
// - idempotency: a second CreateGuild on the same guild-id is REJECTED.
// - (g) Disclaimer surfaced at every signing (the response Disclaimer is
// non-empty).
//
// CreateChapter (REQ-053 + REQ-061 disclaimer):
// - (c) successful Chapter creation with secession terms hash-pinned +
// good-standing liens (SecuredAtFounding=true).
// - (d) Chapter inherits Parent policy + tightens (longer cooling allowed)
// + loosens (shorter cooling REJECTED at ValidateBasic).
// - rejected on non-existent Parent Guild.
// - rejected when Parent is itself a Chapter.
// - (g) Disclaimer surfaced at every signing.
//
// OneTapExitStand (REQ-057):
// - (e) Household one-tap exit succeeds (Stand type Household + StandKeeper
// stub returns "Household" + StashKeeper stub records the call).
// - (e) Crew one-tap exit REJECTED (one-tap is Household-only).
// - rejected on non-existent Stand.
// - rejected on nil StandKeeper (the type check is load-bearing).
//
// DelegateConfederationVoice (REQ-058):
// - (f) Confederation Voice delegation succeeds (one-per-Stand).
// - (f) duplicate delegation REJECTED (one-Stand-one-Vote).
// - rejected on non-Confederation Stand type.
// - rejected on nil StandKeeper.
//
// AddLien (REQ-053):
// - (h) post-founding lien with SecuredAtFounding=false succeeds.
// - (h) post-founding lien with SecuredAtFounding=true REJECTED (founding
// is one-time — REQ-053/REQ-081).
// - rejected on non-existent Guild.
//
// Coverage target: >=80% on x/guild/keeper.
import (
"strings"
"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/guild/keeper"
"github.com/oy/openyield/x/guild/types"
)
// --- Stub expected-keepers (G-003 test exemption) ---------------------------
// stubStandKeeper satisfies types.StandKeeper for the simtest. It returns a
// configurable stand-type per stand-id (a missing key returns ("", false) —
// the non-existent Stand case).
type stubStandKeeper struct {
stands map[string]string // stand-id -> stand-type
}
func (s *stubStandKeeper) GetStand(standID string) (string, bool) {
if s.stands == nil {
return "", false
}
t, ok := s.stands[standID]
return t, ok
}
// stubStashKeeper satisfies types.StashKeeper for the simtest. It records
// every ReturnAssetsToHolder call for assertion (the one-tap exit simtest
// asserts the call was made with the right holder + stand-id).
type stubStashKeeper struct {
calls []struct {
holderReachID string
standID string
}
err error
}
func (s *stubStashKeeper) ReturnAssetsToHolder(holderReachID string, standID string) error {
if s.err != nil {
return s.err
}
s.calls = append(s.calls, struct {
holderReachID string
standID string
}{holderReachID, standID})
return nil
}
// --- Simtest context helper --------------------------------------------------
// newSimtestContext constructs an in-memory sdk.Context with a KVStore
// mounted at the guild store key. Returns the ctx, the two stub keepers,
// the store key, and the Keeper.
func newSimtestContext(t *testing.T) (sdk.Context, *stubStandKeeper, *stubStashKeeper, storetypes.StoreKey, 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)
}
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
sk := &stubStandKeeper{}
stashK := &stubStashKeeper{}
k := keeper.NewKeeper(cdc, storeKey, sk, stashK)
return ctx, sk, stashK, storeKey, k
}
// newSimtestContextNilStand constructs an in-memory ctx with a nil
// StandKeeper (for the nil-shim reject-path coverage).
func newSimtestContextNilStand(t *testing.T) (sdk.Context, storetypes.StoreKey, 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)
}
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
k := keeper.NewKeeper(cdc, storeKey, nil, nil)
return ctx, storeKey, 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
}
// validTerms returns SecessionTerms at the protocol minimums.
func validTerms() types.SecessionTerms {
return types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
LienAuditRequired: true,
CovenantClearanceRequired: true,
}
}
// createParentGuild is a helper that creates a Parent Guild for the Chapter
// simtest cases.
func createParentGuild(t *testing.T, srv types.MsgServer, ctx sdk.Context, guildID string) {
t.Helper()
_, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
GuildID: guildID,
Name: "Parent",
FounderReach: "reach:founder",
CommonBondHash: []byte{0xAA, 0xBB, 0xCC},
PublicProfile: types.GuildPublicProfile{
BondSummary: "bond-summary",
MasonCount: 10,
},
Signer: "reach:founder",
})
if err != nil {
t.Fatalf("createParentGuild %q: %v", guildID, err)
}
}
// --- CreateGuild (REQ-051, REQ-061) ------------------------------------------
// TestCreateGuildSuccess (case a) asserts a successful Guild creation with
// Common Bond hash + Public Profile (MasonCount disclosed).
func TestCreateGuildSuccess(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
resp, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
GuildID: "g-1",
Name: "Task Guild",
FounderReach: "reach:founder",
CommonBondHash: []byte{1, 2, 3},
PublicProfile: types.GuildPublicProfile{
BondSummary: "a bond summary",
Disclaimers: []string{"d1"},
MasonCount: 42,
},
Signer: "reach:founder",
})
if err != nil {
t.Fatalf("CreateGuild: %v", err)
}
g, ok := k.GetGuild(ctx, "g-1")
if !ok {
t.Fatal("Guild not persisted")
}
if g.IsChapter {
t.Error("IsChapter should be false for a Parent Guild")
}
if g.ParentGuildID != "" {
t.Errorf("ParentGuildID = %q, want empty for a Parent Guild", g.ParentGuildID)
}
if len(g.CommonBondHash) != 3 {
t.Errorf("CommonBondHash = %v, want 3 bytes", g.CommonBondHash)
}
if g.PublicProfile.MasonCount != 42 {
t.Errorf("MasonCount = %d, want 42", g.PublicProfile.MasonCount)
}
if g.PublicProfile.MasonCountPrivate {
t.Error("MasonCountPrivate should be false when count is disclosed")
}
if !hasEvent(ctx, "guild.guild_created") {
t.Error("guild.guild_created event not emitted")
}
// (g) Disclaimer surfaced.
if resp.Disclaimer == "" {
t.Error("CreateGuild response Disclaimer is empty (REQ-061)")
}
}
// TestCreateGuildMasonCountPrivate (case b) asserts a Guild creation with
// MasonCountPrivate=true (count not disclosed — MasonCount is 0).
func TestCreateGuildMasonCountPrivate(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
GuildID: "g-priv",
Name: "Private Count Guild",
FounderReach: "reach:f",
CommonBondHash: []byte{1},
PublicProfile: types.GuildPublicProfile{
BondSummary: "private count",
MasonCount: 0,
MasonCountPrivate: true,
},
Signer: "reach:f",
})
if err != nil {
t.Fatalf("CreateGuild: %v", err)
}
g, _ := k.GetGuild(ctx, "g-priv")
if !g.PublicProfile.MasonCountPrivate {
t.Error("MasonCountPrivate should be true")
}
if g.PublicProfile.MasonCount != 0 {
t.Errorf("MasonCount = %d, want 0 (not disclosed)", g.PublicProfile.MasonCount)
}
}
// TestCreateGuildIdempotentReject asserts a second CreateGuild on the same
// guild-id is REJECTED.
func TestCreateGuildIdempotentReject(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
first := &types.MsgCreateGuild{
GuildID: "g-dup", Name: "n", FounderReach: "reach:f",
CommonBondHash: []byte{1}, Signer: "reach:f",
}
if _, err := srv.CreateGuild(ctx, first); err != nil {
t.Fatalf("first CreateGuild: %v", err)
}
_, err := srv.CreateGuild(ctx, first)
if err == nil {
t.Error("second CreateGuild on same guild-id should be rejected (idempotent)")
}
}
// TestCreateGuildValidateBasicReject asserts a CreateGuild with empty
// CommonBondHash is REJECTED at ValidateBasic.
func TestCreateGuildValidateBasicReject(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.CreateGuild(ctx, &types.MsgCreateGuild{
GuildID: "g-bad", Name: "n", FounderReach: "reach:f",
CommonBondHash: nil, Signer: "reach:f",
})
if err == nil {
t.Error("CreateGuild with empty CommonBondHash should be rejected at ValidateBasic")
}
}
// --- CreateChapter (REQ-053, REQ-061) ----------------------------------------
// TestCreateChapterSuccess (case c) asserts a successful Chapter creation
// with secession terms hash-pinned + good-standing liens
// (SecuredAtFounding=true).
func TestCreateChapterSuccess(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-parent")
resp, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-chapter",
Name: "Chapter",
ParentGuildID: "g-parent",
FounderReach: "reach:founder",
SecessionTerms: types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
LienAuditRequired: true,
CovenantClearanceRequired: true,
},
GoodStandingLiens: []types.Lien{
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1"},
},
Signer: "reach:founder",
})
if err != nil {
t.Fatalf("CreateChapter: %v", err)
}
c, ok := k.GetGuild(ctx, "g-chapter")
if !ok {
t.Fatal("Chapter not persisted")
}
if !c.IsChapter {
t.Error("IsChapter should be true for a Chapter")
}
if c.ParentGuildID != "g-parent" {
t.Errorf("ParentGuildID = %q, want g-parent", c.ParentGuildID)
}
// SecessionTermsHash is pinned (non-empty).
if len(c.SecessionTermsHash) == 0 {
t.Error("SecessionTermsHash should be pinned (non-empty)")
}
// The pinned hash matches HashSecessionTerms.
expected := types.HashSecessionTerms(types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
LienAuditRequired: true,
CovenantClearanceRequired: true,
})
if string(c.SecessionTermsHash) != string(expected) {
t.Errorf("SecessionTermsHash mismatch: got %x, want %x", c.SecessionTermsHash, expected)
}
// Good-standing liens recorded with SecuredAtFounding=true.
if len(c.GoodStandingLiens) != 1 || !c.GoodStandingLiens[0].SecuredAtFounding {
t.Errorf("GoodStandingLiens = %v", c.GoodStandingLiens)
}
if c.GoodStandingLiens[0].CoverPoolCovenantRef != "covenant-1" {
t.Errorf("CoverPoolCovenantRef = %q", c.GoodStandingLiens[0].CoverPoolCovenantRef)
}
// Chapter inherits Parent's Common Bond hash + Public Profile.
parent, _ := k.GetGuild(ctx, "g-parent")
if string(c.CommonBondHash) != string(parent.CommonBondHash) {
t.Errorf("Chapter CommonBondHash = %x, want parent's %x", c.CommonBondHash, parent.CommonBondHash)
}
if !hasEvent(ctx, "guild.chapter_created") {
t.Error("guild.chapter_created event not emitted")
}
// (g) Disclaimer surfaced.
if resp.Disclaimer == "" {
t.Error("CreateChapter response Disclaimer is empty (REQ-061)")
}
}
// TestCreateChapterTightenCoolingAllowed (case d) asserts a Chapter MAY
// tighten the cooling (longer than the protocol minimum is allowed).
func TestCreateChapterTightenCoolingAllowed(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-p-tight")
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-c-tight",
Name: "Tight Chapter",
ParentGuildID: "g-p-tight",
FounderReach: "reach:f",
SecessionTerms: types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays + 10, // tighter (longer)
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays + 5, // tighter (longer)
},
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
})
if err != nil {
t.Fatalf("CreateChapter with tighter cooling should succeed: %v", err)
}
if _, ok := k.GetGuild(ctx, "g-c-tight"); !ok {
t.Error("tighter Chapter not persisted")
}
}
// TestCreateChapterLoosenCoolingRejected (case d) asserts a Chapter MAY NOT
// loosen the cooling (shorter than the protocol minimum is REJECTED at
// ValidateBasic).
func TestCreateChapterLoosenCoolingRejected(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-p-loose")
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-c-loose",
Name: "Loose Chapter",
ParentGuildID: "g-p-loose",
FounderReach: "reach:f",
SecessionTerms: types.SecessionTerms{
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays - 1, // looser (shorter) — REJECT
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
},
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
})
if err == nil {
t.Fatal("CreateChapter with looser cooling (shorter) should be rejected (Chapter may tighten but not loosen — REQ-053/REQ-064)")
}
if !strings.Contains(err.Error(), "minimum") {
t.Errorf("error = %q, want 'minimum'", err.Error())
}
// The Chapter was NOT persisted.
if _, ok := k.GetGuild(ctx, "g-c-loose"); ok {
t.Error("loose Chapter should NOT be persisted on reject")
}
}
// TestCreateChapterNonExistentParent asserts a CreateChapter with a non-
// existent Parent Guild is REJECTED.
func TestCreateChapterNonExistentParent(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-c-noparent",
Name: "n",
ParentGuildID: "no-such-parent",
FounderReach: "reach:f",
SecessionTerms: validTerms(),
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
})
if err == nil {
t.Fatal("CreateChapter with non-existent parent should be rejected")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %q, want 'not found'", err.Error())
}
}
// TestCreateChapterParentIsChapter asserts a CreateChapter whose Parent is
// itself a Chapter is REJECTED (a Chapter cannot have a Chapter parent).
func TestCreateChapterParentIsChapter(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-real-parent")
// Create a first Chapter.
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-chapter-1",
Name: "Chapter1",
ParentGuildID: "g-real-parent",
FounderReach: "reach:f",
SecessionTerms: validTerms(),
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
})
if err != nil {
t.Fatalf("first CreateChapter: %v", err)
}
// Attempt to create a second Chapter under the first Chapter (a Chapter
// parent) — REJECTED.
_, err = srv.CreateChapter(ctx, &types.MsgCreateChapter{
GuildID: "g-chapter-2",
Name: "Chapter2",
ParentGuildID: "g-chapter-1",
FounderReach: "reach:f",
SecessionTerms: validTerms(),
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
})
if err == nil {
t.Fatal("CreateChapter with a Chapter parent should be rejected")
}
if !strings.Contains(err.Error(), "Chapter") {
t.Errorf("error = %q, want 'Chapter'", err.Error())
}
}
// TestCreateChapterIdempotentReject asserts a second CreateChapter on the
// same chapter guild-id is REJECTED.
func TestCreateChapterIdempotentReject(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-p-dup")
first := &types.MsgCreateChapter{
GuildID: "g-c-dup",
Name: "n",
ParentGuildID: "g-p-dup",
FounderReach: "reach:f",
SecessionTerms: validTerms(),
GoodStandingLiens: []types.Lien{
{Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: true},
},
Signer: "reach:f",
}
if _, err := srv.CreateChapter(ctx, first); err != nil {
t.Fatalf("first CreateChapter: %v", err)
}
_, err := srv.CreateChapter(ctx, first)
if err == nil {
t.Error("second CreateChapter on same guild-id should be rejected (idempotent)")
}
}
// --- OneTapExitStand (REQ-057) -----------------------------------------------
// TestOneTapExitStandHouseholdSuccess (case e) asserts a Household one-tap
// exit succeeds (Stand type Household + StashKeeper stub records the call).
func TestOneTapExitStandHouseholdSuccess(t *testing.T) {
ctx, sk, stashK, _, k := newSimtestContext(t)
sk.stands = map[string]string{"stand-hh": "Household"}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "stand-hh",
Signer: "reach:holder",
})
if err != nil {
t.Fatalf("OneTapExitStand: %v", err)
}
if !hasEvent(ctx, "guild.one_tap_exit") {
t.Error("guild.one_tap_exit event not emitted")
}
// StashKeeper recorded the asset return.
if len(stashK.calls) != 1 {
t.Fatalf("StashKeeper calls = %d, want 1", len(stashK.calls))
}
if stashK.calls[0].holderReachID != "reach:holder" || stashK.calls[0].standID != "stand-hh" {
t.Errorf("StashKeeper call = %+v", stashK.calls[0])
}
}
// TestOneTapExitStandCrewRejected (case e) asserts a Crew Stand one-tap exit
// is REJECTED (one-tap is Household-only).
func TestOneTapExitStandCrewRejected(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{"stand-crew": "Crew"}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "stand-crew",
Signer: "reach:holder",
})
if err == nil {
t.Fatal("OneTapExitStand on a Crew Stand should be rejected (one-tap is Household-only — REQ-057)")
}
if !strings.Contains(err.Error(), "Household") {
t.Errorf("error = %q, want 'Household'", err.Error())
}
}
// TestOneTapExitStandNonExistent asserts a one-tap exit on a non-existent
// Stand is REJECTED.
func TestOneTapExitStandNonExistent(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "no-such-stand",
Signer: "reach:holder",
})
if err == nil {
t.Fatal("OneTapExitStand on non-existent Stand should be rejected")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %q, want 'not found'", err.Error())
}
}
// TestOneTapExitStandNilStandKeeperReject asserts a nil StandKeeper REJECTS
// the one-tap exit (the type check is load-bearing).
func TestOneTapExitStandNilStandKeeperReject(t *testing.T) {
ctx, _, k := newSimtestContextNilStand(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "any-stand",
Signer: "reach:holder",
})
if err == nil {
t.Fatal("OneTapExitStand with nil StandKeeper should be rejected (type check is load-bearing)")
}
if !strings.Contains(err.Error(), "StandKeeper") {
t.Errorf("error = %q, want 'StandKeeper'", err.Error())
}
}
// TestOneTapExitStandNilStashKeeperSkip asserts a nil StashKeeper skips the
// asset return (the dissolution event is still emitted).
func TestOneTapExitStandNilStashKeeperSkip(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{"stand-hh2": "Household"}
// Wire a nil StashKeeper via the setter (the keeper was constructed with
// a non-nil stub; override to nil for this case).
k.SetStashKeeper(nil)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "stand-hh2",
Signer: "reach:holder",
})
if err != nil {
t.Fatalf("OneTapExitStand with nil StashKeeper should skip asset return: %v", err)
}
if !hasEvent(ctx, "guild.one_tap_exit") {
t.Error("guild.one_tap_exit event should still be emitted with nil StashKeeper")
}
}
// TestOneTapExitStandStashErrorReject asserts a StashKeeper error REJECTS
// the one-tap exit (the asset return is load-bearing).
func TestOneTapExitStandStashErrorReject(t *testing.T) {
ctx, sk, stashK, _, k := newSimtestContext(t)
sk.stands = map[string]string{"stand-hh-err": "Household"}
stashK.err = sentinelErr("stash return failed (simtest)")
srv := keeper.NewMsgServerImpl(k)
_, err := srv.OneTapExitStand(ctx, &types.MsgOneTapExitStand{
StandID: "stand-hh-err",
Signer: "reach:holder",
})
if err == nil {
t.Fatal("OneTapExitStand with StashKeeper error should be rejected")
}
if !strings.Contains(err.Error(), "return assets") {
t.Errorf("error = %q, want 'return assets'", err.Error())
}
}
// --- DelegateConfederationVoice (REQ-058) ------------------------------------
// TestDelegateConfederationVoiceSuccess (case f) asserts a Confederation
// Voice delegation succeeds (one-per-Stand).
func TestDelegateConfederationVoiceSuccess(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{"conf-1": "Confederation"}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
ConfederationStandID: "conf-1",
MemberStandID: "mem-1",
DelegateReachID: "reach:delegate",
Signer: "reach:s",
})
if err != nil {
t.Fatalf("DelegateConfederationVoice: %v", err)
}
v, ok := k.GetDelegation(ctx, "conf-1", "mem-1")
if !ok {
t.Fatal("delegation not persisted")
}
if v.DelegateReachID != "reach:delegate" {
t.Errorf("DelegateReachID = %q, want reach:delegate", v.DelegateReachID)
}
if !hasEvent(ctx, "guild.confederation_voice_delegated") {
t.Error("guild.confederation_voice_delegated event not emitted")
}
}
// TestDelegateConfederationVoiceDuplicateRejected (case f) asserts a
// duplicate delegation from the same MemberStandID is REJECTED (one-Stand-
// one-Vote).
func TestDelegateConfederationVoiceDuplicateRejected(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{"conf-dup": "Confederation"}
srv := keeper.NewMsgServerImpl(k)
first := &types.MsgDelegateConfederationVoice{
ConfederationStandID: "conf-dup",
MemberStandID: "mem-dup",
DelegateReachID: "reach:d1",
Signer: "reach:s",
}
if _, err := srv.DelegateConfederationVoice(ctx, first); err != nil {
t.Fatalf("first delegation: %v", err)
}
// A second delegation from the same MemberStandID (even to a different
// delegate) is REJECTED.
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
ConfederationStandID: "conf-dup",
MemberStandID: "mem-dup",
DelegateReachID: "reach:d2",
Signer: "reach:s",
})
if err == nil {
t.Fatal("duplicate delegation from the same MemberStandID should be rejected (one-Stand-one-Vote — REQ-058)")
}
if !strings.Contains(err.Error(), "duplicate") {
t.Errorf("error = %q, want 'duplicate'", err.Error())
}
}
// TestDelegateConfederationVoiceNonConfederationRejected asserts a
// delegation where the named Confederation Stand is NOT a Confederation type
// is REJECTED.
func TestDelegateConfederationVoiceNonConfederationRejected(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{"not-conf": "Crew"}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
ConfederationStandID: "not-conf",
MemberStandID: "mem-1",
DelegateReachID: "reach:d",
Signer: "reach:s",
})
if err == nil {
t.Fatal("DelegateConfederationVoice on a non-Confederation Stand should be rejected")
}
if !strings.Contains(err.Error(), "Confederation") {
t.Errorf("error = %q, want 'Confederation'", err.Error())
}
}
// TestDelegateConfederationVoiceNonExistent asserts a delegation on a non-
// existent Stand is REJECTED.
func TestDelegateConfederationVoiceNonExistent(t *testing.T) {
ctx, sk, _, _, k := newSimtestContext(t)
sk.stands = map[string]string{}
srv := keeper.NewMsgServerImpl(k)
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
ConfederationStandID: "no-such-conf",
MemberStandID: "mem-1",
DelegateReachID: "reach:d",
Signer: "reach:s",
})
if err == nil {
t.Fatal("DelegateConfederationVoice on non-existent Stand should be rejected")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %q, want 'not found'", err.Error())
}
}
// TestDelegateConfederationVoiceNilStandKeeperReject asserts a nil
// StandKeeper REJECTS the delegation (the type check is load-bearing).
func TestDelegateConfederationVoiceNilStandKeeperReject(t *testing.T) {
ctx, _, k := newSimtestContextNilStand(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.DelegateConfederationVoice(ctx, &types.MsgDelegateConfederationVoice{
ConfederationStandID: "any",
MemberStandID: "mem",
DelegateReachID: "reach:d",
Signer: "reach:s",
})
if err == nil {
t.Fatal("DelegateConfederationVoice with nil StandKeeper should be rejected (type check is load-bearing)")
}
if !strings.Contains(err.Error(), "StandKeeper") {
t.Errorf("error = %q, want 'StandKeeper'", err.Error())
}
}
// --- AddLien (REQ-053) -------------------------------------------------------
// TestAddLienPostFoundingSuccess (case h) asserts a post-founding lien with
// SecuredAtFounding=false succeeds.
func TestAddLienPostFoundingSuccess(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-lien")
_, err := srv.AddLien(ctx, &types.MsgAddLien{
GuildID: "g-lien",
Lien: types.Lien{
Amount: 500,
CreditorReachID: "reach:cred",
SecuredAtFounding: false,
CoverPoolCovenantRef: "covenant-2",
},
Signer: "reach:s",
})
if err != nil {
t.Fatalf("AddLien: %v", err)
}
// The lien is persisted at idx 0.
l, ok := k.GetLien(ctx, "g-lien", 0)
if !ok {
t.Fatal("lien not persisted")
}
if l.Amount != 500 || l.SecuredAtFounding {
t.Errorf("lien = %+v", l)
}
if !hasEvent(ctx, "guild.lien_added") {
t.Error("guild.lien_added event not emitted")
}
if got := k.AllLiens(ctx, "g-lien"); len(got) != 1 {
t.Errorf("AllLiens = %d, want 1", len(got))
}
}
// TestAddLienSecuredAtFoundingRejected (case h) asserts a post-founding lien
// with SecuredAtFounding=true is REJECTED (founding is a one-time event —
// REQ-053/REQ-081).
func TestAddLienSecuredAtFoundingRejected(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
createParentGuild(t, srv, ctx, "g-lien-reject")
_, err := srv.AddLien(ctx, &types.MsgAddLien{
GuildID: "g-lien-reject",
Lien: types.Lien{
Amount: 500,
CreditorReachID: "reach:cred",
SecuredAtFounding: true, // REJECTED — founding is one-time
},
Signer: "reach:s",
})
if err == nil {
t.Fatal("AddLien with SecuredAtFounding=true post-founding should be rejected (founding is one-time — REQ-053/REQ-081)")
}
if !strings.Contains(err.Error(), "SecuredAtFounding") {
t.Errorf("error = %q, want 'SecuredAtFounding'", err.Error())
}
// The lien was NOT persisted.
if got := k.AllLiens(ctx, "g-lien-reject"); len(got) != 0 {
t.Errorf("AllLiens = %d, want 0 (rejected lien not persisted)", len(got))
}
}
// TestAddLienNonExistentGuild asserts an AddLien on a non-existent Guild is
// REJECTED.
func TestAddLienNonExistentGuild(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.AddLien(ctx, &types.MsgAddLien{
GuildID: "no-such-guild",
Lien: types.Lien{
Amount: 100, CreditorReachID: "reach:c", SecuredAtFounding: false,
},
Signer: "reach:s",
})
if err == nil {
t.Fatal("AddLien on non-existent Guild should be rejected")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %q, want 'not found'", err.Error())
}
}
// --- unwrapCtx panic --------------------------------------------------------
// TestUnwrapCtxPanic asserts unwrapCtx panics on a non-sdk.Context value.
func TestUnwrapCtxPanic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("unwrapCtx on non-sdk.Context should panic")
}
}()
_, _ = keeper.NewMsgServerImpl(keeper.Keeper{}).AddLien("not-a-ctx",
&types.MsgAddLien{GuildID: "g", Lien: types.Lien{Amount: 1, CreditorReachID: "c"}, Signer: "s"})
}
// --- Keeper accessors (coverage) --------------------------------------------
// TestKeeperAccessors exercises the exported Keeper accessors that the
// simtest above does not directly hit (AllGuilds, GetLien on empty,
// AllDelegations, the marshal-error paths, the setters) to push coverage
// >=80%.
func TestKeeperAccessors(t *testing.T) {
ctx, sk, _, storeKey, k := newSimtestContext(t)
_ = sk
// Empty-store accessors return empty (not nil) slices.
if got := k.AllGuilds(ctx); len(got) != 0 {
t.Errorf("AllGuilds empty = %d, want 0", len(got))
}
if got := k.AllLiens(ctx, "nobody"); len(got) != 0 {
t.Errorf("AllLiens empty = %d, want 0", len(got))
}
if got := k.AllDelegations(ctx, "nobody"); len(got) != 0 {
t.Errorf("AllDelegations empty = %d, want 0", len(got))
}
if _, ok := k.GetLien(ctx, "nobody", 0); ok {
t.Error("GetLien on empty store should return false")
}
if _, ok := k.GetDelegation(ctx, "nobody", "nobody"); ok {
t.Error("GetDelegation on empty store should return false")
}
// Populate + read back.
k.SetGuild(ctx, types.Guild{GuildID: "g-a", Name: "n", FounderReach: "reach:f"})
if g, ok := k.GetGuild(ctx, "g-a"); !ok || g.Name != "n" {
t.Errorf("GetGuild = %+v ok=%v", g, ok)
}
if got := k.AllGuilds(ctx); len(got) != 1 {
t.Errorf("AllGuilds = %d, want 1", len(got))
}
k.SetLien(ctx, "g-a", 0, types.Lien{Amount: 1, CreditorReachID: "reach:c"})
if l, ok := k.GetLien(ctx, "g-a", 0); !ok || l.Amount != 1 {
t.Errorf("GetLien = %+v ok=%v", l, ok)
}
if got := k.AllLiens(ctx, "g-a"); len(got) != 1 {
t.Errorf("AllLiens = %d, want 1", len(got))
}
if idx := k.NextLienIdx(ctx, "g-a"); idx != 1 {
t.Errorf("NextLienIdx = %d, want 1", idx)
}
k.SetDelegation(ctx, types.ConfederationVoice{
ConfederationStandID: "conf-a", MemberStandID: "mem-a",
DelegateReachID: "reach:d", DelegatedAt: 1,
})
if v, ok := k.GetDelegation(ctx, "conf-a", "mem-a"); !ok || v.DelegateReachID != "reach:d" {
t.Errorf("GetDelegation = %+v ok=%v", v, ok)
}
if got := k.AllDelegations(ctx, "conf-a"); len(got) != 1 {
t.Errorf("AllDelegations = %d, want 1", len(got))
}
// Marshal-error paths (corrupt bytes in store).
store := ctx.KVStore(storeKey)
store.Set([]byte("guild/corrupt"), []byte("not-json"))
if _, ok := k.GetGuild(ctx, "corrupt"); ok {
t.Error("GetGuild on corrupt bytes should return false")
}
store.Set([]byte("lien/corrupt/0"), []byte("not-json"))
if _, ok := k.GetLien(ctx, "corrupt", 0); ok {
t.Error("GetLien on corrupt bytes should return false")
}
store.Set([]byte("delegation/corrupt/m"), []byte("not-json"))
if _, ok := k.GetDelegation(ctx, "corrupt", "m"); ok {
t.Error("GetDelegation on corrupt bytes should return false")
}
// Post-construction setters (coverage).
k.SetStandKeeper(&stubStandKeeper{stands: map[string]string{"s": "Household"}})
k.SetStashKeeper(&stubStashKeeper{})
k.SetParams(types.DefaultParams())
if k.Params().DefaultCoolingCoverActiveDays != types.CoolingSecessionCoverActiveDays {
t.Errorf("Params DefaultCoolingCoverActiveDays = %d", k.Params().DefaultCoolingCoverActiveDays)
}
}
// --- sentinel error helper ---------------------------------------------------
type sentinelErr string
func (e sentinelErr) Error() string { return string(e) }