visionA/visionA-backend/internal/api/device_driver_status_test.go
jim800121chen 67737334c8 fix(device): GET /api/devices/:id proxy 拿即時 driver status(方案 Y-2,解載入模型 disabled)
問題:連線後裝置詳情頁連線狀態顯示 unknown、載入模型按鈕永遠 disabled。
根因=雲端 GET /api/devices/:id 是純 DB 讀、沒 proxy 到 local agent → DB 只有
tunnel 層 status(online/offline/unknown)、沒有 driver 七態(detected/
connected/...)→ 前端 gate isDriverConnected 永遠 false。(上輪 C1/C5 查證
假設 status 拿得到、沒追到寫入點的漏洞)

修法(方案 Y-2、local agent 零改、gate 零改):
- backend device_driver_status.go(新):driverStatusFetcher 介面 +
  forwarderDriverStatusFetcher(走既有 session.Forwarder proxy)+ envelope 解析
- devices.go devicesGetHandler:讀 DB metadata 後,device 有序號時額外 proxy
  打 local agent GET /api/devices/{serial}(serial 路由對齊 WP-C)拿即時 driver
  status 覆蓋 USBStatus;remoteStatus/tunnel_online 保留(offline banner 不壞)
- graceful fallback(全回 200 不掛 500):無序號/tunnel 離線/不可達/timeout/
  非2xx/success:false/空status/Forwarder未配置 → 保留 DB status + Debug log
- 2s 短 timeout(不拖詳情頁)、serial path url.PathEscape 防禦
- frontend:DeviceHardwareStatus 加 unknown + coerceHardwareStatus(非七值→
  unknown)+ normalizeDevice fallback disconnected→unknown(修誤顯未連接)+
  i18n devices.status.unknown 兩語系(未確認/Unknown)

Reviewer 通過(0C/0M/3Mi/2Sug、Y-2 10/10、端到端追證 gate 放行 + 8 fallback
分支無一掛 500)。backend 8 測試 + frontend 49 passed、build/vet/test 綠、
gitleaks 0。端到端「即時 connected 覆蓋 unknown」需在線 agent+登入實測(單元
測試已覆蓋合併+fallback)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:25:26 +08:00

