把 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>
82 lines
2.6 KiB
Go
82 lines
2.6 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/auth"
|
||
"visiona-backend/internal/device"
|
||
)
|
||
|
||
// TestInMemoryDeviceUnpairer_Cascade 驗證 in-memory 模式刪 device 時 cascade 撤銷其 token
|
||
// (塊 5.2 約束:DB 未啟用時 cascade 在 in-memory 也成立)。
|
||
func TestInMemoryDeviceUnpairer_Cascade(t *testing.T) {
|
||
ctx := context.Background()
|
||
devRepo := device.NewInMemoryRepository()
|
||
pairing := auth.NewInMemoryPairingStore()
|
||
sessions := auth.NewInMemorySessionTokenStore()
|
||
|
||
const userID = "user-1"
|
||
const deviceID = "dev-1"
|
||
|
||
// 建一個 device
|
||
require.NoError(t, devRepo.Save(ctx, &device.Device{
|
||
ID: deviceID, OwnerUserID: userID, Name: "d", SerialNumber: "SN1",
|
||
}))
|
||
|
||
// 建並綁定一個 pairing token 到該 device(MarkUsed 綁 device)
|
||
pPlain, _, err := pairing.Create(ctx, userID, 15*time.Minute)
|
||
require.NoError(t, err)
|
||
require.NoError(t, pairing.MarkUsed(ctx, pPlain, deviceID))
|
||
|
||
// 建一個 session token 綁該 device
|
||
sPlain, _, err := sessions.Create(ctx, userID, deviceID, "", 90*24*time.Hour)
|
||
require.NoError(t, err)
|
||
|
||
// 另一個 device 的 token(不應被撤)
|
||
const otherDevice = "dev-2"
|
||
require.NoError(t, devRepo.Save(ctx, &device.Device{
|
||
ID: otherDevice, OwnerUserID: userID, Name: "d2", SerialNumber: "SN2",
|
||
}))
|
||
oPlain, _, err := sessions.Create(ctx, userID, otherDevice, "", 90*24*time.Hour)
|
||
require.NoError(t, err)
|
||
|
||
unpairer := NewInMemoryDeviceUnpairer(devRepo, pairing, sessions)
|
||
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)
|
||
|
||
// 該 device 的 session token 已撤
|
||
_, err = sessions.Get(ctx, sPlain)
|
||
assert.ErrorIs(t, err, auth.ErrTokenRevoked)
|
||
|
||
// 該 device 的 pairing token 已撤
|
||
_, err = pairing.Validate(ctx, pPlain)
|
||
assert.ErrorIs(t, err, auth.ErrTokenRevoked)
|
||
|
||
// 另一個 device 的 token 不受影響
|
||
_, err = sessions.Get(ctx, oPlain)
|
||
assert.NoError(t, err)
|
||
}
|
||
|
||
// TestInMemoryDeviceUnpairer_NotFound 驗證刪不存在 device → device.ErrNotFound、不撤任何 token。
|
||
func TestInMemoryDeviceUnpairer_NotFound(t *testing.T) {
|
||
ctx := context.Background()
|
||
devRepo := device.NewInMemoryRepository()
|
||
pairing := auth.NewInMemoryPairingStore()
|
||
sessions := auth.NewInMemorySessionTokenStore()
|
||
|
||
unpairer := NewInMemoryDeviceUnpairer(devRepo, pairing, sessions)
|
||
_, err := unpairer.Unpair(ctx, "does-not-exist")
|
||
assert.ErrorIs(t, err, device.ErrNotFound)
|
||
}
|