From 40bc47a1461820e1c3ff5bb089b4caaa694da932 Mon Sep 17 00:00:00 2001 From: ericwenn Date: Sun, 15 Aug 2021 12:43:22 +0200 Subject: [PATCH] feat(list): add tests --- internal/plugin/method.go | 36 +++ internal/plugin/resource.go | 1 + internal/plugin/testcase_list.go | 257 ++++++++++++++++++ .../freight/v1/testing/freight_service.go | 190 +++++++++++++ 4 files changed, 484 insertions(+) create mode 100644 internal/plugin/testcase_list.go diff --git a/internal/plugin/method.go b/internal/plugin/method.go index 79bf58d..09dd5a3 100644 --- a/internal/plugin/method.go +++ b/internal/plugin/method.go @@ -114,3 +114,39 @@ func (m methodUpdate) Generate(f *protogen.GeneratedFile, response string, err s } f.P("})") } + +type methodList struct { + resource *annotations.ResourceDescriptor + method *protogen.Method + + parent string + pageSize string + pageToken string +} + +func (m methodList) Generate(f *protogen.GeneratedFile, response string, err string, assign string) { + f.P(response, ", ", err, " ", assign, " fx.service.", m.method.GoName, "(fx.ctx, &", m.method.Input.GoIdent, "{") + if hasParent(m.resource) { + f.P("Parent: ", m.parent, ",") + } + if m.pageSize != "" { + f.P("PageSize: ", m.pageSize, ",") + } + if m.pageToken != "" { + f.P("PageToken: ", m.pageToken, ",") + } + f.P("})") +} + +type methodDelete struct { + resource *annotations.ResourceDescriptor + method *protogen.Method + + name string +} + +func (m methodDelete) Generate(f *protogen.GeneratedFile, response string, err string, assign string) { + f.P(response, ", ", err, " ", assign, " fx.service.", m.method.GoName, "(fx.ctx, &", m.method.Input.GoIdent, "{") + f.P("Name: ", m.name, ",") + f.P("})") +} diff --git a/internal/plugin/resource.go b/internal/plugin/resource.go index 859b5cb..e965cec 100644 --- a/internal/plugin/resource.go +++ b/internal/plugin/resource.go @@ -173,5 +173,6 @@ func (r *resourceGenerator) collectTestCases() []testCase { r.getTestCase(), r.batchGetTestCase(), r.updateTestCase(), + r.listTestCase(), } } diff --git a/internal/plugin/testcase_list.go b/internal/plugin/testcase_list.go new file mode 100644 index 0000000..339d6d4 --- /dev/null +++ b/internal/plugin/testcase_list.go @@ -0,0 +1,257 @@ +package plugin + +import ( + "strconv" + + "go.einride.tech/aip/reflect/aipreflect" + "google.golang.org/protobuf/compiler/protogen" +) + +func (r *resourceGenerator) listTestCase() testCase { + listMethod, ok := r.standardMethod(aipreflect.MethodTypeList) + if !ok { + return disabledTestCase() + } + createMethod, ok := r.standardMethod(aipreflect.MethodTypeCreate) + if !ok { + return disabledTestCase() + } + // TODO: support LROs for create. + if returnsLRO(createMethod.Desc) { + return disabledTestCase() + } + + deleteMethod, hasDelete := r.standardMethod(aipreflect.MethodTypeDelete) + + responseResources := aipreflect.GrammaticalName(r.resource.GetPlural()).UpperCamelCase() + + return newTestCase("List", func(f *protogen.GeneratedFile) { + testingT := f.QualifiedGoIdent(protogen.GoIdent{GoName: "T", GoImportPath: "testing"}) + assertEqual := f.QualifiedGoIdent(protogen.GoIdent{ + GoName: "Equal", + GoImportPath: "gotest.tools/v3/assert", + }) + assertDeepEqual := f.QualifiedGoIdent(protogen.GoIdent{ + GoName: "DeepEqual", + GoImportPath: "gotest.tools/v3/assert", + }) + assertNilError := f.QualifiedGoIdent(protogen.GoIdent{ + GoName: "NilError", + GoImportPath: "gotest.tools/v3/assert", + }) + protocmpTransform := f.QualifiedGoIdent(protogen.GoIdent{ + GoName: "Transform", + GoImportPath: "google.golang.org/protobuf/testing/protocmp", + }) + cmpoptsSortSlices := f.QualifiedGoIdent(protogen.GoIdent{ + GoName: "SortSlices", + GoImportPath: "github.com/google/go-cmp/cmp/cmpopts", + }) + statusCode := f.QualifiedGoIdent(protogen.GoIdent{ + GoName: "Code", + GoImportPath: "google.golang.org/grpc/status", + }) + codesInvalidArgument := f.QualifiedGoIdent(protogen.GoIdent{ + GoName: "InvalidArgument", + GoImportPath: "google.golang.org/grpc/codes", + }) + codesNotFound := f.QualifiedGoIdent(protogen.GoIdent{ + GoName: "NotFound", + GoImportPath: "google.golang.org/grpc/codes", + }) + + f.P("// Standard methods: List") + f.P("// https://google.aip.dev/132") + + if hasParent(r.resource) { + f.P("parent01 := fx.nextParent(t, false)") + f.P("parent02 := fx.nextParent(t, true)") + f.P() + } else { + } + + // create 15 under each parent + f.P("const n = 15") + f.P() + if hasParent(r.resource) { + f.P("parent01msgs := make([]*", r.message.GoIdent, ", n)") + f.P("for i := 0; i < n; i++ {") + methodCreate{ + resource: r.resource, + method: createMethod, + parent: "parent01", + }.Generate(f, "msg", "err", ":=") + f.P(assertNilError, "(t, err)") + f.P("parent01msgs[i] = msg") + f.P("}") + f.P() + } + f.P("parent02msgs := make([]*", r.message.GoIdent, ", n)") + f.P("for i := 0; i < n; i++ {") + methodCreate{ + resource: r.resource, + method: createMethod, + parent: "parent02", + }.Generate(f, "msg", "err", ":=") + f.P(assertNilError, "(t, err)") + f.P("parent02msgs[i] = msg") + f.P("}") + + if hasParent(r.resource) { + f.P() + f.P("// Method should fail with InvalidArgument is provided parent is not valid.") + f.P("t.Run(\"invalid parent\", func(t *", testingT, ") {") + f.P("fx.maybeSkip(t)") + methodList{ + resource: r.resource, + method: listMethod, + parent: strconv.Quote("invalid parent"), + }.Generate(f, "_", "err", ":=") + f.P(assertEqual, "(t, ", codesInvalidArgument, ",", statusCode, "(err), err)") + f.P("})") + } + + f.P() + f.P("// Method should fail with InvalidArgument is provided page token is not valid.") + f.P("t.Run(\"invalid page token\", func(t *", testingT, ") {") + f.P("fx.maybeSkip(t)") + methodList{ + resource: r.resource, + method: listMethod, + parent: "parent01", + pageToken: strconv.Quote("invalid page token"), + }.Generate(f, "_", "err", ":=") + f.P(assertEqual, "(t, ", codesInvalidArgument, ",", statusCode, "(err), err)") + f.P("})") + + f.P() + f.P("// Method should fail with InvalidArgument is provided page size is negative.") + f.P("t.Run(\"negative page size\", func(t *", testingT, ") {") + f.P("fx.maybeSkip(t)") + methodList{ + resource: r.resource, + method: listMethod, + parent: "parent01", + pageSize: "-10", + }.Generate(f, "_", "err", ":=") + f.P(assertEqual, "(t, ", codesInvalidArgument, ",", statusCode, "(err), err)") + f.P("})") + + if hasParent(r.resource) { + f.P() + f.P("// If parent is provided the method must only return resources") + f.P("// under that parent.") + f.P("t.Run(\"isolation\", func(t *", testingT, ") {") + f.P("fx.maybeSkip(t)") + methodList{ + resource: r.resource, + method: listMethod, + parent: "parent02", + pageSize: "999", + }.Generate(f, "response", "err", ":=") + f.P(assertNilError, "(t, err)") + f.P(assertDeepEqual, "(") + f.P("t,") + f.P("parent02msgs,") + f.P("response.", responseResources, ",") + f.P(cmpoptsSortSlices, "(func(a,b *", r.message.GoIdent, ") bool {") + f.P("return a.Name < b.Name") + f.P("}),") + f.P(protocmpTransform, "(),") + f.P(")") + f.P("})") + } + + if hasParent(r.resource) { + f.P() + f.P("t.Run(\"pagination\", func(t *", testingT, ") {") + f.P("fx.maybeSkip(t)") + + f.P() + f.P("// If there are no more resources, next_page_token should be unset.") + f.P("t.Run(\"next page token\", func(t *", testingT, ") {") + f.P("fx.maybeSkip(t)") + methodList{ + resource: r.resource, + method: listMethod, + parent: "parent02", + pageSize: "999", + }.Generate(f, "response", "err", ":=") + f.P(assertNilError, "(t, err)") + f.P("assert.Equal(t, \"\", response.NextPageToken)") + f.P("})") + f.P() + + f.P("// Listing resource one by one should eventually return all resources created.") + f.P("t.Run(\"one by one\", func(t *", testingT, ") {") + f.P("fx.maybeSkip(t)") + f.P("msgs := make([]*", r.message.GoIdent, ", 0, n)") + f.P("var nextPageToken string") + f.P("for {") + methodList{ + resource: r.resource, + method: listMethod, + parent: "parent02", + pageSize: "1", + }.Generate(f, "response", "err", ":=") + f.P(assertNilError, "(t, err)") + f.P(assertEqual, "(t, 1, len(response.", responseResources, "))") + f.P("msgs = append(msgs, response.", responseResources, "...)") + f.P("nextPageToken = response.NextPageToken") + f.P("if nextPageToken == \"\" {") + f.P("break") + f.P("}") + f.P("}") + f.P(assertDeepEqual, "(") + f.P("t,") + f.P("parent02msgs,") + f.P("msgs,") + f.P(cmpoptsSortSlices, "(func(a,b *", r.message.GoIdent, ") bool {") + f.P("return a.Name < b.Name") + f.P("}),") + f.P(protocmpTransform, "(),") + f.P(")") + f.P("})") + f.P("})") + f.P() + } + + if hasParent(r.resource) && hasDelete { + f.P() + f.P("// Method should not return deleted resources.") + f.P("t.Run(\"deleted\", func(t *", testingT, ") {") + f.P("fx.maybeSkip(t)") + f.P("const nDelete = 5") + f.P("for i := 0; i < nDelete; i++ {") + methodDelete{ + method: deleteMethod, + resource: r.resource, + name: "parent02msgs[i].Name", + }.Generate(f, "_", "err", ":=") + f.P(assertNilError, "(t, err)") + f.P("}") + methodList{ + resource: r.resource, + method: listMethod, + parent: "parent02", + pageSize: "9999", + }.Generate(f, "response", "err", ":=") + f.P(assertNilError, "(t, err)") + f.P(assertDeepEqual, "(") + f.P("t,") + f.P("parent02msgs[nDelete:],") + f.P("response.", responseResources, ",") + f.P(cmpoptsSortSlices, "(func(a,b *", r.message.GoIdent, ") bool {") + f.P("return a.Name < b.Name") + f.P("}),") + f.P(protocmpTransform, "(),") + f.P(")") + f.P("})") + + } + + f.P("_ = ", codesNotFound) + f.P("_ = ", protocmpTransform) + f.P("_ = ", cmpoptsSortSlices) + }) +} diff --git a/proto/gen/einride/example/freight/v1/testing/freight_service.go b/proto/gen/einride/example/freight/v1/testing/freight_service.go index 9a4511a..82fc2a0 100644 --- a/proto/gen/einride/example/freight/v1/testing/freight_service.go +++ b/proto/gen/einride/example/freight/v1/testing/freight_service.go @@ -5,6 +5,7 @@ package examplefreightv1test import ( context "context" v1 "github.com/einride/protoc-gen-go-aiptest/proto/gen/einride/example/freight/v1" + cmpopts "github.com/google/go-cmp/cmp/cmpopts" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" proto "google.golang.org/protobuf/proto" @@ -60,6 +61,7 @@ func (fx *Shipper) test(t *testing.T) { t.Run("Create", fx.testCreate) t.Run("Get", fx.testGet) t.Run("Update", fx.testUpdate) + t.Run("List", fx.testList) } func (fx *Shipper) testCreate(t *testing.T) { @@ -345,6 +347,42 @@ func (fx *Shipper) testUpdate(t *testing.T) { _ = protocmp.Transform } +func (fx *Shipper) testList(t *testing.T) { + // Standard methods: List + // https://google.aip.dev/132 + const n = 15 + + parent02msgs := make([]*v1.Shipper, n) + for i := 0; i < n; i++ { + msg, err := fx.service.CreateShipper(fx.ctx, &v1.CreateShipperRequest{ + Shipper: fx.Create(), + }) + assert.NilError(t, err) + parent02msgs[i] = msg + } + + // Method should fail with InvalidArgument is provided page token is not valid. + t.Run("invalid page token", func(t *testing.T) { + fx.maybeSkip(t) + _, err := fx.service.ListShippers(fx.ctx, &v1.ListShippersRequest{ + PageToken: "invalid page token", + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err), err) + }) + + // Method should fail with InvalidArgument is provided page size is negative. + t.Run("negative page size", func(t *testing.T) { + fx.maybeSkip(t) + _, err := fx.service.ListShippers(fx.ctx, &v1.ListShippersRequest{ + PageSize: -10, + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err), err) + }) + _ = codes.NotFound + _ = protocmp.Transform + _ = cmpopts.SortSlices +} + func (fx *Shipper) maybeSkip(t *testing.T) { for _, skip := range fx.Skip { if strings.Contains(t.Name(), skip) { @@ -381,6 +419,7 @@ func (fx *Site) test(t *testing.T) { t.Run("Get", fx.testGet) t.Run("BatchGet", fx.testBatchGet) t.Run("Update", fx.testUpdate) + t.Run("List", fx.testList) } func (fx *Site) testCreate(t *testing.T) { @@ -799,6 +838,157 @@ func (fx *Site) testUpdate(t *testing.T) { _ = protocmp.Transform } +func (fx *Site) testList(t *testing.T) { + // Standard methods: List + // https://google.aip.dev/132 + parent01 := fx.nextParent(t, false) + parent02 := fx.nextParent(t, true) + + const n = 15 + + parent01msgs := make([]*v1.Site, n) + for i := 0; i < n; i++ { + msg, err := fx.service.CreateSite(fx.ctx, &v1.CreateSiteRequest{ + Parent: parent01, + Site: fx.Create(parent01), + }) + assert.NilError(t, err) + parent01msgs[i] = msg + } + + parent02msgs := make([]*v1.Site, n) + for i := 0; i < n; i++ { + msg, err := fx.service.CreateSite(fx.ctx, &v1.CreateSiteRequest{ + Parent: parent02, + Site: fx.Create(parent02), + }) + assert.NilError(t, err) + parent02msgs[i] = msg + } + + // Method should fail with InvalidArgument is provided parent is not valid. + t.Run("invalid parent", func(t *testing.T) { + fx.maybeSkip(t) + _, err := fx.service.ListSites(fx.ctx, &v1.ListSitesRequest{ + Parent: "invalid parent", + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err), err) + }) + + // Method should fail with InvalidArgument is provided page token is not valid. + t.Run("invalid page token", func(t *testing.T) { + fx.maybeSkip(t) + _, err := fx.service.ListSites(fx.ctx, &v1.ListSitesRequest{ + Parent: parent01, + PageToken: "invalid page token", + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err), err) + }) + + // Method should fail with InvalidArgument is provided page size is negative. + t.Run("negative page size", func(t *testing.T) { + fx.maybeSkip(t) + _, err := fx.service.ListSites(fx.ctx, &v1.ListSitesRequest{ + Parent: parent01, + PageSize: -10, + }) + assert.Equal(t, codes.InvalidArgument, status.Code(err), err) + }) + + // If parent is provided the method must only return resources + // under that parent. + t.Run("isolation", func(t *testing.T) { + fx.maybeSkip(t) + response, err := fx.service.ListSites(fx.ctx, &v1.ListSitesRequest{ + Parent: parent02, + PageSize: 999, + }) + assert.NilError(t, err) + assert.DeepEqual( + t, + parent02msgs, + response.Sites, + cmpopts.SortSlices(func(a, b *v1.Site) bool { + return a.Name < b.Name + }), + protocmp.Transform(), + ) + }) + + t.Run("pagination", func(t *testing.T) { + fx.maybeSkip(t) + + // If there are no more resources, next_page_token should be unset. + t.Run("next page token", func(t *testing.T) { + fx.maybeSkip(t) + response, err := fx.service.ListSites(fx.ctx, &v1.ListSitesRequest{ + Parent: parent02, + PageSize: 999, + }) + assert.NilError(t, err) + assert.Equal(t, "", response.NextPageToken) + }) + + // Listing resource one by one should eventually return all resources created. + t.Run("one by one", func(t *testing.T) { + fx.maybeSkip(t) + msgs := make([]*v1.Site, 0, n) + var nextPageToken string + for { + response, err := fx.service.ListSites(fx.ctx, &v1.ListSitesRequest{ + Parent: parent02, + PageSize: 1, + }) + assert.NilError(t, err) + assert.Equal(t, 1, len(response.Sites)) + msgs = append(msgs, response.Sites...) + nextPageToken = response.NextPageToken + if nextPageToken == "" { + break + } + } + assert.DeepEqual( + t, + parent02msgs, + msgs, + cmpopts.SortSlices(func(a, b *v1.Site) bool { + return a.Name < b.Name + }), + protocmp.Transform(), + ) + }) + }) + + // Method should not return deleted resources. + t.Run("deleted", func(t *testing.T) { + fx.maybeSkip(t) + const nDelete = 5 + for i := 0; i < nDelete; i++ { + _, err := fx.service.DeleteSite(fx.ctx, &v1.DeleteSiteRequest{ + Name: parent02msgs[i].Name, + }) + assert.NilError(t, err) + } + response, err := fx.service.ListSites(fx.ctx, &v1.ListSitesRequest{ + Parent: parent02, + PageSize: 9999, + }) + assert.NilError(t, err) + assert.DeepEqual( + t, + parent02msgs[nDelete:], + response.Sites, + cmpopts.SortSlices(func(a, b *v1.Site) bool { + return a.Name < b.Name + }), + protocmp.Transform(), + ) + }) + _ = codes.NotFound + _ = protocmp.Transform + _ = cmpopts.SortSlices +} + func (fx *Site) nextParent(t *testing.T, pristine bool) string { if pristine { fx.currParent++