|
|
@@ -1,11 +1,13 @@
|
|
|
package digitalocean
|
|
|
|
|
|
import (
|
|
|
- "net/http"
|
|
|
- "net/http/httptest"
|
|
|
+ "context"
|
|
|
+ "encoding/json"
|
|
|
+ "fmt"
|
|
|
"os"
|
|
|
"testing"
|
|
|
|
|
|
+ "github.com/digitalocean/godo"
|
|
|
"github.com/opencost/opencost/pkg/cloud/models"
|
|
|
)
|
|
|
|
|
|
@@ -17,20 +19,79 @@ func newTestProviderWithFile(t *testing.T, filename string) (*DOKS, func() int)
|
|
|
t.Fatalf("Failed to read file: %v", err)
|
|
|
}
|
|
|
|
|
|
+ // Parse the JSON data to get sizes
|
|
|
+ var response DOResponse
|
|
|
+ if err := json.Unmarshal(data, &response); err != nil {
|
|
|
+ t.Fatalf("Failed to parse JSON: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
// Set a fake token for testing
|
|
|
t.Setenv("DIGITALOCEAN_ACCESS_TOKEN", "test_token_dop_v1_fake")
|
|
|
|
|
|
- var count int
|
|
|
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
- count++
|
|
|
- w.Header().Set("Content-Type", "application/json")
|
|
|
- _, _ = w.Write(data)
|
|
|
- }))
|
|
|
+ // Convert DOSize to godo.Size for mock
|
|
|
+ var godoSizes []godo.Size
|
|
|
+ for _, doSize := range response.Sizes {
|
|
|
+ godoSize := godo.Size{
|
|
|
+ Slug: doSize.Slug,
|
|
|
+ Memory: doSize.Memory,
|
|
|
+ Vcpus: doSize.VCPUs,
|
|
|
+ Disk: doSize.Disk,
|
|
|
+ Transfer: doSize.Transfer,
|
|
|
+ PriceMonthly: doSize.PriceMonthly,
|
|
|
+ PriceHourly: doSize.PriceHourly,
|
|
|
+ Regions: doSize.Regions,
|
|
|
+ Available: doSize.Available,
|
|
|
+ Description: doSize.Description,
|
|
|
+ }
|
|
|
+ // Convert GPU info if present
|
|
|
+ if doSize.GPUInfo.Count > 0 {
|
|
|
+ godoSize.GPUInfo = &godo.GPUInfo{
|
|
|
+ Count: doSize.GPUInfo.Count,
|
|
|
+ Model: doSize.GPUInfo.Model,
|
|
|
+ VRAM: &godo.VRAM{
|
|
|
+ Amount: doSize.GPUInfo.VRAM.Amount,
|
|
|
+ Unit: doSize.GPUInfo.VRAM.Unit,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ }
|
|
|
+ godoSizes = append(godoSizes, godoSize)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Create a mock godo client with all sizes on a single page
|
|
|
+ var callCount int
|
|
|
+ mockService := &testMockSizesService{
|
|
|
+ sizes: godoSizes,
|
|
|
+ callCount: &callCount,
|
|
|
+ }
|
|
|
+
|
|
|
+ provider := &DOKS{
|
|
|
+ PricingURL: "https://api.digitalocean.com/v2/sizes",
|
|
|
+ Cache: &PricingCache{},
|
|
|
+ Sizes: make(map[string]*DOSize),
|
|
|
+ client: &godo.Client{Sizes: mockService},
|
|
|
+ }
|
|
|
+
|
|
|
+ return provider, func() int { return callCount }
|
|
|
+}
|
|
|
+
|
|
|
+// testMockSizesService is a simple mock that returns all sizes on a single page
|
|
|
+type testMockSizesService struct {
|
|
|
+ sizes []godo.Size
|
|
|
+ callCount *int
|
|
|
+}
|
|
|
|
|
|
- t.Cleanup(server.Close)
|
|
|
+func (m *testMockSizesService) List(ctx context.Context, opt *godo.ListOptions) ([]godo.Size, *godo.Response, error) {
|
|
|
+ *m.callCount++
|
|
|
+ // Return all sizes on page 1 (no pagination for simple tests)
|
|
|
+ return m.sizes, &godo.Response{
|
|
|
+ Links: &godo.Links{
|
|
|
+ Pages: &godo.Pages{},
|
|
|
+ },
|
|
|
+ }, nil
|
|
|
+}
|
|
|
|
|
|
- provider := NewDOKSProvider(server.URL)
|
|
|
- return provider, func() int { return count }
|
|
|
+func (m *testMockSizesService) GetStorage(ctx context.Context, slug string) (*godo.Size, *godo.Response, error) {
|
|
|
+ return nil, nil, nil
|
|
|
}
|
|
|
|
|
|
func newTestProviderWith404(t *testing.T) *DOKS {
|
|
|
@@ -39,16 +100,30 @@ func newTestProviderWith404(t *testing.T) *DOKS {
|
|
|
// Set a fake token for testing
|
|
|
t.Setenv("DIGITALOCEAN_ACCESS_TOKEN", "test_token_dop_v1_fake")
|
|
|
|
|
|
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
- w.WriteHeader(http.StatusNotFound)
|
|
|
- }))
|
|
|
+ // Create a mock service that returns an error
|
|
|
+ errorService := &testErrorSizesService{}
|
|
|
|
|
|
- t.Cleanup(server.Close)
|
|
|
+ provider := &DOKS{
|
|
|
+ PricingURL: "https://api.digitalocean.com/v2/sizes",
|
|
|
+ Cache: &PricingCache{},
|
|
|
+ Sizes: make(map[string]*DOSize),
|
|
|
+ client: &godo.Client{Sizes: errorService},
|
|
|
+ }
|
|
|
|
|
|
- provider := NewDOKSProvider(server.URL)
|
|
|
return provider
|
|
|
}
|
|
|
|
|
|
+// testErrorSizesService returns an error for testing error handling
|
|
|
+type testErrorSizesService struct{}
|
|
|
+
|
|
|
+func (m *testErrorSizesService) List(ctx context.Context, opt *godo.ListOptions) ([]godo.Size, *godo.Response, error) {
|
|
|
+ return nil, nil, fmt.Errorf("API error: 404 Not Found")
|
|
|
+}
|
|
|
+
|
|
|
+func (m *testErrorSizesService) GetStorage(ctx context.Context, slug string) (*godo.Size, *godo.Response, error) {
|
|
|
+ return nil, nil, nil
|
|
|
+}
|
|
|
+
|
|
|
func TestNodePricing_APIMatches(t *testing.T) {
|
|
|
provider, callCount := newTestProviderWithFile(t, "testdata/do_pricing.json")
|
|
|
|
|
|
@@ -621,3 +696,223 @@ func TestNodePricing_GPU(t *testing.T) {
|
|
|
t.Errorf("expected 1 API call, got %d", c)
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+// mockSizesService implements the godo.SizesService interface for testing pagination
|
|
|
+type mockSizesService struct {
|
|
|
+ pages [][]godo.Size
|
|
|
+}
|
|
|
+
|
|
|
+func (m *mockSizesService) List(ctx context.Context, opt *godo.ListOptions) ([]godo.Size, *godo.Response, error) {
|
|
|
+ if opt == nil {
|
|
|
+ opt = &godo.ListOptions{}
|
|
|
+ }
|
|
|
+
|
|
|
+ // Pages are 1-indexed in godo
|
|
|
+ page := opt.Page
|
|
|
+ if page == 0 {
|
|
|
+ page = 1
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if page is within range
|
|
|
+ if page > len(m.pages) {
|
|
|
+ // Return last page indicator
|
|
|
+ return []godo.Size{}, &godo.Response{
|
|
|
+ Links: &godo.Links{
|
|
|
+ Pages: &godo.Pages{}, // No Next link = last page
|
|
|
+ },
|
|
|
+ }, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ sizes := m.pages[page-1]
|
|
|
+
|
|
|
+ // Create response with pagination links
|
|
|
+ // godo.Pages has: First, Last, Next, Prev
|
|
|
+ resp := &godo.Response{
|
|
|
+ Links: &godo.Links{
|
|
|
+ Pages: &godo.Pages{
|
|
|
+ // Set First link (required for CurrentPage to parse)
|
|
|
+ First: "https://api.digitalocean.com/v2/sizes?page=1&per_page=20",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ // Set Last link - always set for godo to work
|
|
|
+ resp.Links.Pages.Last = fmt.Sprintf("https://api.digitalocean.com/v2/sizes?page=%d&per_page=20", len(m.pages))
|
|
|
+
|
|
|
+ // Set Next link if not on the last page
|
|
|
+ if page < len(m.pages) {
|
|
|
+ resp.Links.Pages.Next = fmt.Sprintf("https://api.digitalocean.com/v2/sizes?page=%d&per_page=20", page+1)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Set Prev link if not on the first page
|
|
|
+ if page > 1 {
|
|
|
+ resp.Links.Pages.Prev = fmt.Sprintf("https://api.digitalocean.com/v2/sizes?page=%d&per_page=20", page-1)
|
|
|
+ }
|
|
|
+
|
|
|
+ return sizes, resp, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (m *mockSizesService) GetStorage(ctx context.Context, slug string) (*godo.Size, *godo.Response, error) {
|
|
|
+ return nil, nil, nil
|
|
|
+}
|
|
|
+
|
|
|
+// createMockGodoClient creates a godo client with a mock SizesService for pagination testing
|
|
|
+func createMockGodoClient(t *testing.T) *godo.Client {
|
|
|
+ t.Helper()
|
|
|
+
|
|
|
+ page1 := []godo.Size{
|
|
|
+ {
|
|
|
+ Slug: "s-1vcpu-2gb",
|
|
|
+ Memory: 2048,
|
|
|
+ Vcpus: 1,
|
|
|
+ Disk: 50,
|
|
|
+ PriceHourly: 0.01786,
|
|
|
+ Available: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ Slug: "s-2vcpu-4gb",
|
|
|
+ Memory: 4096,
|
|
|
+ Vcpus: 2,
|
|
|
+ Disk: 80,
|
|
|
+ PriceHourly: 0.03571,
|
|
|
+ Available: true,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ page2 := []godo.Size{
|
|
|
+ {
|
|
|
+ Slug: "m-4vcpu-32gb",
|
|
|
+ Memory: 32768,
|
|
|
+ Vcpus: 4,
|
|
|
+ Disk: 160,
|
|
|
+ PriceHourly: 0.25000,
|
|
|
+ Available: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ Slug: "m-8vcpu-64gb",
|
|
|
+ Memory: 65536,
|
|
|
+ Vcpus: 8,
|
|
|
+ Disk: 320,
|
|
|
+ PriceHourly: 0.50000,
|
|
|
+ Available: true,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ page3 := []godo.Size{
|
|
|
+ {
|
|
|
+ Slug: "c-8-intel",
|
|
|
+ Memory: 16384,
|
|
|
+ Vcpus: 8,
|
|
|
+ Disk: 160,
|
|
|
+ PriceHourly: 0.32440,
|
|
|
+ Available: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ Slug: "c-16-intel",
|
|
|
+ Memory: 32768,
|
|
|
+ Vcpus: 16,
|
|
|
+ Disk: 320,
|
|
|
+ PriceHourly: 0.64880,
|
|
|
+ Available: true,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ // Create a client (we'll replace its Sizes service)
|
|
|
+ client := godo.NewFromToken("test_token")
|
|
|
+
|
|
|
+ // Replace the Sizes service with our mock
|
|
|
+ client.Sizes = &mockSizesService{
|
|
|
+ pages: [][]godo.Size{page1, page2, page3},
|
|
|
+ }
|
|
|
+
|
|
|
+ return client
|
|
|
+}
|
|
|
+
|
|
|
+// TestFetchPricingData_Pagination verifies that pagination is correctly handled when fetching sizes
|
|
|
+func TestFetchPricingData_Pagination(t *testing.T) {
|
|
|
+ t.Setenv("DIGITALOCEAN_ACCESS_TOKEN", "test_token_dop_v1_fake")
|
|
|
+
|
|
|
+ // Create a provider with a mock client that simulates pagination
|
|
|
+ provider := &DOKS{
|
|
|
+ PricingURL: "https://api.digitalocean.com/v2/sizes",
|
|
|
+ Cache: &PricingCache{},
|
|
|
+ Sizes: make(map[string]*DOSize),
|
|
|
+ client: createMockGodoClient(t),
|
|
|
+ }
|
|
|
+
|
|
|
+ // Fetch pricing data which triggers pagination
|
|
|
+ response, err := provider.fetchPricingData()
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("expected no error, got: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if response == nil {
|
|
|
+ t.Fatal("expected non-nil response")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify that all sizes from all pages were collected
|
|
|
+ expectedSizes := map[string]bool{
|
|
|
+ "s-1vcpu-2gb": true,
|
|
|
+ "s-2vcpu-4gb": true,
|
|
|
+ "m-4vcpu-32gb": true,
|
|
|
+ "m-8vcpu-64gb": true,
|
|
|
+ "c-8-intel": true,
|
|
|
+ "c-16-intel": true,
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check that all expected sizes are in the provider's sizes map
|
|
|
+ for slug := range expectedSizes {
|
|
|
+ if _, exists := provider.Sizes[slug]; !exists {
|
|
|
+ t.Errorf("expected size %q to be indexed, but it was not", slug)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify the total count
|
|
|
+ if len(provider.Sizes) != len(expectedSizes) {
|
|
|
+ t.Errorf("expected %d sizes, got %d", len(expectedSizes), len(provider.Sizes))
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify specific size details
|
|
|
+ testCases := []struct {
|
|
|
+ slug string
|
|
|
+ expectedVCPUs int
|
|
|
+ expectedMemory int
|
|
|
+ }{
|
|
|
+ {"s-1vcpu-2gb", 1, 2048},
|
|
|
+ {"s-2vcpu-4gb", 2, 4096},
|
|
|
+ {"m-4vcpu-32gb", 4, 32768},
|
|
|
+ {"m-8vcpu-64gb", 8, 65536},
|
|
|
+ {"c-8-intel", 8, 16384},
|
|
|
+ {"c-16-intel", 16, 32768},
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tc := range testCases {
|
|
|
+ size, exists := provider.Sizes[tc.slug]
|
|
|
+ if !exists {
|
|
|
+ t.Fatalf("expected size %q to exist", tc.slug)
|
|
|
+ }
|
|
|
+
|
|
|
+ if size.VCPUs != tc.expectedVCPUs {
|
|
|
+ t.Errorf("size %q: expected %d vCPUs, got %d", tc.slug, tc.expectedVCPUs, size.VCPUs)
|
|
|
+ }
|
|
|
+
|
|
|
+ if size.Memory != tc.expectedMemory {
|
|
|
+ t.Errorf("size %q: expected %d MB memory, got %d", tc.slug, tc.expectedMemory, size.Memory)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify caching works (second fetch should use cached data)
|
|
|
+ response2, err := provider.fetchPricingData()
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("expected no error on second fetch, got: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if response2 == nil {
|
|
|
+ t.Fatal("expected non-nil response on second fetch")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify cache timestamp was updated
|
|
|
+ if provider.Cache.lastUpdate.IsZero() {
|
|
|
+ t.Error("expected cache to have a non-zero timestamp")
|
|
|
+ }
|
|
|
+}
|