diff --git a/.changes/unreleased/BUG FIXES-20260211-103028.yaml b/.changes/unreleased/BUG FIXES-20260211-103028.yaml new file mode 100644 index 00000000..3e0a81cd --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260211-103028.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'querycheck: Fix behaviour of `ExpectLength` querycheck to match on the provided resource address before comparing the count' +time: 2026-02-11T10:30:28.750109+01:00 +custom: + Issue: "604" diff --git a/.changes/unreleased/BUG FIXES-20260211-122825.yaml b/.changes/unreleased/BUG FIXES-20260211-122825.yaml new file mode 100644 index 00000000..267f17c6 --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260211-122825.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'querycheck: Fix behaviour of `ExpectLengthAtLeast` querycheck to match on the provided resource address before comparing the count' +time: 2026-02-11T12:28:25.372146+01:00 +custom: + Issue: "607" diff --git a/.changes/unreleased/FEATURES-20260211-122845.yaml b/.changes/unreleased/FEATURES-20260211-122845.yaml new file mode 100644 index 00000000..fc360c29 --- /dev/null +++ b/.changes/unreleased/FEATURES-20260211-122845.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'querycheck: Add new `ExpectLengthForMultiple` querycheck for validating the number of found resources for multiple list blocks' +time: 2026-02-11T12:28:45.926523+01:00 +custom: + Issue: "607" diff --git a/querycheck/expect_result_length_atleast.go b/querycheck/expect_result_length_atleast.go index 9a0cd30c..9bcd217d 100644 --- a/querycheck/expect_result_length_atleast.go +++ b/querycheck/expect_result_length_atleast.go @@ -6,6 +6,7 @@ package querycheck import ( "context" "fmt" + "strings" ) var _ QueryResultCheck = expectLengthAtLeast{} @@ -17,15 +18,31 @@ type expectLengthAtLeast struct { // CheckQuery implements the query check logic. func (e expectLengthAtLeast) CheckQuery(_ context.Context, req CheckQueryRequest, resp *CheckQueryResponse) { - if req.QuerySummary == nil { + if req.QuerySummary == nil && len(req.QuerySummaries) == 0 { resp.Error = fmt.Errorf("no completed query information available") return } - if req.QuerySummary.Total < e.check { - resp.Error = fmt.Errorf("Query result of at least length %v - expected but got %v.", e.check, req.QuerySummary.Total) - return + for _, summary := range req.QuerySummaries { + address := summary.Address + + // this brings the behaviour of this check in-line with the other query checks where the resource + // address needs to be provided without the `list.` prefix, but maintains the previous behaviour + // to not break existing tests that may be using the `list.` prefix in the resource address + if !strings.HasPrefix(e.resourceAddress, "list.") { + address = strings.TrimPrefix(summary.Address, "list.") + } + + if strings.EqualFold(address, e.resourceAddress) { + if summary.Total < e.check { + resp.Error = fmt.Errorf("Query result of at least length %v - expected but got %v.", e.check, summary.Total) + return + } + return + } } + + resp.Error = fmt.Errorf("the list block %s was not found in the query results", e.resourceAddress) } // ExpectLengthAtLeast returns a query check that asserts that the length of the query result is at least the given value. diff --git a/querycheck/expect_result_length_atleast_test.go b/querycheck/expect_result_length_atleast_test.go index f77f8701..b957a6b9 100644 --- a/querycheck/expect_result_length_atleast_test.go +++ b/querycheck/expect_result_length_atleast_test.go @@ -56,7 +56,59 @@ func TestResultLengthAtLeast(t *testing.T) { resource_group_name = "foo" } } - list "examplecloud_containerette" "test2" { + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectLengthAtLeast("examplecloud_containerette.test", 2), + }, + }, + }, + }) +} + +func TestResultLengthAtLeast_Multiple(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + ListResources: map[string]testprovider.ListResource{ + "examplecloud_containerette": examplecloudListResource(), + "examplecloud_bananette": examplecloudListResource(), + }, + Resources: map[string]testprovider.Resource{ + "examplecloud_containerette": examplecloudResource(), + "examplecloud_bananette": examplecloudResourceBananette(), + }, + }), + }, + Steps: []r.TestStep{ + { // config mode step 1 needs tf file with terraform providers block + // this step should provision all the resources that the query is support to list + // for simplicity we're only "provisioning" one here + Config: ` + resource "examplecloud_containerette" "primary" { + name = "banana" + resource_group_name = "foo" + location = "westeurope" + + instances = 5 + }`, + }, + { + Query: true, + Config: ` + provider "examplecloud" {} + list "examplecloud_containerette" "test" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + list "examplecloud_bananette" "test" { provider = examplecloud config { @@ -65,8 +117,8 @@ func TestResultLengthAtLeast(t *testing.T) { } `, QueryResultChecks: []querycheck.QueryResultCheck{ - querycheck.ExpectLengthAtLeast("examplecloud_containerette.test", 2), - querycheck.ExpectLengthAtLeast("examplecloud_containerette.test2", 1), + querycheck.ExpectLengthAtLeast("examplecloud_containerette.test", 6), + querycheck.ExpectLengthAtLeast("examplecloud_bananette.test", 2), }, }, }, @@ -114,18 +166,62 @@ func TestResultLengthAtLeast_TooFewResults(t *testing.T) { resource_group_name = "foo" } } - list "examplecloud_containerette" "test2" { + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectLengthAtLeast("examplecloud_containerette.test", 8), + }, + ExpectError: regexp.MustCompile("Query result of at least length 8 - expected but got 6."), + }, + }, + }) +} + +func TestResultLengthAtLeast_WrongResourceAddress(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + ListResources: map[string]testprovider.ListResource{ + "examplecloud_containerette": examplecloudListResource(), + }, + Resources: map[string]testprovider.Resource{ + "examplecloud_containerette": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { // config mode step 1 needs tf file with terraform providers block + // this step should provision all the resources that the query is support to list + // for simplicity we're only "provisioning" one here + Config: ` + resource "examplecloud_containerette" "primary" { + name = "banana" + resource_group_name = "foo" + location = "westeurope" + + instances = 5 + }`, + }, + { + Query: true, + Config: ` + provider "examplecloud" {} + list "examplecloud_containerette" "test" { provider = examplecloud config { - resource_group_name = "bar" + resource_group_name = "foo" } } `, QueryResultChecks: []querycheck.QueryResultCheck{ - querycheck.ExpectLengthAtLeast("examplecloud_containerette.test", 8), + querycheck.ExpectLengthAtLeast("examplecloud_containerette.test2", 8), }, - ExpectError: regexp.MustCompile("Query result of at least length 8 - expected but got 6."), + ExpectError: regexp.MustCompile("the list block examplecloud_containerette.test2 was not found in the query results"), }, }, }) diff --git a/querycheck/expect_total_result_length_for_matching.go b/querycheck/expect_total_result_length_for_matching.go new file mode 100644 index 00000000..94af7637 --- /dev/null +++ b/querycheck/expect_total_result_length_for_matching.go @@ -0,0 +1,54 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package querycheck + +import ( + "context" + "fmt" + "regexp" +) + +var _ QueryResultCheck = expectTotalLengthForMatching{} + +type expectTotalLengthForMatching struct { + regex *regexp.Regexp + check int +} + +// CheckQuery implements the query check logic. +func (e expectTotalLengthForMatching) CheckQuery(_ context.Context, req CheckQueryRequest, resp *CheckQueryResponse) { + if req.QuerySummary == nil && len(req.QuerySummaries) == 0 { + resp.Error = fmt.Errorf("no query summary information available") + return + } + + total := 0 + matchFound := false + for _, summary := range req.QuerySummaries { + if e.regex.MatchString(summary.Address) { + total += summary.Total + matchFound = true + } + } + + if !matchFound { + resp.Error = fmt.Errorf("no list resources matching the provided regex pattern %s were found in the query results", e.regex.String()) + return + } + + if total != e.check { + resp.Error = fmt.Errorf("expected total of found resources to be %d, got %d", e.check, total) + } +} + +// ExpectTotalLengthForMatching returns a query check that asserts that the sum of query result lengths +// produced by multiple list blocks is exactly the given value. +// +// This query check can only be used with managed resources that support query. Query is only supported in Terraform v1.14+ +func ExpectTotalLengthForMatching(regex *regexp.Regexp, length int) QueryResultCheck { + return expectTotalLengthForMatching{ + regex: regex, + check: length, + } +} diff --git a/querycheck/expect_total_result_length_for_matching_test.go b/querycheck/expect_total_result_length_for_matching_test.go new file mode 100644 index 00000000..b959851c --- /dev/null +++ b/querycheck/expect_total_result_length_for_matching_test.go @@ -0,0 +1,249 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package querycheck_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/providerserver" + "github.com/hashicorp/terraform-plugin-testing/querycheck" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestResultTotalLengthForMatching(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + ListResources: map[string]testprovider.ListResource{ + "examplecloud_containerette": examplecloudListResource(), + "examplecloud_bananette": examplecloudListResourceBananette(), + }, + Resources: map[string]testprovider.Resource{ + "examplecloud_containerette": examplecloudResource(), + "examplecloud_bananette": examplecloudResourceBananette(), + }, + }), + }, + Steps: []r.TestStep{ + { // config mode step 1 needs tf file with terraform providers block + // this step should provision all the resources that the query is support to list + // for simplicity we're only "provisioning" one here + Config: ` + resource "examplecloud_containerette" "primary" { + name = "banana" + resource_group_name = "foo" + location = "westeurope" + + instances = 5 + }`, + }, + { + Query: true, + Config: ` + provider "examplecloud" {} + + list "examplecloud_containerette" "test" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + + list "examplecloud_bananette" "test" { + provider = examplecloud + + config { + resource_group_name = "bar" + } + } + + list "examplecloud_containerette" "test2" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + + list "examplecloud_containerette" "test3" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectTotalLengthForMatching(regexp.MustCompile("examplecloud_(.*)ette.test[1-9]"), 12), + }, + }, + }, + }) +} + +func TestResultTotalLengthForMatching_WrongAmount(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + ListResources: map[string]testprovider.ListResource{ + "examplecloud_containerette": examplecloudListResource(), + "examplecloud_bananette": examplecloudListResourceBananette(), + }, + Resources: map[string]testprovider.Resource{ + "examplecloud_containerette": examplecloudResource(), + "examplecloud_bananette": examplecloudResourceBananette(), + }, + }), + }, + Steps: []r.TestStep{ + { // config mode step 1 needs tf file with terraform providers block + // this step should provision all the resources that the query is support to list + // for simplicity we're only "provisioning" one here + Config: ` + resource "examplecloud_containerette" "primary" { + name = "banana" + resource_group_name = "foo" + location = "westeurope" + + instances = 5 + }`, + }, + { + Query: true, + Config: ` + provider "examplecloud" {} + + list "examplecloud_containerette" "test" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + + list "examplecloud_bananette" "test" { + provider = examplecloud + + config { + resource_group_name = "bar" + } + } + + list "examplecloud_containerette" "test2" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + + list "examplecloud_containerette" "test3" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectTotalLengthForMatching(regexp.MustCompile("examplecloud_(.*)ette.test[1-9]"), 10), + }, + ExpectError: regexp.MustCompile("expected total of found resources to be 10, got 12"), + }, + }, + }) +} + +func TestResultTotalLengthForMatching_NoMatches(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + ListResources: map[string]testprovider.ListResource{ + "examplecloud_containerette": examplecloudListResource(), + "examplecloud_bananette": examplecloudListResourceBananette(), + }, + Resources: map[string]testprovider.Resource{ + "examplecloud_containerette": examplecloudResource(), + "examplecloud_bananette": examplecloudResourceBananette(), + }, + }), + }, + Steps: []r.TestStep{ + { // config mode step 1 needs tf file with terraform providers block + // this step should provision all the resources that the query is support to list + // for simplicity we're only "provisioning" one here + Config: ` + resource "examplecloud_containerette" "primary" { + name = "banana" + resource_group_name = "foo" + location = "westeurope" + + instances = 5 + }`, + }, + { + Query: true, + Config: ` + provider "examplecloud" {} + + list "examplecloud_containerette" "test" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + + list "examplecloud_bananette" "test" { + provider = examplecloud + + config { + resource_group_name = "bar" + } + } + + list "examplecloud_containerette" "test2" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + + list "examplecloud_containerette" "test3" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectTotalLengthForMatching(regexp.MustCompile("examplecloud_(.*)ette.test[4-9]"), 10), + }, + ExpectError: regexp.MustCompile("no list resources matching the provided regex pattern .* were found in the query results"), + }, + }, + }) +}