visionA/local-tool/visiona-local/query_firmware_active_tasks_test.go
jim800121chen ff9bbc81ed feat(local-tool): M9-4.5 — server SIGTERM + Wails OnBeforeClose firmware-aware shutdown
A 階段尾端 milestone、雙層防護避免使用者在 firmware 升級進行中關閉 app 造成 dongle brick。

Server 端 (3 改):
- main.go: SIGTERM/SIGINT goroutine 加 firmware-aware preamble
- server/internal/firmware/shutdown.go: 新 211 行(AwaitActiveTasksOrTimeout + 3 interfaces + shutdownBroadcastTask minimal struct + toBroadcastTasks helper)
- server/internal/firmware/shutdown_test.go: 新 384 行、8 tests

Wails 端 (3 新 + 2 改):
- visiona-local/main.go: OnBeforeClose 從 inline → app.OnBeforeClose
- visiona-local/app.go: App struct 加 firmwareCloseGuard
- visiona-local/firmware_close_guard.go: 新 244 行(CloseGuard + OnBeforeClose + ConfirmForceClose)
- visiona-local/firmware_close_guard_test.go: 新 280 行、8 tests
- visiona-local/query_firmware_active_tasks.go: 新 111 行(HTTP helper、fail-open、1s timeout)
- visiona-local/query_firmware_active_tasks_test.go: 新 250 行、7 tests

行為:
- Server SIGTERM 有 active task → broadcast `server:shutdown-pending` to "system" room → RequestShutdown + WaitForActiveTasks(220s) → 走原本 shutdownFn
- Wails OnBeforeClose 有 active task → emit Wails event `app:firmware-in-progress` + return true 擋住關閉
- ConfirmForceClose binding 給 frontend 第二層 FORCE 確認用、走 graceful 7+1s shutdown(不是 SIGKILL bypass、雙層防護)

Reviewer 兩輪審查:
- Round 1: 0 Critical / 1 Major / 3 Minor / 4 Suggestion
- 第 2 輪修法(3 sub-agent 平行):
  - Architect: TDD §8.6 改 event 名 `firmware:shutdown-rejected` → `server:shutdown-pending`、標題「拒絕」→「延遲」、補 payload schema 註明 tasks 不含 startTs
  - Design: control-panel.md §6a 改「SIGKILL bypass」→「graceful 7+1s 雙層防護」、補「為何不採 SIGKILL」5 點設計理由、§6a.11 IPC 規格對齊
  - Backend: MaxShutdownWait 180s → 220s(KL720 200s upgrade + 20s buffer)+ broadcast 過濾 startTs(shutdownBroadcastTask minimal struct + toBroadcastTasks helper)

測試:
- server: go test ./... -race 全綠(firmware 2.7s + api/ws/handlers)
- wails: go test ./... -race 全綠(visiona-local 11.2s、21 tests)
- 合計新增 23 unit tests race-clean、0 regression

下一步: M9-5 三平台實機驗證 + 順手修 MJ3(backend smoke test schema phase→stage / firmware:progress→firmware_progress)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:07:29 +08:00

