diff --git a/providers/azure/provider.go b/providers/azure/provider.go index c6146bb51..4d2c75e6a 100644 --- a/providers/azure/provider.go +++ b/providers/azure/provider.go @@ -421,6 +421,7 @@ func (p *AzureProvider) GetSupportedServices() []common.ServiceType { common.ServiceMemoryDB, common.ServiceSavingsPlans, common.ServiceSearch, + common.ServiceDataWarehouse, } } @@ -480,6 +481,8 @@ func (p *AzureProvider) newServiceClientForSubscription(service common.ServiceTy return NewSavingsPlansClient(p.cred, subscriptionID, region), nil case common.ServiceSearch: return NewSearchClient(p.cred, subscriptionID, region), nil + case common.ServiceDataWarehouse: + return NewSynapseClient(p.cred, subscriptionID, region), nil default: return nil, fmt.Errorf("unsupported service: %s", service) } diff --git a/providers/azure/provider_test.go b/providers/azure/provider_test.go index 95740e89e..2487e771a 100644 --- a/providers/azure/provider_test.go +++ b/providers/azure/provider_test.go @@ -265,6 +265,9 @@ func TestAzureProvider_GetSupportedServices(t *testing.T) { assert.Contains(t, services, common.ServiceNoSQL) assert.Contains(t, services, common.ServiceCache) assert.Contains(t, services, common.ServiceMemoryDB) + assert.Contains(t, services, common.ServiceSavingsPlans) + assert.Contains(t, services, common.ServiceSearch) + assert.Contains(t, services, common.ServiceDataWarehouse) } func TestAzureProvider_IsConfigured(t *testing.T) { @@ -411,6 +414,9 @@ func TestAzureProvider_GetServiceClient_AllServiceTypes(t *testing.T) { {common.ServiceCache}, {common.ServiceNoSQL}, {common.ServiceMemoryDB}, + {common.ServiceSavingsPlans}, + {common.ServiceSearch}, + {common.ServiceDataWarehouse}, } for _, tc := range testCases { @@ -1155,6 +1161,10 @@ func TestAzureProvider_GetServiceClientForAccount(t *testing.T) { common.ServiceRelationalDB, common.ServiceCache, common.ServiceNoSQL, + common.ServiceMemoryDB, + common.ServiceSavingsPlans, + common.ServiceSearch, + common.ServiceDataWarehouse, } for _, svc := range services { t.Run(string(svc), func(t *testing.T) { diff --git a/providers/azure/services.go b/providers/azure/services.go index 8e560673a..c0a940181 100644 --- a/providers/azure/services.go +++ b/providers/azure/services.go @@ -11,6 +11,7 @@ import ( "github.com/LeanerCloud/CUDly/providers/azure/services/managedredis" "github.com/LeanerCloud/CUDly/providers/azure/services/savingsplans" "github.com/LeanerCloud/CUDly/providers/azure/services/search" + "github.com/LeanerCloud/CUDly/providers/azure/services/synapse" ) // NewComputeClient creates a new Azure Compute (VM) client @@ -49,6 +50,11 @@ func NewSearchClient(cred azcore.TokenCredential, subscriptionID, region string) return search.NewClient(cred, subscriptionID, region) } +// NewSynapseClient creates a new Azure Synapse Analytics client +func NewSynapseClient(cred azcore.TokenCredential, subscriptionID, region string) provider.ServiceClient { + return synapse.NewClient(cred, subscriptionID, region) +} + // NewRecommendationsClient creates a new Azure recommendations client. // // Returns an error when subscriptionID is empty — the adapter's downstream diff --git a/providers/azure/services/synapse/client.go b/providers/azure/services/synapse/client.go new file mode 100644 index 000000000..c793590cc --- /dev/null +++ b/providers/azure/services/synapse/client.go @@ -0,0 +1,535 @@ +// Package synapse provides Azure Synapse Analytics Reserved Capacity client. +// Azure Synapse Analytics (formerly SQL Data Warehouse) supports reservation-based +// commitments for Dedicated SQL Pool DWUs and Spark Compute Units (SCUs). +// Reservations are issued via the Azure Capacity / Consumption APIs — the same +// pattern used by cosmosdb and cache in this provider. +package synapse + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/consumption/armconsumption" + "github.com/google/uuid" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/providers/azure/internal/httpclient" + "github.com/LeanerCloud/CUDly/providers/azure/internal/pricing" + "github.com/LeanerCloud/CUDly/providers/azure/internal/recommendations" +) + +// HTTPClient interface for HTTP operations (enables mocking). +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// RecommendationsPager interface for recommendations pager (enables mocking). +type RecommendationsPager interface { + More() bool + NextPage(ctx context.Context) (armconsumption.ReservationRecommendationsClientListResponse, error) +} + +// ReservationsDetailsPager interface for reservations details pager (enables mocking). +type ReservationsDetailsPager interface { + More() bool + NextPage(ctx context.Context) (armconsumption.ReservationsDetailsClientListResponse, error) +} + +// SynapseClient handles Azure Synapse Analytics Reserved Capacity. +type SynapseClient struct { + cred azcore.TokenCredential + subscriptionID string + region string + httpClient HTTPClient + recommendationsPager RecommendationsPager + reservationsPager ReservationsDetailsPager +} + +// NewClient creates a new Azure Synapse Analytics client. +func NewClient(cred azcore.TokenCredential, subscriptionID, region string) *SynapseClient { + return &SynapseClient{ + cred: cred, + subscriptionID: subscriptionID, + region: region, + httpClient: httpclient.New(), + } +} + +// NewClientWithHTTP creates a new Azure Synapse client with a custom HTTP client (for testing). +// If httpClient is nil, http.DefaultClient is used. +func NewClientWithHTTP(cred azcore.TokenCredential, subscriptionID, region string, httpClient HTTPClient) *SynapseClient { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &SynapseClient{ + cred: cred, + subscriptionID: subscriptionID, + region: region, + httpClient: httpClient, + } +} + +// SetRecommendationsPager sets the recommendations pager (for testing). +func (c *SynapseClient) SetRecommendationsPager(pager RecommendationsPager) { + c.recommendationsPager = pager +} + +// SetReservationsPager sets the reservations pager (for testing). +func (c *SynapseClient) SetReservationsPager(pager ReservationsDetailsPager) { + c.reservationsPager = pager +} + +// GetServiceType returns the service type. +func (c *SynapseClient) GetServiceType() common.ServiceType { + return common.ServiceDataWarehouse +} + +// GetRegion returns the region. +func (c *SynapseClient) GetRegion() string { + return c.region +} + +// SynapseRetailPriceItem is the Azure Retail Prices API item shape for +// Synapse Analytics. Used as the type parameter to pricing.FetchAll. +type SynapseRetailPriceItem struct { + CurrencyCode string `json:"currencyCode"` + RetailPrice float64 `json:"retailPrice"` + UnitPrice float64 `json:"unitPrice"` + ArmRegionName string `json:"armRegionName"` + ProductName string `json:"productName"` + ServiceName string `json:"serviceName"` + ArmSKUName string `json:"armSkuName"` + MeterName string `json:"meterName"` + SKUName string `json:"skuName"` + ReservationTerm string `json:"reservationTerm"` + Type string `json:"type"` +} + +// GetRecommendations retrieves Synapse reservation recommendations from the +// Azure Consumption API. +func (c *SynapseClient) GetRecommendations(ctx context.Context, params common.RecommendationParams) ([]common.Recommendation, error) { + recs := make([]common.Recommendation, 0) + + var pager RecommendationsPager + if c.recommendationsPager != nil { + pager = c.recommendationsPager + } else { + client, err := armconsumption.NewReservationRecommendationsClient(c.cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create consumption client: %w", err) + } + scope := fmt.Sprintf("/subscriptions/%s", c.subscriptionID) + filter := "properties/scope eq 'Shared' and properties/resourceType eq 'SQLDatabaseDTU'" + pager = client.NewListPager(scope, &armconsumption.ReservationRecommendationsClientListOptions{Filter: &filter}) + } + + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Synapse recommendations: %w", err) + } + + for _, rec := range page.Value { + converted := c.convertSynapseRecommendation(rec) + if converted == nil { + continue + } + if c.region != "" && !strings.EqualFold(converted.Region, c.region) { + continue + } + recs = append(recs, *converted) + } + } + + return recs, nil +} + +// GetExistingCommitments retrieves existing Synapse reserved capacity +// commitments from the Azure Consumption API. +func (c *SynapseClient) GetExistingCommitments(ctx context.Context) ([]common.Commitment, error) { + pager, err := c.createReservationsPager() + if err != nil { + return nil, fmt.Errorf("synapse: create reservations pager: %w", err) + } + + return c.collectSynapseReservations(ctx, pager) +} + +func (c *SynapseClient) createReservationsPager() (ReservationsDetailsPager, error) { + if c.reservationsPager != nil { + return c.reservationsPager, nil + } + client, err := armconsumption.NewReservationsDetailsClient(c.cred, nil) + if err != nil { + return nil, err + } + scope := fmt.Sprintf("subscriptions/%s", c.subscriptionID) + return client.NewListPager(scope, &armconsumption.ReservationsDetailsClientListOptions{}), nil +} + +func (c *SynapseClient) collectSynapseReservations(ctx context.Context, pager ReservationsDetailsPager) ([]common.Commitment, error) { + commitments := make([]common.Commitment, 0) + + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("synapse: list reservations: %w", err) + } + for _, detail := range page.Value { + if commitment := c.convertSynapseReservation(detail); commitment != nil { + commitments = append(commitments, *commitment) + } + } + } + + return commitments, nil +} + +// convertSynapseReservation converts a reservation detail to a Commitment if +// it is a Synapse SQL Pool or Spark reservation. Identification relies on the +// SKU name containing a Synapse-specific prefix ("DW" for Dedicated SQL Pools +// or "SCU" for Spark Compute Units). +func (c *SynapseClient) convertSynapseReservation(detail *armconsumption.ReservationDetail) *common.Commitment { + if detail == nil || detail.Properties == nil { + return nil + } + props := detail.Properties + if props.SKUName == nil { + return nil + } + skuLower := strings.ToLower(*props.SKUName) + if !strings.HasPrefix(skuLower, "dw") && + !strings.HasPrefix(skuLower, "scu") && + !strings.Contains(skuLower, "synapse") { + return nil + } + + commitment := &common.Commitment{ + Provider: common.ProviderAzure, + Account: c.subscriptionID, + CommitmentType: common.CommitmentReservedInstance, + Service: common.ServiceDataWarehouse, + Region: c.region, + State: "active", + } + if props.ReservationID != nil { + commitment.CommitmentID = *props.ReservationID + } + commitment.ResourceType = *props.SKUName + return commitment +} + +// parseReservationTermYears maps a term string to an integer year count. +// Returns an error for any value outside the explicit allowlist so that +// callers fail closed rather than silently coercing to a 1-year purchase. +func parseReservationTermYears(term string) (int, error) { + switch strings.ToLower(strings.TrimSpace(term)) { + case "", "1", "1yr", "1y": + return 1, nil + case "3", "3yr", "3y": + return 3, nil + default: + return 0, fmt.Errorf("unsupported reservation term: %s", term) + } +} + +// PurchaseCommitment purchases Synapse reserved capacity via the Azure +// Reservations API. The reserved resource type is "SqlDW" which covers +// Dedicated SQL Pool DWU reservations. +func (c *SynapseClient) PurchaseCommitment(ctx context.Context, rec common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error) { + result := common.PurchaseResult{ + Recommendation: rec, + DryRun: false, + Success: false, + Timestamp: time.Now(), + } + + if strings.TrimSpace(rec.ResourceType) == "" { + result.Error = fmt.Errorf("resource type is required") + return result, result.Error + } + if rec.Count <= 0 { + result.Error = fmt.Errorf("quantity must be greater than zero") + return result, result.Error + } + + termYears, err := parseReservationTermYears(rec.Term) + if err != nil { + result.Error = err + return result, result.Error + } + + reservationOrderID := uuid.New().String() + commitmentID, err := c.doPurchaseRequest(ctx, rec, opts, reservationOrderID, termYears) + if err != nil { + result.Error = err + return result, result.Error + } + + result.Success = true + result.CommitmentID = commitmentID + result.Cost = rec.CommitmentCost + return result, nil +} + +// doPurchaseRequest marshals the reservation request body, signs it with a +// bearer token, and executes the PUT against the Azure Reservations API. +// It is extracted from PurchaseCommitment to keep that function's cyclomatic +// complexity within the project limit. +func (c *SynapseClient) doPurchaseRequest(ctx context.Context, rec common.Recommendation, opts common.PurchaseOptions, reservationOrderID string, termYears int) (string, error) { + apiVersion := "2022-11-01" + purchaseURL := fmt.Sprintf("https://management.azure.com/providers/Microsoft.Capacity/reservationOrders/%s?api-version=%s", + reservationOrderID, apiVersion) + + requestBody := map[string]interface{}{ + "sku": map[string]string{ + "name": rec.ResourceType, + }, + "location": c.region, + "properties": map[string]interface{}{ + "reservedResourceType": "SqlDW", + "billingScopeId": fmt.Sprintf("/subscriptions/%s", c.subscriptionID), + "term": fmt.Sprintf("P%dY", termYears), + "quantity": rec.Count, + "displayName": fmt.Sprintf("Synapse SQL Pool Reservation - %s", rec.ResourceType), + "appliedScopeType": "Shared", + "renew": false, + }, + } + applyPurchaseAutomationTag(requestBody, opts.Source) + + bodyBytes, err := json.Marshal(requestBody) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "PUT", purchaseURL, strings.NewReader(string(bodyBytes))) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + token, err := c.cred.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{"https://management.azure.com/.default"}, + }) + if err != nil { + return "", fmt.Errorf("failed to get access token: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token.Token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to purchase reservation: %w", err) + } + defer resp.Body.Close() + + body, readErr := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { + if readErr != nil { + return "", fmt.Errorf("reservation purchase failed with status %d (body read error: %v)", resp.StatusCode, readErr) + } + return "", fmt.Errorf("reservation purchase failed with status %d: %s", resp.StatusCode, string(body)) + } + return reservationOrderID, nil +} + +// ValidateOffering validates that a Synapse SKU is in the known set. +func (c *SynapseClient) ValidateOffering(ctx context.Context, rec common.Recommendation) error { + validSKUs, err := c.GetValidResourceTypes(ctx) + if err != nil { + return fmt.Errorf("failed to get valid SKUs: %w", err) + } + resourceType := strings.TrimSpace(rec.ResourceType) + for _, sku := range validSKUs { + if strings.EqualFold(sku, resourceType) { + return nil + } + } + return fmt.Errorf("invalid Azure Synapse SKU: %s", rec.ResourceType) +} + +// GetOfferingDetails retrieves Synapse reservation offering details from the +// Azure Retail Prices API. +func (c *SynapseClient) GetOfferingDetails(ctx context.Context, rec common.Recommendation) (*common.OfferingDetails, error) { + termYears, err := parseReservationTermYears(rec.Term) + if err != nil { + return nil, err + } + + p, err := c.getSynapsePricing(ctx, rec.ResourceType, c.region, termYears) + if err != nil { + return nil, fmt.Errorf("failed to get pricing: %w", err) + } + + var upfrontCost, recurringCost float64 + totalCost := p.ReservationPrice + + switch rec.PaymentOption { + case "all-upfront", "upfront": + upfrontCost = totalCost + recurringCost = 0 + case "monthly", "no-upfront": + upfrontCost = 0 + recurringCost = totalCost / (float64(termYears) * 12) + default: + upfrontCost = totalCost + } + + return &common.OfferingDetails{ + OfferingID: fmt.Sprintf("azure-synapse-%s-%s-%s", rec.ResourceType, c.region, rec.Term), + ResourceType: rec.ResourceType, + Term: rec.Term, + PaymentOption: rec.PaymentOption, + UpfrontCost: upfrontCost, + RecurringCost: recurringCost, + TotalCost: totalCost, + EffectiveHourlyRate: p.HourlyRate, + Currency: p.Currency, + }, nil +} + +// GetValidResourceTypes returns the known Synapse Dedicated SQL Pool DWU SKUs +// that support reservations. Azure Synapse reservations are available for +// DW100c through DW30000c performance levels. +func (c *SynapseClient) GetValidResourceTypes(_ context.Context) ([]string, error) { + return []string{ + // Dedicated SQL Pool DWU levels (cDWU generation) + "DW100c", + "DW200c", + "DW300c", + "DW400c", + "DW500c", + "DW1000c", + "DW1500c", + "DW2000c", + "DW2500c", + "DW3000c", + "DW5000c", + "DW6000c", + "DW7500c", + "DW10000c", + "DW15000c", + "DW30000c", + }, nil +} + +// SynapsePricing holds pricing information for Synapse Analytics. +type SynapsePricing struct { + HourlyRate float64 + ReservationPrice float64 + OnDemandPrice float64 + Currency string + SavingsPercentage float64 +} + +// getSynapsePricing fetches pricing from the Azure Retail Prices API. +func (c *SynapseClient) getSynapsePricing(ctx context.Context, sku, region string, termYears int) (*SynapsePricing, error) { + filter := fmt.Sprintf("serviceName eq 'Azure Synapse Analytics' and armRegionName eq '%s' and skuName eq '%s'", + region, sku) + + params := url.Values{} + params.Add("$filter", filter) + params.Add("api-version", "2023-01-01-preview") + + initialURL := "https://prices.azure.com/api/retail/prices?" + params.Encode() + items, err := pricing.FetchAll[SynapseRetailPriceItem](ctx, c.httpClient, initialURL, pricing.DefaultPageTimeout, pricing.DefaultMaxPages) + if err != nil { + return nil, err + } + + if len(items) == 0 { + return nil, fmt.Errorf("no pricing data found for Synapse SKU %s in region %s", sku, region) + } + + onDemandPrice, reservationPrice, currency := extractSynapsePricing(items, termYears) + if onDemandPrice == 0 { + return nil, fmt.Errorf("no on-demand pricing found for Synapse SKU %s", sku) + } + + hoursInTerm := 8760.0 * float64(termYears) + if reservationPrice == 0 { + return nil, fmt.Errorf("pricing data unavailable for Synapse SKU %s in region %s: no reservation price returned by API", sku, region) + } + + savingsPercentage := ((onDemandPrice*hoursInTerm - reservationPrice) / (onDemandPrice * hoursInTerm)) * 100 + + return &SynapsePricing{ + HourlyRate: reservationPrice / hoursInTerm, + ReservationPrice: reservationPrice, + OnDemandPrice: onDemandPrice * hoursInTerm, + Currency: currency, + SavingsPercentage: savingsPercentage, + }, nil +} + +// extractSynapsePricing extracts on-demand and reservation pricing from price items. +func extractSynapsePricing(items []SynapseRetailPriceItem, termYears int) (onDemand, reservation float64, currency string) { + currency = "USD" + termStr := fmt.Sprintf("%d Year", termYears) + if termYears > 1 { + termStr = fmt.Sprintf("%d Years", termYears) + } + + for _, item := range items { + if item.CurrencyCode != "" { + currency = item.CurrencyCode + } + switch { + case strings.Contains(item.ReservationTerm, termStr): + if item.RetailPrice > 0 { + reservation = item.RetailPrice + } + case item.Type == "Consumption" && item.RetailPrice > 0: + onDemand = item.RetailPrice + } + } + return onDemand, reservation, currency +} + +// convertSynapseRecommendation converts an Azure reservation recommendation +// to the common Recommendation format. +func (c *SynapseClient) convertSynapseRecommendation(azureRec armconsumption.ReservationRecommendationClassification) *common.Recommendation { + f := recommendations.Extract(azureRec) + if f == nil { + return nil + } + details := common.DataWarehouseDetails{ + NodeType: f.ResourceType, + ClusterType: "dedicated-sql-pool", + } + return &common.Recommendation{ + Provider: common.ProviderAzure, + Service: common.ServiceDataWarehouse, + Account: c.subscriptionID, + Region: f.Region, + ResourceType: f.ResourceType, + Count: f.Count, + OnDemandCost: f.OnDemandCost, + CommitmentCost: f.CommitmentCost, + EstimatedSavings: f.EstimatedSavings, + RecurringMonthlyCost: f.RecurringMonthlyCost, + CommitmentType: common.CommitmentReservedInstance, + Term: f.Term, + PaymentOption: "upfront", + Timestamp: time.Now(), + Details: details, + } +} + +// applyPurchaseAutomationTag attaches the purchase-automation tag to an Azure +// reservation request body when source is non-empty. +func applyPurchaseAutomationTag(body map[string]interface{}, source string) { + if source == "" { + return + } + body["tags"] = map[string]string{common.PurchaseTagKey: source} +} diff --git a/providers/azure/services/synapse/client_test.go b/providers/azure/services/synapse/client_test.go new file mode 100644 index 000000000..4715ea82d --- /dev/null +++ b/providers/azure/services/synapse/client_test.go @@ -0,0 +1,783 @@ +package synapse + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/consumption/armconsumption" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/providers/azure/mocks" +) + +// ---- credential mock ------------------------------------------------------- + +type mockTokenCredential struct { + token string + err error +} + +func (m *mockTokenCredential) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) { + if m.err != nil { + return azcore.AccessToken{}, m.err + } + return azcore.AccessToken{ + Token: m.token, + ExpiresOn: time.Now().Add(time.Hour), + }, nil +} + +// ---- HTTP client mock ------------------------------------------------------- + +type mockHTTPClient struct { + mock.Mock +} + +func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { + args := m.Called(req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*http.Response), args.Error(1) +} + +func newHTTPResponse(statusCode int, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(bytes.NewBufferString(body)), + Header: make(http.Header), + } +} + +// captureHTTPClient captures the request body on each call. +type captureHTTPClient struct { + response *http.Response + captured []byte +} + +func (c *captureHTTPClient) Do(req *http.Request) (*http.Response, error) { + if req.Body != nil { + b, _ := io.ReadAll(req.Body) + c.captured = b + req.Body = io.NopCloser(bytes.NewReader(b)) + } + return c.response, nil +} + +// ---- pager mocks ----------------------------------------------------------- + +type fakeRecommendationsPager struct { + pages []armconsumption.ReservationRecommendationsClientListResponse + index int +} + +func (m *fakeRecommendationsPager) More() bool { + return m.index < len(m.pages) +} + +func (m *fakeRecommendationsPager) NextPage(_ context.Context) (armconsumption.ReservationRecommendationsClientListResponse, error) { + if m.index >= len(m.pages) { + return armconsumption.ReservationRecommendationsClientListResponse{}, errors.New("no more pages") + } + page := m.pages[m.index] + m.index++ + return page, nil +} + +// errorRecommendationsPager returns an error on the first NextPage call. +type errorRecommendationsPager struct { + called bool +} + +func (e *errorRecommendationsPager) More() bool { return !e.called } +func (e *errorRecommendationsPager) NextPage(_ context.Context) (armconsumption.ReservationRecommendationsClientListResponse, error) { + e.called = true + return armconsumption.ReservationRecommendationsClientListResponse{}, errors.New("API error") +} + +type fakeReservationsPager struct { + pages []armconsumption.ReservationsDetailsClientListResponse + index int + err error +} + +func (m *fakeReservationsPager) More() bool { + return m.index < len(m.pages) +} + +func (m *fakeReservationsPager) NextPage(_ context.Context) (armconsumption.ReservationsDetailsClientListResponse, error) { + if m.err != nil { + return armconsumption.ReservationsDetailsClientListResponse{}, m.err + } + if m.index >= len(m.pages) { + return armconsumption.ReservationsDetailsClientListResponse{}, errors.New("no more pages") + } + page := m.pages[m.index] + m.index++ + return page, nil +} + +// ---- helpers --------------------------------------------------------------- + +func newTestClient() *SynapseClient { + return &SynapseClient{ + subscriptionID: "sub-123", + region: "eastus", + } +} + +// ---- GetServiceType / GetRegion ------------------------------------------- + +func TestGetServiceType(t *testing.T) { + c := newTestClient() + assert.Equal(t, common.ServiceDataWarehouse, c.GetServiceType()) +} + +func TestGetRegion(t *testing.T) { + c := newTestClient() + assert.Equal(t, "eastus", c.GetRegion()) +} + +// ---- GetValidResourceTypes ------------------------------------------------ + +func TestGetValidResourceTypes(t *testing.T) { + c := newTestClient() + skus, err := c.GetValidResourceTypes(context.Background()) + require.NoError(t, err) + assert.NotEmpty(t, skus) + assert.Contains(t, skus, "DW100c") + assert.Contains(t, skus, "DW30000c") + assert.Contains(t, skus, "DW1000c") +} + +// ---- GetRecommendations --------------------------------------------------- + +func TestGetRecommendations_empty(t *testing.T) { + c := newTestClient() + c.SetRecommendationsPager(&fakeRecommendationsPager{}) + + recs, err := c.GetRecommendations(context.Background(), common.RecommendationParams{}) + require.NoError(t, err) + assert.Empty(t, recs) +} + +func TestGetRecommendations_singlePage(t *testing.T) { + c := newTestClient() + + azRec := mocks.BuildLegacyReservationRecommendation( + mocks.WithRegion("eastus"), + mocks.WithNormalizedSize("DW1000c"), + mocks.WithQuantity(2), + mocks.WithCosts(5000.0, 3500.0, 1500.0), + ) + c.SetRecommendationsPager(&fakeRecommendationsPager{ + pages: []armconsumption.ReservationRecommendationsClientListResponse{ + { + ReservationRecommendationsListResult: armconsumption.ReservationRecommendationsListResult{ + Value: []armconsumption.ReservationRecommendationClassification{azRec}, + }, + }, + }, + }) + + recs, err := c.GetRecommendations(context.Background(), common.RecommendationParams{}) + require.NoError(t, err) + require.Len(t, recs, 1) + + r := recs[0] + assert.Equal(t, common.ServiceDataWarehouse, r.Service) + assert.Equal(t, "DW1000c", r.ResourceType) + assert.Equal(t, 2, r.Count) + assert.Equal(t, common.ProviderAzure, r.Provider) + assert.Equal(t, common.CommitmentReservedInstance, r.CommitmentType) + assert.Equal(t, "1yr", r.Term) + assert.Equal(t, "upfront", r.PaymentOption) + assert.InDelta(t, 5000.0, r.OnDemandCost, 0.01) + assert.InDelta(t, 3500.0, r.CommitmentCost, 0.01) + assert.InDelta(t, 1500.0, r.EstimatedSavings, 0.01) + require.NotNil(t, r.RecurringMonthlyCost) + assert.Equal(t, 0.0, *r.RecurringMonthlyCost) + + details, ok := r.Details.(common.DataWarehouseDetails) + require.True(t, ok, "Details should be DataWarehouseDetails") + assert.Equal(t, "DW1000c", details.NodeType) + assert.Equal(t, "dedicated-sql-pool", details.ClusterType) +} + +func TestGetRecommendations_multiPage(t *testing.T) { + c := newTestClient() + + rec1 := mocks.BuildLegacyReservationRecommendation( + mocks.WithNormalizedSize("DW500c"), + mocks.WithQuantity(1), + ) + rec2 := mocks.BuildLegacyReservationRecommendation( + mocks.WithNormalizedSize("DW2000c"), + mocks.WithQuantity(3), + ) + + c.SetRecommendationsPager(&fakeRecommendationsPager{ + pages: []armconsumption.ReservationRecommendationsClientListResponse{ + { + ReservationRecommendationsListResult: armconsumption.ReservationRecommendationsListResult{ + Value: []armconsumption.ReservationRecommendationClassification{rec1}, + }, + }, + { + ReservationRecommendationsListResult: armconsumption.ReservationRecommendationsListResult{ + Value: []armconsumption.ReservationRecommendationClassification{rec2}, + }, + }, + }, + }) + + recs, err := c.GetRecommendations(context.Background(), common.RecommendationParams{}) + require.NoError(t, err) + assert.Len(t, recs, 2) +} + +func TestGetRecommendations_pagerError(t *testing.T) { + c := newTestClient() + c.SetRecommendationsPager(&errorRecommendationsPager{}) + + _, err := c.GetRecommendations(context.Background(), common.RecommendationParams{}) + assert.Error(t, err) +} + +func TestGetRecommendations_regionFilter(t *testing.T) { + c := newTestClient() // region = "eastus" + + // One rec in "eastus", one in "westus"; only the matching one should survive. + recMatch := mocks.BuildLegacyReservationRecommendation( + mocks.WithRegion("eastus"), + mocks.WithNormalizedSize("DW500c"), + mocks.WithQuantity(1), + ) + recOther := mocks.BuildLegacyReservationRecommendation( + mocks.WithRegion("westus"), + mocks.WithNormalizedSize("DW1000c"), + mocks.WithQuantity(2), + ) + + c.SetRecommendationsPager(&fakeRecommendationsPager{ + pages: []armconsumption.ReservationRecommendationsClientListResponse{ + { + ReservationRecommendationsListResult: armconsumption.ReservationRecommendationsListResult{ + Value: []armconsumption.ReservationRecommendationClassification{recMatch, recOther}, + }, + }, + }, + }) + + recs, err := c.GetRecommendations(context.Background(), common.RecommendationParams{}) + require.NoError(t, err) + require.Len(t, recs, 1) + assert.Equal(t, "DW500c", recs[0].ResourceType) + assert.Equal(t, "eastus", recs[0].Region) +} + +func TestGetRecommendations_modernShape(t *testing.T) { + c := newTestClient() // region = "eastus" + + azRec := mocks.BuildModernReservationRecommendation( + mocks.WithModernRegion("eastus"), + mocks.WithModernSKUName("DW2000c"), + mocks.WithModernQuantity(3), + ) + + c.SetRecommendationsPager(&fakeRecommendationsPager{ + pages: []armconsumption.ReservationRecommendationsClientListResponse{ + { + ReservationRecommendationsListResult: armconsumption.ReservationRecommendationsListResult{ + Value: []armconsumption.ReservationRecommendationClassification{azRec}, + }, + }, + }, + }) + + recs, err := c.GetRecommendations(context.Background(), common.RecommendationParams{}) + require.NoError(t, err) + require.Len(t, recs, 1) + assert.Equal(t, "DW2000c", recs[0].ResourceType) + assert.Equal(t, common.ProviderAzure, recs[0].Provider) + assert.Equal(t, common.ServiceDataWarehouse, recs[0].Service) + assert.Equal(t, "eastus", recs[0].Region) +} + +// ---- GetExistingCommitments ----------------------------------------------- + +func TestGetExistingCommitments_empty(t *testing.T) { + c := newTestClient() + c.SetReservationsPager(&fakeReservationsPager{}) + + commitments, err := c.GetExistingCommitments(context.Background()) + require.NoError(t, err) + assert.Empty(t, commitments) +} + +func TestGetExistingCommitments_synapseSKU(t *testing.T) { + c := newTestClient() + + skuName := "DW1000c" + reservationID := "synapse-res-123" + c.SetReservationsPager(&fakeReservationsPager{ + pages: []armconsumption.ReservationsDetailsClientListResponse{ + { + ReservationDetailsListResult: armconsumption.ReservationDetailsListResult{ + Value: []*armconsumption.ReservationDetail{ + { + Properties: &armconsumption.ReservationDetailProperties{ + SKUName: &skuName, + ReservationID: &reservationID, + }, + }, + }, + }, + }, + }, + }) + + commitments, err := c.GetExistingCommitments(context.Background()) + require.NoError(t, err) + require.Len(t, commitments, 1) + assert.Equal(t, common.ServiceDataWarehouse, commitments[0].Service) + assert.Equal(t, "DW1000c", commitments[0].ResourceType) + assert.Equal(t, "synapse-res-123", commitments[0].CommitmentID) + assert.Equal(t, common.CommitmentReservedInstance, commitments[0].CommitmentType) + assert.Equal(t, common.ProviderAzure, commitments[0].Provider) +} + +func TestGetExistingCommitments_filterNonSynapse(t *testing.T) { + c := newTestClient() + + vmSKU := "Standard_D2s_v3" + c.SetReservationsPager(&fakeReservationsPager{ + pages: []armconsumption.ReservationsDetailsClientListResponse{ + { + ReservationDetailsListResult: armconsumption.ReservationDetailsListResult{ + Value: []*armconsumption.ReservationDetail{ + { + Properties: &armconsumption.ReservationDetailProperties{ + SKUName: &vmSKU, + }, + }, + }, + }, + }, + }, + }) + + commitments, err := c.GetExistingCommitments(context.Background()) + require.NoError(t, err) + assert.Empty(t, commitments, "non-Synapse SKU should be filtered out") +} + +func TestGetExistingCommitments_pagerError(t *testing.T) { + c := newTestClient() + c.SetReservationsPager(&fakeReservationsPager{ + pages: []armconsumption.ReservationsDetailsClientListResponse{{}}, + err: errors.New("pager error"), + }) + + _, err := c.GetExistingCommitments(context.Background()) + assert.Error(t, err) +} + +// ---- ValidateOffering ----------------------------------------------------- + +func TestValidateOffering_valid(t *testing.T) { + c := newTestClient() + rec := common.Recommendation{ResourceType: "DW1000c"} + err := c.ValidateOffering(context.Background(), rec) + assert.NoError(t, err) +} + +func TestValidateOffering_invalid(t *testing.T) { + c := newTestClient() + rec := common.Recommendation{ResourceType: "not-a-synapse-sku"} + err := c.ValidateOffering(context.Background(), rec) + assert.Error(t, err) +} + +func TestValidateOffering_caseInsensitive(t *testing.T) { + c := newTestClient() + // Lowercase variant of DW1000c should validate. + rec := common.Recommendation{ResourceType: "dw1000c"} + err := c.ValidateOffering(context.Background(), rec) + assert.NoError(t, err) +} + +func TestValidateOffering_trimsWhitespace(t *testing.T) { + c := newTestClient() + rec := common.Recommendation{ResourceType: " DW1000c "} + err := c.ValidateOffering(context.Background(), rec) + assert.NoError(t, err) +} + +// ---- GetOfferingDetails --------------------------------------------------- + +const sampleSynapsePricingJSON = `{ + "Items": [ + { + "currencyCode": "USD", + "retailPrice": 0.50, + "unitPrice": 0.50, + "armRegionName": "eastus", + "type": "Consumption", + "skuName": "DW100c" + }, + { + "currencyCode": "USD", + "retailPrice": 2000.0, + "armRegionName": "eastus", + "reservationTerm": "1 Year", + "type": "Reservation", + "skuName": "DW100c" + } + ], + "NextPageLink": "", + "Count": 2 +}` + +func TestGetOfferingDetails_upfront(t *testing.T) { + mHTTP := &mockHTTPClient{} + mHTTP.On("Do", mock.Anything).Return(newHTTPResponse(http.StatusOK, sampleSynapsePricingJSON), nil) + + c := &SynapseClient{subscriptionID: "sub-123", region: "eastus", httpClient: mHTTP} + + rec := common.Recommendation{ResourceType: "DW100c", Term: "1yr", PaymentOption: "upfront"} + details, err := c.GetOfferingDetails(context.Background(), rec) + require.NoError(t, err) + assert.Equal(t, "DW100c", details.ResourceType) + assert.Equal(t, "1yr", details.Term) + assert.InDelta(t, 2000.0, details.UpfrontCost, 0.01) + assert.Equal(t, 0.0, details.RecurringCost) + assert.Equal(t, "USD", details.Currency) +} + +func TestGetOfferingDetails_monthly(t *testing.T) { + mHTTP := &mockHTTPClient{} + mHTTP.On("Do", mock.Anything).Return(newHTTPResponse(http.StatusOK, sampleSynapsePricingJSON), nil) + + c := &SynapseClient{subscriptionID: "sub-123", region: "eastus", httpClient: mHTTP} + + rec := common.Recommendation{ResourceType: "DW100c", Term: "1yr", PaymentOption: "monthly"} + details, err := c.GetOfferingDetails(context.Background(), rec) + require.NoError(t, err) + assert.Equal(t, 0.0, details.UpfrontCost) + assert.InDelta(t, 2000.0/12.0, details.RecurringCost, 0.01) +} + +func TestGetOfferingDetails_httpError(t *testing.T) { + mHTTP := &mockHTTPClient{} + mHTTP.On("Do", mock.Anything).Return(nil, errors.New("network error")) + + c := &SynapseClient{subscriptionID: "sub-123", region: "eastus", httpClient: mHTTP} + + rec := common.Recommendation{ResourceType: "DW100c", Term: "1yr"} + _, err := c.GetOfferingDetails(context.Background(), rec) + assert.Error(t, err) +} + +// ---- PurchaseCommitment --------------------------------------------------- + +func TestPurchaseCommitment_success(t *testing.T) { + mHTTP := &mockHTTPClient{} + mHTTP.On("Do", mock.Anything).Return(newHTTPResponse(http.StatusOK, `{"id":"res-123"}`), nil) + + cred := &mockTokenCredential{token: "test-token"} + c := NewClientWithHTTP(cred, "sub-123", "eastus", mHTTP) + + rec := common.Recommendation{ + ResourceType: "DW1000c", + Term: "1yr", + Count: 1, + CommitmentCost: 5000.0, + } + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{}) + require.NoError(t, err) + assert.True(t, result.Success) + assert.NotEmpty(t, result.CommitmentID) + assert.InDelta(t, 5000.0, result.Cost, 0.01) +} + +func TestPurchaseCommitment_3yrTerm(t *testing.T) { + mHTTP := &mockHTTPClient{} + mHTTP.On("Do", mock.Anything).Return(newHTTPResponse(http.StatusAccepted, `{}`), nil) + + cred := &mockTokenCredential{token: "test-token"} + c := NewClientWithHTTP(cred, "sub-123", "eastus", mHTTP) + + rec := common.Recommendation{ResourceType: "DW500c", Term: "3yr", Count: 2, CommitmentCost: 9000.0} + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{}) + require.NoError(t, err) + assert.True(t, result.Success) +} + +func TestPurchaseCommitment_withSource(t *testing.T) { + capHTTP := &captureHTTPClient{response: newHTTPResponse(http.StatusCreated, `{}`)} + cred := &mockTokenCredential{token: "test-token"} + c := NewClientWithHTTP(cred, "sub-123", "eastus", capHTTP) + + rec := common.Recommendation{ResourceType: "DW500c", Term: "1yr", Count: 1} + _, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{Source: "automation"}) + require.NoError(t, err) + assert.Contains(t, string(capHTTP.captured), "purchase-automation") + assert.Contains(t, string(capHTTP.captured), "automation") +} + +func TestPurchaseCommitment_apiError(t *testing.T) { + mHTTP := &mockHTTPClient{} + mHTTP.On("Do", mock.Anything).Return( + newHTTPResponse(http.StatusBadRequest, `{"error":"bad request"}`), nil) + + cred := &mockTokenCredential{token: "test-token"} + c := NewClientWithHTTP(cred, "sub-123", "eastus", mHTTP) + + rec := common.Recommendation{ResourceType: "DW1000c", Term: "1yr", Count: 1} + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{}) + require.Error(t, err) + assert.False(t, result.Success) +} + +func TestPurchaseCommitment_tokenError(t *testing.T) { + cred := &mockTokenCredential{err: errors.New("token error")} + c := NewClientWithHTTP(cred, "sub-123", "eastus", nil) + + rec := common.Recommendation{ResourceType: "DW1000c", Term: "1yr", Count: 1} + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{}) + require.Error(t, err) + assert.False(t, result.Success) +} + +// ---- convertSynapseReservation -------------------------------------------- + +func TestConvertSynapseReservation_nil(t *testing.T) { + c := newTestClient() + assert.Nil(t, c.convertSynapseReservation(nil)) +} + +func TestConvertSynapseReservation_nilProperties(t *testing.T) { + c := newTestClient() + assert.Nil(t, c.convertSynapseReservation(&armconsumption.ReservationDetail{})) +} + +func TestConvertSynapseReservation_nonSynapseSKU(t *testing.T) { + c := newTestClient() + sku := "Premium_P1" + assert.Nil(t, c.convertSynapseReservation(&armconsumption.ReservationDetail{ + Properties: &armconsumption.ReservationDetailProperties{SKUName: &sku}, + })) +} + +func TestConvertSynapseReservation_dwSKU(t *testing.T) { + c := newTestClient() + sku := "DW3000c" + resID := "res-abc" + commitment := c.convertSynapseReservation(&armconsumption.ReservationDetail{ + Properties: &armconsumption.ReservationDetailProperties{ + SKUName: &sku, + ReservationID: &resID, + }, + }) + require.NotNil(t, commitment) + assert.Equal(t, "DW3000c", commitment.ResourceType) + assert.Equal(t, "res-abc", commitment.CommitmentID) + assert.Equal(t, common.ServiceDataWarehouse, commitment.Service) +} + +func TestConvertSynapseReservation_scuPrefix(t *testing.T) { + c := newTestClient() + // SCU prefix (Spark Compute Units) should be classified as Synapse. + sku := "SCU_Standard" + resID := "res-scu" + commitment := c.convertSynapseReservation(&armconsumption.ReservationDetail{ + Properties: &armconsumption.ReservationDetailProperties{ + SKUName: &sku, + ReservationID: &resID, + }, + }) + require.NotNil(t, commitment) + assert.Equal(t, "SCU_Standard", commitment.ResourceType) +} + +func TestConvertSynapseReservation_scuSubstringNotMatched(t *testing.T) { + c := newTestClient() + // Non-Synapse SKU that merely contains "scu" as a substring must not + // be misclassified as a Synapse reservation. Guards against the prior + // substring-match false-positive. + sku := "rescue_premium" + assert.Nil(t, c.convertSynapseReservation(&armconsumption.ReservationDetail{ + Properties: &armconsumption.ReservationDetailProperties{SKUName: &sku}, + })) +} + +func TestConvertSynapseReservation_nilSKUName(t *testing.T) { + c := newTestClient() + assert.Nil(t, c.convertSynapseReservation(&armconsumption.ReservationDetail{ + Properties: &armconsumption.ReservationDetailProperties{}, + })) +} + +// ---- extractSynapsePricing ------------------------------------------------ + +func TestExtractSynapsePricing_1yr(t *testing.T) { + items := []SynapseRetailPriceItem{ + {CurrencyCode: "USD", RetailPrice: 0.5, Type: "Consumption"}, + {CurrencyCode: "USD", RetailPrice: 2000.0, ReservationTerm: "1 Year", Type: "Reservation"}, + {CurrencyCode: "USD", RetailPrice: 3500.0, ReservationTerm: "3 Years", Type: "Reservation"}, + } + onDemand, reservation, currency := extractSynapsePricing(items, 1) + assert.InDelta(t, 0.5, onDemand, 0.01) + assert.InDelta(t, 2000.0, reservation, 0.01) + assert.Equal(t, "USD", currency) +} + +func TestExtractSynapsePricing_3yr(t *testing.T) { + items := []SynapseRetailPriceItem{ + {CurrencyCode: "USD", RetailPrice: 0.5, Type: "Consumption"}, + {CurrencyCode: "USD", RetailPrice: 2000.0, ReservationTerm: "1 Year", Type: "Reservation"}, + {CurrencyCode: "USD", RetailPrice: 3500.0, ReservationTerm: "3 Years", Type: "Reservation"}, + } + onDemand, reservation, currency := extractSynapsePricing(items, 3) + assert.InDelta(t, 0.5, onDemand, 0.01) + assert.InDelta(t, 3500.0, reservation, 0.01) + assert.Equal(t, "USD", currency) +} + +func TestExtractSynapsePricing_noReservation(t *testing.T) { + items := []SynapseRetailPriceItem{ + {CurrencyCode: "USD", RetailPrice: 0.5, Type: "Consumption"}, + } + onDemand, reservation, currency := extractSynapsePricing(items, 1) + assert.InDelta(t, 0.5, onDemand, 0.01) + assert.Equal(t, 0.0, reservation) + assert.Equal(t, "USD", currency) +} + +// ---- parseReservationTermYears -------------------------------------------- + +func TestParseReservationTermYears(t *testing.T) { + tests := []struct { + term string + want int + wantErr bool + }{ + {"", 1, false}, + {"1", 1, false}, + {"1yr", 1, false}, + {"1y", 1, false}, + {"1YR", 1, false}, + {"3", 3, false}, + {"3yr", 3, false}, + {"3y", 3, false}, + {"3YR", 3, false}, + {"2yr", 0, true}, + {"5yr", 0, true}, + {"P1Y", 0, true}, + {"bogus", 0, true}, + } + for _, tc := range tests { + got, err := parseReservationTermYears(tc.term) + if tc.wantErr { + assert.Error(t, err, "term=%q should be an error", tc.term) + } else { + require.NoError(t, err, "term=%q should not error", tc.term) + assert.Equal(t, tc.want, got, "term=%q", tc.term) + } + } +} + +// ---- PurchaseCommitment input validation ---------------------------------- + +func TestPurchaseCommitment_emptyResourceType(t *testing.T) { + cred := &mockTokenCredential{token: "tok"} + c := NewClientWithHTTP(cred, "sub-123", "eastus", nil) + rec := common.Recommendation{ResourceType: "", Term: "1yr", Count: 1} + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{}) + require.Error(t, err) + assert.False(t, result.Success) + assert.Contains(t, err.Error(), "resource type is required") +} + +func TestPurchaseCommitment_zeroCount(t *testing.T) { + cred := &mockTokenCredential{token: "tok"} + c := NewClientWithHTTP(cred, "sub-123", "eastus", nil) + rec := common.Recommendation{ResourceType: "DW1000c", Term: "1yr", Count: 0} + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{}) + require.Error(t, err) + assert.False(t, result.Success) + assert.Contains(t, err.Error(), "quantity must be greater than zero") +} + +func TestPurchaseCommitment_negativeCount(t *testing.T) { + cred := &mockTokenCredential{token: "tok"} + c := NewClientWithHTTP(cred, "sub-123", "eastus", nil) + rec := common.Recommendation{ResourceType: "DW1000c", Term: "1yr", Count: -1} + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{}) + require.Error(t, err) + assert.False(t, result.Success) +} + +func TestPurchaseCommitment_unsupportedTerm(t *testing.T) { + cred := &mockTokenCredential{token: "tok"} + c := NewClientWithHTTP(cred, "sub-123", "eastus", nil) + rec := common.Recommendation{ResourceType: "DW1000c", Term: "5yr", Count: 1} + result, err := c.PurchaseCommitment(context.Background(), rec, common.PurchaseOptions{}) + require.Error(t, err) + assert.False(t, result.Success) + assert.Contains(t, err.Error(), "unsupported reservation term") +} + +// ---- GetOfferingDetails: no reservation price ---------------------------- + +func TestGetOfferingDetails_noReservationPrice(t *testing.T) { + onDemandOnlyJSON := `{ + "Items": [ + { + "currencyCode": "USD", + "retailPrice": 0.50, + "unitPrice": 0.50, + "armRegionName": "eastus", + "type": "Consumption", + "skuName": "DW100c" + } + ], + "NextPageLink": "", + "Count": 1 + }` + mHTTP := &mockHTTPClient{} + mHTTP.On("Do", mock.Anything).Return(newHTTPResponse(http.StatusOK, onDemandOnlyJSON), nil) + c := &SynapseClient{subscriptionID: "sub-123", region: "eastus", httpClient: mHTTP} + rec := common.Recommendation{ResourceType: "DW100c", Term: "1yr"} + _, err := c.GetOfferingDetails(context.Background(), rec) + require.Error(t, err) + assert.Contains(t, err.Error(), "pricing data unavailable") +} + +// ---- applyPurchaseAutomationTag ------------------------------------------- + +func TestApplyPurchaseAutomationTag_withSource(t *testing.T) { + body := map[string]interface{}{} + applyPurchaseAutomationTag(body, "api") + tags, ok := body["tags"].(map[string]string) + require.True(t, ok) + assert.Equal(t, "api", tags[common.PurchaseTagKey]) +} + +func TestApplyPurchaseAutomationTag_emptySource(t *testing.T) { + body := map[string]interface{}{} + applyPurchaseAutomationTag(body, "") + _, ok := body["tags"] + assert.False(t, ok) +}