從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑: tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。 Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local), 雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。 Backend / Wails Go(AB1-AB13): - internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped) + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event - internal/auth:encrypted file token store(AES-GCM + scrypt + machineID fallback salt + 13 tests) - internal/config:YAML validation + atomic write + 11 tests - internal/log:ring buffer + ExportLog 升級 zip - visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests - 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage) - end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護 → tunnel drop failover) Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎): - AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab) - ConnectionStatusBadge 5 種狀態 - TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁 - 設定頁 4 區塊(含重新配對 AlertDialog) - agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests Phase 0.7 review-driven fix(Round 2): - A1 Session fixation 防護(RotateSessionID) - A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log - A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態) - A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test - F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL / F4 Settings draft 持久 + 未儲存 badge 驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 / agent frontend pnpm test 119 tests 全綠 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
5.9 KiB
Go
232 lines
5.9 KiB
Go
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)
|
||
}
|
||
}
|