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>
251 lines
9.4 KiB
Go
251 lines
9.4 KiB
Go
package main
|
||
|
||
// query_firmware_active_tasks_test.go — M9-4.5:firmware 查詢 helper 測試
|
||
//
|
||
// 覆蓋情境:
|
||
// 1. port <= 0 → 立即 return hasActive=false(fail-open 預設值)
|
||
// 2. server 正常回 hasActive=true + tasks → 解析正確
|
||
// 3. server 正常回 hasActive=false + 空 tasks → tasks 非 nil(避免 caller 處理 nil panic)
|
||
// 4. server 回 500 → fail-open(hasActive=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)")
|
||
}
|
||
}
|