9abda8d01e
P5 (final execution phase) of v0.7 delivers REQ-056, REQ-059, REQ-064, REQ-066: x/cover — Bill of Rights ceremony (REQ-056) + Pier Selection (REQ-066): MsgCounselReviewBillOfRights (bonded Counsel, Staked=true gate); MsgSelectPier (Guild Council + GuildKeeper shim + PierSelectionIndex); MsgRevokePierSelection (supermajority + Counsel witness); PierSelectionIndex + PierSelectionRecord structs; bill_review/pier_selection/pier_index stores. x/guild — Secession cooling (REQ-064) + Stand->Pier (REQ-059, D-074): MsgInitiateSecession (SecessionStartedAt + lien audit); MsgCompleteSecession (21d Cover-active / 14d non-Cover cooling + lien audit + covenant clearance + pro-rata settlement event); MsgEscalateStandToPier (10M Grain-cents D-074 threshold, soft upgrade); MsgAcceptPierInvitation; Guild.SecessionStartedAt + SecededAt + Lien.Cleared additive; LOCAL StandPierEscalationAnnualPass VolumeCents const (G-003 local-const mirror of x/stand canonical). x/stand — StandPierEscalationAnnualPassVolumeCents=10000000 canonical const. Coverage: cover/keeper 94.6%, guild/keeper 94.1%, stand/types 100%. G-006/G-028 intact. go.mod/go.sum diff EMPTY. go vet clean. Lexicon green. ---ci--- project: oy phase: 5 milestone: v0.7 status: execute ---/ci---
1760 lines
64 KiB
Go
1760 lines
64 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) }
|
|
|
|
// --- P5: Secession cooling + Stand→Pier escalation (REQ-064, REQ-059, D-074) ---
|
|
//
|
|
// (REQ-064 secession cooling enforcement, REQ-059/D-074 Stand→Pier boundary.)
|
|
// The P5 simtest cases exercise the secession lifecycle (initiate + complete
|
|
// with the Cover-active 21d / non-Cover 14d cooling), the lien-audit +
|
|
// covenant-clearance gates, and the Stand→Pier escalation soft-upgrade
|
|
// (eligibility flag + acceptance + decline).
|
|
|
|
// createChapterForSecession is a helper that creates a Parent Guild + a
|
|
// Chapter under it with the given liens (so the secession simtest cases
|
|
// have a Chapter to operate on). The Chapter's SecessionTerms are at the
|
|
// protocol minimums. The liens are recorded as founding-locked
|
|
// (SecuredAtFounding=true — the CreateChapter handler requires this).
|
|
func createChapterForSecession(t *testing.T, srv types.MsgServer, ctx sdk.Context, parentID, chapterID string, liens []types.Lien) {
|
|
t.Helper()
|
|
createParentGuild(t, srv, ctx, parentID)
|
|
_, err := srv.CreateChapter(ctx, &types.MsgCreateChapter{
|
|
GuildID: chapterID,
|
|
Name: "Chapter",
|
|
ParentGuildID: parentID,
|
|
FounderReach: "reach:founder",
|
|
SecessionTerms: types.SecessionTerms{
|
|
CoolingCoverActiveDays: types.CoolingSecessionCoverActiveDays,
|
|
CoolingNonCoverDays: types.CoolingSecessionNonCoverDays,
|
|
LienAuditRequired: true,
|
|
CovenantClearanceRequired: true,
|
|
},
|
|
GoodStandingLiens: liens,
|
|
Signer: "reach:founder",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("createChapterForSecession %q: %v", chapterID, err)
|
|
}
|
|
}
|
|
|
|
// TestInitiateSecessionSuccess (P5 case e) asserts a Chapter's secession
|
|
// initiation succeeds: SecessionStartedAt is set + the event is emitted +
|
|
// the lien-audit result is returned.
|
|
func TestInitiateSecessionSuccess(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
// Chapter with no liens (lien audit passes vacuously).
|
|
createChapterForSecession(t, srv, ctx, "g-par-1", "g-chap-1", nil)
|
|
|
|
resp, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
|
GuildID: "g-chap-1", Signer: "reach:founder",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InitiateSecession: %v", err)
|
|
}
|
|
c, _ := k.GetGuild(ctx, "g-chap-1")
|
|
if c.SecessionStartedAt == 0 {
|
|
t.Error("SecessionStartedAt should be set after InitiateSecession")
|
|
}
|
|
if !hasEvent(ctx, "guild.secession_initiated") {
|
|
t.Error("guild.secession_initiated event not emitted")
|
|
}
|
|
// No liens -> lien audit passes vacuously.
|
|
if !resp.LienAuditPassed {
|
|
t.Error("LienAuditPassed = false, want true (no liens -> audit passes vacuously)")
|
|
}
|
|
}
|
|
|
|
// TestInitiateSecessionNonChapterRejected asserts a non-Chapter Guild
|
|
// (a Parent Guild) is REJECTED (a Parent Guild does not secede).
|
|
func TestInitiateSecessionNonChapterRejected(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
createParentGuild(t, srv, ctx, "g-parent-not-chapter")
|
|
|
|
_, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
|
GuildID: "g-parent-not-chapter", Signer: "reach:founder",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("InitiateSecession on a non-Chapter Guild should be rejected")
|
|
}
|
|
c, _ := k.GetGuild(ctx, "g-parent-not-chapter")
|
|
if c.SecessionStartedAt != 0 {
|
|
t.Error("SecessionStartedAt should NOT be set on a rejected initiation")
|
|
}
|
|
}
|
|
|
|
// TestInitiateSecessionNotFound asserts InitiateSecession on a non-existent
|
|
// Guild is REJECTED.
|
|
func TestInitiateSecessionNotFound(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
|
|
_, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
|
GuildID: "no-such-guild", Signer: "s",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("InitiateSecession on non-existent Guild should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestInitiateSecessionDoubleReject asserts a second InitiateSecession on
|
|
// the same Chapter is REJECTED (secession already initiated).
|
|
func TestInitiateSecessionDoubleReject(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
createChapterForSecession(t, srv, ctx, "g-par-dbl", "g-chap-dbl", nil)
|
|
|
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
|
GuildID: "g-chap-dbl", Signer: "s",
|
|
}); err != nil {
|
|
t.Fatalf("first InitiateSecession: %v", err)
|
|
}
|
|
_, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
|
GuildID: "g-chap-dbl", Signer: "s",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("second InitiateSecession on same Chapter should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestCompleteSecessionCoverActive21DayCooling (P5 case e) asserts a
|
|
// Cover-active Chapter's secession completes after the 21d cooling period
|
|
// + the lien audit + the covenant clearance. The Chapter has a lien with a
|
|
// CoverPoolCovenantRef (Cover-active) — the cooling is 21d. Time-advance
|
|
// to 21d -> succeeds; the pro-rata settlement event is emitted; SecededAt
|
|
// is set.
|
|
func TestCompleteSecessionCoverActive21DayCooling(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
// Chapter with a Cover-active lien (CoverPoolCovenantRef non-empty),
|
|
// Cleared=true so the lien audit passes.
|
|
createChapterForSecession(t, srv, ctx, "g-par-21", "g-chap-21", []types.Lien{
|
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1", Cleared: true},
|
|
})
|
|
|
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
|
GuildID: "g-chap-21", Signer: "s",
|
|
}); err != nil {
|
|
t.Fatalf("InitiateSecession: %v", err)
|
|
}
|
|
|
|
// Advance time to 21d (21 * 86400 seconds). The ctx BlockTime starts at
|
|
// time.Unix(1000, 0); SecessionStartedAt = 1000. Set BlockTime to
|
|
// 1000 + 21*86400.
|
|
cooling21d := int64(types.CoolingSecessionCoverActiveDays) * 24 * 60 * 60
|
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling21d, 0))
|
|
|
|
resp, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
|
GuildID: "g-chap-21",
|
|
CovenantClearancePassed: true,
|
|
ProRataSettlementGrain: 5000,
|
|
Signer: "s",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CompleteSecession at 21d: %v", err)
|
|
}
|
|
if resp.CoolingSeconds != cooling21d {
|
|
t.Errorf("CoolingSeconds = %d, want %d (Cover-active 21d)", resp.CoolingSeconds, cooling21d)
|
|
}
|
|
if resp.ProRataSettlementGrain != 5000 {
|
|
t.Errorf("ProRataSettlementGrain = %d, want 5000", resp.ProRataSettlementGrain)
|
|
}
|
|
c, _ := k.GetGuild(ctx, "g-chap-21")
|
|
if c.SecededAt == 0 {
|
|
t.Error("SecededAt should be set after CompleteSecession")
|
|
}
|
|
if !hasEvent(ctx, "guild.secession_completed") {
|
|
t.Error("guild.secession_completed event not emitted")
|
|
}
|
|
if !hasEvent(ctx, "guild.pro_rata_settlement") {
|
|
t.Error("guild.pro_rata_settlement event not emitted")
|
|
}
|
|
}
|
|
|
|
// TestCompleteSecessionRejectedBeforeCoolingExpires (P5 case f) asserts a
|
|
// secession completion BEFORE the cooling period elapses is REJECTED. Time-
|
|
// advance to 20d (less than 21d for a Cover-active Chapter) -> REJECT; then
|
|
// 21d -> succeeds.
|
|
func TestCompleteSecessionRejectedBeforeCoolingExpires(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
createChapterForSecession(t, srv, ctx, "g-par-20", "g-chap-20", []types.Lien{
|
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1", Cleared: true},
|
|
})
|
|
|
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
|
GuildID: "g-chap-20", Signer: "s",
|
|
}); err != nil {
|
|
t.Fatalf("InitiateSecession: %v", err)
|
|
}
|
|
|
|
// Advance to 20d (less than 21d) -> REJECT.
|
|
cooling20d := int64(20) * 24 * 60 * 60
|
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling20d, 0))
|
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
|
GuildID: "g-chap-20", CovenantClearancePassed: true, Signer: "s",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("CompleteSecession at 20d (Cover-active needs 21d) should be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "cooling") {
|
|
t.Errorf("error = %q, want 'cooling'", err.Error())
|
|
}
|
|
|
|
// Advance to 21d -> succeeds.
|
|
cooling21d := int64(types.CoolingSecessionCoverActiveDays) * 24 * 60 * 60
|
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling21d, 0))
|
|
if _, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
|
GuildID: "g-chap-20", CovenantClearancePassed: true, Signer: "s",
|
|
}); err != nil {
|
|
t.Fatalf("CompleteSecession at 21d: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCompleteSecessionRejectedOutstandingLiens (P5 case g) asserts a
|
|
// secession completion with outstanding liens (a lien with Cleared=false
|
|
// and Amount > 0) is REJECTED (the lien audit fails). Time-advance to 21d
|
|
// first (so the cooling passes), then the lien audit fails.
|
|
func TestCompleteSecessionRejectedOutstandingLiens(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
// Chapter with an uncleared lien (Cleared=false, Amount > 0).
|
|
createChapterForSecession(t, srv, ctx, "g-par-liens", "g-chap-liens", []types.Lien{
|
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1", Cleared: false},
|
|
})
|
|
|
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
|
GuildID: "g-chap-liens", Signer: "s",
|
|
}); err != nil {
|
|
t.Fatalf("InitiateSecession: %v", err)
|
|
}
|
|
|
|
// Advance to 21d (cooling passes).
|
|
cooling21d := int64(types.CoolingSecessionCoverActiveDays) * 24 * 60 * 60
|
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling21d, 0))
|
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
|
GuildID: "g-chap-liens", CovenantClearancePassed: true, Signer: "s",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("CompleteSecession with outstanding liens should be rejected (lien audit)")
|
|
}
|
|
if !strings.Contains(err.Error(), "lien audit") {
|
|
t.Errorf("error = %q, want 'lien audit'", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestCompleteSecessionRejectedCovenantNotCleared asserts a secession
|
|
// completion with CovenantClearancePassed=false is REJECTED (the covenant
|
|
// clearance gate).
|
|
func TestCompleteSecessionRejectedCovenantNotCleared(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
createChapterForSecession(t, srv, ctx, "g-par-cov", "g-chap-cov", []types.Lien{
|
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1", Cleared: true},
|
|
})
|
|
|
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
|
GuildID: "g-chap-cov", Signer: "s",
|
|
}); err != nil {
|
|
t.Fatalf("InitiateSecession: %v", err)
|
|
}
|
|
cooling21d := int64(types.CoolingSecessionCoverActiveDays) * 24 * 60 * 60
|
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling21d, 0))
|
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
|
GuildID: "g-chap-cov", CovenantClearancePassed: false, Signer: "s",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("CompleteSecession with CovenantClearancePassed=false should be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "covenant clearance") {
|
|
t.Errorf("error = %q, want 'covenant clearance'", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestCompleteSecessionRejectedNotInitiated asserts a secession completion
|
|
// on a Chapter that has not initiated (SecessionStartedAt == 0) is
|
|
// REJECTED.
|
|
func TestCompleteSecessionRejectedNotInitiated(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
createChapterForSecession(t, srv, ctx, "g-par-ni", "g-chap-ni", nil)
|
|
|
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
|
GuildID: "g-chap-ni", CovenantClearancePassed: true, Signer: "s",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("CompleteSecession on a Chapter that has not initiated should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestCompleteSecessionRejectedNonChapter asserts a secession completion on
|
|
// a non-Chapter Guild is REJECTED.
|
|
func TestCompleteSecessionRejectedNonChapter(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
createParentGuild(t, srv, ctx, "g-parent-complete")
|
|
|
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
|
GuildID: "g-parent-complete", CovenantClearancePassed: true, Signer: "s",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("CompleteSecession on a non-Chapter Guild should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestCompleteSecessionNotFound asserts CompleteSecession on a non-existent
|
|
// Guild is REJECTED.
|
|
func TestCompleteSecessionNotFound(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
|
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
|
GuildID: "no-such-guild", CovenantClearancePassed: true, Signer: "s",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("CompleteSecession on non-existent Guild should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestCompleteSecessionNonCover14DayCooling (P5 case k) asserts a non-Cover
|
|
// Chapter's secession completes after the 14d cooling period (no liens
|
|
// reference a Cover Pool covenant -> non-Cover -> 14d). Time-advance to 14d
|
|
// -> succeeds.
|
|
func TestCompleteSecessionNonCover14DayCooling(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
// Chapter with a non-Cover lien (no CoverPoolCovenantRef), Cleared=true.
|
|
createChapterForSecession(t, srv, ctx, "g-par-14", "g-chap-14", []types.Lien{
|
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, Cleared: true},
|
|
})
|
|
|
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
|
GuildID: "g-chap-14", Signer: "s",
|
|
}); err != nil {
|
|
t.Fatalf("InitiateSecession: %v", err)
|
|
}
|
|
|
|
// Advance to 14d (non-Cover cooling).
|
|
cooling14d := int64(types.CoolingSecessionNonCoverDays) * 24 * 60 * 60
|
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling14d, 0))
|
|
resp, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
|
GuildID: "g-chap-14", CovenantClearancePassed: true, Signer: "s",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CompleteSecession at 14d (non-Cover): %v", err)
|
|
}
|
|
if resp.CoolingSeconds != cooling14d {
|
|
t.Errorf("CoolingSeconds = %d, want %d (non-Cover 14d)", resp.CoolingSeconds, cooling14d)
|
|
}
|
|
c, _ := k.GetGuild(ctx, "g-chap-14")
|
|
if c.SecededAt == 0 {
|
|
t.Error("SecededAt should be set after CompleteSecession")
|
|
}
|
|
}
|
|
|
|
// TestCompleteSecessionNonCover14DayBeforeRejected asserts a non-Cover
|
|
// Chapter's secession completion at 13d (less than 14d) is REJECTED.
|
|
func TestCompleteSecessionNonCover14DayBeforeRejected(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
createChapterForSecession(t, srv, ctx, "g-par-13", "g-chap-13", []types.Lien{
|
|
{Amount: 1000, CreditorReachID: "reach:cred", SecuredAtFounding: true, Cleared: true},
|
|
})
|
|
|
|
if _, err := srv.InitiateSecession(ctx, &types.MsgInitiateSecession{
|
|
GuildID: "g-chap-13", Signer: "s",
|
|
}); err != nil {
|
|
t.Fatalf("InitiateSecession: %v", err)
|
|
}
|
|
cooling13d := int64(13) * 24 * 60 * 60
|
|
ctx = ctx.WithBlockTime(time.Unix(1000+cooling13d, 0))
|
|
_, err := srv.CompleteSecession(ctx, &types.MsgCompleteSecession{
|
|
GuildID: "g-chap-13", CovenantClearancePassed: true, Signer: "s",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("CompleteSecession at 13d (non-Cover needs 14d) should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestInitiateSecessionValidateBasicErrors exercises each ValidateBasic
|
|
// error path for coverage.
|
|
func TestInitiateSecessionValidateBasicErrors(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
msg types.MsgInitiateSecession
|
|
}{
|
|
{"empty guild-id", types.MsgInitiateSecession{Signer: "s"}},
|
|
{"empty signer", types.MsgInitiateSecession{GuildID: "g"}},
|
|
}
|
|
for _, c := range cases {
|
|
if err := c.msg.ValidateBasic(); err == nil {
|
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCompleteSecessionValidateBasicErrors exercises each ValidateBasic
|
|
// error path for coverage.
|
|
func TestCompleteSecessionValidateBasicErrors(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
msg types.MsgCompleteSecession
|
|
}{
|
|
{"empty guild-id", types.MsgCompleteSecession{Signer: "s"}},
|
|
{"empty signer", types.MsgCompleteSecession{GuildID: "g"}},
|
|
}
|
|
for _, c := range cases {
|
|
if err := c.msg.ValidateBasic(); err == nil {
|
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInitiateSecessionMethods exercises the Msg + MsgResponse Reset/String/
|
|
// ProtoMessage/GetSigners methods for coverage.
|
|
func TestInitiateSecessionMethods(t *testing.T) {
|
|
m := &types.MsgInitiateSecession{GuildID: "g", Signer: "s"}
|
|
if !strings.Contains(m.String(), "g") {
|
|
t.Errorf("MsgInitiateSecession String = %q", m.String())
|
|
}
|
|
m.Reset()
|
|
if m.GuildID != "" {
|
|
t.Errorf("MsgInitiateSecession Reset did not zero: %+v", m)
|
|
}
|
|
m.ProtoMessage()
|
|
m2 := &types.MsgInitiateSecession{Signer: "host-1"}
|
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
|
t.Errorf("MsgInitiateSecession GetSigners = %v", got)
|
|
}
|
|
r := &types.MsgInitiateSecessionResponse{LienAuditPassed: true}
|
|
r.Reset()
|
|
if r.LienAuditPassed {
|
|
t.Errorf("MsgInitiateSecessionResponse Reset did not zero: %+v", r)
|
|
}
|
|
if !strings.Contains(r.String(), "MsgInitiateSecessionResponse") {
|
|
t.Errorf("MsgInitiateSecessionResponse String = %q", r.String())
|
|
}
|
|
r.ProtoMessage()
|
|
}
|
|
|
|
// TestCompleteSecessionMethods exercises the Msg + MsgResponse Reset/String/
|
|
// ProtoMessage/GetSigners methods for coverage.
|
|
func TestCompleteSecessionMethods(t *testing.T) {
|
|
m := &types.MsgCompleteSecession{GuildID: "g", CovenantClearancePassed: true, ProRataSettlementGrain: 100, Signer: "s"}
|
|
if !strings.Contains(m.String(), "g") {
|
|
t.Errorf("MsgCompleteSecession String = %q", m.String())
|
|
}
|
|
m.Reset()
|
|
if m.GuildID != "" {
|
|
t.Errorf("MsgCompleteSecession Reset did not zero: %+v", m)
|
|
}
|
|
m.ProtoMessage()
|
|
m2 := &types.MsgCompleteSecession{Signer: "host-1"}
|
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
|
t.Errorf("MsgCompleteSecession GetSigners = %v", got)
|
|
}
|
|
r := &types.MsgCompleteSecessionResponse{CoolingSeconds: 100, ProRataSettlementGrain: 200}
|
|
r.Reset()
|
|
if r.CoolingSeconds != 0 || r.ProRataSettlementGrain != 0 {
|
|
t.Errorf("MsgCompleteSecessionResponse Reset did not zero: %+v", r)
|
|
}
|
|
if !strings.Contains(r.String(), "MsgCompleteSecessionResponse") {
|
|
t.Errorf("MsgCompleteSecessionResponse String = %q", r.String())
|
|
}
|
|
r.ProtoMessage()
|
|
}
|
|
|
|
// TestEscalateStandToPierAboveThreshold (P5 case h) asserts a Stand whose
|
|
// annual Pass volume exceeds StandPierEscalationAnnualPassVolumeCents is
|
|
// marked Pier-eligible + the event is emitted.
|
|
func TestEscalateStandToPierAboveThreshold(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
|
|
resp, err := srv.EscalateStandToPier(ctx, &types.MsgEscalateStandToPier{
|
|
StandID: "stand-1",
|
|
AnnualPassVolumeCents: types.StandPierEscalationAnnualPassVolumeCents + 1,
|
|
Signer: "s",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("EscalateStandToPier above threshold: %v", err)
|
|
}
|
|
if !resp.PierEligible {
|
|
t.Error("PierEligible = false, want true (volume > threshold)")
|
|
}
|
|
if !k.GetStandPierEligible(ctx, "stand-1") {
|
|
t.Error("GetStandPierEligible = false, want true (flag should be set)")
|
|
}
|
|
if !hasEvent(ctx, "guild.stand_pier_eligible") {
|
|
t.Error("guild.stand_pier_eligible event not emitted")
|
|
}
|
|
}
|
|
|
|
// TestEscalateStandToPierBelowThreshold asserts a Stand whose annual Pass
|
|
// volume does NOT exceed the threshold is NOT marked Pier-eligible + the
|
|
// below-threshold event is emitted (the response PierEligible=false).
|
|
func TestEscalateStandToPierBelowThreshold(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
|
|
resp, err := srv.EscalateStandToPier(ctx, &types.MsgEscalateStandToPier{
|
|
StandID: "stand-below",
|
|
AnnualPassVolumeCents: types.StandPierEscalationAnnualPassVolumeCents,
|
|
Signer: "s",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("EscalateStandToPier at threshold: %v", err)
|
|
}
|
|
if resp.PierEligible {
|
|
t.Error("PierEligible = true, want false (volume == threshold, NOT > threshold)")
|
|
}
|
|
if k.GetStandPierEligible(ctx, "stand-below") {
|
|
t.Error("GetStandPierEligible = true, want false (flag should NOT be set at threshold)")
|
|
}
|
|
if !hasEvent(ctx, "guild.stand_pier_escalation_below_threshold") {
|
|
t.Error("guild.stand_pier_escalation_below_threshold event not emitted")
|
|
}
|
|
}
|
|
|
|
// TestEscalateStandToPierValidateBasicErrors exercises each ValidateBasic
|
|
// error path.
|
|
func TestEscalateStandToPierValidateBasicErrors(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
msg types.MsgEscalateStandToPier
|
|
}{
|
|
{"empty stand-id", types.MsgEscalateStandToPier{AnnualPassVolumeCents: 100, Signer: "s"}},
|
|
{"zero volume", types.MsgEscalateStandToPier{StandID: "s", Signer: "signer"}},
|
|
{"negative volume", types.MsgEscalateStandToPier{StandID: "s", AnnualPassVolumeCents: -1, Signer: "signer"}},
|
|
{"empty signer", types.MsgEscalateStandToPier{StandID: "s", AnnualPassVolumeCents: 100}},
|
|
}
|
|
for _, c := range cases {
|
|
if err := c.msg.ValidateBasic(); err == nil {
|
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEscalateStandToPierMethods exercises the Msg + MsgResponse Reset/
|
|
// String/ProtoMessage/GetSigners methods for coverage.
|
|
func TestEscalateStandToPierMethods(t *testing.T) {
|
|
m := &types.MsgEscalateStandToPier{StandID: "s", AnnualPassVolumeCents: 100, Signer: "signer"}
|
|
if !strings.Contains(m.String(), "s") {
|
|
t.Errorf("MsgEscalateStandToPier String = %q", m.String())
|
|
}
|
|
m.Reset()
|
|
if m.StandID != "" {
|
|
t.Errorf("MsgEscalateStandToPier Reset did not zero: %+v", m)
|
|
}
|
|
m.ProtoMessage()
|
|
m2 := &types.MsgEscalateStandToPier{Signer: "host-1"}
|
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
|
t.Errorf("MsgEscalateStandToPier GetSigners = %v", got)
|
|
}
|
|
r := &types.MsgEscalateStandToPierResponse{PierEligible: true}
|
|
r.Reset()
|
|
if r.PierEligible {
|
|
t.Errorf("MsgEscalateStandToPierResponse Reset did not zero: %+v", r)
|
|
}
|
|
if !strings.Contains(r.String(), "MsgEscalateStandToPierResponse") {
|
|
t.Errorf("MsgEscalateStandToPierResponse String = %q", r.String())
|
|
}
|
|
r.ProtoMessage()
|
|
}
|
|
|
|
// TestAcceptPierInvitationSuccess asserts a Stand marked Pier-eligible can
|
|
// accept the Pier invitation (the acceptance is recorded + the event is
|
|
// emitted).
|
|
func TestAcceptPierInvitationSuccess(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
// First escalate the Stand to Pier-eligible.
|
|
if _, err := srv.EscalateStandToPier(ctx, &types.MsgEscalateStandToPier{
|
|
StandID: "stand-acc",
|
|
AnnualPassVolumeCents: types.StandPierEscalationAnnualPassVolumeCents + 1,
|
|
Signer: "s",
|
|
}); err != nil {
|
|
t.Fatalf("EscalateStandToPier: %v", err)
|
|
}
|
|
// Then accept.
|
|
_, err := srv.AcceptPierInvitation(ctx, &types.MsgAcceptPierInvitation{
|
|
StandID: "stand-acc", Signer: "s",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AcceptPierInvitation: %v", err)
|
|
}
|
|
if !k.GetStandPierAccepted(ctx, "stand-acc") {
|
|
t.Error("GetStandPierAccepted = false, want true (acceptance should be recorded)")
|
|
}
|
|
if !hasEvent(ctx, "guild.stand_pier_accepted") {
|
|
t.Error("guild.stand_pier_accepted event not emitted")
|
|
}
|
|
}
|
|
|
|
// TestAcceptPierInvitationNotEligibleRejected asserts a Stand that is NOT
|
|
// Pier-eligible is REJECTED when trying to accept the Pier invitation.
|
|
func TestAcceptPierInvitationNotEligibleRejected(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
|
|
_, err := srv.AcceptPierInvitation(ctx, &types.MsgAcceptPierInvitation{
|
|
StandID: "stand-not-eligible", Signer: "s",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("AcceptPierInvitation on a non-eligible Stand should be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "Pier-eligible") {
|
|
t.Errorf("error = %q, want 'Pier-eligible'", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestAcceptPierInvitationValidateBasicErrors exercises each ValidateBasic
|
|
// error path.
|
|
func TestAcceptPierInvitationValidateBasicErrors(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
msg types.MsgAcceptPierInvitation
|
|
}{
|
|
{"empty stand-id", types.MsgAcceptPierInvitation{Signer: "s"}},
|
|
{"empty signer", types.MsgAcceptPierInvitation{StandID: "s"}},
|
|
}
|
|
for _, c := range cases {
|
|
if err := c.msg.ValidateBasic(); err == nil {
|
|
t.Errorf("case %q: ValidateBasic should fail", c.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAcceptPierInvitationMethods exercises the Msg + MsgResponse Reset/
|
|
// String/ProtoMessage/GetSigners methods for coverage.
|
|
func TestAcceptPierInvitationMethods(t *testing.T) {
|
|
m := &types.MsgAcceptPierInvitation{StandID: "s", Signer: "signer"}
|
|
if !strings.Contains(m.String(), "s") {
|
|
t.Errorf("MsgAcceptPierInvitation String = %q", m.String())
|
|
}
|
|
m.Reset()
|
|
if m.StandID != "" {
|
|
t.Errorf("MsgAcceptPierInvitation Reset did not zero: %+v", m)
|
|
}
|
|
m.ProtoMessage()
|
|
m2 := &types.MsgAcceptPierInvitation{Signer: "host-1"}
|
|
if got := m2.GetSigners(); len(got) != 1 || string(got[0]) != "host-1" {
|
|
t.Errorf("MsgAcceptPierInvitation GetSigners = %v", got)
|
|
}
|
|
r := &types.MsgAcceptPierInvitationResponse{}
|
|
r.Reset()
|
|
if !strings.Contains(r.String(), "MsgAcceptPierInvitationResponse") {
|
|
t.Errorf("MsgAcceptPierInvitationResponse String = %q", r.String())
|
|
}
|
|
r.ProtoMessage()
|
|
}
|
|
|
|
// TestStandDeclinesPierInvitation (P5 case i) asserts a Stand may decline
|
|
// the Pier invitation: the Stand is escalated to Pier-eligible, but
|
|
// MsgAcceptPierInvitation is NOT called -> no acceptance is recorded (the
|
|
// soft-upgrade: the flag is set + the event is emitted, but no enforcement
|
|
// follows; the Stand must separately accept).
|
|
func TestStandDeclinesPierInvitation(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
srv := keeper.NewMsgServerImpl(k)
|
|
// Escalate the Stand to Pier-eligible.
|
|
if _, err := srv.EscalateStandToPier(ctx, &types.MsgEscalateStandToPier{
|
|
StandID: "stand-decline",
|
|
AnnualPassVolumeCents: types.StandPierEscalationAnnualPassVolumeCents + 1,
|
|
Signer: "s",
|
|
}); err != nil {
|
|
t.Fatalf("EscalateStandToPier: %v", err)
|
|
}
|
|
// The Stand is Pier-eligible but does NOT call AcceptPierInvitation
|
|
// (the Stand declines). The acceptance flag is NOT set.
|
|
if k.GetStandPierAccepted(ctx, "stand-decline") {
|
|
t.Error("GetStandPierAccepted = true, want false (the Stand declined — no acceptance)")
|
|
}
|
|
// The eligibility flag IS set (the soft upgrade: the flag is set
|
|
// regardless of whether the Stand accepts).
|
|
if !k.GetStandPierEligible(ctx, "stand-decline") {
|
|
t.Error("GetStandPierEligible = false, want true (the escalation set the flag)")
|
|
}
|
|
}
|
|
|
|
// TestCheckLiensClearedHelper exercises the CheckLiensCleared keeper helper
|
|
// directly (coverage on the helper + the founding-locked + post-founding
|
|
// lien paths).
|
|
func TestCheckLiensClearedHelper(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
// Non-existent Guild -> false.
|
|
if k.CheckLiensCleared(ctx, "no-such-guild") {
|
|
t.Error("CheckLiensCleared on non-existent Guild should return false")
|
|
}
|
|
// Guild with no liens -> true (vacuous).
|
|
k.SetGuild(ctx, types.Guild{GuildID: "g-empty", IsChapter: true})
|
|
if !k.CheckLiensCleared(ctx, "g-empty") {
|
|
t.Error("CheckLiensCleared on a Chapter with no liens should return true (vacuous)")
|
|
}
|
|
// Guild with a founding-locked lien, Cleared=false, Amount>0 -> false.
|
|
k.SetGuild(ctx, types.Guild{
|
|
GuildID: "g-uncleared",
|
|
IsChapter: true,
|
|
GoodStandingLiens: []types.Lien{{Amount: 100, CreditorReachID: "c", SecuredAtFounding: true, Cleared: false}},
|
|
})
|
|
if k.CheckLiensCleared(ctx, "g-uncleared") {
|
|
t.Error("CheckLiensCleared with an uncleared lien should return false")
|
|
}
|
|
// Same Guild but Cleared=true -> true.
|
|
k.SetGuild(ctx, types.Guild{
|
|
GuildID: "g-cleared",
|
|
IsChapter: true,
|
|
GoodStandingLiens: []types.Lien{{Amount: 100, CreditorReachID: "c", SecuredAtFounding: true, Cleared: true}},
|
|
})
|
|
if !k.CheckLiensCleared(ctx, "g-cleared") {
|
|
t.Error("CheckLiensCleared with a cleared lien should return true")
|
|
}
|
|
// Lien with Amount=0 (cleared by zero amount) -> true.
|
|
k.SetGuild(ctx, types.Guild{
|
|
GuildID: "g-zero",
|
|
IsChapter: true,
|
|
GoodStandingLiens: []types.Lien{{Amount: 0, CreditorReachID: "c", SecuredAtFounding: true, Cleared: false}},
|
|
})
|
|
if !k.CheckLiensCleared(ctx, "g-zero") {
|
|
t.Error("CheckLiensCleared with a zero-amount lien should return true (Amount=0 passes)")
|
|
}
|
|
}
|
|
|
|
// TestChapterIsCoverActiveHelper exercises the ChapterIsCoverActive keeper
|
|
// helper directly (coverage on the helper).
|
|
func TestChapterIsCoverActiveHelper(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
// Non-existent Guild -> false.
|
|
if k.ChapterIsCoverActive(ctx, "no-such-guild") {
|
|
t.Error("ChapterIsCoverActive on non-existent Guild should return false")
|
|
}
|
|
// Guild with no liens -> false.
|
|
k.SetGuild(ctx, types.Guild{GuildID: "g-noliens", IsChapter: true})
|
|
if k.ChapterIsCoverActive(ctx, "g-noliens") {
|
|
t.Error("ChapterIsCoverActive on a Chapter with no liens should return false")
|
|
}
|
|
// Guild with a founding-locked lien + CoverPoolCovenantRef -> true.
|
|
k.SetGuild(ctx, types.Guild{
|
|
GuildID: "g-cover",
|
|
IsChapter: true,
|
|
GoodStandingLiens: []types.Lien{{Amount: 100, CreditorReachID: "c", SecuredAtFounding: true, CoverPoolCovenantRef: "covenant-1"}},
|
|
})
|
|
if !k.ChapterIsCoverActive(ctx, "g-cover") {
|
|
t.Error("ChapterIsCoverActive with a Cover-active lien should return true")
|
|
}
|
|
// Guild with a post-founding lien (lien/ store) + CoverPoolCovenantRef -> true.
|
|
k.SetGuild(ctx, types.Guild{GuildID: "g-cover-post", IsChapter: true})
|
|
k.SetLien(ctx, "g-cover-post", 0, types.Lien{Amount: 50, CreditorReachID: "c", CoverPoolCovenantRef: "covenant-2"})
|
|
if !k.ChapterIsCoverActive(ctx, "g-cover-post") {
|
|
t.Error("ChapterIsCoverActive with a post-founding Cover-active lien should return true")
|
|
}
|
|
}
|
|
|
|
// TestStandPierEligibleAndAcceptedAccessors exercises the
|
|
// GetStandPierEligible + SetStandPierEligible + GetStandPierAccepted +
|
|
// SetStandPierAccepted accessors directly for coverage.
|
|
func TestStandPierEligibleAndAcceptedAccessors(t *testing.T) {
|
|
ctx, _, _, _, k := newSimtestContext(t)
|
|
// Empty-store accessors return false.
|
|
if k.GetStandPierEligible(ctx, "stand-none") {
|
|
t.Error("GetStandPierEligible on empty store should return false")
|
|
}
|
|
if k.GetStandPierAccepted(ctx, "stand-none") {
|
|
t.Error("GetStandPierAccepted on empty store should return false")
|
|
}
|
|
// Set + read back.
|
|
k.SetStandPierEligible(ctx, "stand-1", true)
|
|
if !k.GetStandPierEligible(ctx, "stand-1") {
|
|
t.Error("GetStandPierEligible = false after SetStandPierEligible(true)")
|
|
}
|
|
k.SetStandPierAccepted(ctx, "stand-1", true)
|
|
if !k.GetStandPierAccepted(ctx, "stand-1") {
|
|
t.Error("GetStandPierAccepted = false after SetStandPierAccepted(true)")
|
|
}
|
|
// Set false explicitly.
|
|
k.SetStandPierEligible(ctx, "stand-1", false)
|
|
if k.GetStandPierEligible(ctx, "stand-1") {
|
|
t.Error("GetStandPierEligible = true after SetStandPierEligible(false)")
|
|
}
|
|
k.SetStandPierAccepted(ctx, "stand-1", false)
|
|
if k.GetStandPierAccepted(ctx, "stand-1") {
|
|
t.Error("GetStandPierAccepted = true after SetStandPierAccepted(false)")
|
|
}
|
|
}
|