251 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// query_firmware_active_tasks_test.go — M9-4.5firmware 查詢 helper 測試
//
// 覆蓋情境:
// 1. port <= 0 → 立即 return hasActive=falsefail-open 預設值)
// 2. server 正常回 hasActive=true + tasks → 解析正確
// 3. server 正常回 hasActive=false + 空 tasks → tasks 非 nil避免 caller 處理 nil panic
// 4. server 回 500 → fail-openhasActive=false+ error
// 5. server timeout → fail-open + error不卡 Wails 關閉流程)
// 6. server 回非預期 JSON → fail-open + error
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
)
func portFromTestServerURL(t *testing.T, raw string) int {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse test server url: %v", err)
}
p, err := strconv.Atoi(u.Port())
if err != nil {
t.Fatalf("port atoi: %v", err)
}
return p
}
// ──────────────────────────────────────────────────────────────────────
// 1. port <= 0 → 立即 fail-open
// ──────────────────────────────────────────────────────────────────────
func TestQueryFirmwareActiveTasks_PortZero(t *testing.T) {
res, err := queryFirmwareActiveTasks(context.Background(), 0)
if err == nil {
t.Errorf("expected error when port=0")
}
if res.HasActive {
t.Errorf("expected hasActive=false on port=0")
}
}
func TestQueryFirmwareActiveTasks_PortNegative(t *testing.T) {
res, err := queryFirmwareActiveTasks(context.Background(), -1)
if err == nil {
t.Errorf("expected error when port<0")
}
if res.HasActive {
t.Errorf("expected hasActive=false on port<0")
}
}
// ──────────────────────────────────────────────────────────────────────
// 2. server 正常回 hasActive=true + tasks
// ──────────────────────────────────────────────────────────────────────
func TestQueryFirmwareActiveTasks_HasActiveWithTasks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/firmware/active-tasks" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"hasActive": true,
"tasks": []map[string]interface{}{
{
"taskId": "upgrade-KL520-0",
"deviceId": "kl520-0",
"deviceName": "KL520 #1",
"chip": "KL520",
"direction": "upgrade",
"stage": "flashing",
"elapsedMs": int64(12000),
"etaSeconds": 45,
},
},
},
})
}))
defer srv.Close()
port := portFromTestServerURL(t, srv.URL)
res, err := queryFirmwareActiveTasks(context.Background(), port)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.HasActive {
t.Errorf("expected hasActive=true")
}
if len(res.Tasks) != 1 {
t.Fatalf("expected 1 task, got %d", len(res.Tasks))
}
tk := res.Tasks[0]
if tk.TaskID != "upgrade-KL520-0" || tk.DeviceID != "kl520-0" || tk.Chip != "KL520" {
t.Errorf("task decoded wrong: %+v", tk)
}
if tk.Direction != "upgrade" || tk.Stage != "flashing" {
t.Errorf("task direction/stage decoded wrong: %+v", tk)
}
if tk.EtaSeconds != 45 {
t.Errorf("expected etaSeconds=45, got %d", tk.EtaSeconds)
}
}
// ──────────────────────────────────────────────────────────────────────
// 3. server 回 hasActive=false + null tasks → tasks 非 nil
// ──────────────────────────────────────────────────────────────────────
func TestQueryFirmwareActiveTasks_HasActiveFalseNullTasks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"hasActive": false,
"tasks": nil,
},
})
}))
defer srv.Close()
port := portFromTestServerURL(t, srv.URL)
res, err := queryFirmwareActiveTasks(context.Background(), port)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.HasActive {
t.Errorf("expected hasActive=false")
}
if res.Tasks == nil {
t.Errorf("expected Tasks to be non-nil (empty slice), got nil")
}
}
// ──────────────────────────────────────────────────────────────────────
// 4. server 回 500 → fail-open
// ──────────────────────────────────────────────────────────────────────
func TestQueryFirmwareActiveTasks_ServerError500(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintln(w, "internal error")
}))
defer srv.Close()
port := portFromTestServerURL(t, srv.URL)
res, err := queryFirmwareActiveTasks(context.Background(), port)
if err == nil {
t.Errorf("expected error on 500")
}
if res.HasActive {
t.Errorf("expected hasActive=false on 500 (fail-open)")
}
}
// ──────────────────────────────────────────────────────────────────────
// 5. server timeout → fail-open + error
// ──────────────────────────────────────────────────────────────────────
func TestQueryFirmwareActiveTasks_Timeout(t *testing.T) {
// 縮短 helper timeout
origTimeout := queryFirmwareTasksTimeout
origClient := queryFirmwareTasksClient
queryFirmwareTasksTimeout = 100 * time.Millisecond
queryFirmwareTasksClient = &http.Client{Timeout: queryFirmwareTasksTimeout}
defer func() {
queryFirmwareTasksTimeout = origTimeout
queryFirmwareTasksClient = origClient
}()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(500 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
port := portFromTestServerURL(t, srv.URL)
done := make(chan struct{})
go func() {
res, err := queryFirmwareActiveTasks(context.Background(), port)
if err == nil {
t.Errorf("expected timeout error")
}
if res.HasActive {
t.Errorf("expected hasActive=false on timeout (fail-open)")
}
close(done)
}()
select {
case <-done:
// ok
case <-time.After(2 * time.Second):
t.Fatalf("queryFirmwareActiveTasks blocked > 2s, timeout not respected")
}
}
// ──────────────────────────────────────────────────────────────────────
// 6. server 回非預期 JSON → fail-open + error
// ──────────────────────────────────────────────────────────────────────
func TestQueryFirmwareActiveTasks_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("{this is not json"))
}))
defer srv.Close()
port := portFromTestServerURL(t, srv.URL)
res, err := queryFirmwareActiveTasks(context.Background(), port)
if err == nil {
t.Errorf("expected decode error on malformed JSON")
}
if res.HasActive {
t.Errorf("expected hasActive=false on decode error (fail-open)")
}
}
// ──────────────────────────────────────────────────────────────────────
// 7. success=false 即使 200 → 視為 error
// ──────────────────────────────────────────────────────────────────────
func TestQueryFirmwareActiveTasks_SuccessFalse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"data": map[string]interface{}{"hasActive": false},
})
}))
defer srv.Close()
port := portFromTestServerURL(t, srv.URL)
res, err := queryFirmwareActiveTasks(context.Background(), port)
if err == nil {
t.Errorf("expected error when success=false")
}
if res.HasActive {
t.Errorf("expected hasActive=false (fail-open)")
}
}