readysite / pkg / platform / platform_test.go
16.8 KB
platform_test.go
package platform

import (
	"errors"
	"testing"
)

// --- Region tests ---

func TestRegionConstants(t *testing.T) {
	expected := map[string]Region{
		"nyc": NYC,
		"sfo": SFO,
		"tor": TOR,
		"lon": LON,
		"ams": AMS,
		"fra": FRA,
		"sgp": SGP,
		"syd": SYD,
		"blr": BLR,
	}

	for value, constant := range expected {
		if string(constant) != value {
			t.Errorf("expected region constant %q to have value %q, got %q", constant, value, string(constant))
		}
	}
}

func TestAllRegions(t *testing.T) {
	regions := AllRegions()

	expectedRegions := []Region{NYC, SFO, TOR, LON, AMS, FRA, SGP, SYD, BLR}

	if len(regions) != len(expectedRegions) {
		t.Fatalf("expected %d regions, got %d", len(expectedRegions), len(regions))
	}

	regionSet := make(map[Region]bool)
	for _, r := range regions {
		regionSet[r] = true
	}

	for _, expected := range expectedRegions {
		if !regionSet[expected] {
			t.Errorf("AllRegions() missing region %q", expected)
		}
	}
}

func TestAllRegionsOrder(t *testing.T) {
	regions := AllRegions()
	expected := []Region{NYC, SFO, TOR, LON, AMS, FRA, SGP, SYD, BLR}

	for i, r := range regions {
		if r != expected[i] {
			t.Errorf("AllRegions()[%d] = %q, expected %q", i, r, expected[i])
		}
	}
}

func TestRegionIsString(t *testing.T) {
	// Verify Region is a string type and can be compared/assigned as strings
	var r Region = "custom"
	if string(r) != "custom" {
		t.Errorf("Region should be a string type")
	}
}

// --- Size tests ---

func TestSizeConstants(t *testing.T) {
	expected := map[string]Size{
		"micro":  Micro,
		"small":  Small,
		"medium": Medium,
		"large":  Large,
		"xlarge": XLarge,
	}

	for value, constant := range expected {
		if string(constant) != value {
			t.Errorf("expected size constant %q to have value %q, got %q", constant, value, string(constant))
		}
	}
}

func TestAllSizes(t *testing.T) {
	sizes := AllSizes()

	expectedSizes := []Size{Micro, Small, Medium, Large, XLarge}

	if len(sizes) != len(expectedSizes) {
		t.Fatalf("expected %d sizes, got %d", len(expectedSizes), len(sizes))
	}

	sizeSet := make(map[Size]bool)
	for _, s := range sizes {
		sizeSet[s] = true
	}

	for _, expected := range expectedSizes {
		if !sizeSet[expected] {
			t.Errorf("AllSizes() missing size %q", expected)
		}
	}
}

func TestAllSizesOrder(t *testing.T) {
	sizes := AllSizes()
	expected := []Size{Micro, Small, Medium, Large, XLarge}

	for i, s := range sizes {
		if s != expected[i] {
			t.Errorf("AllSizes()[%d] = %q, expected %q", i, s, expected[i])
		}
	}
}

func TestSizeIsString(t *testing.T) {
	var s Size = "custom"
	if string(s) != "custom" {
		t.Errorf("Size should be a string type")
	}
}

// --- Error tests ---

func TestErrorsExist(t *testing.T) {
	errs := map[string]error{
		"ErrNotFound":          ErrNotFound,
		"ErrInvalidState":      ErrInvalidState,
		"ErrTimeout":           ErrTimeout,
		"ErrUnsupportedRegion": ErrUnsupportedRegion,
		"ErrUnsupportedSize":   ErrUnsupportedSize,
	}

	for name, err := range errs {
		if err == nil {
			t.Errorf("%s should not be nil", name)
		}
	}
}

func TestErrorMessages(t *testing.T) {
	tests := []struct {
		err     error
		message string
	}{
		{ErrNotFound, "resource not found"},
		{ErrInvalidState, "invalid resource state"},
		{ErrTimeout, "operation timed out"},
		{ErrUnsupportedRegion, "unsupported region"},
		{ErrUnsupportedSize, "unsupported size"},
	}

	for _, tt := range tests {
		if tt.err.Error() != tt.message {
			t.Errorf("expected error message %q, got %q", tt.message, tt.err.Error())
		}
	}
}

