把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整 (PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動, 只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。 - 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate runner(embed)+ cmd/migrate + testcontainers 測試基礎建設 - 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、 三維 filter(owner/chip/source)、soft-delete partial index - 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位 - 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK - 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine (tunnel session 維持 in-memory,yamux handle 不可序列化) - 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子) + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error) 降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗 不自動 fallback in-memory(避免多機 session 不同步)。 DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈, in-memory fallback 未受影響。 docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md (409/503 錯誤碼、/healthz 新行為、device unpair cascade)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
120 lines
3.6 KiB
Go
120 lines
3.6 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// TestClassifyDBError 驗證 pgx error → (status, code) 映射(塊 5.4)。
|
||
func TestClassifyDBError(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
err error
|
||
wantStatus int
|
||
wantCode string
|
||
}{
|
||
{
|
||
name: "context deadline → 503",
|
||
err: context.DeadlineExceeded,
|
||
wantStatus: http.StatusServiceUnavailable,
|
||
wantCode: ErrCodeServiceUnavailable,
|
||
},
|
||
{
|
||
name: "context canceled → 503",
|
||
err: context.Canceled,
|
||
wantStatus: http.StatusServiceUnavailable,
|
||
wantCode: ErrCodeServiceUnavailable,
|
||
},
|
||
{
|
||
name: "wrapped deadline → 503",
|
||
err: fmt.Errorf("device: pg List: %w", context.DeadlineExceeded),
|
||
wantStatus: http.StatusServiceUnavailable,
|
||
wantCode: ErrCodeServiceUnavailable,
|
||
},
|
||
{
|
||
name: "unique violation 23505 → 409",
|
||
err: &pgconn.PgError{Code: "23505", Message: "duplicate key value"},
|
||
wantStatus: http.StatusConflict,
|
||
wantCode: ErrCodeConflict,
|
||
},
|
||
{
|
||
name: "wrapped unique violation → 409",
|
||
err: fmt.Errorf("device: pg Save: %w", &pgconn.PgError{Code: "23505"}),
|
||
wantStatus: http.StatusConflict,
|
||
wantCode: ErrCodeConflict,
|
||
},
|
||
{
|
||
name: "other PG error (syntax) → 500",
|
||
err: &pgconn.PgError{Code: "42601", Message: "syntax error"},
|
||
wantStatus: http.StatusInternalServerError,
|
||
wantCode: ErrCodeInternalError,
|
||
},
|
||
{
|
||
name: "connect error → 503",
|
||
err: &pgconn.ConnectError{Config: &pgconn.Config{}},
|
||
wantStatus: http.StatusServiceUnavailable,
|
||
wantCode: ErrCodeServiceUnavailable,
|
||
},
|
||
{
|
||
name: "unknown error → 500",
|
||
err: errors.New("something unexpected"),
|
||
wantStatus: http.StatusInternalServerError,
|
||
wantCode: ErrCodeInternalError,
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
cls := classifyDBError(tc.err)
|
||
assert.Equal(t, tc.wantStatus, cls.status)
|
||
assert.Equal(t, tc.wantCode, cls.code)
|
||
// message 一律是通用字串,不含 raw error 內容
|
||
assert.NotEmpty(t, cls.message)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestWriteDBError_NoRawLeak 驗證 WriteDBError 不把 raw DB error 寫進 response body(收塊 3 M1)。
|
||
func TestWriteDBError_NoRawLeak(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
w := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(w)
|
||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||
|
||
rawErr := &pgconn.PgError{Code: "42601",
|
||
Message: "syntax error at or near SELECT",
|
||
Detail: "secret-schema-detail",
|
||
Hint: "internal hint that should not leak",
|
||
}
|
||
WriteDBError(c, nil, "list devices", rawErr)
|
||
|
||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||
body := w.Body.String()
|
||
// 對外只有通用 code/message,raw error 的細節不得出現
|
||
assert.Contains(t, body, ErrCodeInternalError)
|
||
assert.NotContains(t, body, "syntax error")
|
||
assert.NotContains(t, body, "secret-schema-detail")
|
||
assert.NotContains(t, body, "internal hint")
|
||
}
|
||
|
||
// TestWriteDBError_PGDown503 驗證 PG down(connect error)→ 503 + SERVICE_UNAVAILABLE。
|
||
func TestWriteDBError_PGDown503(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
w := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(w)
|
||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||
|
||
WriteDBError(c, nil, "get model", &pgconn.ConnectError{Config: &pgconn.Config{}})
|
||
|
||
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodeServiceUnavailable)
|
||
}
|