Major changes: - Add internal/logging package with field constants, context propagation, sensitive data auto-redaction, and per-component log levels - Add worker timeout constants (TimeoutQuickOp, TimeoutHealthCheck, etc.) - Extend SDLC with callback handlers, generate endpoints, and executor - Add new cookbook trees for aeries and slackpath progression - Add skeleton templates for queue, realtime, and microservices - Add worker component template with async job processing - Refactor services and handlers to use new logging infrastructure - Split component.go into component_infra.go and component_listing.go Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
795 lines
22 KiB
Go
795 lines
22 KiB
Go
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)
|
|
}
|