package handlers // firmware_handler_test.go — M9-3 unit test // // 覆蓋: // 1. POST /api/devices/:id/firmware/upgrade // - 成功路徑(202 + taskID + progress 透過 WS broadcast) // - device not found(404) // - chip 不支援(400 / KL630 / KL730) // - service 拒絕:busy(409)/ unsupported chip(400)/ brick risk(500)/ generic fail(500) // 2. GET /api/firmware/active-tasks // - 空清單(hasActive=false、tasks=[]) // - 有 task(hasActive=true、tasks 完整欄位) // 3. DeriveFirmwareFields // - KDP1 legacy → IsLegacy=true / CanUpgrade=true // - KDP2 modern → IsLegacy=false / CanUpgrade=false // - 空 firmware → 保守不 legacy // - chip 不支援(KL630)→ CanUpgrade=false 即使 legacy // - 讀 VERSION 檔成功 / 失敗 fallback "unknown" // 4. WebSocket broadcast schema:{type:"firmware_progress", deviceId, stage, percent, ...} import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "visiona-local/server/internal/device" "visiona-local/server/internal/driver" "visiona-local/server/internal/firmware" "github.com/gin-gonic/gin" ) // ────────────────────────────────────────────────────────────────────── // Mocks // ────────────────────────────────────────────────────────────────────── // fakeFirmwareService 是 firmwareService interface 的 test stub。 type fakeFirmwareService struct { mu sync.Mutex // programmed behaviour upgradeErr error upgradeTaskID string upgradeProgress []firmware.FirmwareProgress activeTasks []*firmware.ActiveTaskInfo // observed upgradeCalls []upgradeCall cleanupCalls []string hasActiveCalls int } type upgradeCall struct { deviceID string chip string } func (f *fakeFirmwareService) UpgradeFirmware(_ context.Context, deviceID, chip string) (string, <-chan firmware.FirmwareProgress, error) { f.mu.Lock() f.upgradeCalls = append(f.upgradeCalls, upgradeCall{deviceID: deviceID, chip: chip}) err := f.upgradeErr taskID := f.upgradeTaskID progress := f.upgradeProgress f.mu.Unlock() if err != nil { return "", nil, err } ch := make(chan firmware.FirmwareProgress, len(progress)+1) for _, ev := range progress { ch <- ev } close(ch) return taskID, ch, nil } func (f *fakeFirmwareService) CleanupTask(deviceID string) { f.mu.Lock() f.cleanupCalls = append(f.cleanupCalls, deviceID) f.mu.Unlock() } func (f *fakeFirmwareService) HasActiveTask() bool { f.mu.Lock() defer f.mu.Unlock() f.hasActiveCalls++ return len(f.activeTasks) > 0 } func (f *fakeFirmwareService) GetActiveTaskInfo() []*firmware.ActiveTaskInfo { f.mu.Lock() defer f.mu.Unlock() return f.activeTasks } // fakeDeviceLookup 模擬 deviceLookupSource。 type fakeDeviceLookup struct { sessions map[string]*device.DeviceSession } func (f *fakeDeviceLookup) GetDevice(id string) (*device.DeviceSession, error) { s, ok := f.sessions[id] if !ok { return nil, errors.New("device not found: " + id) } return s, nil } // fakeDriver 是 driver.DeviceDriver 的 minimal stub、只填 Info()。 type fakeDriver struct { info driver.DeviceInfo } func (f *fakeDriver) Info() driver.DeviceInfo { return f.info } func (f *fakeDriver) Connect() error { return nil } func (f *fakeDriver) Disconnect() error { return nil } func (f *fakeDriver) IsConnected() bool { return false } func (f *fakeDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil } func (f *fakeDriver) StartInference() error { return nil } func (f *fakeDriver) StopInference() error { return nil } func (f *fakeDriver) ReadInference() (*driver.InferenceResult, error) { return nil, nil } func (f *fakeDriver) RunInference(_ []byte) (*driver.InferenceResult, error) { return nil, nil } func (f *fakeDriver) GetModelInfo() (*driver.ModelInfo, error) { return nil, nil } // spyBroadcasterFW 抓所有 BroadcastToRoom 呼叫、供斷言。 type spyBroadcasterFW struct { mu sync.Mutex calls []spyBroadcastCall } type spyBroadcastCall struct { room string data interface{} } func (s *spyBroadcasterFW) BroadcastToRoom(room string, data interface{}) { s.mu.Lock() defer s.mu.Unlock() s.calls = append(s.calls, spyBroadcastCall{room: room, data: data}) } func (s *spyBroadcasterFW) snapshot() []spyBroadcastCall { s.mu.Lock() defer s.mu.Unlock() out := make([]spyBroadcastCall, len(s.calls)) copy(out, s.calls) return out } // ────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────── func newTestFirmwareHandler(t *testing.T) (*FirmwareHandler, *fakeFirmwareService, *fakeDeviceLookup, *spyBroadcasterFW, string) { t.Helper() gin.SetMode(gin.TestMode) svc := &fakeFirmwareService{} lookup := &fakeDeviceLookup{sessions: make(map[string]*device.DeviceSession)} hub := &spyBroadcasterFW{} tmpDir := t.TempDir() h := NewFirmwareHandler(svc, lookup, hub, tmpDir) return h, svc, lookup, hub, tmpDir } func addFakeDevice(lookup *fakeDeviceLookup, id, devType, fwVer string) { lookup.sessions[id] = device.NewSession(&fakeDriver{ info: driver.DeviceInfo{ ID: id, Name: "fake-" + id, Type: devType, FirmwareVer: fwVer, }, }) } func writeVersionFile(t *testing.T, root, chip, version string) { t.Helper() chipDir := filepath.Join(root, chip) if err := os.MkdirAll(chipDir, 0o755); err != nil { t.Fatalf("mkdir %s: %v", chipDir, err) } if err := os.WriteFile(filepath.Join(chipDir, "VERSION"), []byte(version+"\n"), 0o644); err != nil { t.Fatalf("write VERSION: %v", err) } } // performUpgradeRequest 觸發 POST /api/devices/:id/firmware/upgrade、回 ResponseRecorder。 func performUpgradeRequest(h *FirmwareHandler, deviceID string) *httptest.ResponseRecorder { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: deviceID}} c.Request = httptest.NewRequest(http.MethodPost, "/api/devices/"+deviceID+"/firmware/upgrade", nil) h.UpgradeDevice(c) return w } func performListActiveTasksRequest(h *FirmwareHandler) *httptest.ResponseRecorder { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/api/firmware/active-tasks", nil) h.ListActiveTasks(c) return w } // ────────────────────────────────────────────────────────────────────── // 1. POST /api/devices/:id/firmware/upgrade // ────────────────────────────────────────────────────────────────────── func TestUpgradeDevice_Success(t *testing.T) { h, svc, lookup, hub, _ := newTestFirmwareHandler(t) addFakeDevice(lookup, "dev-1", "kneron_kl520", "KDP") svc.upgradeTaskID = "upgrade-dev-1-20260525" svc.upgradeProgress = []firmware.FirmwareProgress{ {DeviceID: "dev-1", Stage: firmware.StagePreparing, Percent: 5, Direction: firmware.DirectionUpgrade}, {DeviceID: "dev-1", Stage: firmware.StageLoading, Percent: 20, Direction: firmware.DirectionUpgrade}, {DeviceID: "dev-1", Stage: firmware.StageDone, Percent: 100, AfterVersion: "v2.2.0", Direction: firmware.DirectionUpgrade}, } w := performUpgradeRequest(h, "dev-1") if w.Code != http.StatusAccepted { t.Fatalf("status = %d, want 202; body=%s", w.Code, w.Body.String()) } var resp struct { Success bool `json:"success"` Data map[string]interface{} `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if !resp.Success { t.Errorf("success = false, want true") } if got := resp.Data["taskId"]; got != "upgrade-dev-1-20260525" { t.Errorf("taskId = %v, want upgrade-dev-1-20260525", got) } // service 收到正確 chip if len(svc.upgradeCalls) != 1 || svc.upgradeCalls[0].chip != firmware.ChipKL520 { t.Errorf("svc.upgradeCalls = %+v, want chip=KL520", svc.upgradeCalls) } // 等 broadcast goroutine 跑完 waitForBroadcasts(t, hub, 3, 2*time.Second) waitForCleanup(t, svc, "dev-1", 2*time.Second) calls := hub.snapshot() if len(calls) != 3 { t.Fatalf("broadcast calls = %d, want 3; %+v", len(calls), calls) } for i, c := range calls { if c.room != "firmware:dev-1" { t.Errorf("call[%d].room = %q, want firmware:dev-1", i, c.room) } // JSON round-trip 驗證 schema raw, _ := json.Marshal(c.data) var m map[string]interface{} if err := json.Unmarshal(raw, &m); err != nil { t.Errorf("call[%d] unmarshal: %v", i, err) continue } if m["type"] != "firmware_progress" { t.Errorf("call[%d].type = %v, want firmware_progress", i, m["type"]) } if m["deviceId"] != "dev-1" { t.Errorf("call[%d].deviceId = %v, want dev-1", i, m["deviceId"]) } if _, ok := m["stage"]; !ok { t.Errorf("call[%d] missing stage", i) } if _, ok := m["percent"]; !ok { t.Errorf("call[%d] missing percent", i) } } // 最終 done event:包含 afterVersion doneRaw, _ := json.Marshal(calls[2].data) var doneMsg map[string]interface{} _ = json.Unmarshal(doneRaw, &doneMsg) if doneMsg["afterVersion"] != "v2.2.0" { t.Errorf("done.afterVersion = %v, want v2.2.0", doneMsg["afterVersion"]) } } func TestUpgradeDevice_DeviceNotFound(t *testing.T) { h, _, _, _, _ := newTestFirmwareHandler(t) w := performUpgradeRequest(h, "nope") if w.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404; body=%s", w.Code, w.Body.String()) } // Reviewer M9-3 第 1 輪 Minor M-4:用 response JSON 結構 + 錯誤碼斷言、 // 不檢查 error message string(避免 device manager error wrapping 改變 // 時 test 不必要地破裂)。 var resp struct { Success bool `json:"success"` Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if resp.Success { t.Errorf("success = true, want false") } if resp.Error.Code != "DEVICE_NOT_FOUND" { t.Errorf("error.code = %q, want DEVICE_NOT_FOUND", resp.Error.Code) } } func TestUpgradeDevice_UnsupportedChip(t *testing.T) { cases := []struct { name string devType string }{ {"KL630", "kneron_kl630"}, {"KL730", "kneron_kl730"}, {"unknown", "kneron_unknown"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h, svc, lookup, _, _ := newTestFirmwareHandler(t) addFakeDevice(lookup, "dev-x", tc.devType, "KDP") w := performUpgradeRequest(h, "dev-x") if w.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) } // Reviewer M9-3 第 1 輪 Minor M-4:透過 JSON 結構斷言錯誤碼。 var resp struct { Error struct { Code string `json:"code"` } `json:"error"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if resp.Error.Code != "FW_UNSUPPORTED_CHIP" { t.Errorf("error.code = %q, want FW_UNSUPPORTED_CHIP", resp.Error.Code) } // service 不應該被呼叫 if len(svc.upgradeCalls) != 0 { t.Errorf("svc.upgradeCalls = %d, want 0 (handler should pre-filter)", len(svc.upgradeCalls)) } }) } } func TestUpgradeDevice_ServiceErrors(t *testing.T) { cases := []struct { name string svcErr error wantStatus int wantCode string }{ {"busy", firmware.ErrDeviceBusy, http.StatusConflict, "FW_DEVICE_BUSY"}, {"unsupported_at_service", firmware.ErrUnsupportedChip, http.StatusBadRequest, "FW_UNSUPPORTED_CHIP"}, {"brick_risk", firmware.ErrUpgradeBrickRisk, http.StatusInternalServerError, "FW_UPGRADE_BRICK_RISK"}, {"generic_fail", firmware.ErrUpgradeFailed, http.StatusInternalServerError, "FW_UPGRADE_FAILED"}, {"unknown", errors.New("boom"), http.StatusInternalServerError, "FW_UPGRADE_FAILED"}, {"not_found_at_service", firmware.ErrDeviceNotFound, http.StatusNotFound, "DEVICE_NOT_FOUND"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h, svc, lookup, _, _ := newTestFirmwareHandler(t) addFakeDevice(lookup, "dev-1", "kneron_kl520", "KDP") svc.upgradeErr = tc.svcErr w := performUpgradeRequest(h, "dev-1") if w.Code != tc.wantStatus { t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.wantStatus, w.Body.String()) } // Reviewer M9-3 第 1 輪 Minor M-4:JSON 結構斷言錯誤碼。 var resp struct { Error struct { Code string `json:"code"` } `json:"error"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if resp.Error.Code != tc.wantCode { t.Errorf("error.code = %q, want %q", resp.Error.Code, tc.wantCode) } }) } } // ────────────────────────────────────────────────────────────────────── // 2. GET /api/firmware/active-tasks // ────────────────────────────────────────────────────────────────────── func TestListActiveTasks_Empty(t *testing.T) { h, _, _, _, _ := newTestFirmwareHandler(t) w := performListActiveTasksRequest(h) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) } var resp struct { Success bool `json:"success"` Data struct { HasActive bool `json:"hasActive"` Tasks []firmware.ActiveTaskInfo `json:"tasks"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if !resp.Success { t.Errorf("success = false") } if resp.Data.HasActive { t.Errorf("hasActive = true, want false") } if len(resp.Data.Tasks) != 0 { t.Errorf("tasks = %d, want 0", len(resp.Data.Tasks)) } // tasks 不應為 null(前端較難處理) if !strings.Contains(w.Body.String(), `"tasks":[]`) { t.Errorf("tasks 應為 [] 而非 null: %s", w.Body.String()) } } func TestListActiveTasks_WithTasks(t *testing.T) { h, svc, _, _, _ := newTestFirmwareHandler(t) now := time.Now() svc.activeTasks = []*firmware.ActiveTaskInfo{ { TaskID: "upgrade-dev-1", DeviceID: "dev-1", DeviceName: "KL520 #1", Chip: firmware.ChipKL520, Direction: firmware.DirectionUpgrade, Stage: firmware.StageFlashing, StartTs: now, ElapsedMs: 5000, EtaSeconds: 30, }, } w := performListActiveTasksRequest(h) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) } var resp struct { Success bool `json:"success"` Data struct { HasActive bool `json:"hasActive"` Tasks []firmware.ActiveTaskInfo `json:"tasks"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if !resp.Data.HasActive { t.Errorf("hasActive = false, want true") } if len(resp.Data.Tasks) != 1 { t.Fatalf("tasks = %d, want 1", len(resp.Data.Tasks)) } got := resp.Data.Tasks[0] if got.DeviceID != "dev-1" { t.Errorf("task.deviceId = %q, want dev-1", got.DeviceID) } if got.Stage != firmware.StageFlashing { t.Errorf("task.stage = %q, want flashing", got.Stage) } if got.EtaSeconds != 30 { t.Errorf("task.etaSeconds = %d, want 30", got.EtaSeconds) } } // ────────────────────────────────────────────────────────────────────── // 3. DeriveFirmwareFields // ────────────────────────────────────────────────────────────────────── func TestDeriveFirmwareFields(t *testing.T) { h, _, _, _, tmpDir := newTestFirmwareHandler(t) writeVersionFile(t, tmpDir, "KL520", "v2.2.0") writeVersionFile(t, tmpDir, "KL720", "v2.2.0") // 注意:不寫 KL630 VERSION → 測試 fallback cases := []struct { name string devType string fwVer string wantLegacy bool wantCanUpgrade bool wantBundled string }{ { name: "KL520 KDP1 legacy → can upgrade", devType: "kneron_kl520", fwVer: "KDP", wantLegacy: true, wantCanUpgrade: true, wantBundled: "v2.2.0", }, { name: "KL520 KDP1.0 legacy 字串變體 → can upgrade", devType: "kneron_kl520", fwVer: "KDP1.0", wantLegacy: true, wantCanUpgrade: true, wantBundled: "v2.2.0", }, { name: "KL520 KDP2 modern → cannot upgrade", devType: "kneron_kl520", fwVer: "KDP2-v2.2.0", wantLegacy: false, wantCanUpgrade: false, wantBundled: "v2.2.0", }, { name: "KL520 已是 v2.2.0 → cannot upgrade", devType: "kneron_kl520", fwVer: "v2.2.0", wantLegacy: false, wantCanUpgrade: false, wantBundled: "v2.2.0", }, { name: "KL520 firmware 空字串 → 保守不 legacy", devType: "kneron_kl520", fwVer: "", wantLegacy: false, wantCanUpgrade: false, wantBundled: "v2.2.0", }, { name: "KL720 KDP1 legacy → can upgrade", devType: "kneron_kl720", fwVer: "KDP", wantLegacy: true, wantCanUpgrade: true, wantBundled: "v2.2.0", }, { name: "KL630 即使 KDP1 也 cannot upgrade(A 階段不支援)", devType: "kneron_kl630", fwVer: "KDP", wantLegacy: true, wantCanUpgrade: false, wantBundled: "unknown", // 無 VERSION 檔 }, { name: "未知 chip type → 保守不可升", devType: "kneron_unknown", fwVer: "KDP", wantLegacy: true, wantCanUpgrade: false, wantBundled: "unknown", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := h.DeriveFirmwareFields(tc.devType, tc.fwVer) // Reviewer M9-3 第 1 輪 Major-1:FirmwareDerivedFields 不再含 // firmwareVer 鍵;frontend 直接讀 DeviceInfo.firmwareVersion。 // 此處只斷言 3 個衍生欄位。 if got.FirmwareIsLegacy != tc.wantLegacy { t.Errorf("IsLegacy = %v, want %v", got.FirmwareIsLegacy, tc.wantLegacy) } if got.FirmwareCanUpgrade != tc.wantCanUpgrade { t.Errorf("CanUpgrade = %v, want %v", got.FirmwareCanUpgrade, tc.wantCanUpgrade) } if got.BundledFirmwareVersion != tc.wantBundled { t.Errorf("BundledFirmwareVersion = %q, want %q", got.BundledFirmwareVersion, tc.wantBundled) } }) } } func TestDeriveFirmwareFields_EmptyFirmwareDir(t *testing.T) { // firmwareDir = "" → bundled 永遠回 "unknown" gin.SetMode(gin.TestMode) svc := &fakeFirmwareService{} lookup := &fakeDeviceLookup{sessions: make(map[string]*device.DeviceSession)} hub := &spyBroadcasterFW{} h := NewFirmwareHandler(svc, lookup, hub, "") got := h.DeriveFirmwareFields("kneron_kl520", "KDP") if got.BundledFirmwareVersion != "unknown" { t.Errorf("BundledFirmwareVersion = %q, want unknown when firmwareDir is empty", got.BundledFirmwareVersion) } if !got.FirmwareIsLegacy { t.Errorf("IsLegacy = false, want true (KDP)") } if !got.FirmwareCanUpgrade { t.Errorf("CanUpgrade = false, want true (chip+legacy)") } // Major-1:FirmwareDerivedFields JSON 不應含 firmwareVer 鍵 raw, _ := json.Marshal(got) if strings.Contains(string(raw), `"firmwareVer"`) { t.Errorf("FirmwareDerivedFields JSON 不應含 firmwareVer 鍵;got: %s", string(raw)) } } // TestDeriveFirmwareFields_NoFirmwareVerKey 驗證 Reviewer M9-3 第 1 輪 // Major-1 修正:FirmwareDerivedFields JSON 不再含 firmwareVer 鍵(避免與 // driver.DeviceInfo 的 firmwareVersion 鍵衝突)。 func TestDeriveFirmwareFields_NoFirmwareVerKey(t *testing.T) { h, _, _, _, tmpDir := newTestFirmwareHandler(t) writeVersionFile(t, tmpDir, "KL520", "v2.2.0") got := h.DeriveFirmwareFields("kneron_kl520", "KDP") raw, err := json.Marshal(got) if err != nil { t.Fatalf("marshal: %v", err) } body := string(raw) // 不應該再含 firmwareVer 鍵 if strings.Contains(body, `"firmwareVer"`) { t.Errorf("FirmwareDerivedFields JSON 不應含 firmwareVer 鍵(避免與 DeviceInfo.firmwareVersion 衝突);got: %s", body) } // 但應含 3 個衍生欄位 for _, key := range []string{`"firmwareIsLegacy"`, `"firmwareCanUpgrade"`, `"bundledFirmwareVersion"`} { if !strings.Contains(body, key) { t.Errorf("FirmwareDerivedFields JSON 缺少 %s; got: %s", key, body) } } } // TestEnrichDevicesJSONOutput 驗證 device list endpoint 的 JSON schema: // 用 enrichDevices 模擬 ListDevices / ScanDevices 的內部組裝(不啟整個 gin // router),grep response JSON 確認: // - 有 "firmwareVersion" 鍵(DeviceInfo embedded、frontend SoT) // - 沒 "firmwareVer" 鍵(避免雙鍵衝突) // - 有 firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion 衍生欄位 // // Reviewer M9-3 第 1 輪 Major-1:必須補這個 schema 測試。 func TestEnrichDevicesJSONOutput(t *testing.T) { fwh, _, _, _, tmpDir := newTestFirmwareHandler(t) writeVersionFile(t, tmpDir, "KL520", "v2.2.0") dh := NewDeviceHandler(nil, nil, nil, nil) dh.SetFirmwareHandler(fwh) devices := []driver.DeviceInfo{ { ID: "dev-1", Name: "KL520 #1", Type: "kneron_kl520", FirmwareVer: "KDP", // legacy Status: driver.StatusDetected, }, } enriched := dh.enrichDevices(devices) if len(enriched) != 1 { t.Fatalf("enrichDevices returned %d entries, want 1", len(enriched)) } raw, err := json.Marshal(enriched[0]) if err != nil { t.Fatalf("marshal: %v", err) } body := string(raw) // 1. 必須有 firmwareVersion 鍵(來自 DeviceInfo.FirmwareVer json tag) if !strings.Contains(body, `"firmwareVersion":"KDP"`) { t.Errorf("缺少 firmwareVersion 鍵; got: %s", body) } // 2. 絕對不應有 firmwareVer 鍵(避免雙鍵衝突) if strings.Contains(body, `"firmwareVer"`) { t.Errorf("device JSON 不應含 firmwareVer 鍵(與 firmwareVersion 衝突); got: %s", body) } // 3. 衍生欄位齊全 for _, key := range []string{`"firmwareIsLegacy"`, `"firmwareCanUpgrade"`, `"bundledFirmwareVersion"`} { if !strings.Contains(body, key) { t.Errorf("device JSON 缺少 %s; got: %s", key, body) } } // 4. 既有 DeviceInfo 欄位也應該還在(不破壞既有 frontend client) for _, key := range []string{`"id":"dev-1"`, `"name":"KL520 #1"`, `"type":"kneron_kl520"`, `"status":"detected"`} { if !strings.Contains(body, key) { t.Errorf("device JSON 缺少 DeviceInfo 既有欄位 %s; got: %s", key, body) } } } // TestEnrichDevicesJSONOutput_NilFwHandler 驗證 fallback 路徑:fwHandler=nil // 時、JSON 也不應含 firmwareVer 鍵、firmwareVersion 應從 DeviceInfo 帶上。 func TestEnrichDevicesJSONOutput_NilFwHandler(t *testing.T) { dh := NewDeviceHandler(nil, nil, nil, nil) // 刻意不 SetFirmwareHandler → fwHandler 為 nil devices := []driver.DeviceInfo{ { ID: "dev-1", Type: "kneron_kl520", FirmwareVer: "KDP", Status: driver.StatusDetected, }, } enriched := dh.enrichDevices(devices) raw, _ := json.Marshal(enriched[0]) body := string(raw) if !strings.Contains(body, `"firmwareVersion":"KDP"`) { t.Errorf("nil fwHandler 時 firmwareVersion 應從 DeviceInfo 帶上; got: %s", body) } if strings.Contains(body, `"firmwareVer"`) { t.Errorf("nil fwHandler 時不應含 firmwareVer 鍵; got: %s", body) } if !strings.Contains(body, `"bundledFirmwareVersion":"unknown"`) { t.Errorf("nil fwHandler 時 bundled 應為 unknown; got: %s", body) } } // TestIsLegacyFirmware_BridgeParity 驗證 Go isLegacyFirmware 與 bridge.py // _fw_classify_legacy 對同樣 firmware 字串給出一致結果(Reviewer M9-3 // 第 1 輪 S-1:跨端 drift 防護)。 // // 真值表來源:kneron_bridge.py line 1463-1508 `_fw_classify_legacy`。 // 注意:bridge.py 把空字串 ""(USB Boot state 不回 firmware)視為 legacy、 // 但 Go list endpoint 看到空字串通常是 device 還沒 connect 過 — 保守當 // 非 legacy(不顯示升級按鈕);此差異已在 isLegacyFirmware godoc 註記。 func TestIsLegacyFirmware_BridgeParity(t *testing.T) { // product_id=0x0200 那條規則 Go 端不檢查(KL720 KDP legacy 由 chip // type + upgrade 流程處理、不在 list endpoint canUpgrade 邏輯內)。 cases := []struct { fw string wantLegacy bool note string }{ // 已知 KDP1 legacy 字串 {"KDP", true, "bridge.py legacy_exact"}, {"KDP1", true, "bridge.py legacy_exact"}, {"USB BOOT", true, "bridge.py legacy_exact"}, {"USB Boot", true, "case insensitive"}, {"USB BOOT LOADER", true, "bridge.py legacy_exact"}, {"LOADER", true, "bridge.py legacy_exact"}, {"BOOTLOADER", true, "bridge.py legacy_exact"}, // KDP1.x 變體 {"KDP1.0", true, "bridge.py KDP1. prefix"}, {"KDP1.5", true, "bridge.py KDP1. prefix"}, {"KDP1 alpha", true, "bridge.py KDP1 prefix"}, // KDP2-KDP9 明示放行 {"KDP2", false, "bridge.py KDP2 prefix → modern"}, {"KDP2.0", false, "bridge.py KDP2 prefix"}, {"KDP2-v2.2.0", false, "bridge.py KDP2 prefix"}, {"KDP3", false, "bridge.py KDP3 prefix → forward-compat"}, {"KDP3.1", false, "bridge.py KDP3 prefix → forward-compat"}, {"KDP9", false, "bridge.py KDP9 prefix → forward-compat"}, // 未知字串 → 保守不 legacy(bridge.py default) {"NEF", false, "bridge.py default for unknown"}, {"K3", false, "bridge.py default for unknown"}, {"v2.2.0", false, "version string only → not legacy"}, // Go 端對空字串的特例(保守、與 bridge.py 在 list endpoint 場景一致) {"", false, "Go list endpoint conservative (not connected yet)"}, } for _, tc := range cases { t.Run(tc.fw+"_"+tc.note, func(t *testing.T) { got := isLegacyFirmware(tc.fw) if got != tc.wantLegacy { t.Errorf("isLegacyFirmware(%q) = %v, want %v (%s)", tc.fw, got, tc.wantLegacy, tc.note) } }) } } // TestBundledVersionFor_CacheMissDoesNotPoisonRetry 驗證 Reviewer M9-3 // 第 1 輪 Minor M-3:cache miss 後不寫入 cache、下次 list device 會重試 // 並讀到新版本(情境:CI first build 時 firmware 檔還沒 ready)。 func TestBundledVersionFor_CacheMissDoesNotPoisonRetry(t *testing.T) { h, _, _, _, tmpDir := newTestFirmwareHandler(t) // 1. VERSION 檔還沒 ready → 應回 "unknown" got1 := h.bundledVersionFor("KL520") if got1 != "unknown" { t.Fatalf("first call (no VERSION file) = %q, want unknown", got1) } // 2. 確認沒有 cache 失敗結果(否則下次重試會永遠回 unknown) h.bundledMu.RLock() _, cached := h.bundledVersions["KL520"] h.bundledMu.RUnlock() if cached { t.Errorf("失敗結果不應寫入 cache(M-3);cache 含 KL520 entry") } // 3. 模擬 CI 後續產出 VERSION 檔 writeVersionFile(t, tmpDir, "KL520", "v2.2.0") // 4. 第二次呼叫應該讀到實際版本(重試成功) got2 := h.bundledVersionFor("KL520") if got2 != "v2.2.0" { t.Errorf("retry after VERSION ready = %q, want v2.2.0", got2) } // 5. 第三次應從 cache 命中(success 才 cache) h.bundledMu.RLock() cachedVal := h.bundledVersions["KL520"] h.bundledMu.RUnlock() if cachedVal != "v2.2.0" { t.Errorf("success 應 cache;cache value = %q", cachedVal) } } // TestBundledVersionFor_EmptyFileNotCached 驗證空 VERSION 檔(不合理狀態) // 也不應該污染 cache。 func TestBundledVersionFor_EmptyFileNotCached(t *testing.T) { h, _, _, _, tmpDir := newTestFirmwareHandler(t) // 寫空 VERSION 檔 chipDir := filepath.Join(tmpDir, "KL520") if err := os.MkdirAll(chipDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(chipDir, "VERSION"), []byte(""), 0o644); err != nil { t.Fatal(err) } got := h.bundledVersionFor("KL520") if got != "unknown" { t.Errorf("empty file = %q, want unknown", got) } h.bundledMu.RLock() _, cached := h.bundledVersions["KL520"] h.bundledMu.RUnlock() if cached { t.Errorf("空檔不應寫入 cache(避免下次重試永遠回 unknown)") } } // TestForwardGoroutine_ExitsOnChannelClose 驗證 Reviewer M9-3 第 1 輪 S-5: // service 端 close progressCh 後、forward goroutine 必須退出(不能 leak)。 // // 透過觀察 svc.CleanupTask 被呼叫 = forwardProgressToWS 從 for-range 退出 // 並執行了 deferred-like cleanup(程式碼是在 for 結束後同步呼叫、非 defer)。 func TestForwardGoroutine_ExitsOnChannelClose(t *testing.T) { h, svc, lookup, hub, _ := newTestFirmwareHandler(t) addFakeDevice(lookup, "dev-leak", "kneron_kl520", "KDP") svc.upgradeTaskID = "tk-leak" svc.upgradeProgress = []firmware.FirmwareProgress{ {DeviceID: "dev-leak", Stage: firmware.StagePreparing, Percent: 1}, {DeviceID: "dev-leak", Stage: firmware.StageDone, Percent: 100}, } w := performUpgradeRequest(h, "dev-leak") if w.Code != http.StatusAccepted { t.Fatalf("status = %d, want 202", w.Code) } // 等 broadcast + cleanup 都完成 = goroutine 已退出 waitForBroadcasts(t, hub, 2, 2*time.Second) waitForCleanup(t, svc, "dev-leak", 2*time.Second) // 直接斷言:cleanupCalls 含 dev-leak 且只一次(goroutine 退出後不會再 // 重複呼) svc.mu.Lock() count := 0 for _, id := range svc.cleanupCalls { if id == "dev-leak" { count++ } } svc.mu.Unlock() if count != 1 { t.Errorf("CleanupTask(dev-leak) 呼叫 %d 次,want 1(goroutine 該乾淨退出一次)", count) } } func TestChipFromDeviceType(t *testing.T) { cases := []struct { devType string want string }{ {"kneron_kl520", "KL520"}, {"kneron_kl720", "KL720"}, {"kneron_kl630", "KL630"}, {"kneron_kl730", "KL730"}, {"Kneron_KL520", "KL520"}, // case insensitive {"unknown", ""}, {"", ""}, } for _, tc := range cases { t.Run(tc.devType, func(t *testing.T) { got := ChipFromDeviceType(tc.devType) if got != tc.want { t.Errorf("ChipFromDeviceType(%q) = %q, want %q", tc.devType, got, tc.want) } }) } } // ────────────────────────────────────────────────────────────────────── // helpers:等 goroutine 完成 // ────────────────────────────────────────────────────────────────────── func waitForBroadcasts(t *testing.T, hub *spyBroadcasterFW, n int, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if len(hub.snapshot()) >= n { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("timeout waiting for %d broadcasts, got %d", n, len(hub.snapshot())) } func waitForCleanup(t *testing.T, svc *fakeFirmwareService, deviceID string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { svc.mu.Lock() for _, id := range svc.cleanupCalls { if id == deviceID { svc.mu.Unlock() return } } svc.mu.Unlock() time.Sleep(5 * time.Millisecond) } t.Fatalf("timeout waiting for CleanupTask(%q)", deviceID) }