把 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>
190 lines
6.1 KiB
Go
190 lines
6.1 KiB
Go
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/session"
|
||
)
|
||
|
||
// fakeSessionStore 是測試用 Store 實作,只回 List 結果;其他方法 panic 表示
|
||
// 不應被呼叫(以利早期偵錯)。
|
||
type fakeSessionStore struct {
|
||
sessions []*session.Summary
|
||
listErr error
|
||
}
|
||
|
||
func (f *fakeSessionStore) Register(context.Context, string, session.Handle) error {
|
||
panic("Register should not be called")
|
||
}
|
||
func (f *fakeSessionStore) Unregister(context.Context, string) error {
|
||
panic("Unregister should not be called")
|
||
}
|
||
func (f *fakeSessionStore) Lookup(context.Context, string) (session.Handle, error) {
|
||
panic("Lookup should not be called")
|
||
}
|
||
func (f *fakeSessionStore) Exists(context.Context, string) (bool, error) {
|
||
panic("Exists should not be called")
|
||
}
|
||
func (f *fakeSessionStore) List(context.Context) ([]*session.Summary, error) {
|
||
return f.sessions, f.listErr
|
||
}
|
||
func (f *fakeSessionStore) Heartbeat(context.Context, string) error {
|
||
panic("Heartbeat should not be called")
|
||
}
|
||
func (f *fakeSessionStore) CleanupExpired(context.Context, time.Duration) (int, error) {
|
||
panic("CleanupExpired should not be called")
|
||
}
|
||
|
||
// fakePinger 是 /healthz 依賴 ping 的測試替身(純 Go,不需真 DB)。
|
||
type fakePinger struct{ err error }
|
||
|
||
func (f fakePinger) Ping(context.Context) error { return f.err }
|
||
|
||
// TestHealthzHandler 驗證 /healthz 在「無依賴」時回 200 + status:ok(退化為最小檢查)。
|
||
func TestHealthzHandler(t *testing.T) {
|
||
r := gin.New()
|
||
r.GET("/healthz", HealthzHandler(HealthDeps{}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||
assert.Equal(t, http.StatusOK, w.Code)
|
||
assert.Contains(t, w.Body.String(), `"status":"ok"`)
|
||
}
|
||
|
||
// TestHealthz_AllDepsHealthy 驗證 PG + Redis 都 ping 成功 → 200 + checks 全 ok。
|
||
func TestHealthz_AllDepsHealthy(t *testing.T) {
|
||
r := gin.New()
|
||
r.GET("/healthz", HealthzHandler(HealthDeps{
|
||
DBPool: fakePinger{},
|
||
Redis: fakePinger{},
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||
assert.Equal(t, http.StatusOK, w.Code)
|
||
assert.Contains(t, w.Body.String(), `"postgres":"ok"`)
|
||
assert.Contains(t, w.Body.String(), `"redis":"ok"`)
|
||
}
|
||
|
||
// TestHealthz_PostgresDown 驗證 PG ping 失敗 → 503(塊 5.4 fail-fast)。
|
||
func TestHealthz_PostgresDown(t *testing.T) {
|
||
r := gin.New()
|
||
r.GET("/healthz", HealthzHandler(HealthDeps{
|
||
DBPool: fakePinger{err: errors.New("connection refused")},
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||
assert.Contains(t, w.Body.String(), `"postgres":"down"`)
|
||
// 不洩漏 raw error 進 response(只進 log)
|
||
assert.NotContains(t, w.Body.String(), "connection refused")
|
||
}
|
||
|
||
// TestHealthz_RedisDown 驗證 Redis 啟用但 ping 失敗 → 503;PG ok 仍標出 redis down。
|
||
func TestHealthz_RedisDown(t *testing.T) {
|
||
r := gin.New()
|
||
r.GET("/healthz", HealthzHandler(HealthDeps{
|
||
DBPool: fakePinger{},
|
||
Redis: fakePinger{err: errors.New("redis down")},
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||
assert.Contains(t, w.Body.String(), `"redis":"down"`)
|
||
assert.Contains(t, w.Body.String(), `"postgres":"ok"`)
|
||
}
|
||
|
||
// TestHealthz_RedisNotEnabled 驗證 Redis 未啟用(nil)→ 不檢查、PG ok 即 200。
|
||
func TestHealthz_RedisNotEnabled(t *testing.T) {
|
||
r := gin.New()
|
||
r.GET("/healthz", HealthzHandler(HealthDeps{
|
||
DBPool: fakePinger{},
|
||
// Redis nil — 未啟用,略過
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||
assert.Equal(t, http.StatusOK, w.Code)
|
||
assert.NotContains(t, w.Body.String(), "redis")
|
||
}
|
||
|
||
// TestSystemHealth_TunnelDisconnected 驗證沒 session 時回 connected=false。
|
||
func TestSystemHealth_TunnelDisconnected(t *testing.T) {
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
g := r.Group("/api")
|
||
registerSystemRoutes(g, Deps{
|
||
SessionStore: &fakeSessionStore{sessions: nil},
|
||
Logger: nil,
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/health", nil))
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
|
||
var body SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||
require.True(t, body.Success)
|
||
data, _ := body.Data.(map[string]any)
|
||
require.NotNil(t, data)
|
||
assert.Equal(t, "ok", data["api_server"])
|
||
assert.Equal(t, false, data["tunnel_connected"])
|
||
}
|
||
|
||
// TestSystemHealth_TunnelConnected 驗證有 session 時回 connected=true 並帶 last_seen_at。
|
||
func TestSystemHealth_TunnelConnected(t *testing.T) {
|
||
now := time.Now().UTC().Truncate(time.Second)
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
g := r.Group("/api")
|
||
registerSystemRoutes(g, Deps{
|
||
SessionStore: &fakeSessionStore{
|
||
sessions: []*session.Summary{
|
||
{Token: "vAc_a", LastHeartbeat: now.Add(-5 * time.Second)},
|
||
{Token: "vAc_b", LastHeartbeat: now}, // 最新
|
||
},
|
||
},
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/health", nil))
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
|
||
var body SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||
data := body.Data.(map[string]any)
|
||
assert.Equal(t, true, data["tunnel_connected"])
|
||
assert.EqualValues(t, 2, data["agent_session_count"])
|
||
assert.NotEmpty(t, data["agent_last_seen_at"])
|
||
}
|
||
|
||
// TestSystemInfo 驗證 GET /api/system/info 回基本欄位。
|
||
func TestSystemInfo(t *testing.T) {
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
g := r.Group("/api")
|
||
registerSystemRoutes(g, Deps{})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/info", nil))
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
|
||
var body SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||
data := body.Data.(map[string]any)
|
||
assert.Equal(t, "visiona-api-server", data["service"])
|
||
assert.Equal(t, "phase-0-prototype", data["phase"])
|
||
}
|