func TestErrorsAreDistinct(t *testing.T) {
	allErrors := []error{ErrNotFound, ErrInvalidState, ErrTimeout, ErrUnsupportedRegion, ErrUnsupportedSize}

	for i, e1 := range allErrors {
		for j, e2 := range allErrors {
			if i != j && errors.Is(e1, e2) {
				t.Errorf("error %q should not match error %q", e1, e2)
			}
		}
	}
}

func TestErrorWrapping(t *testing.T) {
	// Verify errors can be wrapped and unwrapped correctly
	wrapped := errors.Join(ErrNotFound, errors.New("additional context"))
	if !errors.Is(wrapped, ErrNotFound) {
		t.Error("wrapped ErrNotFound should still match via errors.Is")
	}
}

// --- ServerOptions tests ---

func TestServerOptionsFields(t *testing.T) {
	opts := ServerOptions{
		Name:   "my-server",
		Size:   Small,
		Region: NYC,
		Image:  "ubuntu-22-04",
		SSHKey: "aa:bb:cc:dd",
		Tags:   []string{"env=prod", "team=backend"},
	}

	if opts.Name != "my-server" {
		t.Errorf("expected Name 'my-server', got %q", opts.Name)
	}
	if opts.Size != Small {
		t.Errorf("expected Size Small, got %q", opts.Size)
	}
	if opts.Region != NYC {
		t.Errorf("expected Region NYC, got %q", opts.Region)
	}
	if opts.Image != "ubuntu-22-04" {
		t.Errorf("expected Image 'ubuntu-22-04', got %q", opts.Image)
	}
	if opts.SSHKey != "aa:bb:cc:dd" {
		t.Errorf("expected SSHKey 'aa:bb:cc:dd', got %q", opts.SSHKey)
	}
	if len(opts.Tags) != 2 {
		t.Errorf("expected 2 tags, got %d", len(opts.Tags))
	}
}

func TestServerOptionsZeroValue(t *testing.T) {
	var opts ServerOptions

	if opts.Name != "" {
		t.Errorf("zero value Name should be empty")
	}
	if opts.Size != "" {
		t.Errorf("zero value Size should be empty")
	}
	if opts.Region != "" {
		t.Errorf("zero value Region should be empty")
	}
	if opts.Image != "" {
		t.Errorf("zero value Image should be empty")
	}
	if opts.SSHKey != "" {
		t.Errorf("zero value SSHKey should be empty")
	}
	if opts.Tags != nil {
		t.Errorf("zero value Tags should be nil")
	}
}

// --- Server struct tests ---

func TestServerFields(t *testing.T) {
	server := Server{
		ID:     "srv-123",
		Name:   "web-1",
		IP:     "10.0.0.1",
		Size:   "s-1vcpu-2gb",
		Region: "nyc1",
		Status: "active",
	}

	if server.ID != "srv-123" {
		t.Errorf("expected ID 'srv-123', got %q", server.ID)
	}
	if server.Name != "web-1" {
		t.Errorf("expected Name 'web-1', got %q", server.Name)
	}
	if server.IP != "10.0.0.1" {
		t.Errorf("expected IP '10.0.0.1', got %q", server.IP)
	}
	if server.Status != "active" {
		t.Errorf("expected Status 'active', got %q", server.Status)
	}
}

// --- Volume struct tests ---

func TestVolumeFields(t *testing.T) {
	volume := Volume{
		ID:       "vol-123",
		Name:     "data",
		Size:     100,
		Region:   "nyc1",
		ServerID: "srv-456",
	}

	if volume.ID != "vol-123" {
		t.Errorf("expected ID 'vol-123', got %q", volume.ID)
	}
	if volume.Name != "data" {
		t.Errorf("expected Name 'data', got %q", volume.Name)
	}
	if volume.Size != 100 {
		t.Errorf("expected Size 100, got %d", volume.Size)
	}
	if volume.ServerID != "srv-456" {
		t.Errorf("expected ServerID 'srv-456', got %q", volume.ServerID)
	}
}

