package api import ( "context" "encoding/json" "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" ) // newRegisterFixture 建 router(InMemory repo + 無 session),user context = demo-user。 // 回傳 router + repo 供測試直接塞 device / 驗 registered_at。 func newRegisterFixture(t *testing.T) (*gin.Engine, *device.InMemoryRepository) { t.Helper() repo := device.NewInMemoryRepository() r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext("demo-user", "")) g := r.Group("/api") registerDeviceRoutes(g, Deps{ DeviceRepo: repo, SessionStore: &fakeSessionStore{}, }) return r, repo } func postRegister(t *testing.T, r *gin.Engine, path string) *httptest.ResponseRecorder { t.Helper() w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil)) return w } // errCodeOf 解析錯誤回應的 error.code。 func errCodeOf(t *testing.T, w *httptest.ResponseRecorder) string { t.Helper() var eb ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &eb), "body=%s", w.Body.String()) require.NotNil(t, eb.Error) return eb.Error.Code } // dataItemOf 解析成功回應的 data(DeviceListItem map)。 func dataItemOf(t *testing.T, w *httptest.ResponseRecorder) map[string]any { t.Helper() var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb), "body=%s", w.Body.String()) item, ok := sb.Data.(map[string]any) require.True(t, ok, "data should be object, body=%s", w.Body.String()) return item } // --------------------------------------------------------------------------- // register // --------------------------------------------------------------------------- // TestRegister_Success 未註冊 → register → 200 且 registered_at 非 null。 func TestRegister_Success(t *testing.T) { r, repo := newRegisterFixture(t) require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "d1", OwnerUserID: "demo-user", Name: "usb", DeviceType: "kl520", SerialNumber: "0xAAAA", // 未註冊:RegisteredAt 留 nil })) w := postRegister(t, r, "/api/devices/d1/register") require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) item := dataItemOf(t, w) assert.Equal(t, "d1", item["id"]) assert.NotNil(t, item["registered_at"], "register 後 registered_at 應非 null") assert.NotEmpty(t, item["registered_at"]) // repo 端也確認翻轉。 got, err := repo.Get(context.Background(), "d1") require.NoError(t, err) require.NotNil(t, got.RegisteredAt) } // TestRegister_AlreadyRegistered 已註冊再 register → 409 ALREADY_REGISTERED。 func TestRegister_AlreadyRegistered(t *testing.T) { r, repo := newRegisterFixture(t) past := time.Now().UTC().Add(-time.Hour) require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA", RegisteredAt: &past, })) w := postRegister(t, r, "/api/devices/d1/register") require.Equal(t, http.StatusConflict, w.Code) assert.Equal(t, ErrCodeAlreadyRegistered, errCodeOf(t, w)) } // TestRegister_NotOwner 非 owner → 403 FORBIDDEN(IDOR 主防線)。 func TestRegister_NotOwner(t *testing.T) { r, repo := newRegisterFixture(t) require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "d1", OwnerUserID: "someone-else", Name: "usb", SerialNumber: "0xAAAA", })) w := postRegister(t, r, "/api/devices/d1/register") require.Equal(t, http.StatusForbidden, w.Code) assert.Equal(t, ErrCodeForbidden, errCodeOf(t, w)) } // TestRegister_NotFound device 不存在 → 404。 func TestRegister_NotFound(t *testing.T) { r, _ := newRegisterFixture(t) w := postRegister(t, r, "/api/devices/ghost/register") require.Equal(t, http.StatusNotFound, w.Code) assert.Equal(t, ErrCodeNotFound, errCodeOf(t, w)) } // TestRegister_Representative representative device → 409 REPRESENTATIVE_DEVICE。 func TestRegister_Representative(t *testing.T) { r, repo := newRegisterFixture(t) require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "rep", OwnerUserID: "demo-user", Name: "agent", IsRepresentative: true, })) w := postRegister(t, r, "/api/devices/rep/register") require.Equal(t, http.StatusConflict, w.Code) assert.Equal(t, ErrCodeRepresentativeDevice, errCodeOf(t, w), "representative 用 REPRESENTATIVE_DEVICE 碼區分於 ALREADY_REGISTERED") } // TestRegister_MissingUserContext 缺 UserContext → 500(auth 沒配好不可 fallthrough)。 func TestRegister_MissingUserContext(t *testing.T) { repo := device.NewInMemoryRepository() require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA", })) r := gin.New() r.Use(RequestIDMiddleware()) // 刻意不注入 UserContext。 g := r.Group("/api") registerDeviceRoutes(g, Deps{DeviceRepo: repo, SessionStore: &fakeSessionStore{}}) w := postRegister(t, r, "/api/devices/d1/register") require.Equal(t, http.StatusInternalServerError, w.Code) assert.Equal(t, ErrCodeInternalError, errCodeOf(t, w)) } // --------------------------------------------------------------------------- // unregister // --------------------------------------------------------------------------- // TestUnregister_Success 已註冊 → unregister → 200 且 registered_at=null,device 仍在 List。 func TestUnregister_Success(t *testing.T) { r, repo := newRegisterFixture(t) past := time.Now().UTC().Add(-time.Hour) require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA", RegisteredAt: &past, })) w := postRegister(t, r, "/api/devices/d1/unregister") require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) item := dataItemOf(t, w) assert.Nil(t, item["registered_at"], "unregister 後 registered_at 應為 null") // device 仍存在(未軟刪、保留列)。 got, err := repo.Get(context.Background(), "d1") require.NoError(t, err, "unregister 不軟刪、device 應仍在") assert.Nil(t, got.RegisteredAt) // 仍列在 List。 list, err := repo.List(context.Background(), "demo-user") require.NoError(t, err) require.Len(t, list, 1, "unregister 後 device 仍在清單(與 unpair 不同)") } // TestUnregister_IdempotentWhenUnregistered 未註冊 → unregister → 200 冪等 no-op。 func TestUnregister_IdempotentWhenUnregistered(t *testing.T) { r, repo := newRegisterFixture(t) require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA", // RegisteredAt nil = 未註冊 })) w := postRegister(t, r, "/api/devices/d1/unregister") require.Equal(t, http.StatusOK, w.Code, "未註冊 unregister 應冪等回 200,body=%s", w.Body.String()) item := dataItemOf(t, w) assert.Nil(t, item["registered_at"]) } // TestUnregister_NotOwner 非 owner → 403。 func TestUnregister_NotOwner(t *testing.T) { r, repo := newRegisterFixture(t) past := time.Now().UTC() require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "d1", OwnerUserID: "someone-else", Name: "usb", SerialNumber: "0xAAAA", RegisteredAt: &past, })) w := postRegister(t, r, "/api/devices/d1/unregister") require.Equal(t, http.StatusForbidden, w.Code) assert.Equal(t, ErrCodeForbidden, errCodeOf(t, w)) } // TestUnregister_Representative representative → 409 REPRESENTATIVE_DEVICE。 func TestUnregister_Representative(t *testing.T) { r, repo := newRegisterFixture(t) require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "rep", OwnerUserID: "demo-user", Name: "agent", IsRepresentative: true, })) w := postRegister(t, r, "/api/devices/rep/unregister") require.Equal(t, http.StatusConflict, w.Code) assert.Equal(t, ErrCodeRepresentativeDevice, errCodeOf(t, w)) } // TestUnregister_NotFound device 不存在 → 404。 func TestUnregister_NotFound(t *testing.T) { r, _ := newRegisterFixture(t) w := postRegister(t, r, "/api/devices/ghost/unregister") require.Equal(t, http.StatusNotFound, w.Code) assert.Equal(t, ErrCodeNotFound, errCodeOf(t, w)) } // TestRegisterUnregister_RoundTrip register → 綠,unregister → 退回,device 全程保留。 func TestRegisterUnregister_RoundTrip(t *testing.T) { r, repo := newRegisterFixture(t) require.NoError(t, repo.Save(context.Background(), &device.Device{ ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA", })) // register w := postRegister(t, r, "/api/devices/d1/register") require.Equal(t, http.StatusOK, w.Code) got, _ := repo.Get(context.Background(), "d1") require.NotNil(t, got.RegisteredAt) // unregister w = postRegister(t, r, "/api/devices/d1/unregister") require.Equal(t, http.StatusOK, w.Code) got, _ = repo.Get(context.Background(), "d1") require.Nil(t, got.RegisteredAt) // device 全程未消失。 list, _ := repo.List(context.Background(), "demo-user") require.Len(t, list, 1) }