Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go.mod'
cache: true

- name: Install dependencies
Expand All @@ -47,12 +47,12 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22.10.0'
cache: 'npm'

- name: Install dependencies
Expand Down
75 changes: 75 additions & 0 deletions oracle/oracle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)

func TestOracleSetGetAndCopySemantics(t *testing.T) {
o := NewOracle()
o.SetPrice("node-1", 0.05, "mock")

entry, ok := o.GetPrice("node-1")
if !ok {
t.Fatal("expected price entry")
}
entry.Price = 999

entry2, ok := o.GetPrice("node-1")
if !ok {
t.Fatal("expected price entry")
}
if entry2.Price == 999 {
t.Fatal("expected GetPrice to return a copy")
}
}

func TestHandleGetPriceFoundAndNotFound(t *testing.T) {
o := NewOracle()
o.SetPrice("node-1", 0.05, "mock")

foundReq := httptest.NewRequest(http.MethodGet, "/price/node-1", nil)
foundReq.SetPathValue("providerID", "node-1")
foundRec := httptest.NewRecorder()
o.HandleGetPrice(foundRec, foundReq)
if foundRec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", foundRec.Code)
}

var payload PriceEntry
if err := json.Unmarshal(foundRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.ProviderID != "node-1" {
t.Fatalf("expected provider id node-1, got %s", payload.ProviderID)
}

missReq := httptest.NewRequest(http.MethodGet, "/price/missing", nil)
missReq.SetPathValue("providerID", "missing")
missRec := httptest.NewRecorder()
o.HandleGetPrice(missRec, missReq)
if missRec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", missRec.Code)
}
}

func TestHandleGetAllPricesReturnsArray(t *testing.T) {
o := NewOracle()

req := httptest.NewRequest(http.MethodGet, "/prices", nil)
rec := httptest.NewRecorder()
o.HandleGetAllPrices(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}

var entries []PriceEntry
if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(entries) != 0 {
t.Fatalf("expected empty array, got %d entries", len(entries))
}
Comment on lines +68 to +74
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't validate the "array not null" behavior: unmarshalling null into a slice results in nil with len==0, so the test would pass even if the handler encoded null. To assert the response is an empty array, compare the raw body (trimmed) against [], or unmarshal into *[]PriceEntry and ensure the pointer is non-nil.

Copilot uses AI. Check for mistakes.
}
37 changes: 37 additions & 0 deletions oracle/pricing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package main

import "testing"

func TestFetchAllPricesContainsExpectedProvidersWithPositiveValues(t *testing.T) {
prices := FetchAllPrices()
if len(prices) == 0 {
t.Fatal("expected non-empty price map")
}

required := []string{"aws-t3-medium", "gcp-n1-standard-2", "node-1"}
for _, id := range required {
p, ok := prices[id]
if !ok {
t.Fatalf("expected provider %s in feed", id)
}
if p.PricePerHour <= 0 {
t.Fatalf("expected positive price for %s, got %f", id, p.PricePerHour)
}
if p.Source == "" {
t.Fatalf("expected non-empty source for %s", id)
}
}
}

func TestJitterIsWithinFivePercent(t *testing.T) {
base := 100.0
min := base * 0.95
max := base * 1.05

for i := 0; i < 1000; i++ {
v := jitter(base)
if v < min || v > max {
t.Fatalf("jitter value %f out of bounds [%f, %f]", v, min, max)
}
}
}
14 changes: 14 additions & 0 deletions scheduler/provider_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@ func (pm *ProviderManager) Get(id string) (*Provider, bool) {
return &copy, true
}

// GetCount returns the number of active providers.
func (pm *ProviderManager) GetCount() int {
pm.mu.RLock()
defer pm.mu.RUnlock()

count := 0
for _, p := range pm.providers {
if p.Active {
count++
}
}
return count
}