205 lines
8.0 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 api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/device"
"visiona-backend/internal/session"
)
// stubDriverStatusFetcher 是 driverStatusFetcher 的測試替身。
//
// 可設定:回傳的即時 statusliveStatus、回傳的 errorerr並記錄呼叫參數
// 用來驗證 handler 是否有嘗試查詢、以及傳的序號 / userID 正確。
type stubDriverStatusFetcher struct {
liveStatus string
err error
called bool
gotUserID string
gotSerial string
}
func (s *stubDriverStatusFetcher) FetchDriverStatus(_ context.Context, userID, serial string) (string, error) {
s.called = true
s.gotUserID = userID
s.gotSerial = serial
return s.liveStatus, s.err
}
// getDeviceStatus 打 GET /api/devices/:id 並解出回應的 data.status 欄。
func getDeviceStatus(t *testing.T, r *gin.Engine, id string) (int, string) {
t.Helper()
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/devices/"+id, nil))
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb), "body=%s", w.Body.String())
if w.Code != http.StatusOK {
return w.Code, ""
}
data, ok := sb.Data.(map[string]any)
require.True(t, ok, "data 應為物件,實際 body=%s", w.Body.String())
status, _ := data["status"].(string)
return w.Code, status
}
// newGetDeviceFixture 建 router + 塞一顆 device可注入自訂 Deps 欄位fetcher
func newGetDeviceFixture(t *testing.T, d *device.Device, mutate func(*Deps)) *gin.Engine {
t.Helper()
repo := device.NewInMemoryRepository()
require.NoError(t, repo.Save(context.Background(), d))
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext(d.OwnerUserID, ""))
g := r.Group("/api")
deps := Deps{
DeviceRepo: repo,
SessionStore: &fakeSessionStore{},
}
if mutate != nil {
mutate(&deps)
}
registerDeviceRoutes(g, deps)
return r
}
// TestGetDevice_ProxySuccess_OverridesDBStatus 驗證proxy 成功拿到即時 driver status
// "connected")時,回應的 status 用即時值覆蓋 DB 的靜態值("unknown")。
//
// 這是 gate isDriverConnected 能通過的核心路徑DB unknown → 即時 connected
func TestGetDevice_ProxySuccess_OverridesDBStatus(t *testing.T) {
fetcher := &stubDriverStatusFetcher{liveStatus: "connected"}
r := newGetDeviceFixture(t, &device.Device{
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
SerialNumber: "0xB906162C",
RemoteStatus: device.RemoteStatusOnline,
Status: device.USBStatusUnknown, // DB 靜態值
CreatedAt: time.Now().UTC(),
}, func(d *Deps) { d.DriverStatusFetcher = fetcher })
code, status := getDeviceStatus(t, r, "dev1")
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "connected", status, "應以即時 driver status 覆蓋 DB unknown")
assert.True(t, fetcher.called, "應嘗試打 local agent")
assert.Equal(t, "0xB906162C", fetcher.gotSerial, "應以序號走 serial 路由")
assert.Equal(t, "demo-user", fetcher.gotUserID, "應帶當前 user")
}
// TestGetDevice_ProxyFails_FallbackDBStatus 驗證proxy 回 errorlocal agent 不可達 /
// 該序號無 session時 graceful fallback——回 DB 的 status且 GET :id 仍是 200。
func TestGetDevice_ProxyFails_FallbackDBStatus(t *testing.T) {
fetcher := &stubDriverStatusFetcher{err: errDriverStatusUnavailable}
r := newGetDeviceFixture(t, &device.Device{
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
SerialNumber: "0xB906162C",
RemoteStatus: device.RemoteStatusOnline,
Status: device.USBStatusOnline, // DB 靜態值
CreatedAt: time.Now().UTC(),
}, func(d *Deps) { d.DriverStatusFetcher = fetcher })
code, status := getDeviceStatus(t, r, "dev1")
require.Equal(t, http.StatusOK, code, "proxy 失敗不能讓詳情頁掛")
assert.Equal(t, device.USBStatusOnline, status, "應 fallback 回 DB status")
assert.True(t, fetcher.called)
}
// TestGetDevice_TunnelDisconnected_FallbackDBStatus 驗證tunnel 離線
// fetcher 回 session.ErrSessionNotFound時 fallback DB status、仍回 200。
func TestGetDevice_TunnelDisconnected_FallbackDBStatus(t *testing.T) {
fetcher := &stubDriverStatusFetcher{err: session.ErrSessionNotFound}
r := newGetDeviceFixture(t, &device.Device{
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
SerialNumber: "0xB906162C",
RemoteStatus: device.RemoteStatusOffline,
Status: device.USBStatusUnknown,
CreatedAt: time.Now().UTC(),
}, func(d *Deps) { d.DriverStatusFetcher = fetcher })
code, status := getDeviceStatus(t, r, "dev1")
require.Equal(t, http.StatusOK, code)
assert.Equal(t, device.USBStatusUnknown, status, "tunnel 離線應 fallback DB unknown")
assert.True(t, errors.Is(fetcher.err, session.ErrSessionNotFound))
}
// TestGetDevice_EmptyLiveStatus_FallbackDBStatus 驗證proxy 成功但回空 status
// (不該覆蓋成空字串)時,保留 DB status。
func TestGetDevice_EmptyLiveStatus_FallbackDBStatus(t *testing.T) {
fetcher := &stubDriverStatusFetcher{liveStatus: ""}
r := newGetDeviceFixture(t, &device.Device{
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
SerialNumber: "0xB906162C",
RemoteStatus: device.RemoteStatusOnline,
Status: device.USBStatusOnline,
CreatedAt: time.Now().UTC(),
}, func(d *Deps) { d.DriverStatusFetcher = fetcher })
code, status := getDeviceStatus(t, r, "dev1")
require.Equal(t, http.StatusOK, code)
assert.Equal(t, device.USBStatusOnline, status, "空即時 status 不該覆蓋 DB status")
}
// TestGetDevice_NoSerial_SkipsProxy 驗證device 無序號時完全不打 local agent
// (無序號不支援 serial 路由),直接回 DB status。
func TestGetDevice_NoSerial_SkipsProxy(t *testing.T) {
fetcher := &stubDriverStatusFetcher{liveStatus: "connected"}
r := newGetDeviceFixture(t, &device.Device{
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
SerialNumber: "", // 無序號
RemoteStatus: device.RemoteStatusOnline,
Status: device.USBStatusUnknown,
CreatedAt: time.Now().UTC(),
}, func(d *Deps) { d.DriverStatusFetcher = fetcher })
code, status := getDeviceStatus(t, r, "dev1")
require.Equal(t, http.StatusOK, code)
assert.Equal(t, device.USBStatusUnknown, status, "無序號回 DB status")
assert.False(t, fetcher.called, "無序號不該打 local agent")
}
// TestGetDevice_NoFetcher_ReturnsDBStatus 驗證:沒有 fetcherForwarder/SessionStore
// 未配置resolveDriverStatusFetcher 回 nil時 handler 不 panic直接回 DB status。
func TestGetDevice_NoFetcher_ReturnsDBStatus(t *testing.T) {
r := newGetDeviceFixture(t, &device.Device{
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
SerialNumber: "0xB906162C",
RemoteStatus: device.RemoteStatusOnline,
Status: device.USBStatusOnline,
CreatedAt: time.Now().UTC(),
}, nil) // 不注入 fetcherfakeSessionStore 存在但 Forwarder 為 nil → newForwarder... 回 nil
code, status := getDeviceStatus(t, r, "dev1")
require.Equal(t, http.StatusOK, code)
assert.Equal(t, device.USBStatusOnline, status)
}
// TestResolveDriverStatusFetcher_NilWhenNoForwarder 驗證Forwarder 為 nil 時
// default fetcher 為 nilhandler 據此略過即時查詢)。
func TestResolveDriverStatusFetcher_NilWhenNoForwarder(t *testing.T) {
assert.Nil(t, resolveDriverStatusFetcher(Deps{SessionStore: &fakeSessionStore{}}),
"Forwarder 為 nil 應回 nil fetcher")
assert.Nil(t, resolveDriverStatusFetcher(Deps{}),
"Forwarder + SessionStore 皆 nil 應回 nil fetcher")
}
// TestResolveDriverStatusFetcher_InjectedWins 驗證Deps.DriverStatusFetcher 非 nil 時
// 優先用注入的 stub不 fallback default
func TestResolveDriverStatusFetcher_InjectedWins(t *testing.T) {
stub := &stubDriverStatusFetcher{}
got := resolveDriverStatusFetcher(Deps{DriverStatusFetcher: stub})
assert.Same(t, stub, got)
}