// errors_test.go — errors.go 3 個純 helper(WriteError / WriteSuccess / WriteNotImplemented)的 unit test。 // // Owner: testing agent(補測任務 — internal/api 弱處純函式) // // 這 3 個函式直接寫 gin.Context 的 JSON response(envelope 形狀對齊 api-spec.md §11)。 // 用 httptest.NewRecorder() + gin.CreateTestContext 驅動,驗 status code + envelope 結構 + // request_id 帶入 + details / nil 邊界。 // // 注意:errors_db.go 的 DB 錯誤映射另由 errors_db_test.go 覆蓋,本檔不重複。 package api import ( "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // newTestCtx 建立一個帶 ResponseRecorder 的 gin.Context;reqID 非空時塞入 request_id。 func newTestCtx(reqID string) (*gin.Context, *httptest.ResponseRecorder) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) if reqID != "" { c.Set(ctxKeyRequestID, reqID) } return c, w } // decodeErrorBody 把 recorder body 解成 ErrorBody。 func decodeErrorBody(t *testing.T, w *httptest.ResponseRecorder) ErrorBody { t.Helper() var body ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) return body } // ───────────────────────── WriteError ───────────────────────── // happy:寫一個帶 request_id 的 404 錯誤,envelope 形狀正確。 func TestWriteError_Happy_WithRequestID(t *testing.T) { c, w := newTestCtx("req-123") WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) assert.Equal(t, http.StatusNotFound, w.Code) assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) body := decodeErrorBody(t, w) assert.False(t, body.Success, "錯誤 envelope success 必為 false") require.NotNil(t, body.Error) assert.Equal(t, ErrCodeNotFound, body.Error.Code) assert.Equal(t, "device not found", body.Error.Message) assert.Equal(t, "req-123", body.Error.RequestID, "WriteError 應自動帶上 request_id") assert.Nil(t, body.Error.Details, "未傳 details 時應為 nil(omitempty)") } // boundary:帶 details(validation 細節)時,details 應出現在 envelope。 func TestWriteError_WithDetails(t *testing.T) { c, w := newTestCtx("req-val") details := []FieldError{ {Field: "name", Message: "required"}, {Field: "target_chip", Message: "unsupported"}, } WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "validation failed", details) assert.Equal(t, http.StatusBadRequest, w.Code) body := decodeErrorBody(t, w) require.NotNil(t, body.Error) require.Len(t, body.Error.Details, 2) assert.Equal(t, "name", body.Error.Details[0].Field) assert.Equal(t, "required", body.Error.Details[0].Message) assert.Equal(t, "target_chip", body.Error.Details[1].Field) } // empty / edge:沒有 request_id 時,request_id 欄位被 omitempty 省略(不應出現空字串 key 影響 client)。 func TestWriteError_NoRequestID_OmitsField(t *testing.T) { c, w := newTestCtx("") // 不塞 request_id WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "boom", nil) assert.Equal(t, http.StatusInternalServerError, w.Code) // request_id 為 omitempty,空值時不應出現在 JSON raw := w.Body.String() assert.NotContains(t, raw, "request_id", "request_id 為空時應被 omitempty 省略") body := decodeErrorBody(t, w) require.NotNil(t, body.Error) assert.Equal(t, ErrCodeInternalError, body.Error.Code) assert.Empty(t, body.Error.RequestID) } // edge:空 details slice(len 0)→ omitempty 省略(不應出現 "details": [])。 func TestWriteError_EmptyDetailsSlice_Omitted(t *testing.T) { c, w := newTestCtx("req-x") WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "msg", []FieldError{}) raw := w.Body.String() assert.NotContains(t, raw, "details", "空 details slice 應被 omitempty 省略") } // ───────────────────────── WriteSuccess ───────────────────────── // happy:寫一個 200 成功回應,data 帶入。 func TestWriteSuccess_Happy(t *testing.T) { c, w := newTestCtx("") payload := map[string]any{"id": "m-1", "name": "yolo"} WriteSuccess(c, http.StatusOK, payload) assert.Equal(t, http.StatusOK, w.Code) var body SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.True(t, body.Success, "成功 envelope success 必為 true") data, ok := body.Data.(map[string]any) require.True(t, ok) assert.Equal(t, "m-1", data["id"]) assert.Equal(t, "yolo", data["name"]) } // edge:data 為 nil(如 201 Created 無 body)→ data 被 omitempty 省略,但 success 仍在。 func TestWriteSuccess_NilData_OmitsData(t *testing.T) { c, w := newTestCtx("") WriteSuccess(c, http.StatusCreated, nil) assert.Equal(t, http.StatusCreated, w.Code) raw := w.Body.String() assert.Contains(t, raw, `"success":true`) assert.NotContains(t, raw, "data", "nil data 應被 omitempty 省略") } // boundary:non-201 status 也能用(WriteSuccess 不限定 status)。 func TestWriteSuccess_CustomStatus(t *testing.T) { c, w := newTestCtx("") WriteSuccess(c, http.StatusAccepted, map[string]string{"state": "queued"}) assert.Equal(t, http.StatusAccepted, w.Code) var body SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.True(t, body.Success) } // ───────────────────────── WriteNotImplemented ───────────────────────── // happy:回 501 NOT_IMPLEMENTED,hint 帶進 message。 func TestWriteNotImplemented_Happy(t *testing.T) { c, w := newTestCtx("req-ni") WriteNotImplemented(c, "clusters endpoint pending B5") assert.Equal(t, http.StatusNotImplemented, w.Code) body := decodeErrorBody(t, w) require.NotNil(t, body.Error) assert.False(t, body.Success) assert.Equal(t, ErrCodeNotImplemented, body.Error.Code) assert.Equal(t, "clusters endpoint pending B5", body.Error.Message) assert.Equal(t, "req-ni", body.Error.RequestID) } // edge:空 hint 也成立(message 為空字串、code 仍為 NOT_IMPLEMENTED)。 func TestWriteNotImplemented_EmptyHint(t *testing.T) { c, w := newTestCtx("") WriteNotImplemented(c, "") assert.Equal(t, http.StatusNotImplemented, w.Code) body := decodeErrorBody(t, w) require.NotNil(t, body.Error) assert.Equal(t, ErrCodeNotImplemented, body.Error.Code) assert.Empty(t, body.Error.Message) }