package api import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/session" ) // fakeSessionStore 是測試用 Store 實作,只回 List 結果;其他方法 panic 表示 // 不應被呼叫(以利早期偵錯)。 type fakeSessionStore struct { sessions []*session.Summary listErr error } func (f *fakeSessionStore) Register(context.Context, string, session.Handle) error { panic("Register should not be called") } func (f *fakeSessionStore) Unregister(context.Context, string) error { panic("Unregister should not be called") } func (f *fakeSessionStore) Lookup(context.Context, string) (session.Handle, error) { panic("Lookup should not be called") } func (f *fakeSessionStore) Exists(context.Context, string) (bool, error) { panic("Exists should not be called") } func (f *fakeSessionStore) List(context.Context) ([]*session.Summary, error) { return f.sessions, f.listErr } func (f *fakeSessionStore) Heartbeat(context.Context, string) error { panic("Heartbeat should not be called") } func (f *fakeSessionStore) CleanupExpired(context.Context, time.Duration) (int, error) { panic("CleanupExpired should not be called") } // fakePinger 是 /healthz 依賴 ping 的測試替身(純 Go,不需真 DB)。 type fakePinger struct{ err error } func (f fakePinger) Ping(context.Context) error { return f.err } // TestHealthzHandler 驗證 /healthz 在「無依賴」時回 200 + status:ok(退化為最小檢查)。 func TestHealthzHandler(t *testing.T) { r := gin.New() r.GET("/healthz", HealthzHandler(HealthDeps{})) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), `"status":"ok"`) } // TestHealthz_AllDepsHealthy 驗證 PG + Redis 都 ping 成功 → 200 + checks 全 ok。 func TestHealthz_AllDepsHealthy(t *testing.T) { r := gin.New() r.GET("/healthz", HealthzHandler(HealthDeps{ DBPool: fakePinger{}, Redis: fakePinger{}, })) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), `"postgres":"ok"`) assert.Contains(t, w.Body.String(), `"redis":"ok"`) } // TestHealthz_PostgresDown 驗證 PG ping 失敗 → 503(塊 5.4 fail-fast)。 func TestHealthz_PostgresDown(t *testing.T) { r := gin.New() r.GET("/healthz", HealthzHandler(HealthDeps{ DBPool: fakePinger{err: errors.New("connection refused")}, })) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) assert.Equal(t, http.StatusServiceUnavailable, w.Code) assert.Contains(t, w.Body.String(), `"postgres":"down"`) // 不洩漏 raw error 進 response(只進 log) assert.NotContains(t, w.Body.String(), "connection refused") } // TestHealthz_RedisDown 驗證 Redis 啟用但 ping 失敗 → 503;PG ok 仍標出 redis down。 func TestHealthz_RedisDown(t *testing.T) { r := gin.New() r.GET("/healthz", HealthzHandler(HealthDeps{ DBPool: fakePinger{}, Redis: fakePinger{err: errors.New("redis down")}, })) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) assert.Equal(t, http.StatusServiceUnavailable, w.Code) assert.Contains(t, w.Body.String(), `"redis":"down"`) assert.Contains(t, w.Body.String(), `"postgres":"ok"`) } // TestHealthz_RedisNotEnabled 驗證 Redis 未啟用(nil)→ 不檢查、PG ok 即 200。 func TestHealthz_RedisNotEnabled(t *testing.T) { r := gin.New() r.GET("/healthz", HealthzHandler(HealthDeps{ DBPool: fakePinger{}, // Redis nil — 未啟用,略過 })) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) assert.Equal(t, http.StatusOK, w.Code) assert.NotContains(t, w.Body.String(), "redis") } // TestSystemHealth_TunnelDisconnected 驗證沒 session 時回 connected=false。 func TestSystemHealth_TunnelDisconnected(t *testing.T) { r := gin.New() r.Use(RequestIDMiddleware()) g := r.Group("/api") registerSystemRoutes(g, Deps{ SessionStore: &fakeSessionStore{sessions: nil}, Logger: nil, }) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/health", nil)) require.Equal(t, http.StatusOK, w.Code) var body SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.True(t, body.Success) data, _ := body.Data.(map[string]any) require.NotNil(t, data) assert.Equal(t, "ok", data["api_server"]) assert.Equal(t, false, data["tunnel_connected"]) } // TestSystemHealth_TunnelConnected 驗證有 session 時回 connected=true 並帶 last_seen_at。 func TestSystemHealth_TunnelConnected(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) r := gin.New() r.Use(RequestIDMiddleware()) g := r.Group("/api") registerSystemRoutes(g, Deps{ SessionStore: &fakeSessionStore{ sessions: []*session.Summary{ {Token: "vAc_a", LastHeartbeat: now.Add(-5 * time.Second)}, {Token: "vAc_b", LastHeartbeat: now}, // 最新 }, }, }) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/health", nil)) require.Equal(t, http.StatusOK, w.Code) var body SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) data := body.Data.(map[string]any) assert.Equal(t, true, data["tunnel_connected"]) assert.EqualValues(t, 2, data["agent_session_count"]) assert.NotEmpty(t, data["agent_last_seen_at"]) } // TestSystemInfo 驗證 GET /api/system/info 回基本欄位。 func TestSystemInfo(t *testing.T) { r := gin.New() r.Use(RequestIDMiddleware()) g := r.Group("/api") registerSystemRoutes(g, Deps{}) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/info", nil)) require.Equal(t, http.StatusOK, w.Code) var body SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) data := body.Data.(map[string]any) assert.Equal(t, "visiona-api-server", data["service"]) assert.Equal(t, "phase-0-prototype", data["phase"]) }