把 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>
146 lines
5.6 KiB
Go
146 lines
5.6 KiB
Go
//go:build dbtest
|
||
|
||
// Postgres cascade unpair 的真 DB 整合測試(DB 接入塊 5.2 / 5.5)。
|
||
//
|
||
// build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。
|
||
// 預設 `go test ./...`(無 Docker)不觸碰本檔,維持綠燈。
|
||
//
|
||
// 執行:
|
||
//
|
||
// go test -tags=dbtest ./internal/api/...
|
||
// # 無本機 Docker 時,Orchestrator 在 130 補跑:
|
||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||
// go test -tags=dbtest ./internal/api/...
|
||
//
|
||
// 涵蓋:
|
||
// - cascade 成功:刪 device → 同 tx 撤 pairing + session token(database.md §6)。
|
||
// - rollback 原子性:cascade 中途失敗 → device 軟刪也回滾(device 仍存在、token 未撤)。
|
||
// - device 不存在 → device.ErrNotFound、不撤任何 token。
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/auth"
|
||
"visiona-backend/internal/db"
|
||
"visiona-backend/internal/db/testsupport"
|
||
"visiona-backend/internal/device"
|
||
)
|
||
|
||
// pgUnpairFixture 建一個已就緒的 Postgres 環境:device + 綁該 device 的 pairing/session token。
|
||
func pgUnpairFixture(t *testing.T) (
|
||
tdb *testsupport.TestDB,
|
||
devRepo *device.PostgresRepository,
|
||
pairing *auth.PostgresPairingStore,
|
||
sessions *auth.PostgresSessionTokenStore,
|
||
owner, deviceID, pairingPlain, sessionPlain string,
|
||
) {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
tdb = testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users")
|
||
owner = tdb.EnsureDemoUser(t)
|
||
|
||
devRepo = device.NewPostgresRepository(tdb.Pool)
|
||
pairing = auth.NewPostgresPairingStore(tdb.Pool)
|
||
sessions = auth.NewPostgresSessionTokenStore(tdb.Pool)
|
||
|
||
deviceID = tdb.InsertDevice(t, "", owner)
|
||
|
||
pPlain, _, err := pairing.Create(ctx, owner, 15*time.Minute)
|
||
require.NoError(t, err)
|
||
require.NoError(t, pairing.MarkUsed(ctx, pPlain, deviceID))
|
||
pairingPlain = pPlain
|
||
|
||
sPlain, _, err := sessions.Create(ctx, owner, deviceID, "", auth.SessionTokenTTL)
|
||
require.NoError(t, err)
|
||
sessionPlain = sPlain
|
||
|
||
return
|
||
}
|
||
|
||
// TestPGUnpair_CascadeSuccess 驗證 cascade 成功:device 軟刪 + 兩張 token 表撤銷,單一交易落地。
|
||
func TestPGUnpair_CascadeSuccess(t *testing.T) {
|
||
ctx := context.Background()
|
||
tdb, devRepo, pairing, sessions, _, deviceID, pPlain, sPlain := pgUnpairFixture(t)
|
||
|
||
unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, sessions, nil)
|
||
res, err := unpairer.Unpair(ctx, deviceID)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, 1, res.PairingRevoked)
|
||
assert.Equal(t, 1, res.SessionRevoked)
|
||
|
||
// device 已軟刪
|
||
_, err = devRepo.Get(ctx, deviceID)
|
||
assert.ErrorIs(t, err, device.ErrNotFound)
|
||
// pairing token 已撤
|
||
_, err = pairing.Validate(ctx, pPlain)
|
||
assert.ErrorIs(t, err, auth.ErrTokenRevoked)
|
||
// session token 已撤
|
||
_, err = sessions.Get(ctx, sPlain)
|
||
assert.ErrorIs(t, err, auth.ErrTokenRevoked)
|
||
}
|
||
|
||
// failingRevoker 是會回 error 的 token revoker,用來觸發 cascade 中途失敗、驗證 rollback。
|
||
type failingRevoker struct{}
|
||
|
||
func (failingRevoker) RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) {
|
||
return 0, errors.New("simulated revoke failure")
|
||
}
|
||
|
||
// TestPGUnpair_RollbackOnCascadeFailure 驗證 cascade 中途失敗 → device 軟刪也整筆回滾。
|
||
//
|
||
// 用 failingRevoker 取代 session token revoker:device 在 tx 內已軟刪、pairing 已撤,但 session
|
||
// 撤銷回 error → WithTx rollback,最終 device 仍未刪、pairing token 也未撤(整筆原子)。
|
||
func TestPGUnpair_RollbackOnCascadeFailure(t *testing.T) {
|
||
ctx := context.Background()
|
||
tdb, devRepo, pairing, _, _, deviceID, pPlain, _ := pgUnpairFixture(t)
|
||
|
||
unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, failingRevoker{}, nil)
|
||
_, err := unpairer.Unpair(ctx, deviceID)
|
||
require.Error(t, err)
|
||
|
||
// device 仍存在(軟刪被 rollback)
|
||
d, getErr := devRepo.Get(ctx, deviceID)
|
||
require.NoError(t, getErr, "device 應因 rollback 仍存在")
|
||
assert.Nil(t, d.DeletedAt)
|
||
|
||
// pairing token 也未撤(同一交易 rollback)。
|
||
// fixture 已 MarkUsed(cascade 撤銷靠 WHERE device_id,token 須先綁 device),
|
||
// 故不能用 Validate 驗——used token 必回 ErrTokenUsed。直接查 revoked_at 是否仍為 NULL。
|
||
var pairingRevokedAt *time.Time
|
||
qErr := tdb.Pool.QueryRow(ctx,
|
||
`SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`,
|
||
auth.HashToken(pPlain)).Scan(&pairingRevokedAt)
|
||
require.NoError(t, qErr)
|
||
assert.Nil(t, pairingRevokedAt, "pairing token 應因 rollback 未被撤銷")
|
||
}
|
||
|
||
// TestPGUnpair_DeviceNotFound 驗證刪不存在 device → device.ErrNotFound、不撤任何 token。
|
||
func TestPGUnpair_DeviceNotFound(t *testing.T) {
|
||
ctx := context.Background()
|
||
tdb, devRepo, pairing, sessions, _, _, pPlain, sPlain := pgUnpairFixture(t)
|
||
|
||
unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, sessions, nil)
|
||
_, err := unpairer.Unpair(ctx, "00000000-0000-0000-0000-0000000000ff")
|
||
assert.ErrorIs(t, err, device.ErrNotFound)
|
||
|
||
// 原 device 的 token 不受影響(沒被誤撤)。
|
||
// pairing token fixture 已 MarkUsed,不能用 Validate(必回 ErrTokenUsed);直接查 revoked_at 仍為 NULL。
|
||
var pairingRevokedAt *time.Time
|
||
qErr := tdb.Pool.QueryRow(ctx,
|
||
`SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`,
|
||
auth.HashToken(pPlain)).Scan(&pairingRevokedAt)
|
||
require.NoError(t, qErr)
|
||
assert.Nil(t, pairingRevokedAt, "不存在 device 的 unpair 不應誤撤 pairing token")
|
||
// session token 無 used 概念,未撤銷時 Get 正常回 NoError,沿用原斷言。
|
||
_, err = sessions.Get(ctx, sPlain)
|
||
require.NoError(t, err)
|
||
}
|