func TestVolumeDetachedState(t *testing.T) {
	volume := Volume{
		ID:   "vol-123",
		Name: "data",
	}

	if volume.ServerID != "" {
		t.Errorf("detached volume should have empty ServerID")
	}
}

// --- DNSZone and DNSRecord tests ---

func TestDNSZoneFields(t *testing.T) {
	zone := DNSZone{
		Name: "example.com",
		TTL:  300,
	}

	if zone.Name != "example.com" {
		t.Errorf("expected Name 'example.com', got %q", zone.Name)
	}
	if zone.TTL != 300 {
		t.Errorf("expected TTL 300, got %d", zone.TTL)
	}
}

func TestDNSRecordFields(t *testing.T) {
	record := DNSRecord{
		ID:       "rec-123",
		Type:     "A",
		Name:     "@",
		Value:    "1.2.3.4",
		TTL:      300,
		Priority: 0,
	}

	if record.Type != "A" {
		t.Errorf("expected Type 'A', got %q", record.Type)
	}
	if record.Name != "@" {
		t.Errorf("expected Name '@', got %q", record.Name)
	}
	if record.Value != "1.2.3.4" {
		t.Errorf("expected Value '1.2.3.4', got %q", record.Value)
	}
}

func TestDNSRecordMX(t *testing.T) {
	record := DNSRecord{
		Type:     "MX",
		Name:     "@",
		Value:    "mail.example.com",
		TTL:      3600,
		Priority: 10,
	}

	if record.Priority != 10 {
		t.Errorf("expected Priority 10, got %d", record.Priority)
	}
}

// --- Platform delegation via mock backend tests ---

// mockBackend is a minimal mock to test that Platform delegates correctly.
type mockBackend struct {
	createServerCalled bool
	getServerCalled    bool
	deleteServerCalled bool
	createVolumeCalled bool
	getVolumeCalled    bool
	attachVolumeCalled bool
	detachVolumeCalled bool
	createDNSCalled    bool
	getDNSCalled       bool
	addRecordCalled    bool
	deleteRecordCalled bool
}

func (m *mockBackend) CreateServer(opts ServerOptions) (*Server, error) {
	m.createServerCalled = true
	return &Server{ID: "mock-id", Name: opts.Name, Status: "active"}, nil
}

func (m *mockBackend) GetServer(name string) (*Server, error) {
	m.getServerCalled = true
	return &Server{ID: "mock-id", Name: name}, nil
}

func (m *mockBackend) DeleteServer(id string) error {
	m.deleteServerCalled = true
	return nil
}

func (m *mockBackend) CreateVolume(name string, sizeGB int, region Region) (*Volume, error) {
	m.createVolumeCalled = true
	return &Volume{ID: "vol-mock", Name: name, Size: sizeGB}, nil
}

func (m *mockBackend) GetVolume(name string) (*Volume, error) {
	m.getVolumeCalled = true
	return &Volume{ID: "vol-mock", Name: name}, nil
}

func (m *mockBackend) AttachVolume(volumeID, serverID string) error {
	m.attachVolumeCalled = true
	return nil
}

func (m *mockBackend) DetachVolume(volumeID string) error {
	m.detachVolumeCalled = true
	return nil
}

func (m *mockBackend) CreateDNSZone(domain string) (*DNSZone, error) {
	m.createDNSCalled = true
	return &DNSZone{Name: domain, TTL: 300}, nil
}

func (m *mockBackend) GetDNSZone(domain string) (*DNSZone, error) {
	m.getDNSCalled = true
	return &DNSZone{Name: domain}, nil
}

func (m *mockBackend) AddDNSRecord(zone string, record DNSRecord) error {
	m.addRecordCalled = true
	return nil
}

func (m *mockBackend) DeleteDNSRecord(zone, recordID string) error {
	m.deleteRecordCalled = true
	return nil
}

func TestPlatformDelegatesCreateServer(t *testing.T) {
	mock := &mockBackend{}
	p := &Platform{Backend: mock}

	server, err := p.CreateServer(ServerOptions{Name: "test", Size: Small, Region: NYC})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !mock.createServerCalled {
		t.Error("expected CreateServer to be delegated to backend")
	}
	if server.Name != "test" {
		t.Errorf("expected server name 'test', got %q", server.Name)
	}
}

