fix(config+app): production fail-fast + readiness收紧

1. config.go: AI_CS_ENV runtime mode with production restriction
   - New RuntimeConfig.Env field (AI_CS_ENV / AI_CS_RUNTIME_ENV)
   - production + Postgres.Enabled=false → Load() returns error
   - production + empty webhook secret → Load() returns error
   - normalizeRuntimeEnv: dev/dev/ → development, prod/production → production, test → test

2. app.go: probe.SetReady only when store is confirmed ready
   - Postgres.Enabled: probe.SetReady(true) after DB+migration OK
   - Memory mode: probe.SetReady(false) — not production-ready

3. health_handler_test.go: add probe live+ready state transition tests

4. config_test.go: add TestLoad_RejectsProdWhenPostgresDisabled,
   TestLoad_RejectsProdWhenWebhookSecretMissing

5. app_test.go: add TestNew_RejectsMemoryModeWithoutExplicitNonProdEnv,
   TestNew_AllowsMemoryModeInTestEnv, TestNew_WithPostgresEnabled_*
   for invalid DSN and migration-failure paths

Phase 1 (code gate) objectives met:
 prod cannot fall back to memory store
 readiness reflects actual store readiness
 both changes have test coverage
This commit is contained in:
Your Name
2026-05-04 07:38:10 +08:00
parent ac44f826ca
commit 142b991334
17 changed files with 1242 additions and 343 deletions

View File

@@ -24,6 +24,7 @@ func minimalHTTPConfig() *config.Config {
cfg.HTTP.MaxHeaderBytes = 1 << 20
cfg.HTTP.MaxBodyBytes = 1 << 20
cfg.Postgres.Enabled = false
cfg.Runtime.Env = "test"
return cfg
}
@@ -38,16 +39,9 @@ func TestNew_NilConfig(t *testing.T) {
}
func TestNew_DefaultLogger(t *testing.T) {
cfg := &config.Config{}
cfg.HTTP.Addr = ":0"
cfg.HTTP.ReadHeaderTimeout = 5
cfg.HTTP.ReadTimeout = 10
cfg.HTTP.WriteTimeout = 15
cfg.HTTP.IdleTimeout = 60
cfg.HTTP.MaxHeaderBytes = 1 << 20
cfg.HTTP.MaxBodyBytes = 1 << 20
cfg := minimalHTTPConfig()
cfg.Webhook.Secret = "test-secret"
// Passing nil logger should not panic and should use default
app, err := New(cfg, nil)
if err != nil {
t.Fatalf("New() with nil logger failed: %v", err)
@@ -61,15 +55,8 @@ func TestNew_DefaultLogger(t *testing.T) {
}
func TestNew_WithPostgresDisabled(t *testing.T) {
cfg := &config.Config{}
cfg.HTTP.Addr = ":0"
cfg.HTTP.ReadHeaderTimeout = 5
cfg.HTTP.ReadTimeout = 10
cfg.HTTP.WriteTimeout = 15
cfg.HTTP.IdleTimeout = 60
cfg.HTTP.MaxHeaderBytes = 1 << 20
cfg.HTTP.MaxBodyBytes = 1 << 20
cfg.Postgres.Enabled = false
cfg := minimalHTTPConfig()
cfg.Webhook.Secret = "test-secret"
app, err := New(cfg, logging.New())
if err != nil {
@@ -86,7 +73,7 @@ func TestNew_WithPostgresDisabled(t *testing.T) {
}
}
func TestApp_TicketStore(t *testing.T) {
func TestNew_RejectsMemoryModeWithoutExplicitNonProdEnv(t *testing.T) {
cfg := &config.Config{}
cfg.HTTP.Addr = ":0"
cfg.HTTP.ReadHeaderTimeout = 5
@@ -96,6 +83,30 @@ func TestApp_TicketStore(t *testing.T) {
cfg.HTTP.MaxHeaderBytes = 1 << 20
cfg.HTTP.MaxBodyBytes = 1 << 20
cfg.Postgres.Enabled = false
cfg.Webhook.Secret = "test-secret"
_, err := New(cfg, logging.New())
if err == nil {
t.Fatal("expected error when runtime env is not explicitly non-prod for memory mode")
}
}
func TestNew_AllowsMemoryModeInTestEnv(t *testing.T) {
cfg := minimalHTTPConfig()
cfg.Webhook.Secret = "test-secret"
app, err := New(cfg, logging.New())
if err != nil {
t.Fatalf("New() failed in test env: %v", err)
}
if app == nil {
t.Fatal("expected non-nil app")
}
}
func TestApp_TicketStore(t *testing.T) {
cfg := minimalHTTPConfig()
cfg.Webhook.Secret = "test-secret"
app, err := New(cfg, logging.New())
if err != nil {
@@ -107,8 +118,6 @@ func TestApp_TicketStore(t *testing.T) {
t.Fatal("TicketStore() returned nil")
}
// Should be usable as ticketLister
// Just verify it's not nil and the type assertion works
_ = store
}
@@ -129,7 +138,6 @@ func TestApp_Shutdown_NilServer(t *testing.T) {
func TestApp_Shutdown_ServerShutdownCalled(t *testing.T) {
t.Run("server is shut down and stops accepting connections", func(t *testing.T) {
// Use a real httptest server to get a valid listener
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
listener := ts.Listener
ts.Close()
@@ -150,7 +158,6 @@ func TestApp_Shutdown_ServerShutdownCalled(t *testing.T) {
t.Fatalf("Shutdown returned unexpected error: %v", err)
}
// Verify the server is actually shut down by checking it no longer accepts connections
conn, err := net.Dial("tcp", listener.Addr().String())
if err == nil {
conn.Close()
@@ -215,7 +222,7 @@ func TestApp_Shutdown_ProbeSetNotReady(t *testing.T) {
Addr: listener.Addr().String(),
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
},
Probe: probe,
Probe: probe,
Logger: logging.New(),
}
@@ -234,6 +241,8 @@ func TestApp_Shutdown_ProbeSetNotReady(t *testing.T) {
func TestNew_WithPostgresEnabled_InvalidDSN(t *testing.T) {
cfg := minimalHTTPConfig()
cfg.Runtime.Env = "production"
cfg.Webhook.Secret = "test-secret"
cfg.Postgres.Enabled = true
cfg.Postgres.DSN = "invalid_dsn_format"
cfg.Postgres.MaxOpenConns = 5
@@ -248,8 +257,9 @@ func TestNew_WithPostgresEnabled_InvalidDSN(t *testing.T) {
func TestNew_WithPostgresEnabled_MigrationFails(t *testing.T) {
cfg := minimalHTTPConfig()
cfg.Runtime.Env = "production"
cfg.Webhook.Secret = "test-secret"
cfg.Postgres.Enabled = true
// Point to a db that exists but migration dir doesn't exist
cfg.Postgres.DSN = "host=127.0.0.1 port=9999 user=postgres dbname=nonexistent password=nonexistent sslmode=disable"
cfg.Postgres.MigrationDir = "/nonexistent/migration/dir"
cfg.Postgres.MaxOpenConns = 5