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 的測試替身。 // // 可設定:回傳的即時 status(liveStatus)、回傳的 error(err),並記錄呼叫參數, // 用來驗證 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 回 error(local 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 驗證:沒有 fetcher(Forwarder/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) // 不注入 fetcher;fakeSessionStore 存在但 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 為 nil(handler 據此略過即時查詢)。 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) }