package handlers // system_handler_test.go — MAJ-4 補丁:shutdown-notify endpoint 單元測試 // // 驗證 POST /api/system/shutdown-notify 的行為: // 1. reason=quit → 200 + 廣播 payload.reason = "quit" // 2. reason=restart → 200 + 廣播 payload.reason = "restart" // 3. reason=invalid / 空 → 200 + 廣播 payload.reason = "unknown" // 4. wsHub = nil → 仍回 200(不 panic) // // 用 spy broadcaster 代替 real *ws.Hub,避免測試需要真的 goroutine / channel。 import ( "encoding/json" "net/http" "net/http/httptest" "sync" "testing" "time" "github.com/gin-gonic/gin" ) func init() { gin.SetMode(gin.TestMode) } // spyBroadcaster 實作 shutdownNotifyBroadcaster,把每次呼叫紀錄起來供斷言。 type spyBroadcaster struct { mu sync.Mutex calls []spyCall } type spyCall struct { room string data map[string]interface{} } func (s *spyBroadcaster) BroadcastToRoom(room string, data interface{}) { s.mu.Lock() defer s.mu.Unlock() // 把 gin.H 轉成 map[string]interface{} 方便比對 m := map[string]interface{}{} switch v := data.(type) { case gin.H: for k, val := range v { m[k] = val } case map[string]interface{}: m = v default: // 最後一招:透過 JSON round-trip b, _ := json.Marshal(data) _ = json.Unmarshal(b, &m) } s.calls = append(s.calls, spyCall{room: room, data: m}) } func (s *spyBroadcaster) snapshot() []spyCall { s.mu.Lock() defer s.mu.Unlock() out := make([]spyCall, len(s.calls)) copy(out, s.calls) return out } // newTestHandler 組一個 SystemHandler 但用 spy broadcaster 替代 real Hub。 func newTestHandler(spy *spyBroadcaster) *SystemHandler { h := &SystemHandler{ startTime: time.Now(), version: "test", buildTime: "test-build", bootID: "test-boot-id", } if spy != nil { h.wsHub = spy } return h } // newTestRouter 建一個只掛 shutdown-notify 的最小 router。 func newTestRouter(h *SystemHandler) *gin.Engine { r := gin.New() r.POST("/api/system/shutdown-notify", h.ShutdownNotify) return r } // 整組測試前把 sleep 時間歸零,避免拖慢 test suite。 func withNoSleep(t *testing.T) { t.Helper() orig := shutdownNotifySleepDuration shutdownNotifySleepDuration = 0 t.Cleanup(func() { shutdownNotifySleepDuration = orig }) } func TestShutdownNotify_ReasonQuit(t *testing.T) { withNoSleep(t) spy := &spyBroadcaster{} h := newTestHandler(spy) r := newTestRouter(h) req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=quit", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("want 200, got %d; body=%s", w.Code, w.Body.String()) } var body struct { OK bool `json:"ok"` Reason string `json:"reason"` } if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("bad json response: %v", err) } if !body.OK || body.Reason != "quit" { t.Errorf("response fields wrong: %+v", body) } calls := spy.snapshot() if len(calls) != 1 { t.Fatalf("want 1 broadcast, got %d", len(calls)) } if calls[0].room != "system" { t.Errorf("want room=system, got %q", calls[0].room) } if calls[0].data["type"] != "server:shutdown-imminent" { t.Errorf("want type=server:shutdown-imminent, got %v", calls[0].data["type"]) } if calls[0].data["reason"] != "quit" { t.Errorf("want reason=quit, got %v", calls[0].data["reason"]) } if _, ok := calls[0].data["ts"]; !ok { t.Errorf("payload missing ts") } } func TestShutdownNotify_ReasonRestart(t *testing.T) { withNoSleep(t) spy := &spyBroadcaster{} h := newTestHandler(spy) r := newTestRouter(h) req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=restart", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("want 200, got %d", w.Code) } calls := spy.snapshot() if len(calls) != 1 || calls[0].data["reason"] != "restart" { t.Fatalf("want 1 broadcast reason=restart, got %+v", calls) } } func TestShutdownNotify_ReasonInvalid(t *testing.T) { withNoSleep(t) cases := []string{"", "halt", "kill9", "QUIT" /* 大小寫不同應視為 unknown */} for _, reasonQuery := range cases { name := reasonQuery if name == "" { name = "empty" } t.Run("reason_"+name, func(t *testing.T) { spy := &spyBroadcaster{} h := newTestHandler(spy) r := newTestRouter(h) url := "/api/system/shutdown-notify" if reasonQuery != "" { url += "?reason=" + reasonQuery } req := httptest.NewRequest(http.MethodPost, url, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("want 200, got %d", w.Code) } var body struct { OK bool `json:"ok"` Reason string `json:"reason"` } _ = json.Unmarshal(w.Body.Bytes(), &body) if body.Reason != "unknown" { t.Errorf("want response.reason=unknown, got %q", body.Reason) } calls := spy.snapshot() if len(calls) != 1 { t.Fatalf("want 1 broadcast, got %d", len(calls)) } if calls[0].data["reason"] != "unknown" { t.Errorf("want payload.reason=unknown, got %v", calls[0].data["reason"]) } }) } } func TestShutdownNotify_NoHub(t *testing.T) { withNoSleep(t) // wsHub = nil 代表單元測試或啟動失敗情境。handler 不應 panic,必須回 200。 h := newTestHandler(nil) r := newTestRouter(h) req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=quit", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("want 200 even without hub, got %d", w.Code) } } // TestShutdownNotify_DefaultSleepIsPositive 保護常數不被誤改為 0 在生產路徑上。 // 單元測試透過 withNoSleep 暫時設為 0,這裡只驗證原始預設值。 func TestShutdownNotify_DefaultSleepIsPositive(t *testing.T) { if shutdownNotifySleepDuration <= 0 { t.Errorf("shutdownNotifySleepDuration should be > 0 in production, got %v", shutdownNotifySleepDuration) } }