package main // shutdown_notify_test.go — MAJ-4 補丁:notifyShutdownImminent helper 單元測試 // // 驗證: // 1. port <= 0 → 直接 return,不發 HTTP(不 panic) // 2. reason=quit → 真的打到 /api/system/shutdown-notify?reason=quit // 3. server 無回應(sink hole)→ timeout 後不阻塞 caller // 4. server 回 5xx / 非 200 → 依然不阻塞 caller(best-effort) import ( "context" "net" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "sync" "testing" "time" ) func TestNotifyShutdownImminent_ZeroPortIsNoop(t *testing.T) { // port = 0 / 負數 → 直接 return,沒 panic 就代表正確 notifyShutdownImminent(context.Background(), 0, "quit") notifyShutdownImminent(context.Background(), -1, "quit") } func TestNotifyShutdownImminent_SendsPostWithReason(t *testing.T) { var ( mu sync.Mutex seenPath string seenQuery url.Values seenMethod string requestDone = make(chan struct{}, 1) ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() seenPath = r.URL.Path seenQuery = r.URL.Query() seenMethod = r.Method mu.Unlock() w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true,"reason":"quit"}`)) select { case requestDone <- struct{}{}: default: } })) defer srv.Close() port := portFromURL(t, srv.URL) notifyShutdownImminent(context.Background(), port, "quit") select { case <-requestDone: case <-time.After(2 * time.Second): t.Fatalf("server never received request") } mu.Lock() defer mu.Unlock() if seenMethod != http.MethodPost { t.Errorf("want POST, got %q", seenMethod) } if seenPath != "/api/system/shutdown-notify" { t.Errorf("wrong path: %q", seenPath) } if seenQuery.Get("reason") != "quit" { t.Errorf("wrong reason: %q", seenQuery.Get("reason")) } } func TestNotifyShutdownImminent_SendsReasonRestart(t *testing.T) { var seenReason string var mu sync.Mutex srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() seenReason = r.URL.Query().Get("reason") mu.Unlock() w.WriteHeader(http.StatusOK) })) defer srv.Close() notifyShutdownImminent(context.Background(), portFromURL(t, srv.URL), "restart") mu.Lock() defer mu.Unlock() if seenReason != "restart" { t.Errorf("want reason=restart, got %q", seenReason) } } func TestNotifyShutdownImminent_TimeoutDoesNotBlock(t *testing.T) { // 短 timeout,確保 caller 不會被卡住 orig := notifyShutdownImminentTimeout notifyShutdownImminentTimeout = 150 * time.Millisecond defer func() { notifyShutdownImminentTimeout = orig }() // Server 回應得「比 client timeout 慢一倍」,因此 client 應該先 timeout 回來 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(500 * time.Millisecond) w.WriteHeader(http.StatusOK) })) defer srv.Close() start := time.Now() done := make(chan struct{}) go func() { notifyShutdownImminent(context.Background(), portFromURL(t, srv.URL), "quit") close(done) }() select { case <-done: if elapsed := time.Since(start); elapsed > 400*time.Millisecond { t.Errorf("client timeout 沒生效 — elapsed=%v(應 < 400ms)", elapsed) } case <-time.After(1 * time.Second): t.Fatalf("notifyShutdownImminent 被卡住超過 1s") } } func TestNotifyShutdownImminent_ServerErrorIsBestEffort(t *testing.T) { // server 回 500 → caller 不該 panic,也不該有任何可見副作用 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) })) defer srv.Close() notifyShutdownImminent(context.Background(), portFromURL(t, srv.URL), "quit") } func TestNotifyShutdownImminent_ConnectionRefusedIsIgnored(t *testing.T) { // 啟一個 listener 後立刻關閉 → port 上沒服務 → caller 應靜默失敗 l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } port := l.Addr().(*net.TCPAddr).Port _ = l.Close() done := make(chan struct{}) go func() { notifyShutdownImminent(context.Background(), port, "quit") close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatalf("connection refused path should return quickly") } } // portFromURL 從 httptest.Server.URL(例 http://127.0.0.1:54321)抽出 port。 func portFromURL(t *testing.T, rawURL string) int { t.Helper() u, err := url.Parse(rawURL) if err != nil { t.Fatalf("parse url: %v", err) } portStr := u.Port() if portStr == "" { t.Fatalf("no port in URL %q", rawURL) } if strings.ContainsAny(portStr, "/") { t.Fatalf("bad port %q", portStr) } p, err := strconv.Atoi(portStr) if err != nil { t.Fatalf("bad port: %v", err) } return p }