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