func TestPlatformDelegatesGetServer(t *testing.T) {
	mock := &mockBackend{}
	p := &Platform{Backend: mock}

	server, err := p.GetServer("web-1")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !mock.getServerCalled {
		t.Error("expected GetServer to be delegated to backend")
	}
	if server.Name != "web-1" {
		t.Errorf("expected server name 'web-1', got %q", server.Name)
	}
}

func TestPlatformDelegatesDeleteServer(t *testing.T) {
	mock := &mockBackend{}
	p := &Platform{Backend: mock}

	err := p.DeleteServer("srv-123")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !mock.deleteServerCalled {
		t.Error("expected DeleteServer to be delegated to backend")
	}
}

func TestPlatformDelegatesCreateVolume(t *testing.T) {
	mock := &mockBackend{}
	p := &Platform{Backend: mock}

	vol, err := p.CreateVolume("data", 50, NYC)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !mock.createVolumeCalled {
		t.Error("expected CreateVolume to be delegated to backend")
	}
	if vol.Name != "data" {
		t.Errorf("expected volume name 'data', got %q", vol.Name)
	}
	if vol.Size != 50 {
		t.Errorf("expected volume size 50, got %d", vol.Size)
	}
}

func TestPlatformDelegatesGetVolume(t *testing.T) {
	mock := &mockBackend{}
	p := &Platform{Backend: mock}

	vol, err := p.GetVolume("data")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !mock.getVolumeCalled {
		t.Error("expected GetVolume to be delegated to backend")
	}
	if vol.Name != "data" {
		t.Errorf("expected volume name 'data', got %q", vol.Name)
	}
}

func TestPlatformDelegatesAttachVolume(t *testing.T) {
	mock := &mockBackend{}
	p := &Platform{Backend: mock}

	err := p.AttachVolume("vol-1", "srv-1")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !mock.attachVolumeCalled {
		t.Error("expected AttachVolume to be delegated to backend")
	}
}

func TestPlatformDelegatesDetachVolume(t *testing.T) {
	mock := &mockBackend{}
	p := &Platform{Backend: mock}

	err := p.DetachVolume("vol-1")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !mock.detachVolumeCalled {
		t.Error("expected DetachVolume to be delegated to backend")
	}
}

func TestPlatformDelegatesCreateDNSZone(t *testing.T) {
	mock := &mockBackend{}
	p := &Platform{Backend: mock}

	zone, err := p.CreateDNSZone("example.com")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !mock.createDNSCalled {
		t.Error("expected CreateDNSZone to be delegated to backend")
	}
	if zone.Name != "example.com" {
		t.Errorf("expected zone name 'example.com', got %q", zone.Name)
	}
}

func TestPlatformDelegatesGetDNSZone(t *testing.T) {
	mock := &mockBackend{}
	p := &Platform{Backend: mock}

	zone, err := p.GetDNSZone("example.com")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !mock.getDNSCalled {
		t.Error("expected GetDNSZone to be delegated to backend")
	}
	if zone.Name != "example.com" {
		t.Errorf("expected zone name 'example.com', got %q", zone.Name)
	}
}

func TestPlatformDelegatesAddDNSRecord(t *testing.T) {
	mock := &mockBackend{}
	p := &Platform{Backend: mock}

	err := p.AddDNSRecord("example.com", DNSRecord{Type: "A", Name: "@", Value: "1.2.3.4"})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !mock.addRecordCalled {
		t.Error("expected AddDNSRecord to be delegated to backend")
	}
}

func TestPlatformDelegatesDeleteDNSRecord(t *testing.T) {
	mock := &mockBackend{}
	p := &Platform{Backend: mock}

	err := p.DeleteDNSRecord("example.com", "rec-123")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !mock.deleteRecordCalled {
		t.Error("expected DeleteDNSRecord to be delegated to backend")
	}
}

// --- Service and related struct tests ---

