|
|
@@ -0,0 +1,1145 @@
|
|
|
+package gcp
|
|
|
+
|
|
|
+import (
|
|
|
+ "bytes"
|
|
|
+ "encoding/json"
|
|
|
+ "math"
|
|
|
+ "net/http"
|
|
|
+ "net/http/httptest"
|
|
|
+ "testing"
|
|
|
+
|
|
|
+ "github.com/opencost/opencost/core/pkg/pricing"
|
|
|
+)
|
|
|
+
|
|
|
+const floatTolerance = 0.0000001
|
|
|
+
|
|
|
+func TestNewGCPPricingSource(t *testing.T) {
|
|
|
+ config := GCPPricingSourceConfig{
|
|
|
+ APIKey: "test-api-key",
|
|
|
+ CurrencyCode: "USD",
|
|
|
+ }
|
|
|
+
|
|
|
+ source := NewGCPPricingSource(config)
|
|
|
+
|
|
|
+ if source == nil {
|
|
|
+ t.Fatal("NewGCPPricingSource() returned nil")
|
|
|
+ }
|
|
|
+
|
|
|
+ if source.config.APIKey != "test-api-key" {
|
|
|
+ t.Errorf("APIKey = %v, want test-api-key", source.config.APIKey)
|
|
|
+ }
|
|
|
+
|
|
|
+ if source.config.CurrencyCode != "USD" {
|
|
|
+ t.Errorf("CurrencyCode = %v, want USD", source.config.CurrencyCode)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestBuildURL(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ apiKey string
|
|
|
+ currencyCode string
|
|
|
+ pageToken string
|
|
|
+ wantContains []string
|
|
|
+ wantNotContain string
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "Basic URL without page token",
|
|
|
+ apiKey: "test-key",
|
|
|
+ currencyCode: "USD",
|
|
|
+ pageToken: "",
|
|
|
+ wantContains: []string{
|
|
|
+ "cloudbilling.googleapis.com",
|
|
|
+ "key=test-key",
|
|
|
+ "currencyCode=USD",
|
|
|
+ },
|
|
|
+ wantNotContain: "pageToken",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "URL with page token",
|
|
|
+ apiKey: "test-key",
|
|
|
+ currencyCode: "EUR",
|
|
|
+ pageToken: "next-page-123",
|
|
|
+ wantContains: []string{
|
|
|
+ "cloudbilling.googleapis.com",
|
|
|
+ "key=test-key",
|
|
|
+ "currencyCode=EUR",
|
|
|
+ "pageToken=next-page-123",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ source := &GCPPricingSource{
|
|
|
+ config: GCPPricingSourceConfig{
|
|
|
+ APIKey: tt.apiKey,
|
|
|
+ CurrencyCode: tt.currencyCode,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ url := source.buildURL(tt.pageToken)
|
|
|
+
|
|
|
+ for _, want := range tt.wantContains {
|
|
|
+ if !contains(url, want) {
|
|
|
+ t.Errorf("buildURL() = %v, want to contain %v", url, want)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if tt.wantNotContain != "" && contains(url, tt.wantNotContain) {
|
|
|
+ t.Errorf("buildURL() = %v, should not contain %v", url, tt.wantNotContain)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestExtractHourlyPrice(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ sku *GCPPricing
|
|
|
+ wantPrice float64
|
|
|
+ wantErr bool
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "Valid pricing with single tier",
|
|
|
+ sku: &GCPPricing{
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "0",
|
|
|
+ Nanos: 41600000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ wantPrice: 0.0416,
|
|
|
+ wantErr: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Valid pricing with multiple tiers",
|
|
|
+ sku: &GCPPricing{
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "0",
|
|
|
+ Nanos: 10000000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "0",
|
|
|
+ Nanos: 50000000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ wantPrice: 0.05, // Should use last tier
|
|
|
+ wantErr: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Pricing with whole units",
|
|
|
+ sku: &GCPPricing{
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "1",
|
|
|
+ Nanos: 500000000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ wantPrice: 1.5,
|
|
|
+ wantErr: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "No pricing info",
|
|
|
+ sku: &GCPPricing{
|
|
|
+ PricingInfo: []*PricingInfo{},
|
|
|
+ },
|
|
|
+ wantPrice: 0,
|
|
|
+ wantErr: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "No tiered rates",
|
|
|
+ sku: &GCPPricing{
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ wantPrice: 0,
|
|
|
+ wantErr: true,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ source := &GCPPricingSource{}
|
|
|
+ price, err := source.extractHourlyPrice(tt.sku)
|
|
|
+
|
|
|
+ if (err != nil) != tt.wantErr {
|
|
|
+ t.Errorf("extractHourlyPrice() error = %v, wantErr %v", err, tt.wantErr)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if !tt.wantErr && math.Abs(price-tt.wantPrice) > floatTolerance {
|
|
|
+ t.Errorf("extractHourlyPrice() = %v, want %v", price, tt.wantPrice)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestExpandInstanceTypes(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ instanceType string
|
|
|
+ resourceGroup string
|
|
|
+ want []string
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "E2 CPU expands",
|
|
|
+ instanceType: "e2",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ want: []string{"e2-micro", "e2-small", "e2-medium", "e2-standard", "e2-custom"},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "E2 RAM expands",
|
|
|
+ instanceType: "e2",
|
|
|
+ resourceGroup: "RAM",
|
|
|
+ want: []string{"e2-micro", "e2-small", "e2-medium", "e2-standard", "e2-custom"},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "A2 CPU expands",
|
|
|
+ instanceType: "a2",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ want: []string{"a2-highgpu", "a2-megagpu", "a2-ultragpu"},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "A2 RAM expands",
|
|
|
+ instanceType: "a2",
|
|
|
+ resourceGroup: "RAM",
|
|
|
+ want: []string{"a2-highgpu", "a2-megagpu", "a2-ultragpu"},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "N2 does not expand",
|
|
|
+ instanceType: "n2-standard",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ want: []string{"n2-standard"},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Custom does not expand",
|
|
|
+ instanceType: "custom",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ want: []string{"custom"},
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ source := &GCPPricingSource{}
|
|
|
+ got := source.expandInstanceTypes(tt.instanceType, tt.resourceGroup)
|
|
|
+
|
|
|
+ if len(got) != len(tt.want) {
|
|
|
+ t.Errorf("expandInstanceTypes() returned %d types, want %d", len(got), len(tt.want))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ for i, wantType := range tt.want {
|
|
|
+ if got[i] != wantType {
|
|
|
+ t.Errorf("expandInstanceTypes()[%d] = %v, want %v", i, got[i], wantType)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestParsePage(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ response GCPPricingResponse
|
|
|
+ wantNextPageToken string
|
|
|
+ wantErr bool
|
|
|
+ checkCPUCosts bool
|
|
|
+ checkRAMCosts bool
|
|
|
+ checkVolumeCosts bool
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "Valid compute SKU",
|
|
|
+ response: GCPPricingResponse{
|
|
|
+ Skus: []*GCPPricing{
|
|
|
+ {
|
|
|
+ Description: "N2 Instance Core running in Americas",
|
|
|
+ Category: &GCPResourceInfo{
|
|
|
+ ResourceGroup: "CPU",
|
|
|
+ UsageType: "OnDemand",
|
|
|
+ },
|
|
|
+ ServiceRegions: []string{"us-central1"},
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "0",
|
|
|
+ Nanos: 31611000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ NextPageToken: "next-token-123",
|
|
|
+ },
|
|
|
+ wantNextPageToken: "next-token-123",
|
|
|
+ wantErr: false,
|
|
|
+ checkCPUCosts: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Valid storage SKU",
|
|
|
+ response: GCPPricingResponse{
|
|
|
+ Skus: []*GCPPricing{
|
|
|
+ {
|
|
|
+ Description: "SSD backed PD Capacity",
|
|
|
+ Category: &GCPResourceInfo{
|
|
|
+ ResourceGroup: "SSD",
|
|
|
+ UsageType: "OnDemand",
|
|
|
+ },
|
|
|
+ ServiceRegions: []string{"us-central1"},
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "0",
|
|
|
+ Nanos: 170000000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ NextPageToken: "",
|
|
|
+ },
|
|
|
+ wantNextPageToken: "",
|
|
|
+ wantErr: false,
|
|
|
+ checkVolumeCosts: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "SKU with no category",
|
|
|
+ response: GCPPricingResponse{
|
|
|
+ Skus: []*GCPPricing{
|
|
|
+ {
|
|
|
+ Description: "Some SKU",
|
|
|
+ Category: nil,
|
|
|
+ PricingInfo: []*PricingInfo{},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ NextPageToken: "",
|
|
|
+ },
|
|
|
+ wantNextPageToken: "",
|
|
|
+ wantErr: false,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ source := &GCPPricingSource{
|
|
|
+ config: GCPPricingSourceConfig{
|
|
|
+ CurrencyCode: "USD",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ // Marshal response to JSON
|
|
|
+ data, err := json.Marshal(tt.response)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("Failed to marshal test response: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ nodeCPUCosts := make(map[nodeKey]float64)
|
|
|
+ nodeRAMCosts := make(map[nodeKey]float64)
|
|
|
+ volumeCosts := make(map[volumeKey]float64)
|
|
|
+
|
|
|
+ nextToken, err := source.parsePage(bytes.NewReader(data), nodeCPUCosts, nodeRAMCosts, volumeCosts)
|
|
|
+
|
|
|
+ if (err != nil) != tt.wantErr {
|
|
|
+ t.Errorf("parsePage() error = %v, wantErr %v", err, tt.wantErr)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if nextToken != tt.wantNextPageToken {
|
|
|
+ t.Errorf("parsePage() nextToken = %v, want %v", nextToken, tt.wantNextPageToken)
|
|
|
+ }
|
|
|
+
|
|
|
+ if tt.checkCPUCosts && len(nodeCPUCosts) == 0 {
|
|
|
+ t.Error("parsePage() should have populated CPU costs")
|
|
|
+ }
|
|
|
+
|
|
|
+ if tt.checkRAMCosts && len(nodeRAMCosts) == 0 {
|
|
|
+ t.Error("parsePage() should have populated RAM costs")
|
|
|
+ }
|
|
|
+
|
|
|
+ if tt.checkVolumeCosts && len(volumeCosts) == 0 {
|
|
|
+ t.Error("parsePage() should have populated volume costs")
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestParseVolumeSKU(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ sku *GCPPricing
|
|
|
+ wantCosts int // Expected number of volume cost entries
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "Valid PD-SSD",
|
|
|
+ sku: &GCPPricing{
|
|
|
+ Description: "SSD backed PD Capacity",
|
|
|
+ Category: &GCPResourceInfo{
|
|
|
+ ResourceGroup: "SSD",
|
|
|
+ },
|
|
|
+ ServiceRegions: []string{"us-central1", "us-east1"},
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "0",
|
|
|
+ Nanos: 170000000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ wantCosts: 2, // Two regions
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Regional disk",
|
|
|
+ sku: &GCPPricing{
|
|
|
+ Description: "Regional SSD backed PD Capacity",
|
|
|
+ Category: &GCPResourceInfo{
|
|
|
+ ResourceGroup: "SSD",
|
|
|
+ },
|
|
|
+ ServiceRegions: []string{"us-central1"},
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "0",
|
|
|
+ Nanos: 204000000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ wantCosts: 1,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Unknown volume type",
|
|
|
+ sku: &GCPPricing{
|
|
|
+ Description: "Unknown Disk Type",
|
|
|
+ Category: &GCPResourceInfo{
|
|
|
+ ResourceGroup: "UnknownType",
|
|
|
+ },
|
|
|
+ ServiceRegions: []string{"us-central1"},
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "0",
|
|
|
+ Nanos: 100000000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ wantCosts: 0, // Should not add unknown types
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ source := &GCPPricingSource{}
|
|
|
+ volumeCosts := make(map[volumeKey]float64)
|
|
|
+
|
|
|
+ source.parseVolumeSKU(tt.sku, volumeCosts)
|
|
|
+
|
|
|
+ if len(volumeCosts) != tt.wantCosts {
|
|
|
+ t.Errorf("parseVolumeSKU() added %d costs, want %d", len(volumeCosts), tt.wantCosts)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestParseComputeSKU(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ sku *GCPPricing
|
|
|
+ usageType string
|
|
|
+ wantCPUCosts int
|
|
|
+ wantRAMCosts int
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "CPU SKU",
|
|
|
+ sku: &GCPPricing{
|
|
|
+ Description: "N2 Instance Core running in Americas",
|
|
|
+ Category: &GCPResourceInfo{
|
|
|
+ ResourceGroup: "CPU",
|
|
|
+ },
|
|
|
+ ServiceRegions: []string{"us-central1"},
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "0",
|
|
|
+ Nanos: 31611000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ usageType: "ondemand",
|
|
|
+ wantCPUCosts: 1,
|
|
|
+ wantRAMCosts: 0,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "RAM SKU",
|
|
|
+ sku: &GCPPricing{
|
|
|
+ Description: "N2 Instance RAM running in Americas",
|
|
|
+ Category: &GCPResourceInfo{
|
|
|
+ ResourceGroup: "RAM",
|
|
|
+ },
|
|
|
+ ServiceRegions: []string{"us-central1"},
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "0",
|
|
|
+ Nanos: 4237000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ usageType: "ondemand",
|
|
|
+ wantCPUCosts: 0,
|
|
|
+ wantRAMCosts: 1,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "E2 CPU expands to multiple types",
|
|
|
+ sku: &GCPPricing{
|
|
|
+ Description: "E2 Instance Core running in Americas",
|
|
|
+ Category: &GCPResourceInfo{
|
|
|
+ ResourceGroup: "CPU",
|
|
|
+ },
|
|
|
+ ServiceRegions: []string{"us-central1"},
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {
|
|
|
+ UnitPrice: &UnitPriceInfo{
|
|
|
+ Units: "0",
|
|
|
+ Nanos: 21811000,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ usageType: "ondemand",
|
|
|
+ wantCPUCosts: 5, // e2-micro, e2-small, e2-medium, e2-standard, e2-custom
|
|
|
+ wantRAMCosts: 0,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ source := &GCPPricingSource{}
|
|
|
+ nodeCPUCosts := make(map[nodeKey]float64)
|
|
|
+ nodeRAMCosts := make(map[nodeKey]float64)
|
|
|
+
|
|
|
+ source.parseComputeSKU(tt.sku, tt.usageType, nodeCPUCosts, nodeRAMCosts)
|
|
|
+
|
|
|
+ if len(nodeCPUCosts) != tt.wantCPUCosts {
|
|
|
+ t.Errorf("parseComputeSKU() added %d CPU costs, want %d", len(nodeCPUCosts), tt.wantCPUCosts)
|
|
|
+ }
|
|
|
+
|
|
|
+ if len(nodeRAMCosts) != tt.wantRAMCosts {
|
|
|
+ t.Errorf("parseComputeSKU() added %d RAM costs, want %d", len(nodeRAMCosts), tt.wantRAMCosts)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestBuildNodePricing(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ cpuCosts map[nodeKey]float64
|
|
|
+ ramCosts map[nodeKey]float64
|
|
|
+ wantNodes int
|
|
|
+ currencyCode string
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "Complete node with CPU and RAM",
|
|
|
+ cpuCosts: map[nodeKey]float64{
|
|
|
+ {Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.031611,
|
|
|
+ },
|
|
|
+ ramCosts: map[nodeKey]float64{
|
|
|
+ {Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.004237,
|
|
|
+ },
|
|
|
+ wantNodes: 1,
|
|
|
+ currencyCode: "USD",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Missing RAM cost",
|
|
|
+ cpuCosts: map[nodeKey]float64{
|
|
|
+ {Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.031611,
|
|
|
+ },
|
|
|
+ ramCosts: map[nodeKey]float64{},
|
|
|
+ wantNodes: 0,
|
|
|
+ currencyCode: "USD",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Missing CPU cost",
|
|
|
+ cpuCosts: map[nodeKey]float64{},
|
|
|
+ ramCosts: map[nodeKey]float64{
|
|
|
+ {Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.004237,
|
|
|
+ },
|
|
|
+ wantNodes: 0,
|
|
|
+ currencyCode: "USD",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Preemptible instance - excluded",
|
|
|
+ cpuCosts: map[nodeKey]float64{
|
|
|
+ {Region: "us-central1", InstanceType: "n2-standard", UsageType: "preemptible"}: 0.007583,
|
|
|
+ },
|
|
|
+ ramCosts: map[nodeKey]float64{
|
|
|
+ {Region: "us-central1", InstanceType: "n2-standard", UsageType: "preemptible"}: 0.001017,
|
|
|
+ },
|
|
|
+ wantNodes: 0,
|
|
|
+ currencyCode: "USD",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Multiple regions",
|
|
|
+ cpuCosts: map[nodeKey]float64{
|
|
|
+ {Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.031611,
|
|
|
+ {Region: "us-east1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.031611,
|
|
|
+ },
|
|
|
+ ramCosts: map[nodeKey]float64{
|
|
|
+ {Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.004237,
|
|
|
+ {Region: "us-east1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.004237,
|
|
|
+ },
|
|
|
+ wantNodes: 2,
|
|
|
+ currencyCode: "USD",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ source := &GCPPricingSource{
|
|
|
+ config: GCPPricingSourceConfig{
|
|
|
+ CurrencyCode: tt.currencyCode,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ ps := &pricing.PricingSet{
|
|
|
+ NodePricing: []*pricing.NodePricing{},
|
|
|
+ PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
|
|
|
+ }
|
|
|
+
|
|
|
+ source.buildNodePricing(ps, tt.cpuCosts, tt.ramCosts)
|
|
|
+
|
|
|
+ if len(ps.NodePricing) != tt.wantNodes {
|
|
|
+ t.Errorf("buildNodePricing() created %d nodes, want %d", len(ps.NodePricing), tt.wantNodes)
|
|
|
+ }
|
|
|
+
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestBuildVolumePricing(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ volumeCosts map[volumeKey]float64
|
|
|
+ wantVolumes int
|
|
|
+ currencyCode string
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "Single volume type",
|
|
|
+ volumeCosts: map[volumeKey]float64{
|
|
|
+ {Region: "us-central1", VolumeType: pricing.VolumeTypePDSSD, Regional: false}: 0.000232876,
|
|
|
+ },
|
|
|
+ wantVolumes: 1,
|
|
|
+ currencyCode: "USD",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Multiple volume types",
|
|
|
+ volumeCosts: map[volumeKey]float64{
|
|
|
+ {Region: "us-central1", VolumeType: pricing.VolumeTypePDSSD, Regional: false}: 0.000232876,
|
|
|
+ {Region: "us-central1", VolumeType: pricing.VolumeTypePDStandard, Regional: false}: 0.000054795,
|
|
|
+ },
|
|
|
+ wantVolumes: 2,
|
|
|
+ currencyCode: "USD",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Regional and zonal",
|
|
|
+ volumeCosts: map[volumeKey]float64{
|
|
|
+ {Region: "us-central1", VolumeType: pricing.VolumeTypePDSSD, Regional: false}: 0.000232876,
|
|
|
+ {Region: "us-central1", VolumeType: pricing.VolumeTypePDSSD, Regional: true}: 0.000279452,
|
|
|
+ },
|
|
|
+ wantVolumes: 2,
|
|
|
+ currencyCode: "USD",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ source := &GCPPricingSource{
|
|
|
+ config: GCPPricingSourceConfig{
|
|
|
+ CurrencyCode: tt.currencyCode,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ ps := &pricing.PricingSet{
|
|
|
+ NodePricing: []*pricing.NodePricing{},
|
|
|
+ PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
|
|
|
+ }
|
|
|
+
|
|
|
+ source.buildVolumePricing(ps, tt.volumeCosts)
|
|
|
+
|
|
|
+ if len(ps.PersistentVolumePricing) != tt.wantVolumes {
|
|
|
+ t.Errorf("buildVolumePricing() created %d volumes, want %d", len(ps.PersistentVolumePricing), tt.wantVolumes)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestGetPricing_Integration(t *testing.T) {
|
|
|
+ // Serve one page of pricing: one N2 CPU SKU, one N2 RAM SKU, one SSD volume SKU.
|
|
|
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ response := GCPPricingResponse{
|
|
|
+ Skus: []*GCPPricing{
|
|
|
+ {
|
|
|
+ Description: "N2 Instance Core running in Americas",
|
|
|
+ Category: &GCPResourceInfo{
|
|
|
+ ResourceGroup: "CPU",
|
|
|
+ UsageType: "OnDemand",
|
|
|
+ },
|
|
|
+ ServiceRegions: []string{"us-central1"},
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {UnitPrice: &UnitPriceInfo{Units: "0", Nanos: 31611000}},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ Description: "N2 Instance RAM running in Americas",
|
|
|
+ Category: &GCPResourceInfo{
|
|
|
+ ResourceGroup: "RAM",
|
|
|
+ UsageType: "OnDemand",
|
|
|
+ },
|
|
|
+ ServiceRegions: []string{"us-central1"},
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {UnitPrice: &UnitPriceInfo{Units: "0", Nanos: 4237000}},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ Description: "SSD backed PD Capacity in Americas",
|
|
|
+ Category: &GCPResourceInfo{
|
|
|
+ ResourceGroup: "SSD",
|
|
|
+ UsageType: "OnDemand",
|
|
|
+ },
|
|
|
+ ServiceRegions: []string{"us-central1"},
|
|
|
+ PricingInfo: []*PricingInfo{
|
|
|
+ {
|
|
|
+ PricingExpression: &PricingExpression{
|
|
|
+ TieredRates: []*TieredRates{
|
|
|
+ {UnitPrice: &UnitPriceInfo{Units: "0", Nanos: 170000000}},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ NextPageToken: "",
|
|
|
+ }
|
|
|
+
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
+ _ = json.NewEncoder(w).Encode(response)
|
|
|
+ }))
|
|
|
+ defer server.Close()
|
|
|
+
|
|
|
+ // Point the package-level base URL at the test server.
|
|
|
+ originalURL := BillingAPIBaseURL
|
|
|
+ BillingAPIBaseURL = server.URL
|
|
|
+ defer func() { BillingAPIBaseURL = originalURL }()
|
|
|
+
|
|
|
+ // Use the test server's own client so TLS/redirect rules match.
|
|
|
+ originalClient := gcpHTTPClient
|
|
|
+ gcpHTTPClient = server.Client()
|
|
|
+ defer func() { gcpHTTPClient = originalClient }()
|
|
|
+
|
|
|
+ source := NewGCPPricingSource(GCPPricingSourceConfig{
|
|
|
+ APIKey: "test-key",
|
|
|
+ CurrencyCode: "USD",
|
|
|
+ })
|
|
|
+
|
|
|
+ ps, err := source.GetPricing()
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("GetPricing() unexpected error: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if len(ps.NodePricing) != 1 {
|
|
|
+ t.Errorf("NodePricing len = %d, want 1", len(ps.NodePricing))
|
|
|
+ }
|
|
|
+ if len(ps.PersistentVolumePricing) != 1 {
|
|
|
+ t.Errorf("PersistentVolumePricing len = %d, want 1", len(ps.PersistentVolumePricing))
|
|
|
+ }
|
|
|
+
|
|
|
+ node := ps.NodePricing[0]
|
|
|
+ if node.Properties.Region != "us-central1" {
|
|
|
+ t.Errorf("NodePricing region = %q, want us-central1", node.Properties.Region)
|
|
|
+ }
|
|
|
+ if node.Properties.InstanceType != "n2-standard" {
|
|
|
+ t.Errorf("NodePricing instance type = %q, want n2-standard", node.Properties.InstanceType)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestMapGCPVolumeType(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ resourceGroup string
|
|
|
+ description string
|
|
|
+ wantType pricing.VolumeType
|
|
|
+ wantRegional bool
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "PD-SSD zonal",
|
|
|
+ resourceGroup: "SSD",
|
|
|
+ description: "SSD backed PD Capacity",
|
|
|
+ wantType: pricing.VolumeTypePDSSD,
|
|
|
+ wantRegional: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "PD-SSD regional",
|
|
|
+ resourceGroup: "SSD",
|
|
|
+ description: "Regional SSD backed PD Capacity",
|
|
|
+ wantType: pricing.VolumeTypePDSSD,
|
|
|
+ wantRegional: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "PD-Standard",
|
|
|
+ resourceGroup: "PDStandard",
|
|
|
+ description: "Storage PD Capacity",
|
|
|
+ wantType: pricing.VolumeTypePDStandard,
|
|
|
+ wantRegional: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "PD-Balanced",
|
|
|
+ resourceGroup: "PDBalanced",
|
|
|
+ description: "Balanced PD Capacity",
|
|
|
+ wantType: pricing.VolumeTypePDBalanced,
|
|
|
+ wantRegional: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "PD-Extreme",
|
|
|
+ resourceGroup: "PDExtreme",
|
|
|
+ description: "Extreme PD Capacity",
|
|
|
+ wantType: pricing.VolumeTypePDExtreme,
|
|
|
+ wantRegional: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Hyperdisk Balanced",
|
|
|
+ resourceGroup: "HyperdiskBalanced",
|
|
|
+ description: "Hyperdisk Balanced",
|
|
|
+ wantType: pricing.VolumeTypeHyperdiskBalanced,
|
|
|
+ wantRegional: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Hyperdisk Extreme",
|
|
|
+ resourceGroup: "HyperdiskExtreme",
|
|
|
+ description: "Hyperdisk Extreme",
|
|
|
+ wantType: pricing.VolumeTypeHyperdiskExtreme,
|
|
|
+ wantRegional: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Hyperdisk Throughput",
|
|
|
+ resourceGroup: "HyperdiskThroughput",
|
|
|
+ description: "Hyperdisk Throughput",
|
|
|
+ wantType: pricing.VolumeTypeHyperdiskThroughput,
|
|
|
+ wantRegional: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Unknown type",
|
|
|
+ resourceGroup: "UnknownDisk",
|
|
|
+ description: "Some unknown disk",
|
|
|
+ wantType: pricing.VolumeTypeNil,
|
|
|
+ wantRegional: false,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ gotType, gotRegional := mapGCPVolumeType(tt.resourceGroup, tt.description)
|
|
|
+ if gotType != tt.wantType {
|
|
|
+ t.Errorf("mapGCPVolumeType() type = %v, want %v", gotType, tt.wantType)
|
|
|
+ }
|
|
|
+ if gotRegional != tt.wantRegional {
|
|
|
+ t.Errorf("mapGCPVolumeType() regional = %v, want %v", gotRegional, tt.wantRegional)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestNormalizeInstanceType(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ resourceGroup string
|
|
|
+ description string
|
|
|
+ want string
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "Custom instance",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ description: "Custom Instance Core running in Americas",
|
|
|
+ want: "custom",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "N2 standard",
|
|
|
+ resourceGroup: "RAM",
|
|
|
+ description: "N2 Instance Ram running in Americas",
|
|
|
+ want: "n2-standard",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "N2D AMD",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ description: "N2D AMD Instance Core running in Americas",
|
|
|
+ want: "n2d-standard",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "N4 instance",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ description: "N4 Instance Core running in Americas",
|
|
|
+ want: "n4-standard",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "A2 instance",
|
|
|
+ resourceGroup: "RAM",
|
|
|
+ description: "A2 Instance Ram running in Americas",
|
|
|
+ want: "a2",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "C2 compute optimized",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ description: "Compute Optimized Core running in Americas",
|
|
|
+ want: "c2-standard",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "E2 instance",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ description: "E2 Instance Core running in Americas",
|
|
|
+ want: "e2",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "T2D AMD",
|
|
|
+ resourceGroup: "RAM",
|
|
|
+ description: "T2D AMD Instance Ram running in Americas",
|
|
|
+ want: "t2d-standard",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "T2A ARM",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ description: "T2A ARM Instance Core running in Americas",
|
|
|
+ want: "t2a-standard",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "Unknown type defaults to resource group",
|
|
|
+ resourceGroup: "SomeType",
|
|
|
+ description: "Some Instance Type",
|
|
|
+ want: "sometype",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ got := normalizeInstanceType(tt.resourceGroup, tt.description)
|
|
|
+ if got != tt.want {
|
|
|
+ t.Errorf("normalizeInstanceType() = %v, want %v", got, tt.want)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestIsComputeResource(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ resourceGroup string
|
|
|
+ want bool
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "CPU is compute",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ want: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "RAM is compute",
|
|
|
+ resourceGroup: "RAM",
|
|
|
+ want: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "cpu lowercase is compute",
|
|
|
+ resourceGroup: "cpu",
|
|
|
+ want: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "ram lowercase is compute",
|
|
|
+ resourceGroup: "ram",
|
|
|
+ want: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "SSD is not compute",
|
|
|
+ resourceGroup: "SSD",
|
|
|
+ want: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "GPU is not compute",
|
|
|
+ resourceGroup: "GPU",
|
|
|
+ want: false,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ got := isComputeResource(tt.resourceGroup)
|
|
|
+ if got != tt.want {
|
|
|
+ t.Errorf("isComputeResource() = %v, want %v", got, tt.want)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestIsStorageResource(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ resourceGroup string
|
|
|
+ want bool
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "SSD is storage",
|
|
|
+ resourceGroup: "SSD",
|
|
|
+ want: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "PDStandard is storage",
|
|
|
+ resourceGroup: "PDStandard",
|
|
|
+ want: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "PDBalanced is storage",
|
|
|
+ resourceGroup: "PDBalanced",
|
|
|
+ want: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "PDExtreme is storage",
|
|
|
+ resourceGroup: "PDExtreme",
|
|
|
+ want: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "HyperdiskBalanced is storage",
|
|
|
+ resourceGroup: "HyperdiskBalanced",
|
|
|
+ want: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "HyperdiskExtreme is storage",
|
|
|
+ resourceGroup: "HyperdiskExtreme",
|
|
|
+ want: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "HyperdiskThroughput is storage",
|
|
|
+ resourceGroup: "HyperdiskThroughput",
|
|
|
+ want: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "CPU is not storage",
|
|
|
+ resourceGroup: "CPU",
|
|
|
+ want: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "RAM is not storage",
|
|
|
+ resourceGroup: "RAM",
|
|
|
+ want: false,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ got := isStorageResource(tt.resourceGroup)
|
|
|
+ if got != tt.want {
|
|
|
+ t.Errorf("isStorageResource() = %v, want %v", got, tt.want)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Helper function to check if a string contains a substring
|
|
|
+func contains(s, substr string) bool {
|
|
|
+ return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
|
|
+ (len(s) > 0 && (s[0:len(substr)] == substr || contains(s[1:], substr))))
|
|
|
+}
|
|
|
+
|
|
|
+// Made with Bob
|