package service import ( "context" "errors" "log/slog" "os" "testing" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) func TestAssignComponentPath(t *testing.T) { tests := []struct { name string component *domain.Component expectedPath string }{ { name: "service gets /api/{name}", component: &domain.Component{ Type: domain.ComponentTypeService, Name: "auth", }, expectedPath: "/api/auth", }, { name: "service with different name", component: &domain.Component{ Type: domain.ComponentTypeService, Name: "users", }, expectedPath: "/api/users", }, { name: "app-react gets /", component: &domain.Component{ Type: domain.ComponentTypeAppReact, Name: "web", }, expectedPath: "/", }, { name: "app-astro gets /", component: &domain.Component{ Type: domain.ComponentTypeAppAstro, Name: "landing", }, expectedPath: "/", }, { name: "app-nextjs gets /", component: &domain.Component{ Type: domain.ComponentTypeAppNextJS, Name: "dashboard", }, expectedPath: "/", }, { name: "worker gets empty path", component: &domain.Component{ Type: domain.ComponentTypeWorker, Name: "processor", }, expectedPath: "", }, { name: "cli gets empty path", component: &domain.Component{ Type: domain.ComponentTypeCLI, Name: "tool", }, expectedPath: "", }, { name: "postgres gets empty path (infrastructure)", component: &domain.Component{ Type: domain.ComponentTypePostgres, Name: "main-db", }, expectedPath: "", }, { name: "redis gets empty path (infrastructure)", component: &domain.Component{ Type: domain.ComponentTypeRedis, Name: "cache", }, expectedPath: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := assignComponentPath(tc.component) if result != tc.expectedPath { t.Errorf("assignComponentPath(%s %s) = %q, want %q", tc.component.Type, tc.component.Name, result, tc.expectedPath) } }) } } // --- Mock implementations for infrastructure component tests --- type mockDatabaseProvisioner struct { createCalled bool deleteCalled bool getCalled bool existingDB *domain.DatabaseCredentials createError error deleteError error getError error createdCredentials *domain.DatabaseCredentials } func (m *mockDatabaseProvisioner) CreateProjectDatabase(ctx context.Context, projectID string) (*domain.DatabaseCredentials, error) { m.createCalled = true if m.createError != nil { return nil, m.createError } creds := &domain.DatabaseCredentials{ ProjectID: projectID, DatabaseName: "project_" + projectID, Username: "project_" + projectID, Password: "testpassword", Host: "localhost", Port: 26257, SSLMode: "disable", URL: "postgres://project_" + projectID + ":testpassword@localhost:26257/project_" + projectID + "?sslmode=disable", URLStaging: "postgres://project_" + projectID + ":testpassword@localhost:26257/project_" + projectID + "?sslmode=disable", CreatedAt: time.Now(), } m.createdCredentials = creds return creds, nil } func (m *mockDatabaseProvisioner) DeleteProjectDatabase(ctx context.Context, projectID string) error { m.deleteCalled = true return m.deleteError } func (m *mockDatabaseProvisioner) GetProjectDatabase(ctx context.Context, projectID string) (*domain.DatabaseCredentials, error) { m.getCalled = true if m.getError != nil { return nil, m.getError } return m.existingDB, nil } func (m *mockDatabaseProvisioner) TestConnection(ctx context.Context) error { return nil } type mockCacheProvisioner struct { createCalled bool deleteCalled bool getCalled bool existingCache *domain.CacheCredentials createError error deleteError error getError error createdCredentials *domain.CacheCredentials } func (m *mockCacheProvisioner) CreateProjectCache(ctx context.Context, projectID string) (*domain.CacheCredentials, error) { m.createCalled = true if m.createError != nil { return nil, m.createError } creds := &domain.CacheCredentials{ ProjectID: projectID, URL: "redis://proj-" + projectID + ":testpassword@localhost:6379", URLStaging: "redis://proj-" + projectID + ":testpassword@localhost:6379", Prefix: "project:" + projectID + ":", Username: "proj-" + projectID, Host: "localhost", Port: 6379, CreatedAt: time.Now(), } m.createdCredentials = creds return creds, nil } func (m *mockCacheProvisioner) DeleteProjectCache(ctx context.Context, projectID string, purgeKeys bool) error { m.deleteCalled = true return m.deleteError } func (m *mockCacheProvisioner) GetProjectCache(ctx context.Context, projectID string) (*domain.CacheCredentials, error) { m.getCalled = true if m.getError != nil { return nil, m.getError } return m.existingCache, nil } func (m *mockCacheProvisioner) TestConnection(ctx context.Context) error { return nil } type mockCredentialStore struct { stored map[string]string setError error } func newMockCredentialStore() *mockCredentialStore { return &mockCredentialStore{stored: make(map[string]string)} } func (m *mockCredentialStore) Get(ctx context.Context, key string) (string, error) { return m.stored[key], nil } func (m *mockCredentialStore) GetRequired(ctx context.Context, key string) (string, error) { v, ok := m.stored[key] if !ok { return "", domain.ErrCredentialNotFound } return v, nil } func (m *mockCredentialStore) Set(ctx context.Context, cred domain.Credential) error { if m.setError != nil { return m.setError } m.stored[cred.Key] = cred.Value return nil } func (m *mockCredentialStore) Delete(ctx context.Context, key string) error { delete(m.stored, key) return nil } func (m *mockCredentialStore) List(ctx context.Context) ([]domain.Credential, error) { return nil, nil } func (m *mockCredentialStore) ListByCategory(ctx context.Context, category string) ([]domain.Credential, error) { return nil, nil } func (m *mockCredentialStore) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { result := make(map[string]string) for _, k := range keys { if v, ok := m.stored[k]; ok { result[k] = v } } return result, nil } func (m *mockCredentialStore) SetMultiple(ctx context.Context, creds []domain.Credential) error { for _, c := range creds { if err := m.Set(ctx, c); err != nil { return err } } return nil } // --- Infrastructure component provisioning tests --- func TestProvisionPostgres(t *testing.T) { tests := []struct { name string projectID string componentName string dbProvisioner *mockDatabaseProvisioner credStore *mockCredentialStore wantErr bool wantErrContains string }{ { name: "successful postgres provisioning", projectID: "test-project", componentName: "main-db", dbProvisioner: &mockDatabaseProvisioner{}, credStore: newMockCredentialStore(), wantErr: false, }, { name: "postgres already exists", projectID: "test-project", componentName: "main-db", dbProvisioner: &mockDatabaseProvisioner{ existingDB: &domain.DatabaseCredentials{ProjectID: "test-project"}, }, credStore: newMockCredentialStore(), wantErr: true, wantErrContains: "already provisioned", }, { name: "provisioning fails", projectID: "test-project", componentName: "main-db", dbProvisioner: &mockDatabaseProvisioner{ createError: errors.New("cockroachdb connection failed"), }, credStore: newMockCredentialStore(), wantErr: true, wantErrContains: "failed to provision database", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc := &ComponentService{ dbProvisioner: tc.dbProvisioner, credentialStore: tc.credStore, } ctx := context.Background() component, err := svc.provisionPostgres(ctx, tc.projectID, tc.componentName) if tc.wantErr { if err == nil { t.Errorf("expected error containing %q, got nil", tc.wantErrContains) return } if tc.wantErrContains != "" && !contains(err.Error(), tc.wantErrContains) { t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrContains) } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if component.Type != domain.ComponentTypePostgres { t.Errorf("component type = %v, want %v", component.Type, domain.ComponentTypePostgres) } if component.Name != tc.componentName { t.Errorf("component name = %q, want %q", component.Name, tc.componentName) } // Verify credentials were stored if tc.credStore != nil { key := tc.projectID + ":DATABASE_URL" if _, ok := tc.credStore.stored[key]; !ok { t.Errorf("DATABASE_URL not stored in credential store") } } }) } } func TestProvisionRedis(t *testing.T) { tests := []struct { name string projectID string componentName string cacheProvisioner *mockCacheProvisioner credStore *mockCredentialStore wantErr bool wantErrContains string }{ { name: "successful redis provisioning", projectID: "test-project", componentName: "cache", cacheProvisioner: &mockCacheProvisioner{}, credStore: newMockCredentialStore(), wantErr: false, }, { name: "redis already exists", projectID: "test-project", componentName: "cache", cacheProvisioner: &mockCacheProvisioner{ existingCache: &domain.CacheCredentials{ProjectID: "test-project"}, }, credStore: newMockCredentialStore(), wantErr: true, wantErrContains: "already provisioned", }, { name: "provisioning fails", projectID: "test-project", componentName: "cache", cacheProvisioner: &mockCacheProvisioner{ createError: errors.New("redis connection failed"), }, credStore: newMockCredentialStore(), wantErr: true, wantErrContains: "failed to provision cache", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc := &ComponentService{ cacheProvisioner: tc.cacheProvisioner, credentialStore: tc.credStore, } ctx := context.Background() component, err := svc.provisionRedis(ctx, tc.projectID, tc.componentName) if tc.wantErr { if err == nil { t.Errorf("expected error containing %q, got nil", tc.wantErrContains) return } if tc.wantErrContains != "" && !contains(err.Error(), tc.wantErrContains) { t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrContains) } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if component.Type != domain.ComponentTypeRedis { t.Errorf("component type = %v, want %v", component.Type, domain.ComponentTypeRedis) } if component.Name != tc.componentName { t.Errorf("component name = %q, want %q", component.Name, tc.componentName) } // Verify credentials were stored if tc.credStore != nil { key := tc.projectID + ":REDIS_URL" if _, ok := tc.credStore.stored[key]; !ok { t.Errorf("REDIS_URL not stored in credential store") } } }) } } func TestAddInfraComponent(t *testing.T) { tests := []struct { name string projectID string componentType domain.ComponentType componentName string dbProvisioner port.DatabaseProvisioner cacheProvisioner port.CacheProvisioner credStore port.CredentialStore wantErr bool wantErrContains string }{ { name: "postgres component routes to provisioner", projectID: "test-project", componentType: domain.ComponentTypePostgres, componentName: "main-db", dbProvisioner: &mockDatabaseProvisioner{}, credStore: newMockCredentialStore(), wantErr: false, }, { name: "redis component routes to provisioner", projectID: "test-project", componentType: domain.ComponentTypeRedis, componentName: "cache", cacheProvisioner: &mockCacheProvisioner{}, credStore: newMockCredentialStore(), wantErr: false, }, { name: "postgres without provisioner fails", projectID: "test-project", componentType: domain.ComponentTypePostgres, componentName: "main-db", dbProvisioner: nil, // nil interface credStore: newMockCredentialStore(), wantErr: true, wantErrContains: "database provisioner not configured", }, { name: "redis without provisioner fails", projectID: "test-project", componentType: domain.ComponentTypeRedis, componentName: "cache", cacheProvisioner: nil, // nil interface credStore: newMockCredentialStore(), wantErr: true, wantErrContains: "cache provisioner not configured", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc := &ComponentService{ dbProvisioner: tc.dbProvisioner, cacheProvisioner: tc.cacheProvisioner, credentialStore: tc.credStore, } ctx := context.Background() component, err := svc.addInfraComponent(ctx, tc.projectID, tc.componentType, tc.componentName) if tc.wantErr { if err == nil { t.Errorf("expected error containing %q, got nil", tc.wantErrContains) return } if tc.wantErrContains != "" && !contains(err.Error(), tc.wantErrContains) { t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrContains) } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if component.Type != tc.componentType { t.Errorf("component type = %v, want %v", component.Type, tc.componentType) } }) } } func TestIsInfraComponent(t *testing.T) { tests := []struct { componentType domain.ComponentType want bool }{ {domain.ComponentTypePostgres, true}, {domain.ComponentTypeRedis, true}, {domain.ComponentTypeService, false}, {domain.ComponentTypeWorker, false}, {domain.ComponentTypeAppAstro, false}, {domain.ComponentTypeAppReact, false}, {domain.ComponentTypeCLI, false}, } for _, tc := range tests { t.Run(string(tc.componentType), func(t *testing.T) { got := tc.componentType.IsInfraComponent() if got != tc.want { t.Errorf("IsInfraComponent(%s) = %v, want %v", tc.componentType, got, tc.want) } }) } } func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr))) } func findSubstring(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } // Ensure mock implements interface var _ port.DatabaseProvisioner = (*mockDatabaseProvisioner)(nil) var _ port.CacheProvisioner = (*mockCacheProvisioner)(nil) var _ port.CredentialStore = (*mockCredentialStore)(nil) func TestToUpperSnake(t *testing.T) { tests := []struct { input string want string }{ {"auth-svc", "AUTH_SVC"}, {"chat-svc", "CHAT_SVC"}, {"user-service", "USER_SERVICE"}, {"my-auth-svc-v2", "MY_AUTH_SVC_V2"}, {"simple", "SIMPLE"}, {"already-UPPER", "ALREADY_UPPER"}, {"a-b-c-d", "A_B_C_D"}, {"", ""}, } for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { got := toUpperSnake(tc.input) if got != tc.want { t.Errorf("toUpperSnake(%q) = %q, want %q", tc.input, got, tc.want) } }) } } func TestBuildSiblingServiceURLs(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) tests := []struct { name string projectID string currentComponent string components []domain.Component listErr error want map[string]string }{ { name: "builds URLs for sibling services", projectID: "testproject", currentComponent: "chat-svc", components: []domain.Component{ {Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001}, {Name: "chat-svc", Type: domain.ComponentTypeService, Port: 8002}, {Name: "user-svc", Type: domain.ComponentTypeService, Port: 8003}, }, want: map[string]string{ "AUTH_SVC_URL": "http://testproject-auth-svc:8001", "USER_SVC_URL": "http://testproject-user-svc:8003", }, }, { name: "excludes current component", projectID: "myapp", currentComponent: "auth-svc", components: []domain.Component{ {Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001}, }, want: map[string]string{}, }, { name: "excludes workers", projectID: "myapp", currentComponent: "api", components: []domain.Component{ {Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001}, {Name: "processor", Type: domain.ComponentTypeWorker, Port: 0}, }, want: map[string]string{ "AUTH_SVC_URL": "http://myapp-auth-svc:8001", }, }, { name: "excludes apps", projectID: "myapp", currentComponent: "api", components: []domain.Component{ {Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001}, {Name: "web", Type: domain.ComponentTypeAppReact, Port: 3001}, {Name: "landing", Type: domain.ComponentTypeAppAstro, Port: 3002}, }, want: map[string]string{ "AUTH_SVC_URL": "http://myapp-auth-svc:8001", }, }, { name: "excludes CLI", projectID: "myapp", currentComponent: "api", components: []domain.Component{ {Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001}, {Name: "tool", Type: domain.ComponentTypeCLI, Port: 0}, }, want: map[string]string{ "AUTH_SVC_URL": "http://myapp-auth-svc:8001", }, }, { name: "excludes infrastructure components", projectID: "myapp", currentComponent: "api", components: []domain.Component{ {Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001}, {Name: "main-db", Type: domain.ComponentTypePostgres, Port: 26257}, {Name: "cache", Type: domain.ComponentTypeRedis, Port: 6379}, }, want: map[string]string{ "AUTH_SVC_URL": "http://myapp-auth-svc:8001", }, }, { name: "excludes services with zero port", projectID: "myapp", currentComponent: "api", components: []domain.Component{ {Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001}, {Name: "broken-svc", Type: domain.ComponentTypeService, Port: 0}, }, want: map[string]string{ "AUTH_SVC_URL": "http://myapp-auth-svc:8001", }, }, { name: "handles empty component list", projectID: "myapp", currentComponent: "api", components: []domain.Component{}, want: map[string]string{}, }, { name: "returns nil on list error", projectID: "myapp", currentComponent: "api", listErr: errors.New("database error"), want: nil, }, { name: "handles hyphenated component names correctly", projectID: "my-project", currentComponent: "api", components: []domain.Component{ {Name: "user-auth-service", Type: domain.ComponentTypeService, Port: 8001}, }, want: map[string]string{ "USER_AUTH_SERVICE_URL": "http://my-project-user-auth-service:8001", }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // We need to test the actual function, so we'll use a helper approach // by directly calling the function logic with mock data got := buildSiblingServiceURLsHelper(tc.components, tc.projectID, tc.currentComponent, tc.listErr, logger) // Compare results if tc.want == nil { if got != nil { t.Errorf("buildSiblingServiceURLs() = %v, want nil", got) } return } if len(got) != len(tc.want) { t.Errorf("buildSiblingServiceURLs() returned %d URLs, want %d", len(got), len(tc.want)) t.Errorf("got: %v", got) t.Errorf("want: %v", tc.want) return } for key, wantURL := range tc.want { if gotURL, ok := got[key]; !ok { t.Errorf("missing key %q in result", key) } else if gotURL != wantURL { t.Errorf("URL for %q = %q, want %q", key, gotURL, wantURL) } } // Verify no extra keys for key := range got { if _, ok := tc.want[key]; !ok { t.Errorf("unexpected key %q in result", key) } } }) } } // buildSiblingServiceURLsHelper extracts the core logic for testing without needing a full ComponentService. // This mirrors the logic in ComponentService.buildSiblingServiceURLs. func buildSiblingServiceURLsHelper(components []domain.Component, projectID, currentComponent string, listErr error, logger *slog.Logger) map[string]string { if listErr != nil { logger.Warn("failed to list components for sibling discovery", "project", projectID, "error", listErr, ) return nil } urls := make(map[string]string) for _, c := range components { // Skip the current component, non-service types, and components without ports if c.Name == currentComponent || c.Type != domain.ComponentTypeService || c.Port == 0 { continue } // Build env var name: auth-svc -> AUTH_SVC_URL envKey := toUpperSnake(c.Name) + "_URL" // Build internal K8s service URL: http://projectid-componentname:port serviceName := projectID + "-" + c.Name urls[envKey] = "http://" + serviceName + ":" + itoa(c.Port) } return urls } // itoa converts int to string without importing strconv func itoa(n int) string { if n == 0 { return "0" } var digits []byte for n > 0 { digits = append([]byte{byte('0' + n%10)}, digits...) n /= 10 } return string(digits) }