diff --git a/supply-api/internal/app/runtime.go b/supply-api/internal/app/runtime.go index dfb2bad9..38cc2e05 100644 --- a/supply-api/internal/app/runtime.go +++ b/supply-api/internal/app/runtime.go @@ -81,6 +81,16 @@ type runtimeFactory struct { newRedisCache func(cfg config.RedisConfig) (*cache.RedisCache, error) } +type runtimeBuildInputs struct { + env string + cfg *config.Config + logger logging.Logger + initCtx context.Context + now func() time.Time + isProd bool + tuning runtimeTuning +} + type runtimeStoreBundle struct { accountStore domain.AccountStore packageStore domain.PackageStore @@ -129,23 +139,42 @@ func BuildRuntime(opts RuntimeOptions) (*Runtime, error) { } func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runtime, error) { - if opts.Config == nil { - return nil, errors.New("config is required") - } - if opts.Logger == nil { - return nil, errors.New("logger is required") + inputs, err := resolveRuntimeBuildInputs(opts) + if err != nil { + return nil, err } - if factory.newDB == nil { - factory.newDB = repository.NewDB + resources, err := initializeRuntimeExternalResources(inputs, factory) + if err != nil { + return nil, err } - if factory.newRedisCache == nil { - factory.newRedisCache = cache.NewRedisCache + + storeBundle := buildStoreBundle(resources.db, inputs.logger) + securityBundle := buildSecurityBundle(inputs.env, inputs.cfg, inputs.logger, storeBundle.auditStore, resources.redisCache, storeBundle.tokenStatusRepo) + apiBundle, err := buildAPIBundle(inputs.env, inputs.cfg, inputs.now, inputs.tuning, inputs.logger, inputs.isProd, storeBundle) + if err != nil { + return nil, err + } + + startupViews := buildRuntimeStartupViews(inputs.env, inputs.logger, inputs.cfg.Server, inputs.tuning, securityBundle, apiBundle) + + return &Runtime{ + resources: resources, + startupViews: startupViews, + }, nil +} + +func resolveRuntimeBuildInputs(opts RuntimeOptions) (runtimeBuildInputs, error) { + if opts.Config == nil { + return runtimeBuildInputs{}, errors.New("config is required") + } + if opts.Logger == nil { + return runtimeBuildInputs{}, errors.New("logger is required") } env, err := ResolveEnv(opts.Env) if err != nil { - return nil, err + return runtimeBuildInputs{}, err } now := opts.Now if now == nil { @@ -156,62 +185,88 @@ func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runt initCtx = context.Background() } - isProd := env == "prod" - tuning := defaultRuntimeTuning() + return runtimeBuildInputs{ + env: env, + cfg: opts.Config, + logger: opts.Logger, + initCtx: initCtx, + now: now, + isProd: env == "prod", + tuning: defaultRuntimeTuning(), + }, nil +} - db, err := factory.newDB(initCtx, opts.Config.Database) +func withDefaultRuntimeFactory(factory runtimeFactory) runtimeFactory { + if factory.newDB == nil { + factory.newDB = repository.NewDB + } + if factory.newRedisCache == nil { + factory.newRedisCache = cache.NewRedisCache + } + return factory +} + +func initializeRuntimeExternalResources(inputs runtimeBuildInputs, factory runtimeFactory) (runtimeExternalResources, error) { + factory = withDefaultRuntimeFactory(factory) + + db, err := factory.newDB(inputs.initCtx, inputs.cfg.Database) if err != nil { - if isProd { - return nil, fmt.Errorf("database unavailable: %w", err) + if inputs.isProd { + return runtimeExternalResources{}, fmt.Errorf("database unavailable: %w", err) } - warnf(opts.Logger, "failed to connect to database: %v (using in-memory store)", err) + warnf(inputs.logger, "failed to connect to database: %v (using in-memory store)", err) db = nil } else if db != nil { - infof(opts.Logger, "connected to database at %s:%d", opts.Config.Database.Host, opts.Config.Database.Port) + infof(inputs.logger, "connected to database at %s:%d", inputs.cfg.Database.Host, inputs.cfg.Database.Port) } - redisCache, err := factory.newRedisCache(opts.Config.Redis) + redisCache, err := factory.newRedisCache(inputs.cfg.Redis) if err != nil { - if isProd { - warnf(opts.Logger, "redis unavailable at startup: %v", err) + if inputs.isProd { + warnf(inputs.logger, "redis unavailable at startup: %v", err) } else { - warnf(opts.Logger, "failed to connect to redis: %v (caching disabled)", err) + warnf(inputs.logger, "failed to connect to redis: %v (caching disabled)", err) } redisCache = nil } else if redisCache != nil { - infof(opts.Logger, "connected to redis at %s:%d", opts.Config.Redis.Host, opts.Config.Redis.Port) + infof(inputs.logger, "connected to redis at %s:%d", inputs.cfg.Redis.Host, inputs.cfg.Redis.Port) } - storeBundle := buildStoreBundle(db, opts.Logger) - securityBundle := buildSecurityBundle(env, opts.Config, opts.Logger, storeBundle.auditStore, redisCache, storeBundle.tokenStatusRepo) - apiBundle, err := buildAPIBundle(env, opts.Config, now, tuning, opts.Logger, isProd, storeBundle) - if err != nil { - return nil, err - } + return buildRuntimeResources(db, redisCache), nil +} - return &Runtime{ - resources: runtimeExternalResources{ - db: db, - redisCache: redisCache, +func buildRuntimeResources(db *repository.DB, redisCache *cache.RedisCache) runtimeExternalResources { + return runtimeExternalResources{ + db: db, + redisCache: redisCache, + } +} + +func buildRuntimeStartupViews( + env string, + logger logging.Logger, + serverConfig config.ServerConfig, + tuning runtimeTuning, + securityBundle runtimeSecurityBundle, + apiBundle runtimeAPIBundle, +) runtimeStartupViews { + return runtimeStartupViews{ + http: runtimeHTTPStartupView{ + env: env, + logger: logger, + serverConfig: normalizeServerConfig(serverConfig), + supplyAPI: apiBundle.supplyAPI, + alertAPI: apiBundle.alertAPI, + authMiddleware: securityBundle.authMiddleware, + rateLimitConfig: apiBundle.rateLimitConfig, }, - startupViews: runtimeStartupViews{ - http: runtimeHTTPStartupView{ - env: env, - logger: opts.Logger, - serverConfig: normalizeServerConfig(opts.Config.Server), - supplyAPI: apiBundle.supplyAPI, - alertAPI: apiBundle.alertAPI, - authMiddleware: securityBundle.authMiddleware, - rateLimitConfig: apiBundle.rateLimitConfig, - }, - background: runtimeBackgroundStartupView{ - env: env, - logger: opts.Logger, - tuning: tuning, - revocationSubscriber: securityBundle.revocationSubscriber, - }, + background: runtimeBackgroundStartupView{ + env: env, + logger: logger, + tuning: tuning, + revocationSubscriber: securityBundle.revocationSubscriber, }, - }, nil + } } func buildStoreBundle(db *repository.DB, logger logging.Logger) runtimeStoreBundle { diff --git a/supply-api/internal/app/runtime_test.go b/supply-api/internal/app/runtime_test.go index 036ae69c..69b0a39f 100644 --- a/supply-api/internal/app/runtime_test.go +++ b/supply-api/internal/app/runtime_test.go @@ -198,6 +198,95 @@ func TestResolveEnv_RejectsUnsupportedValue(t *testing.T) { } } +func TestResolveRuntimeBuildInputs_NormalizesDefaults(t *testing.T) { + inputs, err := resolveRuntimeBuildInputs(RuntimeOptions{ + Config: testRuntimeConfig(), + Logger: testLogger{}, + }) + if err != nil { + t.Fatalf("expected input normalization to succeed, got %v", err) + } + if inputs.cfg == nil { + t.Fatal("expected config to be preserved") + } + if inputs.logger == nil { + t.Fatal("expected logger to be preserved") + } + if inputs.env != "dev" { + t.Fatalf("unexpected env: %s", inputs.env) + } + if inputs.isProd { + t.Fatal("expected default env to be non-prod") + } + if inputs.now == nil { + t.Fatal("expected now func to be defaulted") + } + if inputs.initCtx == nil { + t.Fatal("expected init context to be defaulted") + } + if inputs.tuning.outboxStreamName != "supply:outbox:stream" { + t.Fatalf("unexpected outbox stream: %s", inputs.tuning.outboxStreamName) + } +} + +func TestInitializeRuntimeExternalResources_ProdRejectsDatabaseFailure(t *testing.T) { + _, err := initializeRuntimeExternalResources(runtimeBuildInputs{ + env: "prod", + cfg: testRuntimeConfig(), + logger: testLogger{}, + initCtx: context.Background(), + isProd: true, + tuning: defaultRuntimeTuning(), + now: time.Now, + }, runtimeFactory{ + newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) { + return nil, errors.New("db down") + }, + newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) { + return nil, nil + }, + }) + if err == nil { + t.Fatal("expected prod runtime resources to reject database outage") + } + if !strings.Contains(err.Error(), "database unavailable") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInitializeRuntimeExternalResources_DevFallsBackToNilResources(t *testing.T) { + logger := &captureLogger{} + + resources, err := initializeRuntimeExternalResources(runtimeBuildInputs{ + env: "dev", + cfg: testRuntimeConfig(), + logger: logger, + initCtx: context.Background(), + isProd: false, + tuning: defaultRuntimeTuning(), + now: time.Now, + }, runtimeFactory{ + newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) { + return nil, errors.New("db down") + }, + newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) { + return nil, errors.New("redis down") + }, + }) + if err != nil { + t.Fatalf("expected dev runtime resources to fall back, got %v", err) + } + if resources.db != nil { + t.Fatal("expected nil db after dev fallback") + } + if resources.redisCache != nil { + t.Fatal("expected nil redis cache after dev fallback") + } + if len(logger.warnMessages) == 0 { + t.Fatal("expected warning logs during dev resource fallback") + } +} + func TestBuildRuntime_RejectsUnsupportedEnv(t *testing.T) { _, err := buildRuntimeWithFactory(RuntimeOptions{ Env: "qa", @@ -355,6 +444,74 @@ func TestBuildRuntime_DevFallbackLogsWarnings(t *testing.T) { } } +func TestBuildRuntimeResources_GroupsExternalDependencies(t *testing.T) { + db := &repository.DB{} + redisCache := &cache.RedisCache{} + + resources := buildRuntimeResources(db, redisCache) + if resources.db != db { + t.Fatal("expected db resource to be preserved") + } + if resources.redisCache != redisCache { + t.Fatal("expected redis cache resource to be preserved") + } +} + +func TestBuildRuntimeStartupViews_GroupsHTTPAndBackgroundDependencies(t *testing.T) { + supplyAPI, alertAPI := mustBuildTestAPIs(t) + logger := testLogger{} + authMiddleware := &middleware.AuthMiddleware{} + rateLimitConfig := &middleware.RateLimitConfig{Enabled: true} + tuning := defaultRuntimeTuning() + subscriber := stubRevocationSubscriber{} + + views := buildRuntimeStartupViews( + "staging", + logger, + config.ServerConfig{}, + tuning, + runtimeSecurityBundle{ + authMiddleware: authMiddleware, + revocationSubscriber: subscriber, + }, + runtimeAPIBundle{ + supplyAPI: supplyAPI, + alertAPI: alertAPI, + rateLimitConfig: rateLimitConfig, + }, + ) + if views.http.env != "staging" { + t.Fatalf("unexpected http env: %s", views.http.env) + } + if views.background.env != "staging" { + t.Fatalf("unexpected background env: %s", views.background.env) + } + if views.http.serverConfig.Addr != ":18082" { + t.Fatalf("unexpected default addr: %s", views.http.serverConfig.Addr) + } + if views.http.serverConfig.ShutdownTimeout != 5*time.Second { + t.Fatalf("unexpected default shutdown timeout: %s", views.http.serverConfig.ShutdownTimeout) + } + if views.http.supplyAPI != supplyAPI { + t.Fatal("expected supply api to be preserved") + } + if views.http.alertAPI != alertAPI { + t.Fatal("expected alert api to be preserved") + } + if views.http.authMiddleware != authMiddleware { + t.Fatal("expected auth middleware to be preserved") + } + if views.http.rateLimitConfig != rateLimitConfig { + t.Fatal("expected rate limit config to be preserved") + } + if views.background.revocationSubscriber != subscriber { + t.Fatal("expected revocation subscriber to be preserved") + } + if views.background.tuning.outboxStreamName != tuning.outboxStreamName { + t.Fatalf("unexpected background outbox stream: %s", views.background.tuning.outboxStreamName) + } +} + func TestBuildRuntime_GroupsResourcesAndStartupViews(t *testing.T) { runtime, err := buildRuntimeWithFactory(RuntimeOptions{ Env: "dev",