DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
6.5 KiB
Go
173 lines
6.5 KiB
Go
// 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)
|
||
}
|