From 811bc62d65b5bc8e7d991391d97fb10f0cd040ab Mon Sep 17 00:00:00 2001 From: Nikola Divic Date: Tue, 8 Feb 2022 17:15:45 +0100 Subject: [PATCH 01/19] test: cli test setup & test chain head CLI actions lack unit tests. I decided to use the approach similar to what I found in `send_test.go` using gomock, but I don't rely on custom "service" implementations but mock the whole FullNode API. This first commit validates the test setup by testing the simplest method of the chain category, e.g. `chain head`. This requires a minor refactor of the CLI action code: - The constructor (`GetFullNodeAPI`) checks if there's an injected mock API in the app Metadata and uses that in unit tests. - Actions shouldn't use raw `fmt.*` but instead write to the `app.Writer` so the CLI output is testable --- cli/chain.go | 4 +++- cli/chain_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++++++ cli/util/api.go | 5 +++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 cli/chain_test.go diff --git a/cli/chain.go b/cli/chain.go index 0cbdaa0f7..e782d2ca9 100644 --- a/cli/chain.go +++ b/cli/chain.go @@ -67,6 +67,8 @@ var ChainHeadCmd = &cli.Command{ Name: "head", Usage: "Print chain head", Action: func(cctx *cli.Context) error { + afmt := NewAppFmt(cctx.App) + api, closer, err := GetFullNodeAPI(cctx) if err != nil { return err @@ -80,7 +82,7 @@ var ChainHeadCmd = &cli.Command{ } for _, c := range head.Cids() { - fmt.Println(c) + afmt.Println(c) } return nil }, diff --git a/cli/chain_test.go b/cli/chain_test.go new file mode 100644 index 000000000..c8c491e4d --- /dev/null +++ b/cli/chain_test.go @@ -0,0 +1,54 @@ +package cli + +import ( + "bytes" + "context" + "regexp" + "testing" + + "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/api/mocks" + "github.com/filecoin-project/lotus/chain/types/mock" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + ucli "github.com/urfave/cli/v2" +) + +// newMockAppWithFullAPI returns a gomock-ed CLI app used for unit tests +// see cli/util/api.go:GetFullNodeAPI for mock API injection +func newMockAppWithFullAPI(t *testing.T, cmd *ucli.Command) (*ucli.App, *mocks.MockFullNode, *bytes.Buffer, func()) { + app := ucli.NewApp() + app.Commands = ucli.Commands{cmd} + app.Setup() + + // create and inject the mock API into app Metadata + ctrl := gomock.NewController(t) + mockFullNode := mocks.NewMockFullNode(ctrl) + var fullNode api.FullNode = mockFullNode + app.Metadata["test-full-api"] = fullNode + + // this will only work if the implementation uses the app.Writer, + // if it uses fmt.*, it has to be refactored + buf := &bytes.Buffer{} + app.Writer = buf + + return app, mockFullNode, buf, ctrl.Finish +} + +func TestChainHead(t *testing.T) { + app, mockApi, buf, done := newMockAppWithFullAPI(t, WithCategory("chain", ChainHeadCmd)) + defer done() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ts := mock.TipSet(mock.MkBlock(nil, 0, 0)) + gomock.InOrder( + mockApi.EXPECT().ChainHead(ctx).Return(ts, nil), + ) + + err := app.Run([]string{"chain", "head"}) + assert.NoError(t, err) + + assert.Regexp(t, regexp.MustCompile(ts.Cids()[0].String()), buf.String()) +} diff --git a/cli/util/api.go b/cli/util/api.go index 4a7247b32..97e4f2cb8 100644 --- a/cli/util/api.go +++ b/cli/util/api.go @@ -223,6 +223,11 @@ func GetCommonAPI(ctx *cli.Context) (api.CommonNet, jsonrpc.ClientCloser, error) } func GetFullNodeAPI(ctx *cli.Context) (v0api.FullNode, jsonrpc.ClientCloser, error) { + // use the mocked API in CLI unit tests, see cli/chain_test.go for mock definition + if mock, ok := ctx.App.Metadata["test-full-api"]; ok { + return &v0api.WrapperV1Full{FullNode: mock.(v1api.FullNode)}, func() {}, nil + } + if tn, ok := ctx.App.Metadata["testnode-full"]; ok { return &v0api.WrapperV1Full{FullNode: tn.(v1api.FullNode)}, func() {}, nil } From 78649d45b90576411e9821bc7a86f065e9b79165 Mon Sep 17 00:00:00 2001 From: Nikola Divic Date: Tue, 8 Feb 2022 18:24:45 +0100 Subject: [PATCH 02/19] test: cli chain getblock command Unit test for the cli `chain getblock` command. Tests if output is JSON in the expected format. --- cli/chain.go | 7 ++++--- cli/chain_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/cli/chain.go b/cli/chain.go index e782d2ca9..4d03ac4f7 100644 --- a/cli/chain.go +++ b/cli/chain.go @@ -99,6 +99,8 @@ var ChainGetBlock = &cli.Command{ }, }, Action: func(cctx *cli.Context) error { + afmt := NewAppFmt(cctx.App) + api, closer, err := GetFullNodeAPI(cctx) if err != nil { return err @@ -126,7 +128,7 @@ var ChainGetBlock = &cli.Command{ return err } - fmt.Println(string(out)) + afmt.Println(string(out)) return nil } @@ -165,9 +167,8 @@ var ChainGetBlock = &cli.Command{ return err } - fmt.Println(string(out)) + afmt.Println(string(out)) return nil - }, } diff --git a/cli/chain_test.go b/cli/chain_test.go index c8c491e4d..eb98110f0 100644 --- a/cli/chain_test.go +++ b/cli/chain_test.go @@ -3,13 +3,16 @@ package cli import ( "bytes" "context" + "encoding/json" "regexp" "testing" "github.com/filecoin-project/lotus/api" "github.com/filecoin-project/lotus/api/mocks" + types "github.com/filecoin-project/lotus/chain/types" "github.com/filecoin-project/lotus/chain/types/mock" "github.com/golang/mock/gomock" + cid "github.com/ipfs/go-cid" "github.com/stretchr/testify/assert" ucli "github.com/urfave/cli/v2" ) @@ -52,3 +55,38 @@ func TestChainHead(t *testing.T) { assert.Regexp(t, regexp.MustCompile(ts.Cids()[0].String()), buf.String()) } + +// TestGetBlock checks if "lotus chain getblock" returns the block information in the expected format +func TestGetBlock(t *testing.T) { + app, mockApi, buf, done := newMockAppWithFullAPI(t, WithCategory("chain", ChainGetBlock)) + defer done() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + block := mock.MkBlock(nil, 0, 0) + blockMsgs := api.BlockMessages{} + + gomock.InOrder( + mockApi.EXPECT().ChainGetBlock(ctx, block.Cid()).Return(block, nil), + mockApi.EXPECT().ChainGetBlockMessages(ctx, block.Cid()).Return(&blockMsgs, nil), + mockApi.EXPECT().ChainGetParentMessages(ctx, block.Cid()).Return([]api.Message{}, nil), + mockApi.EXPECT().ChainGetParentReceipts(ctx, block.Cid()).Return([]*types.MessageReceipt{}, nil), + ) + + err := app.Run([]string{"chain", "getblock", block.Cid().String()}) + assert.NoError(t, err) + + out := struct { + types.BlockHeader + BlsMessages []*types.Message + SecpkMessages []*types.SignedMessage + ParentReceipts []*types.MessageReceipt + ParentMessages []cid.Cid + }{} + + err = json.Unmarshal(buf.Bytes(), &out) + assert.NoError(t, err) + + assert.True(t, block.Cid().Equals(out.Cid())) +} From 1cd590ace93d627fee4d8d8d92ae130dd125f45e Mon Sep 17 00:00:00 2001 From: Nikola Divic Date: Wed, 9 Feb 2022 15:29:10 +0100 Subject: [PATCH 03/19] test: chain read-obj Simple test that checks if this CLI method prints the IPLD node referenced by the given CID encoded in hexadecimal. --- cli/chain.go | 4 +++- cli/chain_test.go | 27 ++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/cli/chain.go b/cli/chain.go index 4d03ac4f7..43d5ffb1f 100644 --- a/cli/chain.go +++ b/cli/chain.go @@ -185,6 +185,8 @@ var ChainReadObjCmd = &cli.Command{ Usage: "Read the raw bytes of an object", ArgsUsage: "[objectCid]", Action: func(cctx *cli.Context) error { + afmt := NewAppFmt(cctx.App) + api, closer, err := GetFullNodeAPI(cctx) if err != nil { return err @@ -202,7 +204,7 @@ var ChainReadObjCmd = &cli.Command{ return err } - fmt.Printf("%x\n", obj) + afmt.Printf("%x\n", obj) return nil }, } diff --git a/cli/chain_test.go b/cli/chain_test.go index eb98110f0..4a961d022 100644 --- a/cli/chain_test.go +++ b/cli/chain_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "regexp" "testing" @@ -56,7 +57,7 @@ func TestChainHead(t *testing.T) { assert.Regexp(t, regexp.MustCompile(ts.Cids()[0].String()), buf.String()) } -// TestGetBlock checks if "lotus chain getblock" returns the block information in the expected format +// TestGetBlock checks if "chain getblock" returns the block information in the expected format func TestGetBlock(t *testing.T) { app, mockApi, buf, done := newMockAppWithFullAPI(t, WithCategory("chain", ChainGetBlock)) defer done() @@ -77,6 +78,7 @@ func TestGetBlock(t *testing.T) { err := app.Run([]string{"chain", "getblock", block.Cid().String()}) assert.NoError(t, err) + // expected output format out := struct { types.BlockHeader BlsMessages []*types.Message @@ -90,3 +92,26 @@ func TestGetBlock(t *testing.T) { assert.True(t, block.Cid().Equals(out.Cid())) } + +// TestChainReadObj checks if "chain read-obj" prints the referenced IPLD node as hex, if exists +func TestReadOjb(t *testing.T) { + app, mockApi, buf, done := newMockAppWithFullAPI(t, WithCategory("chain", ChainReadObjCmd)) + defer done() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + block := mock.MkBlock(nil, 0, 0) + obj := new(bytes.Buffer) + err := block.MarshalCBOR(obj) + assert.NoError(t, err) + + gomock.InOrder( + mockApi.EXPECT().ChainReadObj(ctx, block.Cid()).Return(obj.Bytes(), nil), + ) + + err = app.Run([]string{"chain", "read-obj", block.Cid().String()}) + assert.NoError(t, err) + + assert.Equal(t, buf.String(), fmt.Sprintf("%x\n", obj.Bytes())) +} From c0f47e5eed5e6e83aa289d0455e075ef9d8f3d17 Mon Sep 17 00:00:00 2001 From: Nikola Divic Date: Wed, 9 Feb 2022 15:56:13 +0100 Subject: [PATCH 04/19] test: chain delete-obj cli command Contains two subtests, that check if the --really-do-it flag (force) is respected, since removing wrong objects may lead to sync issues. --- cli/chain.go | 4 +++- cli/chain_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/cli/chain.go b/cli/chain.go index 43d5ffb1f..26524badb 100644 --- a/cli/chain.go +++ b/cli/chain.go @@ -220,6 +220,8 @@ var ChainDeleteObjCmd = &cli.Command{ }, }, Action: func(cctx *cli.Context) error { + afmt := NewAppFmt(cctx.App) + api, closer, err := GetFullNodeAPI(cctx) if err != nil { return err @@ -241,7 +243,7 @@ var ChainDeleteObjCmd = &cli.Command{ return err } - fmt.Printf("Obj %s deleted\n", c.String()) + afmt.Printf("Obj %s deleted\n", c.String()) return nil }, } diff --git a/cli/chain_test.go b/cli/chain_test.go index 4a961d022..b20ecbf31 100644 --- a/cli/chain_test.go +++ b/cli/chain_test.go @@ -115,3 +115,36 @@ func TestReadOjb(t *testing.T) { assert.Equal(t, buf.String(), fmt.Sprintf("%x\n", obj.Bytes())) } + +// TestChainDeleteObj checks if "chain delete-obj" deletes an object from the chain blockstore, respecting the --really-do-it flag +func TestChainDeleteObj(t *testing.T) { + cmd := WithCategory("chain", ChainDeleteObjCmd) + block := mock.MkBlock(nil, 0, 0) + + // given no force flag, it should return an error and no API calls should be made + t.Run("no-really-do-it", func(t *testing.T) { + app, _, _, done := newMockAppWithFullAPI(t, cmd) + defer done() + + err := app.Run([]string{"chain", "delete-obj", block.Cid().String()}) + assert.Error(t, err) + }) + + // given a force flag, API delete should be called + t.Run("really-do-it", func(t *testing.T) { + app, mockApi, buf, done := newMockAppWithFullAPI(t, cmd) + defer done() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gomock.InOrder( + mockApi.EXPECT().ChainDeleteObj(ctx, block.Cid()).Return(nil), + ) + + err := app.Run([]string{"chain", "delete-obj", "--really-do-it=true", block.Cid().String()}) + assert.NoError(t, err) + + assert.Contains(t, buf.String(), block.Cid().String()) + }) +} From a923d7c884d155d7fb83811b320878205b1592fa Mon Sep 17 00:00:00 2001 From: Nikola Divic Date: Wed, 9 Feb 2022 16:22:52 +0100 Subject: [PATCH 05/19] test: chain stat-obj cli command Test expected output with respect to the --base flag --- cli/chain.go | 5 +++-- cli/chain_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/cli/chain.go b/cli/chain.go index 26524badb..10fa9900f 100644 --- a/cli/chain.go +++ b/cli/chain.go @@ -264,6 +264,7 @@ var ChainStatObjCmd = &cli.Command{ }, }, Action: func(cctx *cli.Context) error { + afmt := NewAppFmt(cctx.App) api, closer, err := GetFullNodeAPI(cctx) if err != nil { return err @@ -289,8 +290,8 @@ var ChainStatObjCmd = &cli.Command{ return err } - fmt.Printf("Links: %d\n", stats.Links) - fmt.Printf("Size: %s (%d)\n", types.SizeStr(types.NewInt(stats.Size)), stats.Size) + afmt.Printf("Links: %d\n", stats.Links) + afmt.Printf("Size: %s (%d)\n", types.SizeStr(types.NewInt(stats.Size)), stats.Size) return nil }, } diff --git a/cli/chain_test.go b/cli/chain_test.go index b20ecbf31..fd43644d1 100644 --- a/cli/chain_test.go +++ b/cli/chain_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "regexp" + "strings" "testing" "github.com/filecoin-project/lotus/api" @@ -130,7 +131,7 @@ func TestChainDeleteObj(t *testing.T) { assert.Error(t, err) }) - // given a force flag, API delete should be called + // given a force flag, it calls API delete t.Run("really-do-it", func(t *testing.T) { app, mockApi, buf, done := newMockAppWithFullAPI(t, cmd) defer done() @@ -148,3 +149,53 @@ func TestChainDeleteObj(t *testing.T) { assert.Contains(t, buf.String(), block.Cid().String()) }) } + +func TestChainStatObj(t *testing.T) { + cmd := WithCategory("chain", ChainStatObjCmd) + block := mock.MkBlock(nil, 0, 0) + stat := api.ObjStat{Size: 123, Links: 321} + + checkOutput := func(buf *bytes.Buffer) { + out := buf.String() + outSplit := strings.Split(out, "\n") + + assert.Contains(t, outSplit[0], fmt.Sprintf("%d", stat.Links)) + assert.Contains(t, outSplit[1], fmt.Sprintf("%d", stat.Size)) + } + + // given no --base flag, it calls ChainStatObj with base=cid.Undef + t.Run("no-base", func(t *testing.T) { + app, mockApi, buf, done := newMockAppWithFullAPI(t, cmd) + defer done() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gomock.InOrder( + mockApi.EXPECT().ChainStatObj(ctx, block.Cid(), cid.Undef).Return(stat, nil), + ) + + err := app.Run([]string{"chain", "stat-obj", block.Cid().String()}) + assert.NoError(t, err) + + checkOutput(buf) + }) + + // given a --base flag, it calls ChainStatObj with that base + t.Run("base", func(t *testing.T) { + app, mockApi, buf, done := newMockAppWithFullAPI(t, cmd) + defer done() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gomock.InOrder( + mockApi.EXPECT().ChainStatObj(ctx, block.Cid(), block.Cid()).Return(stat, nil), + ) + + err := app.Run([]string{"chain", "stat-obj", fmt.Sprintf("-base=%s", block.Cid().String()), block.Cid().String()}) + assert.NoError(t, err) + + checkOutput(buf) + }) +} From e797ec138d32b5840af6c52deb0e1d4c323b3b2b Mon Sep 17 00:00:00 2001 From: Nikola Divic Date: Wed, 9 Feb 2022 17:29:29 +0100 Subject: [PATCH 06/19] test: chain getmessage cli command I also added some helper functions for mocking in the types/mock pkg --- chain/types/mock/chain.go | 38 +++++++++++++++++++++++++++++--------- cli/chain.go | 4 +++- cli/chain_test.go | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/chain/types/mock/chain.go b/chain/types/mock/chain.go index e4bb2fcee..c69f7f56f 100644 --- a/chain/types/mock/chain.go +++ b/chain/types/mock/chain.go @@ -2,6 +2,7 @@ package mock import ( "context" + "crypto/rand" "fmt" "github.com/filecoin-project/go-address" @@ -24,15 +25,7 @@ func Address(i uint64) address.Address { } func MkMessage(from, to address.Address, nonce uint64, w *wallet.LocalWallet) *types.SignedMessage { - msg := &types.Message{ - To: to, - From: from, - Value: types.NewInt(1), - Nonce: nonce, - GasLimit: 1000000, - GasFeeCap: types.NewInt(100), - GasPremium: types.NewInt(1), - } + msg := UnsignedMessage(from, to, nonce) sig, err := w.WalletSign(context.TODO(), from, msg.Cid().Bytes(), api.MsgMeta{}) if err != nil { @@ -96,3 +89,30 @@ func TipSet(blks ...*types.BlockHeader) *types.TipSet { } return ts } + +func RandomActorAddress() (*address.Address, error) { + bytes := make([]byte, 32) + _, err := rand.Read(bytes) + if err != nil { + return nil, err + } + + addr, err := address.NewActorAddress(bytes) + if err != nil { + return nil, err + } + + return &addr, nil +} + +func UnsignedMessage(from, to address.Address, nonce uint64) *types.Message { + return &types.Message{ + To: to, + From: from, + Value: types.NewInt(1), + Nonce: nonce, + GasLimit: 1000000, + GasFeeCap: types.NewInt(100), + GasPremium: types.NewInt(1), + } +} diff --git a/cli/chain.go b/cli/chain.go index 10fa9900f..d3259c6c8 100644 --- a/cli/chain.go +++ b/cli/chain.go @@ -301,6 +301,8 @@ var ChainGetMsgCmd = &cli.Command{ Usage: "Get and print a message by its cid", ArgsUsage: "[messageCid]", Action: func(cctx *cli.Context) error { + afmt := NewAppFmt(cctx.App) + if !cctx.Args().Present() { return fmt.Errorf("must pass a cid of a message to get") } @@ -339,7 +341,7 @@ var ChainGetMsgCmd = &cli.Command{ return err } - fmt.Println(string(enc)) + afmt.Println(string(enc)) return nil }, } diff --git a/cli/chain_test.go b/cli/chain_test.go index fd43644d1..064393f3a 100644 --- a/cli/chain_test.go +++ b/cli/chain_test.go @@ -150,6 +150,7 @@ func TestChainDeleteObj(t *testing.T) { }) } +// TestChainStatObj checks if "chain delete-obj" prints size and IPLD link counts for object, respecting the --base flag func TestChainStatObj(t *testing.T) { cmd := WithCategory("chain", ChainStatObjCmd) block := mock.MkBlock(nil, 0, 0) @@ -199,3 +200,37 @@ func TestChainStatObj(t *testing.T) { checkOutput(buf) }) } + +// TestChainGetMsg checks if "chain getmessage" properly decodes and serializes as JSON a Message fetched from the IPLD store +func TestChainGetMsg(t *testing.T) { + app, mockApi, buf, done := newMockAppWithFullAPI(t, WithCategory("chain", ChainGetMsgCmd)) + defer done() + + from, err := mock.RandomActorAddress() + assert.NoError(t, err) + + to, err := mock.RandomActorAddress() + assert.NoError(t, err) + + msg := mock.UnsignedMessage(*from, *to, 0) + + obj := new(bytes.Buffer) + err = msg.MarshalCBOR(obj) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gomock.InOrder( + mockApi.EXPECT().ChainReadObj(ctx, msg.Cid()).Return(obj.Bytes(), nil), + ) + + err = app.Run([]string{"chain", "getmessage", msg.Cid().String()}) + assert.NoError(t, err) + + var out types.Message + err = json.Unmarshal(buf.Bytes(), &out) + assert.NoError(t, err) + + assert.Equal(t, *msg, out) +} From ae49729afb5699a85fba7707dc798e0d9822ecb5 Mon Sep 17 00:00:00 2001 From: Nikola Divic Date: Wed, 9 Feb 2022 20:46:51 +0100 Subject: [PATCH 07/19] test: chain sethead cli command Also moved the mock definition to a separate file (mocks_test.go) because it's gonna be used in other test files, and it didn't make sense for it to stay inside chain_test.go. --- cli/chain_test.go | 100 ++++++++++++++++++++++++++++++++-------------- cli/mocks_test.go | 32 +++++++++++++++ cli/util/api.go | 2 +- 3 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 cli/mocks_test.go diff --git a/cli/chain_test.go b/cli/chain_test.go index 064393f3a..7607ea08d 100644 --- a/cli/chain_test.go +++ b/cli/chain_test.go @@ -9,39 +9,17 @@ import ( "strings" "testing" + "github.com/filecoin-project/go-state-types/abi" "github.com/filecoin-project/lotus/api" - "github.com/filecoin-project/lotus/api/mocks" types "github.com/filecoin-project/lotus/chain/types" "github.com/filecoin-project/lotus/chain/types/mock" "github.com/golang/mock/gomock" cid "github.com/ipfs/go-cid" "github.com/stretchr/testify/assert" - ucli "github.com/urfave/cli/v2" ) -// newMockAppWithFullAPI returns a gomock-ed CLI app used for unit tests -// see cli/util/api.go:GetFullNodeAPI for mock API injection -func newMockAppWithFullAPI(t *testing.T, cmd *ucli.Command) (*ucli.App, *mocks.MockFullNode, *bytes.Buffer, func()) { - app := ucli.NewApp() - app.Commands = ucli.Commands{cmd} - app.Setup() - - // create and inject the mock API into app Metadata - ctrl := gomock.NewController(t) - mockFullNode := mocks.NewMockFullNode(ctrl) - var fullNode api.FullNode = mockFullNode - app.Metadata["test-full-api"] = fullNode - - // this will only work if the implementation uses the app.Writer, - // if it uses fmt.*, it has to be refactored - buf := &bytes.Buffer{} - app.Writer = buf - - return app, mockFullNode, buf, ctrl.Finish -} - func TestChainHead(t *testing.T) { - app, mockApi, buf, done := newMockAppWithFullAPI(t, WithCategory("chain", ChainHeadCmd)) + app, mockApi, buf, done := NewMockAppWithFullAPI(t, WithCategory("chain", ChainHeadCmd)) defer done() ctx, cancel := context.WithCancel(context.Background()) @@ -60,7 +38,7 @@ func TestChainHead(t *testing.T) { // TestGetBlock checks if "chain getblock" returns the block information in the expected format func TestGetBlock(t *testing.T) { - app, mockApi, buf, done := newMockAppWithFullAPI(t, WithCategory("chain", ChainGetBlock)) + app, mockApi, buf, done := NewMockAppWithFullAPI(t, WithCategory("chain", ChainGetBlock)) defer done() ctx, cancel := context.WithCancel(context.Background()) @@ -96,7 +74,7 @@ func TestGetBlock(t *testing.T) { // TestChainReadObj checks if "chain read-obj" prints the referenced IPLD node as hex, if exists func TestReadOjb(t *testing.T) { - app, mockApi, buf, done := newMockAppWithFullAPI(t, WithCategory("chain", ChainReadObjCmd)) + app, mockApi, buf, done := NewMockAppWithFullAPI(t, WithCategory("chain", ChainReadObjCmd)) defer done() ctx, cancel := context.WithCancel(context.Background()) @@ -124,7 +102,7 @@ func TestChainDeleteObj(t *testing.T) { // given no force flag, it should return an error and no API calls should be made t.Run("no-really-do-it", func(t *testing.T) { - app, _, _, done := newMockAppWithFullAPI(t, cmd) + app, _, _, done := NewMockAppWithFullAPI(t, cmd) defer done() err := app.Run([]string{"chain", "delete-obj", block.Cid().String()}) @@ -133,7 +111,7 @@ func TestChainDeleteObj(t *testing.T) { // given a force flag, it calls API delete t.Run("really-do-it", func(t *testing.T) { - app, mockApi, buf, done := newMockAppWithFullAPI(t, cmd) + app, mockApi, buf, done := NewMockAppWithFullAPI(t, cmd) defer done() ctx, cancel := context.WithCancel(context.Background()) @@ -166,7 +144,7 @@ func TestChainStatObj(t *testing.T) { // given no --base flag, it calls ChainStatObj with base=cid.Undef t.Run("no-base", func(t *testing.T) { - app, mockApi, buf, done := newMockAppWithFullAPI(t, cmd) + app, mockApi, buf, done := NewMockAppWithFullAPI(t, cmd) defer done() ctx, cancel := context.WithCancel(context.Background()) @@ -184,7 +162,7 @@ func TestChainStatObj(t *testing.T) { // given a --base flag, it calls ChainStatObj with that base t.Run("base", func(t *testing.T) { - app, mockApi, buf, done := newMockAppWithFullAPI(t, cmd) + app, mockApi, buf, done := NewMockAppWithFullAPI(t, cmd) defer done() ctx, cancel := context.WithCancel(context.Background()) @@ -203,7 +181,7 @@ func TestChainStatObj(t *testing.T) { // TestChainGetMsg checks if "chain getmessage" properly decodes and serializes as JSON a Message fetched from the IPLD store func TestChainGetMsg(t *testing.T) { - app, mockApi, buf, done := newMockAppWithFullAPI(t, WithCategory("chain", ChainGetMsgCmd)) + app, mockApi, buf, done := NewMockAppWithFullAPI(t, WithCategory("chain", ChainGetMsgCmd)) defer done() from, err := mock.RandomActorAddress() @@ -234,3 +212,63 @@ func TestChainGetMsg(t *testing.T) { assert.Equal(t, *msg, out) } + +func TestSetHead(t *testing.T) { + cmd := WithCategory("chain", ChainSetHeadCmd) + genesis := mock.TipSet(mock.MkBlock(nil, 0, 0)) + ts := mock.TipSet(mock.MkBlock(genesis, 1, 0)) + epoch := abi.ChainEpoch(uint64(0)) + + // given the -genesis flag, resets head to genesis ignoring the provided ts positional argument + t.Run("genesis", func(t *testing.T) { + app, mockApi, _, done := NewMockAppWithFullAPI(t, cmd) + defer done() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gomock.InOrder( + mockApi.EXPECT().ChainGetGenesis(ctx).Return(genesis, nil), + mockApi.EXPECT().ChainSetHead(ctx, genesis.Key()).Return(nil), + ) + + err := app.Run([]string{"chain", "sethead", "-genesis=true", ts.Key().String()}) + assert.NoError(t, err) + }) + + // given the -epoch flag, resets head to given epoch, ignoring the provided ts positional argument + t.Run("epoch", func(t *testing.T) { + app, mockApi, _, done := NewMockAppWithFullAPI(t, cmd) + defer done() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gomock.InOrder( + mockApi.EXPECT().ChainGetTipSetByHeight(ctx, epoch, types.EmptyTSK).Return(genesis, nil), + mockApi.EXPECT().ChainSetHead(ctx, genesis.Key()).Return(nil), + ) + + err := app.Run([]string{"chain", "sethead", fmt.Sprintf("-epoch=%s", epoch), ts.Key().String()}) + assert.NoError(t, err) + }) + + // given no flag, resets the head to given tipset key + t.Run("default", func(t *testing.T) { + app, mockApi, _, done := NewMockAppWithFullAPI(t, cmd) + defer done() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gomock.InOrder( + mockApi.EXPECT().ChainGetBlock(ctx, ts.Key().Cids()[0]).Return(ts.Blocks()[0], nil), + mockApi.EXPECT().ChainSetHead(ctx, ts.Key()).Return(nil), + ) + + // ts.Key should be passed as an array of arguments (CIDs) + // since we have only one CID in the key, this is ok + err := app.Run([]string{"chain", "sethead", ts.Key().Cids()[0].String()}) + assert.NoError(t, err) + }) +} diff --git a/cli/mocks_test.go b/cli/mocks_test.go new file mode 100644 index 000000000..c9cccac08 --- /dev/null +++ b/cli/mocks_test.go @@ -0,0 +1,32 @@ +package cli + +import ( + "bytes" + "testing" + + "github.com/filecoin-project/lotus/api" + "github.com/filecoin-project/lotus/api/mocks" + "github.com/golang/mock/gomock" + ucli "github.com/urfave/cli/v2" +) + +// newMockAppWithFullAPI returns a gomock-ed CLI app used for unit tests +// see cli/util/api.go:GetFullNodeAPI for mock API injection +func NewMockAppWithFullAPI(t *testing.T, cmd *ucli.Command) (*ucli.App, *mocks.MockFullNode, *bytes.Buffer, func()) { + app := ucli.NewApp() + app.Commands = ucli.Commands{cmd} + app.Setup() + + // create and inject the mock API into app Metadata + ctrl := gomock.NewController(t) + mockFullNode := mocks.NewMockFullNode(ctrl) + var fullNode api.FullNode = mockFullNode + app.Metadata["test-full-api"] = fullNode + + // this will only work if the implementation uses the app.Writer, + // if it uses fmt.*, it has to be refactored + buf := &bytes.Buffer{} + app.Writer = buf + + return app, mockFullNode, buf, ctrl.Finish +} diff --git a/cli/util/api.go b/cli/util/api.go index 97e4f2cb8..d87817bb3 100644 --- a/cli/util/api.go +++ b/cli/util/api.go @@ -223,7 +223,7 @@ func GetCommonAPI(ctx *cli.Context) (api.CommonNet, jsonrpc.ClientCloser, error) } func GetFullNodeAPI(ctx *cli.Context) (v0api.FullNode, jsonrpc.ClientCloser, error) { - // use the mocked API in CLI unit tests, see cli/chain_test.go for mock definition + // use the mocked API in CLI unit tests, see cli/mocks_test.go for mock definition if mock, ok := ctx.App.Metadata["test-full-api"]; ok { return &v0api.WrapperV1Full{FullNode: mock.(v1api.FullNode)}, func() {}, nil } From 4e37131602c30e2734d4c7a20a1908cc311499be Mon Sep 17 00:00:00 2001 From: Nikola Divic Date: Wed, 9 Feb 2022 23:47:40 +0100 Subject: [PATCH 08/19] test: chain inspect-usage cli command --- cli/chain.go | 19 ++++++++-------- cli/chain_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/cli/chain.go b/cli/chain.go index d3259c6c8..03dc309e0 100644 --- a/cli/chain.go +++ b/cli/chain.go @@ -416,6 +416,7 @@ var ChainInspectUsage = &cli.Command{ }, }, Action: func(cctx *cli.Context) error { + afmt := NewAppFmt(cctx.App) api, closer, err := GetFullNodeAPI(cctx) if err != nil { return err @@ -517,23 +518,23 @@ var ChainInspectUsage = &cli.Command{ numRes := cctx.Int("num-results") - fmt.Printf("Total Gas Limit: %d\n", sum) - fmt.Printf("By Sender:\n") + afmt.Printf("Total Gas Limit: %d\n", sum) + afmt.Printf("By Sender:\n") for i := 0; i < numRes && i < len(senderVals); i++ { sv := senderVals[i] - fmt.Printf("%s\t%0.2f%%\t(total: %d, count: %d)\n", sv.Key, (100*float64(sv.Gas))/float64(sum), sv.Gas, bySenderC[sv.Key]) + afmt.Printf("%s\t%0.2f%%\t(total: %d, count: %d)\n", sv.Key, (100*float64(sv.Gas))/float64(sum), sv.Gas, bySenderC[sv.Key]) } - fmt.Println() - fmt.Printf("By Receiver:\n") + afmt.Println() + afmt.Printf("By Receiver:\n") for i := 0; i < numRes && i < len(destVals); i++ { sv := destVals[i] - fmt.Printf("%s\t%0.2f%%\t(total: %d, count: %d)\n", sv.Key, (100*float64(sv.Gas))/float64(sum), sv.Gas, byDestC[sv.Key]) + afmt.Printf("%s\t%0.2f%%\t(total: %d, count: %d)\n", sv.Key, (100*float64(sv.Gas))/float64(sum), sv.Gas, byDestC[sv.Key]) } - fmt.Println() - fmt.Printf("By Method:\n") + afmt.Println() + afmt.Printf("By Method:\n") for i := 0; i < numRes && i < len(methodVals); i++ { sv := methodVals[i] - fmt.Printf("%s\t%0.2f%%\t(total: %d, count: %d)\n", sv.Key, (100*float64(sv.Gas))/float64(sum), sv.Gas, byMethodC[sv.Key]) + afmt.Printf("%s\t%0.2f%%\t(total: %d, count: %d)\n", sv.Key, (100*float64(sv.Gas))/float64(sum), sv.Gas, byMethodC[sv.Key]) } return nil diff --git a/cli/chain_test.go b/cli/chain_test.go index 7607ea08d..105d9d550 100644 --- a/cli/chain_test.go +++ b/cli/chain_test.go @@ -10,9 +10,11 @@ import ( "testing" "github.com/filecoin-project/go-state-types/abi" + "github.com/filecoin-project/go-state-types/big" "github.com/filecoin-project/lotus/api" types "github.com/filecoin-project/lotus/chain/types" "github.com/filecoin-project/lotus/chain/types/mock" + "github.com/filecoin-project/specs-actors/v7/actors/builtin" "github.com/golang/mock/gomock" cid "github.com/ipfs/go-cid" "github.com/stretchr/testify/assert" @@ -36,7 +38,6 @@ func TestChainHead(t *testing.T) { assert.Regexp(t, regexp.MustCompile(ts.Cids()[0].String()), buf.String()) } -// TestGetBlock checks if "chain getblock" returns the block information in the expected format func TestGetBlock(t *testing.T) { app, mockApi, buf, done := NewMockAppWithFullAPI(t, WithCategory("chain", ChainGetBlock)) defer done() @@ -72,7 +73,6 @@ func TestGetBlock(t *testing.T) { assert.True(t, block.Cid().Equals(out.Cid())) } -// TestChainReadObj checks if "chain read-obj" prints the referenced IPLD node as hex, if exists func TestReadOjb(t *testing.T) { app, mockApi, buf, done := NewMockAppWithFullAPI(t, WithCategory("chain", ChainReadObjCmd)) defer done() @@ -95,7 +95,6 @@ func TestReadOjb(t *testing.T) { assert.Equal(t, buf.String(), fmt.Sprintf("%x\n", obj.Bytes())) } -// TestChainDeleteObj checks if "chain delete-obj" deletes an object from the chain blockstore, respecting the --really-do-it flag func TestChainDeleteObj(t *testing.T) { cmd := WithCategory("chain", ChainDeleteObjCmd) block := mock.MkBlock(nil, 0, 0) @@ -128,7 +127,6 @@ func TestChainDeleteObj(t *testing.T) { }) } -// TestChainStatObj checks if "chain delete-obj" prints size and IPLD link counts for object, respecting the --base flag func TestChainStatObj(t *testing.T) { cmd := WithCategory("chain", ChainStatObjCmd) block := mock.MkBlock(nil, 0, 0) @@ -179,7 +177,6 @@ func TestChainStatObj(t *testing.T) { }) } -// TestChainGetMsg checks if "chain getmessage" properly decodes and serializes as JSON a Message fetched from the IPLD store func TestChainGetMsg(t *testing.T) { app, mockApi, buf, done := NewMockAppWithFullAPI(t, WithCategory("chain", ChainGetMsgCmd)) defer done() @@ -272,3 +269,53 @@ func TestSetHead(t *testing.T) { assert.NoError(t, err) }) } + +func TestInspectUsage(t *testing.T) { + cmd := WithCategory("chain", ChainInspectUsage) + ts := mock.TipSet(mock.MkBlock(nil, 0, 0)) + + from, err := mock.RandomActorAddress() + assert.NoError(t, err) + + to, err := mock.RandomActorAddress() + assert.NoError(t, err) + + msg := mock.UnsignedMessage(*from, *to, 0) + msgs := []api.Message{{Cid: msg.Cid(), Message: msg}} + + actor := &types.Actor{ + Code: builtin.StorageMarketActorCodeID, + Nonce: 0, + Balance: big.NewInt(1000000000), + } + + t.Run("default", func(t *testing.T) { + app, mockApi, buf, done := NewMockAppWithFullAPI(t, cmd) + defer done() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gomock.InOrder( + mockApi.EXPECT().ChainHead(ctx).Return(ts, nil), + mockApi.EXPECT().ChainGetParentMessages(ctx, ts.Blocks()[0].Cid()).Return(msgs, nil), + mockApi.EXPECT().ChainGetTipSet(ctx, ts.Parents()).Return(nil, nil), + mockApi.EXPECT().StateGetActor(ctx, *to, ts.Key()).Return(actor, nil), + ) + + err := app.Run([]string{"chain", "inspect-usage"}) + assert.NoError(t, err) + + out := buf.String() + + fmt.Println("🔥: ", out) + + // output is plaintext, had to do string matching + assert.Contains(t, out, "By Sender") + assert.Contains(t, out, from.String()) + assert.Contains(t, out, "By Receiver") + assert.Contains(t, out, to.String()) + assert.Contains(t, out, "By Method") + assert.Contains(t, out, "Send") + }) +} From b3f7db7a15dbf1219b0cd419f45eec2bc963ffd3 Mon Sep 17 00:00:00 2001 From: Nikola Divic Date: Thu, 10 Feb 2022 00:36:38 +0100 Subject: [PATCH 09/19] test: chain list (love) cli command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some "funky" string matching in this one, but I think that's ok. Chain is love. ❤️ --- cli/chain.go | 15 +++++++------- cli/chain_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/cli/chain.go b/cli/chain.go index 03dc309e0..cc024dbd3 100644 --- a/cli/chain.go +++ b/cli/chain.go @@ -559,6 +559,7 @@ var ChainListCmd = &cli.Command{ }, }, Action: func(cctx *cli.Context) error { + afmt := NewAppFmt(cctx.App) api, closer, err := GetFullNodeAPI(cctx) if err != nil { return err @@ -606,7 +607,7 @@ var ChainListCmd = &cli.Command{ tss = otss for i, ts := range tss { pbf := ts.Blocks()[0].ParentBaseFee - fmt.Printf("%d: %d blocks (baseFee: %s -> maxFee: %s)\n", ts.Height(), len(ts.Blocks()), ts.Blocks()[0].ParentBaseFee, types.FIL(types.BigMul(pbf, types.NewInt(uint64(build.BlockGasLimit))))) + afmt.Printf("%d: %d blocks (baseFee: %s -> maxFee: %s)\n", ts.Height(), len(ts.Blocks()), ts.Blocks()[0].ParentBaseFee, types.FIL(types.BigMul(pbf, types.NewInt(uint64(build.BlockGasLimit))))) for _, b := range ts.Blocks() { msgs, err := api.ChainGetBlockMessages(ctx, b.Cid()) @@ -632,7 +633,7 @@ var ChainListCmd = &cli.Command{ avgpremium = big.Div(psum, big.NewInt(int64(lenmsgs))) } - fmt.Printf("\t%s: \t%d msgs, gasLimit: %d / %d (%0.2f%%), avgPremium: %s\n", b.Miner, len(msgs.BlsMessages)+len(msgs.SecpkMessages), limitSum, build.BlockGasLimit, 100*float64(limitSum)/float64(build.BlockGasLimit), avgpremium) + afmt.Printf("\t%s: \t%d msgs, gasLimit: %d / %d (%0.2f%%), avgPremium: %s\n", b.Miner, len(msgs.BlsMessages)+len(msgs.SecpkMessages), limitSum, build.BlockGasLimit, 100*float64(limitSum)/float64(build.BlockGasLimit), avgpremium) } if i < len(tss)-1 { msgs, err := api.ChainGetParentMessages(ctx, tss[i+1].Blocks()[0].Cid()) @@ -657,13 +658,13 @@ var ChainListCmd = &cli.Command{ gasEfficiency := 100 * float64(gasUsed) / float64(limitSum) gasCapacity := 100 * float64(limitSum) / float64(build.BlockGasLimit) - fmt.Printf("\ttipset: \t%d msgs, %d (%0.2f%%) / %d (%0.2f%%)\n", len(msgs), gasUsed, gasEfficiency, limitSum, gasCapacity) + afmt.Printf("\ttipset: \t%d msgs, %d (%0.2f%%) / %d (%0.2f%%)\n", len(msgs), gasUsed, gasEfficiency, limitSum, gasCapacity) } - fmt.Println() + afmt.Println() } } else { for i := len(tss) - 1; i >= 0; i-- { - printTipSet(cctx.String("format"), tss[i]) + printTipSet(cctx.String("format"), tss[i], afmt) } } return nil @@ -889,7 +890,7 @@ func handleHamtAddress(ctx context.Context, api v0api.FullNode, r cid.Cid) error }) } -func printTipSet(format string, ts *types.TipSet) { +func printTipSet(format string, ts *types.TipSet, afmt *AppFmt) { format = strings.ReplaceAll(format, "", fmt.Sprint(ts.Height())) format = strings.ReplaceAll(format, "