// UpdateReputation adjusts a provider's reputation score (clamped 0-100).
func (pm *ProviderManager) UpdateReputation(id string, delta int) {
pm.mu.Lock()
Expand Down
115 changes: 115 additions & 0 deletions scheduler/provider_manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestProviderManagerRegisterGetAndCopySemantics(t *testing.T) {
pm := NewProviderManager()
pm.Register(Provider{ID: "p1", CPU: 4, MemoryMB: 8192, PricePerHour: 0.1, Reputation: 80})

if pm.GetCount() != 1 {
t.Fatalf("expected 1 active provider, got %d", pm.GetCount())
}

got, ok := pm.Get("p1")
if !ok {
t.Fatal("expected provider to exist")
}
got.CPU = 999

gotAgain, ok := pm.Get("p1")
if !ok {
t.Fatal("expected provider to exist")
}
if gotAgain.CPU == 999 {
t.Fatal("expected Get to return a copy, not original pointer")
}

active := pm.GetActive()
if len(active) != 1 {
t.Fatalf("expected one active provider, got %d", len(active))
}
active[0].MemoryMB = 1

active2 := pm.GetActive()
if active2[0].MemoryMB == 1 {
t.Fatal("expected GetActive to return copies, not original pointers")
}
}

func TestProviderManagerDeregisterAndUpdateReputationClamp(t *testing.T) {
pm := NewProviderManager()
pm.Register(Provider{ID: "p1", CPU: 2, MemoryMB: 2048, PricePerHour: 0.05, Reputation: 50})

if !pm.Deregister("p1") {
t.Fatal("expected deregister to return true for existing provider")
}
if pm.GetCount() != 0 {
t.Fatalf("expected no active providers, got %d", pm.GetCount())
}
if pm.Deregister("missing") {
t.Fatal("expected deregister to return false for missing provider")
}

pm.UpdateReputation("p1", 1000)
p, _ := pm.Get("p1")
if p.Reputation != 100 {
t.Fatalf("expected reputation clamp to 100, got %d", p.Reputation)
}

pm.UpdateReputation("p1", -1000)
p, _ = pm.Get("p1")
if p.Reputation != 0 {
t.Fatalf("expected reputation clamp to 0, got %d", p.Reputation)
}
}

func TestHandleRegisterProviderValidationAndSuccess(t *testing.T) {
pm := NewProviderManager()

invalidReq := httptest.NewRequest(http.MethodPost, "/providers/register", strings.NewReader(`{"id":"","cpu":0,"memoryMB":0}`))
invalidRec := httptest.NewRecorder()
pm.HandleRegisterProvider(invalidRec, invalidReq)
if invalidRec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid payload, got %d", invalidRec.Code)
}

validReq := httptest.NewRequest(http.MethodPost, "/providers/register", strings.NewReader(`{"id":"p1","cpu":4,"memoryMB":8192,"pricePerHour":0.1}`))
validRec := httptest.NewRecorder()
pm.HandleRegisterProvider(validRec, validReq)
if validRec.Code != http.StatusCreated {
t.Fatalf("expected 201 for valid payload, got %d", validRec.Code)
}

var body map[string]string
if err := json.Unmarshal(validRec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body["id"] != "p1" {
t.Fatalf("expected id p1, got %q", body["id"])
}
}

func TestHandleListProvidersReturnsEmptyArrayNotNull(t *testing.T) {
pm := NewProviderManager()
req := httptest.NewRequest(http.MethodGet, "/providers", nil)
rec := httptest.NewRecorder()

pm.HandleListProviders(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}

var providers []Provider
if err := json.Unmarshal(rec.Body.Bytes(), &providers); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(providers) != 0 {
t.Fatalf("expected empty provider list, got %d", len(providers))
}
Comment on lines +108 to +114
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't actually distinguish between null and [] in the JSON response: unmarshalling null into a slice yields a nil slice with len==0, so the assertion still passes. To verify the handler returns an empty array, inspect the raw response bytes (after trimming whitespace) and assert it equals [], or unmarshal into *[]Provider and assert the pointer is non-nil.

Suggested change
var providers []Provider
if err := json.Unmarshal(rec.Body.Bytes(), &providers); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(providers) != 0 {
t.Fatalf("expected empty provider list, got %d", len(providers))
}
body := strings.TrimSpace(rec.Body.String())
if body != "[]" {
t.Fatalf("expected empty JSON array [], got %q", body)
}

Copilot uses AI. Check for mistakes.
}
Loading
Loading