把 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>
133 lines
4.4 KiB
Go
133 lines
4.4 KiB
Go
package db
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
|
||
"visiona-backend/internal/config"
|
||
)
|
||
|
||
// DSN / SafeTarget 為純函式,不需 DB,故不帶 dbtest tag,預設 go test 即執行。
|
||
|
||
func TestBuildDSN_Basic(t *testing.T) {
|
||
cfg := config.DatabaseConfig{
|
||
Host: "db.example.com",
|
||
Port: 5432,
|
||
User: "appuser",
|
||
Password: "s3cret",
|
||
DBName: "visiona",
|
||
SSLMode: "require",
|
||
MaxConns: 10,
|
||
MinConns: 2,
|
||
}
|
||
dsn := BuildDSN(cfg)
|
||
|
||
assert.True(t, strings.HasPrefix(dsn, "postgres://"), "scheme 應為 postgres://,got %s", dsn)
|
||
assert.Contains(t, dsn, "appuser:s3cret@db.example.com:5432/visiona")
|
||
assert.Contains(t, dsn, "sslmode=require")
|
||
assert.Contains(t, dsn, "pool_max_conns=10")
|
||
assert.Contains(t, dsn, "pool_min_conns=2")
|
||
}
|
||
|
||
func TestBuildDSN_PasswordWithSpecialChars(t *testing.T) {
|
||
cfg := config.DatabaseConfig{
|
||
Host: "localhost",
|
||
Port: 5432,
|
||
User: "user@org",
|
||
Password: "p@ss:w/rd?x",
|
||
DBName: "db",
|
||
SSLMode: "disable",
|
||
}
|
||
dsn := BuildDSN(cfg)
|
||
|
||
// 特殊字元應被 percent-encode,不破壞 DSN 結構(仍能被解析)。
|
||
assert.NotContains(t, dsn, "p@ss:w/rd?x", "密碼特殊字元應被 encode")
|
||
assert.Contains(t, dsn, "sslmode=disable")
|
||
}
|
||
|
||
func TestBuildDSN_DefaultSSLMode(t *testing.T) {
|
||
cfg := config.DatabaseConfig{Host: "h", Port: 5432, User: "u", DBName: "d"}
|
||
dsn := BuildDSN(cfg)
|
||
assert.Contains(t, dsn, "sslmode=require", "SSLMode 留空應預設 require")
|
||
}
|
||
|
||
func TestSafeTarget_NoCredentials(t *testing.T) {
|
||
cfg := config.DatabaseConfig{
|
||
Host: "db.example.com",
|
||
Port: 5432,
|
||
User: "appuser",
|
||
Password: "topsecret",
|
||
DBName: "visiona",
|
||
}
|
||
target := SafeTarget(cfg)
|
||
|
||
assert.Equal(t, "db.example.com:5432/visiona", target)
|
||
assert.NotContains(t, target, "appuser", "SafeTarget 不應含 user")
|
||
assert.NotContains(t, target, "topsecret", "SafeTarget 不應含密碼")
|
||
}
|
||
|
||
func TestSafeTarget_DefaultPort(t *testing.T) {
|
||
cfg := config.DatabaseConfig{Host: "h", DBName: "d"}
|
||
assert.Equal(t, "h:5432/d", SafeTarget(cfg))
|
||
}
|
||
|
||
func TestDatabaseConfig_Enabled(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
cfg config.DatabaseConfig
|
||
want bool
|
||
}{
|
||
{"all set", config.DatabaseConfig{Host: "h", User: "u", DBName: "d"}, true},
|
||
{"missing host", config.DatabaseConfig{User: "u", DBName: "d"}, false},
|
||
{"missing user", config.DatabaseConfig{Host: "h", DBName: "d"}, false},
|
||
{"missing dbname", config.DatabaseConfig{Host: "h", User: "u"}, false},
|
||
{"empty", config.DatabaseConfig{}, false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
assert.Equal(t, tc.want, tc.cfg.Enabled())
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRedisConfig_Enabled(t *testing.T) {
|
||
assert.True(t, config.RedisConfig{Host: "h"}.Enabled())
|
||
assert.False(t, config.RedisConfig{}.Enabled())
|
||
}
|
||
|
||
// migrateDSN 應把 postgres:// scheme 去掉(golang-migrate pgx5 driver 期望的形式)。
|
||
func TestMigrateDSN_StripsScheme(t *testing.T) {
|
||
cfg := config.DatabaseConfig{
|
||
Host: "h", Port: 5432, User: "u", Password: "pw", DBName: "d", SSLMode: "disable",
|
||
ConnTimeout: 5 * time.Second,
|
||
}
|
||
got := migrateDSN(cfg)
|
||
assert.False(t, strings.HasPrefix(got, "postgres://"), "migrateDSN 不應保留 postgres:// prefix,got %s", got)
|
||
assert.Contains(t, got, "@h:5432/d")
|
||
}
|
||
|
||
// Regression(塊 0 修正):migrateDSN 絕不可帶 pgxpool 專屬參數。
|
||
// golang-migrate 的 pgx5 driver 走標準 database/sql 單連線,PG server 端不認得
|
||
// pool_max_conns / pool_min_conns,會直接 FATAL(SQLSTATE 42704)導致所有 migration 失敗。
|
||
// 這個無 docker 也能跑的純函式測試,確保不會再 regression。
|
||
func TestMigrateDSN_NoPoolParams(t *testing.T) {
|
||
cfg := config.DatabaseConfig{
|
||
Host: "h", Port: 5432, User: "u", Password: "pw", DBName: "d", SSLMode: "disable",
|
||
MaxConns: 10, MinConns: 2,
|
||
}
|
||
got := migrateDSN(cfg)
|
||
|
||
assert.NotContains(t, got, "pool_max_conns", "migrateDSN 不可帶 pgxpool 專屬參數 pool_max_conns,got %s", got)
|
||
assert.NotContains(t, got, "pool_min_conns", "migrateDSN 不可帶 pgxpool 專屬參數 pool_min_conns,got %s", got)
|
||
// 標準 libpq 參數仍須正確帶上。
|
||
assert.Contains(t, got, "sslmode=disable", "migrateDSN 應保留 sslmode,got %s", got)
|
||
|
||
// 對照組:BuildDSN(pgxpool 路徑)仍應帶 pool 參數。
|
||
pool := BuildDSN(cfg)
|
||
assert.Contains(t, pool, "pool_max_conns=10", "BuildDSN 應保留 pool_max_conns")
|
||
assert.Contains(t, pool, "pool_min_conns=2", "BuildDSN 應保留 pool_min_conns")
|
||
}
|