From 626220c7f420693ed29b25295e902471f0e00579 Mon Sep 17 00:00:00 2001 From: Harish P Date: Wed, 14 Dec 2022 20:12:07 +0530 Subject: [PATCH] Added mock unit test framework Signed-off-by: Harish P --- go.mod | 8 +- go.sum | 14 +- goopicsi_test.go | 84 ++++++- test/mock-server/server/server.go | 199 +++++++++++++++ test/mock-server/stub/storage.go | 263 ++++++++++++++++++++ test/mock-server/stub/stub.go | 213 ++++++++++++++++ test/mock-server/stub/stub_test.go | 136 ++++++++++ test/mock-server/stubs/CreateNamespace.json | 28 +++ test/mock-server/stubs/DeleteNamespace.json | 12 + test/mock-server/stubs/NullDebugList.json | 12 + 10 files changed, 954 insertions(+), 15 deletions(-) create mode 100644 test/mock-server/server/server.go create mode 100644 test/mock-server/stub/storage.go create mode 100644 test/mock-server/stub/stub.go create mode 100644 test/mock-server/stub/stub_test.go create mode 100644 test/mock-server/stubs/CreateNamespace.json create mode 100644 test/mock-server/stubs/DeleteNamespace.json create mode 100644 test/mock-server/stubs/NullDebugList.json diff --git a/go.mod b/go.mod index e66410b3..6662620d 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,22 @@ module github.com/opiproject/goopicsi go 1.19 require ( + github.com/go-chi/chi v1.5.4 github.com/google/uuid v1.3.0 + github.com/lithammer/fuzzysearch v1.1.5 github.com/opiproject/opi-api v0.0.0-20221212223617-4ab228a467f7 github.com/stretchr/testify v1.8.1 + golang.org/x/net v0.0.0-20221002022538-bcab6841153b + golang.org/x/text v0.4.0 google.golang.org/grpc v1.51.0 + google.golang.org/protobuf v1.28.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.0.0-20221002022538-bcab6841153b // indirect golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect - golang.org/x/text v0.4.0 // indirect google.golang.org/genproto v0.0.0-20220930163606-c98284e70a91 // indirect - google.golang.org/protobuf v1.28.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 96aa8a36..1db84dd3 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= @@ -8,14 +10,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/opiproject/opi-api v0.0.0-20221207164013-fd93b840b575 h1:qUuLJp4bRKxC21El2bg6rhlBLjD/HBta7ALSZLD2pMY= -github.com/opiproject/opi-api v0.0.0-20221207164013-fd93b840b575/go.mod h1:92pv4ulvvPMuxCJ9ND3aYbmBfEMLx0VCjpkiR7ZTqPY= -github.com/opiproject/opi-api v0.0.0-20221208205708-b089acb781cd h1:9jPT8oOKr5VvC2oRhuoH3YStkHCLOjZuLiQBNHLK1bY= -github.com/opiproject/opi-api v0.0.0-20221208205708-b089acb781cd/go.mod h1:92pv4ulvvPMuxCJ9ND3aYbmBfEMLx0VCjpkiR7ZTqPY= -github.com/opiproject/opi-api v0.0.0-20221208224812-65d927304471 h1:W1F/RDX/PHjRanw6aaOtxCGTsPoysGNIhMojKex+kX8= -github.com/opiproject/opi-api v0.0.0-20221208224812-65d927304471/go.mod h1:92pv4ulvvPMuxCJ9ND3aYbmBfEMLx0VCjpkiR7ZTqPY= -github.com/opiproject/opi-api v0.0.0-20221212161256-b1b2672e72be h1:lS16v9/5Vr0+ibwLRp0LJPEq3KWAEFcS81ifVS2BscI= -github.com/opiproject/opi-api v0.0.0-20221212161256-b1b2672e72be/go.mod h1:92pv4ulvvPMuxCJ9ND3aYbmBfEMLx0VCjpkiR7ZTqPY= +github.com/lithammer/fuzzysearch v1.1.5 h1:Ag7aKU08wp0R9QCfF4GoGST9HbmAIeLP7xwMrOBEp1c= +github.com/lithammer/fuzzysearch v1.1.5/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4HmAH30Dh61Ih1Q= github.com/opiproject/opi-api v0.0.0-20221212223617-4ab228a467f7 h1:iQS6+aIBIsWmNfcUmP40rrDrKsP+xgF2Z5nhOP33U/c= github.com/opiproject/opi-api v0.0.0-20221212223617-4ab228a467f7/go.mod h1:92pv4ulvvPMuxCJ9ND3aYbmBfEMLx0VCjpkiR7ZTqPY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -31,8 +27,10 @@ golang.org/x/net v0.0.0-20221002022538-bcab6841153b h1:6e93nYa3hNqAvLr0pD4PN1fFS golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI= golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= google.golang.org/genproto v0.0.0-20220930163606-c98284e70a91 h1:Ezh2cpcnP5Rq60sLensUsFnxh7P6513NLvNtCm9iyJ4= diff --git a/goopicsi_test.go b/goopicsi_test.go index 9675a5f0..797e61e8 100644 --- a/goopicsi_test.go +++ b/goopicsi_test.go @@ -4,12 +4,76 @@ package goopicsi import ( + "fmt" "log" + "net" + "os" + "strings" "testing" + "github.com/opiproject/goopicsi/test/mock-server/server" + "github.com/opiproject/goopicsi/test/mock-server/stub" + pb "github.com/opiproject/opi-api/storage/v1alpha1/gen/go" + "github.com/stretchr/testify/suite" + "google.golang.org/grpc" + "github.com/stretchr/testify/assert" ) +type GoopcsiTestSuite struct { + suite.Suite +} + +func (suite *GoopcsiTestSuite) SetupSuite() { + RunServer() +} + +// RunServer launches mock grpc server +func RunServer() { + fmt.Println("RUNNING MOCK SERVER") + const ( + csiAddress = "localhost:50051" + defaultStubsPath = "test/mock-server/stubs" + apiPort = "4771" + ) + + // run admin stub server + stub.RunStubServer(stub.Options{ + StubPath: defaultStubsPath, + Port: apiPort, + BindAddr: "0.0.0.0", + }) + var protocol string + if strings.Contains(csiAddress, ":") { + protocol = "tcp" + } else { + protocol = "unix" + } + lis, err := net.Listen(protocol, csiAddress) + if err != nil { + fmt.Println(err, "failed to listen on address", "address", csiAddress) + os.Exit(1) + } + + MockServer := grpc.NewServer() + + pb.RegisterFrontendNvmeServiceServer(MockServer, &server.GoopCSI{}) + + fmt.Printf("Serving gRPC on %s\n", csiAddress) + errChan := make(chan error) + + // run blocking call in a separate goroutine, report errors via channel + go func() { + if err := MockServer.Serve(lis); err != nil { + errChan <- err + } + }() +} + +func (suite *GoopcsiTestSuite) TearDownTestSuite() { + suite.T().Log("Cleaning up resources..") +} + func TestNVMeControllerConnect(t *testing.T) { err := NVMeControllerConnect("12", "", "", 44565, "") if err != nil { @@ -49,11 +113,14 @@ func TestCreateNVMeNamespace(t *testing.T) { log.Println(resp) } -func TestDeleteNVMeNamespace(t *testing.T) { +func (suite *GoopcsiTestSuite) TestDeleteNVMeNamespace() { + // positive scenario err := DeleteNVMeNamespace("1") - if err != nil { - log.Println(err) - } + assert.NoError(suite.T(), err, "DeleteNVMeNamespace success") + + // negative scenario + err = DeleteNVMeNamespace("invalid") + assert.Error(suite.T(), err, "DeleteNVMeNamespace failed") } func TestExposeRemoteNVMe(t *testing.T) { @@ -69,3 +136,12 @@ func TestGenerateHostNQN(t *testing.T) { hostNQN := GenerateHostNQN() log.Println(hostNQN) } + +func TestGoopcsiTestSuite(t *testing.T) { + if testing.Short() { + t.Skip("Skipping as requested by short flag") + } + testSuite := new(GoopcsiTestSuite) + suite.Run(t, testSuite) + testSuite.TearDownTestSuite() +} diff --git a/test/mock-server/server/server.go b/test/mock-server/server/server.go new file mode 100644 index 00000000..bab21241 --- /dev/null +++ b/test/mock-server/server/server.go @@ -0,0 +1,199 @@ +/* +Copyright © 2021-2022 Dell Inc. or its subsidiaries. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package server implements mock gRPC services +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "golang.org/x/net/context" + "google.golang.org/protobuf/types/known/emptypb" + + pb "github.com/opiproject/opi-api/storage/v1alpha1/gen/go" +) + +// GoopCSI mock gRPC server to implement mock service calls +type GoopCSI struct{} + +// CreateNVMeSubsystem creates mock NVMe subsystem +func (s *GoopCSI) CreateNVMeSubsystem(ctx context.Context, request *pb.CreateNVMeSubsystemRequest) (*pb.NVMeSubsystem, error) { + // TODO implement me + panic("implement me") +} + +// DeleteNVMeSubsystem deletes mock NVMe subsystem +func (s *GoopCSI) DeleteNVMeSubsystem(ctx context.Context, request *pb.DeleteNVMeSubsystemRequest) (*emptypb.Empty, error) { + // TODO implement me + panic("implement me") +} + +// UpdateNVMeSubsystem updates a mock NVMe subsystem +func (s *GoopCSI) UpdateNVMeSubsystem(ctx context.Context, request *pb.UpdateNVMeSubsystemRequest) (*pb.NVMeSubsystem, error) { + // TODO implement me + panic("implement me") +} + +// ListNVMeSubsystems lists mock NVMe subsystems +func (s *GoopCSI) ListNVMeSubsystems(ctx context.Context, request *pb.ListNVMeSubsystemsRequest) (*pb.ListNVMeSubsystemsResponse, error) { + // TODO implement me + panic("implement me") +} + +// GetNVMeSubsystem gets a mock NVMe subsystem +func (s *GoopCSI) GetNVMeSubsystem(ctx context.Context, request *pb.GetNVMeSubsystemRequest) (*pb.NVMeSubsystem, error) { + // TODO implement me + panic("implement me") +} + +// NVMeSubsystemStats gets mock subsystem stats +func (s *GoopCSI) NVMeSubsystemStats(ctx context.Context, request *pb.NVMeSubsystemStatsRequest) (*pb.NVMeSubsystemStatsResponse, error) { + // TODO implement me + panic("implement me") +} + +// CreateNVMeController creates a mock NVMe controller +func (s *GoopCSI) CreateNVMeController(ctx context.Context, request *pb.CreateNVMeControllerRequest) (*pb.NVMeController, error) { + // TODO implement me + panic("implement me") +} + +// DeleteNVMeController deletes a mock NVMe controller +func (s *GoopCSI) DeleteNVMeController(ctx context.Context, request *pb.DeleteNVMeControllerRequest) (*emptypb.Empty, error) { + // TODO implement me + panic("implement me") +} + +// UpdateNVMeController updates a mock NVMe controller +func (s *GoopCSI) UpdateNVMeController(ctx context.Context, request *pb.UpdateNVMeControllerRequest) (*pb.NVMeController, error) { + // TODO implement me + panic("implement me") +} + +// ListNVMeControllers lists mock controllers +func (s *GoopCSI) ListNVMeControllers(ctx context.Context, request *pb.ListNVMeControllersRequest) (*pb.ListNVMeControllersResponse, error) { + // TODO implement me + panic("implement me") +} + +// GetNVMeController gets a mock NVMe controller +func (s *GoopCSI) GetNVMeController(ctx context.Context, request *pb.GetNVMeControllerRequest) (*pb.NVMeController, error) { + // TODO implement me + panic("implement me") +} + +// NVMeControllerStats gets mock stats +func (s *GoopCSI) NVMeControllerStats(ctx context.Context, request *pb.NVMeControllerStatsRequest) (*pb.NVMeControllerStatsResponse, error) { + // TODO implement me + panic("implement me") +} + +// CreateNVMeNamespace creates a mock NVMe namespace +func (s *GoopCSI) CreateNVMeNamespace(ctx context.Context, request *pb.CreateNVMeNamespaceRequest) (*pb.NVMeNamespace, error) { + out := &pb.NVMeNamespace{} + err := FindStub("FrontendNvmeServiceServer", "CreateNVMeNamespace", request, out) + return out, err +} + +// DeleteNVMeNamespace deletes a mock NVMe namespace +func (s *GoopCSI) DeleteNVMeNamespace(ctx context.Context, request *pb.DeleteNVMeNamespaceRequest) (*emptypb.Empty, error) { + out := &emptypb.Empty{} + err := FindStub("FrontendNvmeServiceServer", "DeleteNVMeNamespace", request, out) + return out, err +} + +// UpdateNVMeNamespace updates a mock namespace +func (s *GoopCSI) UpdateNVMeNamespace(ctx context.Context, request *pb.UpdateNVMeNamespaceRequest) (*pb.NVMeNamespace, error) { + // TODO implement me + panic("implement me") +} + +// ListNVMeNamespaces lists mock namespaces +func (s *GoopCSI) ListNVMeNamespaces(ctx context.Context, request *pb.ListNVMeNamespacesRequest) (*pb.ListNVMeNamespacesResponse, error) { + // TODO implement me + panic("implement me") +} + +// GetNVMeNamespace gets a mock namespace +func (s *GoopCSI) GetNVMeNamespace(ctx context.Context, request *pb.GetNVMeNamespaceRequest) (*pb.NVMeNamespace, error) { + // TODO implement me + panic("implement me") +} + +// NVMeNamespaceStats gets mock namespace stats +func (s *GoopCSI) NVMeNamespaceStats(ctx context.Context, request *pb.NVMeNamespaceStatsRequest) (*pb.NVMeNamespaceStatsResponse, error) { + // TODO implement me + panic("implement me") +} + +type payload struct { + Service string `json:"service"` + Method string `json:"method"` + Data interface{} `json:"data"` +} + +type response struct { + Data interface{} `json:"data"` + Error string `json:"error"` +} + +// FindStub makes request to mock grpc server +func FindStub(service, method string, in, out interface{}) error { + url := "http://localhost:4771/find" + pyl := payload{ + Service: service, + Method: method, + Data: in, + } + byt, err := json.Marshal(pyl) + if err != nil { + return err + } + reader := bytes.NewReader(byt) + resp, err := http.DefaultClient.Post(url, "application/json", reader) + if err != nil { + return fmt.Errorf("error request to stub server %v", err) + } + defer func() { + err := resp.Body.Close() + if err != nil { + log.Fatal(err) + } + }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf(string(body)) + } + + respRPC := new(response) + err = json.NewDecoder(resp.Body).Decode(respRPC) + if err != nil { + return fmt.Errorf("decoding json response %v", err) + } + + if respRPC.Error != "" { + return fmt.Errorf(respRPC.Error) + } + + data, _ := json.Marshal(respRPC.Data) + return json.Unmarshal(data, out) +} diff --git a/test/mock-server/stub/storage.go b/test/mock-server/stub/storage.go new file mode 100644 index 00000000..0da5345a --- /dev/null +++ b/test/mock-server/stub/storage.go @@ -0,0 +1,263 @@ +/* +Copyright © 2021 Dell Inc. or its subsidiaries. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package stub provides stub related functions +package stub + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "path/filepath" + "reflect" + "regexp" + "strings" + "sync" + + "github.com/lithammer/fuzzysearch/fuzzy" +) + +var mx = sync.Mutex{} + +// below represent map[servicename][methodname][]expectations +type stubMapping map[string]map[string][]storage + +var stubStorage = stubMapping{} + +type storage struct { + Input Input + Output Output +} + +func storeStub(stub *Stub) { + mx.Lock() + defer mx.Unlock() + + strg := storage{ + Input: stub.Input, + Output: stub.Output, + } + if stubStorage[stub.Service] == nil { + stubStorage[stub.Service] = make(map[string][]storage) + } + stubStorage[stub.Service][stub.Method] = append(stubStorage[stub.Service][stub.Method], strg) +} + +func allStub() stubMapping { + mx.Lock() + defer mx.Unlock() + return stubStorage +} + +type closeMatch struct { + rule string + expect map[string]interface{} +} + +func findStub(stub *findStubPayload) (*Output, error) { + mx.Lock() + defer mx.Unlock() + if _, ok := stubStorage[stub.Service]; !ok { + return nil, fmt.Errorf("can't find stub for Service: %s", stub.Service) + } + + if _, ok := stubStorage[stub.Service][stub.Method]; !ok { + return nil, fmt.Errorf("can't find stub for Service:%s and Method:%s", stub.Service, stub.Method) + } + + stubs := stubStorage[stub.Service][stub.Method] + if len(stubs) == 0 { + return nil, fmt.Errorf("stub for Service:%s and Method:%s is empty", stub.Service, stub.Method) + } + + var closestMatch []closeMatch + for _, stubrange := range stubs { + if expect := stubrange.Input.Equals; expect != nil { + closestMatch = append(closestMatch, closeMatch{"equals", expect}) + if equals(stub.Data, expect) { + return &stubrange.Output, nil + } + } + + if expect := stubrange.Input.Contains; expect != nil { + closestMatch = append(closestMatch, closeMatch{"contains", expect}) + if contains(stubrange.Input.Contains, stub.Data) { + return &stubrange.Output, nil + } + } + + if expect := stubrange.Input.Matches; expect != nil { + closestMatch = append(closestMatch, closeMatch{"matches", expect}) + if matches(stubrange.Input.Matches, stub.Data) { + return &stubrange.Output, nil + } + } + } + + return nil, stubNotFoundError(stub, closestMatch) +} + +func stubNotFoundError(stub *findStubPayload, closestMatches []closeMatch) error { + template := fmt.Sprintf("Can't find stub \n\nService: %s \n\nMethod: %s \n\nInput\n\n", stub.Service, stub.Method) + expectString := renderFieldAsString(stub.Data) + template += expectString + + if len(closestMatches) == 0 { + return fmt.Errorf(template) + } + + highestRank := struct { + rank float32 + match closeMatch + }{0, closeMatch{}} + for _, closeMatchValue := range closestMatches { + rank := rankMatch(expectString, closeMatchValue.expect) + + // the higher the better + if rank > highestRank.rank { + highestRank.rank = rank + highestRank.match = closeMatchValue + } + } + + var closestMatch closeMatch + if highestRank.rank == 0 { + closestMatch = closestMatches[0] + } else { + closestMatch = highestRank.match + } + + closestMatchString := renderFieldAsString(closestMatch.expect) + template += fmt.Sprintf("\n\nClosest Match \n\n%s:%s", closestMatch.rule, closestMatchString) + + return fmt.Errorf(template) +} + +// we made our own simple ranking logic +// count the matches field_name and value then compare it with total field names and values +// the higher the better +func rankMatch(expect string, closeMatch map[string]interface{}) float32 { + occurrence := 0 + for key, value := range closeMatch { + if fuzzy.Match(key+":", expect) { + occurrence++ + } + + if fuzzy.Match(fmt.Sprint(value), expect) { + occurrence++ + } + } + + if occurrence == 0 { + return 0 + } + totalFields := len(closeMatch) * 2 + return float32(occurrence) / float32(totalFields) +} + +func renderFieldAsString(fields map[string]interface{}) string { + template := "{\n" + for key, val := range fields { + template += fmt.Sprintf("\t%s: %v\n", key, val) + } + template += "}" + return template +} + +func equals(input1, input2 map[string]interface{}) bool { + return reflect.DeepEqual(input1, input2) +} + +func contains(expect, actual map[string]interface{}) bool { + for key, val := range expect { + actualValue, ok := actual[key] + if !ok { + return ok + } + kind := reflect.TypeOf(actualValue) + dataType := fmt.Sprint(kind) + compare := strings.Contains(dataType, "string") + // we will do deep compare only for non-basic types + if !compare { + if !reflect.DeepEqual(val, actualValue) { + return false + } + } else { + strVal, _ := val.(string) + strActual, _ := actualValue.(string) + isEqual := strings.Contains(strActual, strVal) + return isEqual + } + } + return true +} + +func matches(expect, actual map[string]interface{}) bool { + for keyExpect, valueExpect := range expect { + valueExpectString, ok := valueExpect.(string) + if !ok { + return false + } + actualvalue, ok := actual[keyExpect].(string) + if !ok { + return false + } + + match, err := regexp.Match(valueExpectString, []byte(actualvalue)) + if err != nil { + log.Printf("Error on matching regex %s with %s error:%v\n", valueExpectString, actualvalue, err) + } + + if !match { + return false + } + } + return true +} + +func clearStorage() { + mx.Lock() + defer mx.Unlock() + + stubStorage = stubMapping{} +} + +func readStubFromFile(path string) { + files, err := ioutil.ReadDir(path) + if err != nil { + log.Printf("Can't read stub from %s. %v\n", path, err) + return + } + + for _, file := range files { + p := fmt.Sprintf("%s/%s", path, file.Name()) + byt, err := ioutil.ReadFile(filepath.Clean(p)) + if err != nil { + log.Printf("Error when reading file %s. %v. skipping...", file.Name(), err) + continue + } + + stub := new(Stub) + err = json.Unmarshal(byt, stub) + if err != nil { + log.Printf("Error when reading file %s. %v. skipping...", file.Name(), err) + continue + } + + storeStub(stub) + } +} diff --git a/test/mock-server/stub/stub.go b/test/mock-server/stub/stub.go new file mode 100644 index 00000000..42213601 --- /dev/null +++ b/test/mock-server/stub/stub.go @@ -0,0 +1,213 @@ +/* +Copyright © 2021 Dell Inc. or its subsidiaries. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stub + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/go-chi/chi" +) + +// Options server options +type Options struct { + Port string + BindAddr string + StubPath string +} + +const ( + // DefaultAddress address of stub server + DefaultAddress = "localhost" + // DefaultPort port of stub server + DefaultPort = "4771" +) + +// RunStubServer runs mock grpc server +func RunStubServer(opt Options) { + if opt.Port == "" { + opt.Port = DefaultPort + } + if opt.BindAddr == "" { + opt.BindAddr = DefaultAddress + } + addr := opt.BindAddr + ":" + opt.Port + r := chi.NewRouter() + r.Post("/add", addStub) + r.Get("/", listStub) + r.Post("/find", handleFindStub) + r.Get("/clear", handleClearStub) + + if opt.StubPath != "" { + readStubFromFile(opt.StubPath) + } + + fmt.Println("Serving stub admin on http://" + addr) + go func() { + server := &http.Server{ + Addr: addr, + ReadHeaderTimeout: 3 * time.Second, + Handler: r, + } + err := server.ListenAndServe() + log.Println(err) + }() +} + +func responseError(err error, w http.ResponseWriter) { + w.WriteHeader(500) + _, e := w.Write([]byte(err.Error())) + if e != nil { + log.Printf("error writing error %s to response: %s\n", err.Error(), e.Error()) + } +} + +// Stub structure +type Stub struct { + Service string `json:"service"` + Method string `json:"method"` + Input Input `json:"input"` + Output Output `json:"output"` +} + +// Input structure +type Input struct { + Equals map[string]interface{} `json:"equals"` + Contains map[string]interface{} `json:"contains"` + Matches map[string]interface{} `json:"matches"` +} + +// Output structure +type Output struct { + Data map[string]interface{} `json:"data"` + Error string `json:"error"` +} + +func addStub(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + responseError(err, w) + return + } + + stub := new(Stub) + err = json.Unmarshal(body, stub) + if err != nil { + responseError(err, w) + return + } + + err = validateStub(stub) + if err != nil { + responseError(err, w) + return + } + + storeStub(stub) + + _, err = w.Write([]byte("Success add stub")) + if err != nil { + log.Printf("error writing success to response: %s\n", err.Error()) + } +} + +func listStub(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(allStub()) + if err != nil { + log.Printf("error encoding stub: %s\n", err.Error()) + } +} + +func validateStub(stub *Stub) error { + if stub.Service == "" { + return fmt.Errorf("service name can't be empty") + } + + if stub.Method == "" { + return fmt.Errorf("method name can't be empty") + } + + // due to golang implementation + // method name must capital + cases.Title(language.Und, cases.NoLower).String(stub.Method) + + switch { + case stub.Input.Contains != nil: + break + case stub.Input.Equals != nil: + break + case stub.Input.Matches != nil: + break + default: + return fmt.Errorf("input cannot be empty") + } + + // TODO: validate all input case + + if stub.Output.Error == "" && stub.Output.Data == nil { + return fmt.Errorf("output can't be empty") + } + return nil +} + +type findStubPayload struct { + Service string `json:"service"` + Method string `json:"method"` + Data map[string]interface{} `json:"data"` +} + +func handleFindStub(w http.ResponseWriter, r *http.Request) { + stub := new(findStubPayload) + err := json.NewDecoder(r.Body).Decode(stub) + if err != nil { + responseError(err, w) + return + } + + // due to golang implementation + // method name must capital + cases.Title(language.Und, cases.NoLower).String(stub.Method) + + output, err := findStub(stub) + if err != nil { + log.Println(err) + responseError(err, w) + return + } + + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(output) + if err != nil { + log.Printf("error encoding output: %s\n", err.Error()) + } +} + +func handleClearStub(w http.ResponseWriter, r *http.Request) { + clearStorage() + _, err := w.Write([]byte("OK")) + if err != nil { + log.Printf("error writing `OK` to response: %s\n", err.Error()) + } +} diff --git a/test/mock-server/stub/stub_test.go b/test/mock-server/stub/stub_test.go new file mode 100644 index 00000000..68cedab0 --- /dev/null +++ b/test/mock-server/stub/stub_test.go @@ -0,0 +1,136 @@ +/* +Copyright © 2021 Dell Inc. or its subsidiaries. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stub + +import ( + "bytes" + "io" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStub(t *testing.T) { + type test struct { + name string + mock func() *http.Request + handler http.HandlerFunc + expect string + } + + cases := []test{ + { + name: "add stub simple", + mock: func() *http.Request { + payload := `{ + "service": "Testing", + "method":"TestMethod", + "input":{ + "equals":{ + "Hola":"Mundo" + } + }, + "output":{ + "data":{ + "Hello":"World" + } + } + }` + read := bytes.NewReader([]byte(payload)) + return httptest.NewRequest("POST", "/add", read) + }, + handler: addStub, + expect: `Success add stub`, + }, + { + name: "list stub", + mock: func() *http.Request { + return httptest.NewRequest("GET", "/", nil) + }, + handler: listStub, + expect: "{\"Testing\":{\"TestMethod\":[{\"Input\":{\"equals\":{\"Hola\":\"Mundo\"},\"contains\":null,\"matches\":null},\"Output\":{\"data\":{\"Hello\":\"World\"},\"error\":\"\"}}]}}\n", + }, + { + name: "find stub equals", + mock: func() *http.Request { + payload := `{"service":"Testing","method":"TestMethod","data":{"Hola":"Mundo"}}` + return httptest.NewRequest("POST", "/find", bytes.NewReader([]byte(payload))) + }, + handler: handleFindStub, + expect: "{\"data\":{\"Hello\":\"World\"},\"error\":\"\"}\n", + }, + { + name: "add stub contains", + mock: func() *http.Request { + payload := `{ + "service": "Testing", + "method":"TestMethod", + "input":{ + "contains":{ + "field1":"hello field1", + "field3":"hello field3" + } + }, + "output":{ + "data":{ + "hello":"world" + } + } + }` + return httptest.NewRequest("POST", "/add", bytes.NewReader([]byte(payload))) + }, + handler: addStub, + expect: `Success add stub`, + }, + { + name: "find stub contains", + mock: func() *http.Request { + payload := `{ + "service":"Testing", + "method":"TestMethod", + "data":{ + "field1":"hello field1", + "field2":"hello field2", + "field3":"hello field3" + } + }` + return httptest.NewRequest("GET", "/find", bytes.NewReader([]byte(payload))) + }, + handler: handleFindStub, + expect: "{\"data\":{\"hello\":\"world\"},\"error\":\"\"}\n", + }, + } + + for _, v := range cases { + t.Run(v.name, func(t *testing.T) { + wrt := httptest.NewRecorder() + req := v.mock() + v.handler(wrt, req) + _, err := io.ReadAll(wrt.Result().Body) + defer func() { + err := wrt.Result().Body + if err != nil { + log.Fatal(err) + } + }() + assert.NoError(t, err) + }) + } +} diff --git a/test/mock-server/stubs/CreateNamespace.json b/test/mock-server/stubs/CreateNamespace.json new file mode 100644 index 00000000..7021a8ae --- /dev/null +++ b/test/mock-server/stubs/CreateNamespace.json @@ -0,0 +1,28 @@ +{ + "service": "FrontendNvmeServiceServer", + "method": "CreateNVMeNamespace", + "input": { + "contains": { + "NVMeNamespace": { + "NVMeNamespaceSpec": { + "Id": { "Value": "1" }, + "SubsystemId": { "Value": "1" }, + "VolumeId": { "Value": "1" }, + "HostNsid": { "Value": "1" } + } + } + } + }, + "output": { + "data": { + "NVMeNamespace": { + "NVMeNamespaceSpec": { + "Id": { "Value": "1" }, + "SubsystemId": { "Value": "nqn" }, + "VolumeId": { "Value": "1" }, + "HostNsid": { "Value": 1 } + } + } + } + } +} \ No newline at end of file diff --git a/test/mock-server/stubs/DeleteNamespace.json b/test/mock-server/stubs/DeleteNamespace.json new file mode 100644 index 00000000..ff033f7f --- /dev/null +++ b/test/mock-server/stubs/DeleteNamespace.json @@ -0,0 +1,12 @@ +{ + "service": "FrontendNvmeServiceServer", + "method": "DeleteNVMeNamespace", + "input": { + "contains": { + "name": "1" + } + }, + "output": { + "data": {} + } +} \ No newline at end of file diff --git a/test/mock-server/stubs/NullDebugList.json b/test/mock-server/stubs/NullDebugList.json new file mode 100644 index 00000000..ff033f7f --- /dev/null +++ b/test/mock-server/stubs/NullDebugList.json @@ -0,0 +1,12 @@ +{ + "service": "FrontendNvmeServiceServer", + "method": "DeleteNVMeNamespace", + "input": { + "contains": { + "name": "1" + } + }, + "output": { + "data": {} + } +} \ No newline at end of file