func TestServiceFields(t *testing.T) {
	svc := Service{
		Name:    "web",
		Image:   "nginx:latest",
		Command: []string{"nginx", "-g", "daemon off;"},
		Ports:   []Port{{Host: 80, Container: 8080}},
		Volumes: []Mount{{Source: "/data", Target: "/var/data"}},
		Env:     map[string]string{"ENV": "production"},
		Network: "app-net",
		Restart: "always",
	}

	if svc.Name != "web" {
		t.Errorf("expected Name 'web', got %q", svc.Name)
	}
	if svc.Image != "nginx:latest" {
		t.Errorf("expected Image 'nginx:latest', got %q", svc.Image)
	}
	if len(svc.Command) != 3 {
		t.Errorf("expected 3 command args, got %d", len(svc.Command))
	}
	if len(svc.Ports) != 1 {
		t.Errorf("expected 1 port, got %d", len(svc.Ports))
	}
	if svc.Ports[0].Host != 80 || svc.Ports[0].Container != 8080 {
		t.Errorf("unexpected port mapping: %+v", svc.Ports[0])
	}
}

func TestPortWithBind(t *testing.T) {
	p := Port{Host: 443, Container: 8443, Bind: "127.0.0.1"}

	if p.Bind != "127.0.0.1" {
		t.Errorf("expected Bind '127.0.0.1', got %q", p.Bind)
	}
}

func TestHealthcheckFields(t *testing.T) {
	hc := Healthcheck{
		Cmd:         "curl -f http://localhost/health",
		Interval:    "30s",
		Timeout:     "10s",
		Retries:     3,
		StartPeriod: "5s",
	}

	if hc.Cmd != "curl -f http://localhost/health" {
		t.Errorf("unexpected Cmd: %q", hc.Cmd)
	}
	if hc.Retries != 3 {
		t.Errorf("expected Retries 3, got %d", hc.Retries)
	}
}

func TestResourcesFields(t *testing.T) {
	r := Resources{CPUs: "2", Memory: "1g"}

	if r.CPUs != "2" {
		t.Errorf("expected CPUs '2', got %q", r.CPUs)
	}
	if r.Memory != "1g" {
		t.Errorf("expected Memory '1g', got %q", r.Memory)
	}
}

func TestLogConfigFields(t *testing.T) {
	lc := LogConfig{
		Driver:  "json-file",
		Options: map[string]string{"max-size": "10m", "max-file": "3"},
	}

	if lc.Driver != "json-file" {
		t.Errorf("expected Driver 'json-file', got %q", lc.Driver)
	}
	if len(lc.Options) != 2 {
		t.Errorf("expected 2 log options, got %d", len(lc.Options))
	}
}

// --- shellEscape tests ---

func TestShellEscapeSimpleStrings(t *testing.T) {
	tests := []struct {
		input    string
		expected string
	}{
		{"hello", "hello"},
		{"docker", "docker"},
		{"my-server", "my-server"},
		{"path/to/file", "path/to/file"},
		{"key=value", "key=value"},
		{"v1.0.0", "v1.0.0"},
		{"host:port", "host:port"},
		{"a_b", "a_b"},
		{"item1,item2", "item1,item2"},
	}

	for _, tt := range tests {
		result := shellEscape(tt.input)
		if result != tt.expected {
			t.Errorf("shellEscape(%q) = %q, expected %q", tt.input, result, tt.expected)
		}
	}
}

func TestShellEscapeSpecialCharacters(t *testing.T) {
	tests := []struct {
		input    string
		expected string
	}{
		{"hello world", "'hello world'"},
		{"it's", "'it'\"'\"'s'"},
		{"$HOME", "'$HOME'"},
		{"foo;bar", "'foo;bar'"},
		{"a&b", "'a&b'"},
		{"", "''"},
	}

	for _, tt := range tests {
		result := shellEscape(tt.input)
		if result != tt.expected {
			t.Errorf("shellEscape(%q) = %q, expected %q", tt.input, result, tt.expected)
		}
	}
}

// --- SSHKeyProvider interface test ---

func TestSSHKeyProviderInterface(t *testing.T) {
	// Verify the interface signature is correct by implementing it
	var _ SSHKeyProvider = &testSSHKeyProvider{}
}

type testSSHKeyProvider struct{}

func (p *testSSHKeyProvider) GetSSHKeyFingerprint(publicKey string) (string, error) {
	return "aa:bb:cc:dd", nil
}
← Back