把 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>
427 lines
15 KiB
Go
427 lines
15 KiB
Go
//go:build dbtest
|
||
|
||
// PostgresRepository(device)的真 DB 整合測試(DB 接入塊 2,子任務 2.5–2.7)。
|
||
//
|
||
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。
|
||
// 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。
|
||
//
|
||
// 執行:
|
||
//
|
||
// go test -tags=dbtest ./internal/device/...
|
||
// # 無本機 Docker 時,Orchestrator 在 130 補跑:
|
||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||
// go test -tags=dbtest ./internal/device/...
|
||
//
|
||
// 涵蓋:
|
||
// - 2.5 unit/邏輯:對齊既有 inmemory_repository_test.go(SaveAndGet、GetBySerial 跨 owner 不串、
|
||
// List by owner、soft delete、再刪回 NotFound、保留 CreatedAt、Save 需 ID)。
|
||
// - 2.6 integration/真 DB:partial unique 衝突(兩筆未刪除同 owner+serial)、partial unique 讓
|
||
// 已刪 serial 可重註冊、雙狀態欄位 + paired_at round-trip、upsert 保留 CreatedAt。
|
||
// - 2.7 邊界:空 List、併發註冊同 serial、context cancel。
|
||
package device
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/db/testsupport"
|
||
)
|
||
|
||
// newPGRepo 啟動一次性測試 DB、truncate、確保 demo user 存在,回傳 repo + ownerID。
|
||
//
|
||
// 每個測試各自呼叫一次(SetupTestDB 內含 t.Cleanup teardown)。owner 用 testsupport
|
||
// 的固定 demo user UUID,滿足 devices.owner_user_id 的 FK。
|
||
func newPGRepo(t *testing.T) (*PostgresRepository, *testsupport.TestDB, string) {
|
||
t.Helper()
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "devices", "users")
|
||
owner := tdb.EnsureDemoUser(t)
|
||
return NewPostgresRepository(tdb.Pool), tdb, owner
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2.5 unit/邏輯(對齊 inmemory_repository_test.go 的 case)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func TestPG_SaveAndGet(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
d := &Device{
|
||
ID: uuid.NewString(),
|
||
OwnerUserID: owner,
|
||
Name: "Lab KL520",
|
||
DeviceType: "kl520",
|
||
SerialNumber: "KL520-AAA",
|
||
RemoteStatus: RemoteStatusOffline,
|
||
Status: USBStatusUnknown,
|
||
}
|
||
require.NoError(t, r.Save(ctx, d))
|
||
|
||
got, err := r.Get(ctx, d.ID)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "Lab KL520", got.Name)
|
||
assert.Equal(t, owner, got.OwnerUserID)
|
||
assert.Equal(t, "kl520", got.DeviceType)
|
||
assert.Equal(t, "KL520-AAA", got.SerialNumber)
|
||
assert.Equal(t, RemoteStatusOffline, got.RemoteStatus)
|
||
assert.Equal(t, USBStatusUnknown, got.Status)
|
||
assert.False(t, got.CreatedAt.IsZero())
|
||
assert.False(t, got.UpdatedAt.IsZero())
|
||
}
|
||
|
||
func TestPG_Get_NotFound(t *testing.T) {
|
||
r, _, _ := newPGRepo(t)
|
||
_, err := r.Get(context.Background(), uuid.NewString())
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
}
|
||
|
||
func TestPG_Save_RequiresID(t *testing.T) {
|
||
r, _, owner := newPGRepo(t)
|
||
assert.Error(t, r.Save(context.Background(), &Device{Name: "no-id", OwnerUserID: owner}))
|
||
}
|
||
|
||
// GetBySerial:跨 owner 同 serial 不互串(owner 過濾)。
|
||
func TestPG_GetBySerial(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, ownerA := newPGRepo(t)
|
||
ownerB := tdb.InsertUser(t, "", "")
|
||
|
||
idA, idB := uuid.NewString(), uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: idA, OwnerUserID: ownerA, Name: "a", SerialNumber: "S-1"}))
|
||
require.NoError(t, r.Save(ctx, &Device{ID: idB, OwnerUserID: ownerB, Name: "b", SerialNumber: "S-1"}))
|
||
|
||
gotA, err := r.GetBySerial(ctx, ownerA, "S-1")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, idA, gotA.ID)
|
||
|
||
gotB, err := r.GetBySerial(ctx, ownerB, "S-1")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, idB, gotB.ID)
|
||
|
||
// 不存在的 owner
|
||
_, err = r.GetBySerial(ctx, uuid.NewString(), "S-1")
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
}
|
||
|
||
// GetBySerial:soft-delete 後查不到。
|
||
func TestPG_GetBySerial_SkipsDeleted(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "x", SerialNumber: "S-9"}))
|
||
require.NoError(t, r.Delete(ctx, id))
|
||
|
||
_, err := r.GetBySerial(ctx, owner, "S-9")
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
}
|
||
|
||
func TestPG_List_ByOwner(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
owner2 := tdb.InsertUser(t, "", "")
|
||
|
||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "a", SerialNumber: "S-A"}))
|
||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "b", SerialNumber: "S-B"}))
|
||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner2, Name: "c", SerialNumber: "S-C"}))
|
||
|
||
listOwner, err := r.List(ctx, owner)
|
||
require.NoError(t, err)
|
||
assert.Len(t, listOwner, 2)
|
||
|
||
listOwner2, err := r.List(ctx, owner2)
|
||
require.NoError(t, err)
|
||
assert.Len(t, listOwner2, 1)
|
||
|
||
// 不存在的 owner
|
||
listNone, err := r.List(ctx, uuid.NewString())
|
||
require.NoError(t, err)
|
||
assert.Empty(t, listNone)
|
||
}
|
||
|
||
func TestPG_Delete_SoftDelete(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "x", SerialNumber: "S-D"}))
|
||
require.NoError(t, r.Delete(ctx, id))
|
||
|
||
// Get 不到
|
||
_, err := r.Get(ctx, id)
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
|
||
// List 不含
|
||
list, _ := r.List(ctx, owner)
|
||
assert.Empty(t, list)
|
||
|
||
// 重複 Delete 回 ErrNotFound
|
||
assert.ErrorIs(t, r.Delete(ctx, id), ErrNotFound)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2.6 integration/真 DB
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// partial unique 衝突:兩筆「未刪除」同 (owner, serial)、不同 id → 第二筆撞 unique(23505)。
|
||
func TestPG_PartialUnique_ActiveConflict(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "first", SerialNumber: "SN-DUP"}))
|
||
|
||
// 不同 id、同 owner+serial、皆未刪除 → 違反 uq_devices_owner_serial_active。
|
||
err := r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "second", SerialNumber: "SN-DUP"})
|
||
require.Error(t, err, "兩筆未刪除同 owner+serial 應違反 partial unique")
|
||
|
||
var pgErr *pgconn.PgError
|
||
require.ErrorAs(t, err, &pgErr)
|
||
assert.Equal(t, "23505", pgErr.Code, "應為 unique_violation")
|
||
assert.Equal(t, "uq_devices_owner_serial_active", pgErr.ConstraintName)
|
||
}
|
||
|
||
// partial unique × soft-delete:已刪 serial 可重新註冊(核心決策 2.3)。
|
||
func TestPG_PartialUnique_ReRegisterAfterSoftDelete(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
id1 := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: id1, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-RE"}))
|
||
require.NoError(t, r.Delete(ctx, id1))
|
||
|
||
// 同 owner+serial、新 id → 因舊列已 soft-delete、退出 partial index,重註冊應成功。
|
||
id2 := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: id2, OwnerUserID: owner, Name: "v2", SerialNumber: "SN-RE"}),
|
||
"已 soft-delete 的 serial 應可重新註冊")
|
||
|
||
// GetBySerial 應回新註冊的那筆(未刪除)。
|
||
got, err := r.GetBySerial(ctx, owner, "SN-RE")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, id2, got.ID)
|
||
assert.Equal(t, "v2", got.Name)
|
||
|
||
// DB 共有兩列(一筆 deleted、一筆 active)。
|
||
assert.Equal(t, 2, tdb.CountRows(t, "devices"), "重註冊後應有兩列:舊的 soft-deleted + 新的 active")
|
||
|
||
// List(未刪除)只回新的一筆。
|
||
list, err := r.List(ctx, owner)
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 1)
|
||
assert.Equal(t, id2, list[0].ID)
|
||
}
|
||
|
||
// 雙狀態欄位 + paired_at + 時間欄位 round-trip。
|
||
func TestPG_DualStatus_RoundTrip(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
lastSeen := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Microsecond)
|
||
lastConnected := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Microsecond)
|
||
paired := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond)
|
||
|
||
id := uuid.NewString()
|
||
in := &Device{
|
||
ID: id,
|
||
OwnerUserID: owner,
|
||
Name: "dual",
|
||
DeviceType: "kl720",
|
||
SerialNumber: "SN-DUAL",
|
||
RemoteStatus: RemoteStatusReconnecting,
|
||
LastSeenAt: &lastSeen,
|
||
LastConnectedAt: &lastConnected,
|
||
Status: USBStatusOnline,
|
||
PairedAt: &paired,
|
||
}
|
||
require.NoError(t, r.Save(ctx, in))
|
||
|
||
got, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, RemoteStatusReconnecting, got.RemoteStatus)
|
||
assert.Equal(t, USBStatusOnline, got.Status)
|
||
require.NotNil(t, got.LastSeenAt)
|
||
require.NotNil(t, got.LastConnectedAt)
|
||
require.NotNil(t, got.PairedAt)
|
||
assert.True(t, lastSeen.Equal(*got.LastSeenAt), "last_seen_at round-trip")
|
||
assert.True(t, lastConnected.Equal(*got.LastConnectedAt), "last_connected_at round-trip")
|
||
assert.True(t, paired.Equal(*got.PairedAt), "paired_at round-trip")
|
||
}
|
||
|
||
// nullable 時間欄位:不帶值寫入,讀回為 nil(對齊 in-memory omitempty 語意)。
|
||
func TestPG_NullableTimes_RoundTrip(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "no-times", SerialNumber: "SN-NT"}))
|
||
|
||
got, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Nil(t, got.LastSeenAt)
|
||
assert.Nil(t, got.LastConnectedAt)
|
||
assert.Nil(t, got.PairedAt)
|
||
assert.Nil(t, got.DeletedAt)
|
||
// 預設值(migration DEFAULT):remote_status='offline'、status='unknown'。
|
||
assert.Equal(t, RemoteStatusOffline, got.RemoteStatus)
|
||
assert.Equal(t, USBStatusUnknown, got.Status)
|
||
}
|
||
|
||
// upsert 保留 CreatedAt:第二次 Save(同 id、未刪除)保留首次 created_at、更新其他欄位 + updated_at。
|
||
func TestPG_Upsert_PreservesCreatedAt(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-UP"}))
|
||
first, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
|
||
time.Sleep(10 * time.Millisecond)
|
||
|
||
// 第二次 Save:帶不同(更早)的 CreatedAt,應被忽略而保留 first.CreatedAt。
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: id,
|
||
OwnerUserID: owner,
|
||
Name: "v2",
|
||
SerialNumber: "SN-UP",
|
||
RemoteStatus: RemoteStatusOnline,
|
||
Status: USBStatusOnline,
|
||
CreatedAt: time.Now().Add(-72 * time.Hour).UTC(), // 試圖覆蓋,應被忽略
|
||
}))
|
||
second, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
|
||
assert.Equal(t, "v2", second.Name)
|
||
assert.Equal(t, RemoteStatusOnline, second.RemoteStatus)
|
||
assert.WithinDuration(t, first.CreatedAt, second.CreatedAt, time.Microsecond, "created_at 應保留首次值")
|
||
assert.True(t, second.UpdatedAt.After(first.UpdatedAt) || second.UpdatedAt.Equal(first.UpdatedAt), "updated_at 應推進")
|
||
}
|
||
|
||
// soft-delete 後再 Save 同 id(復活):採用新 created_at、deleted_at 清回 nil。
|
||
func TestPG_Upsert_AfterSoftDelete_ResetsCreatedAt(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
id := uuid.NewString()
|
||
oldCreated := time.Now().Add(-100 * time.Hour).UTC()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-RV", CreatedAt: oldCreated}))
|
||
require.NoError(t, r.Delete(ctx, id))
|
||
|
||
newCreated := time.Now().UTC()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "revived", SerialNumber: "SN-RV", CreatedAt: newCreated}))
|
||
|
||
got, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "revived", got.Name)
|
||
assert.Nil(t, got.DeletedAt, "復活後不應仍為 deleted")
|
||
assert.WithinDuration(t, newCreated, got.CreatedAt, time.Microsecond, "復活後 created_at 應採新值")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2.7 邊界
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// 空 List:乾淨 DB 回 non-nil 空 slice。
|
||
func TestPG_List_Empty(t *testing.T) {
|
||
r, _, owner := newPGRepo(t)
|
||
list, err := r.List(context.Background(), owner)
|
||
require.NoError(t, err)
|
||
assert.Empty(t, list)
|
||
assert.NotNil(t, list, "List 應回 non-nil 空 slice")
|
||
}
|
||
|
||
// 併發註冊同 (owner, serial)、不同 id:partial unique 確保至多一筆成功,其餘撞 23505。
|
||
// 不應 panic;最終 active 列恰為一筆。
|
||
func TestPG_ConcurrentRegisterSameSerial(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
const n = 20
|
||
var wg sync.WaitGroup
|
||
errs := make([]error, n)
|
||
for i := 0; i < n; i++ {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
errs[i] = r.Save(ctx, &Device{
|
||
ID: uuid.NewString(),
|
||
OwnerUserID: owner,
|
||
Name: "concurrent",
|
||
SerialNumber: "SN-RACE",
|
||
})
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
|
||
var ok, conflict int
|
||
for _, e := range errs {
|
||
if e == nil {
|
||
ok++
|
||
continue
|
||
}
|
||
var pgErr *pgconn.PgError
|
||
require.ErrorAs(t, e, &pgErr, "非 nil error 應為 PgError")
|
||
assert.Equal(t, "23505", pgErr.Code, "衝突應為 unique_violation")
|
||
conflict++
|
||
}
|
||
assert.Equal(t, 1, ok, "恰一筆成功註冊")
|
||
assert.Equal(t, n-1, conflict, "其餘皆撞 partial unique")
|
||
|
||
// active(未刪除)列恰一筆。
|
||
list, err := r.List(ctx, owner)
|
||
require.NoError(t, err)
|
||
assert.Len(t, list, 1, "最終 active device 恰一筆")
|
||
_ = tdb // tdb 保留供除錯(CountRows)
|
||
}
|
||
|
||
// 併發 Save 同 id:upsert by id,不應 panic;最終單一列、最後內容。
|
||
func TestPG_ConcurrentSaveSameID(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
id := uuid.NewString()
|
||
const n = 20
|
||
var wg sync.WaitGroup
|
||
errs := make([]error, n)
|
||
for i := 0; i < n; i++ {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
errs[i] = r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "same-id", SerialNumber: "SN-SID"})
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
|
||
for i, e := range errs {
|
||
assert.NoError(t, e, "併發 Save 同 id #%d", i)
|
||
}
|
||
assert.Equal(t, 1, tdb.CountRows(t, "devices"), "併發 upsert 同 id 應只有一列")
|
||
}
|
||
|
||
// context cancel:已取消的 ctx 應讓操作回 error(不 hang、不 panic)。
|
||
func TestPG_ContextCancel(t *testing.T) {
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
cancel() // 立即取消
|
||
|
||
err := r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "x", SerialNumber: "SN-CC"})
|
||
assert.Error(t, err, "已取消 ctx 的 Save 應回 error")
|
||
|
||
_, err = r.Get(ctx, uuid.NewString())
|
||
assert.Error(t, err, "已取消 ctx 的 Get 應回 error")
|
||
|
||
_, err = r.GetBySerial(ctx, owner, "SN-CC")
|
||
assert.Error(t, err, "已取消 ctx 的 GetBySerial 應回 error")
|
||
|
||
_, err = r.List(ctx, owner)
|
||
assert.Error(t, err, "已取消 ctx 的 List 應回 error")
|
||
}
|