把 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>
351 lines
12 KiB
Go
351 lines
12 KiB
Go
//go:build dbtest
|
||
|
||
// PostgresRepository 的真 DB 整合測試(DB 接入塊 1,子任務 1.5–1.7)。
|
||
//
|
||
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。
|
||
// 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。
|
||
//
|
||
// 執行:
|
||
//
|
||
// go test -tags=dbtest ./internal/model/...
|
||
// # 無本機 Docker 時,Orchestrator 在 130 補跑:
|
||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||
// go test -tags=dbtest ./internal/model/...
|
||
//
|
||
// 涵蓋:
|
||
// - 1.5 unit/邏輯:對齊既有 inmemory_repository_test.go(SaveAndGet、NotFound、List 三 filter、
|
||
// soft delete、Save 需 ID)。
|
||
// - 1.6 integration/真 DB:array round-trip、upsert 保留 CreatedAt、soft-delete 後 List 不含、
|
||
// List filter SQL 正確性、faa_object_key nullable round-trip。
|
||
// - 1.7 邊界:空 List、重複 Save、併發 Save 同 ID、context cancel。
|
||
package model
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"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,滿足 models.owner_user_id 的 FK。
|
||
func newPGRepo(t *testing.T) (*PostgresRepository, *testsupport.TestDB, string) {
|
||
t.Helper()
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "models", "users")
|
||
owner := tdb.EnsureDemoUser(t)
|
||
return NewPostgresRepository(tdb.Pool), tdb, owner
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1.5 unit/邏輯(對齊 inmemory_repository_test.go 的 case)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func TestPG_SaveAndGet(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
m := &Model{
|
||
ID: uuid.NewString(),
|
||
OwnerUserID: owner,
|
||
Name: "yolo-v5",
|
||
StorageKey: "models/" + owner + "/m-1.nef",
|
||
FileSize: 1024 * 1024,
|
||
Source: SourceUploaded,
|
||
TargetChip: "kl520",
|
||
}
|
||
require.NoError(t, r.Save(ctx, m))
|
||
|
||
got, err := r.Get(ctx, m.ID)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "yolo-v5", got.Name)
|
||
assert.Equal(t, owner, got.OwnerUserID)
|
||
assert.Equal(t, int64(1024*1024), got.FileSize)
|
||
assert.Equal(t, SourceUploaded, got.Source)
|
||
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_List_Filter(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
// 第二個 owner,驗證 owner 過濾。
|
||
owner2 := tdb.InsertUser(t, "", "")
|
||
|
||
id1, id2 := uuid.NewString(), uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Model{ID: id1, OwnerUserID: owner, Name: "a", StorageKey: "k1", Source: SourceUploaded, TargetChip: "kl520"}))
|
||
require.NoError(t, r.Save(ctx, &Model{ID: id2, OwnerUserID: owner, Name: "b", StorageKey: "k2", Source: SourceConverted, TargetChip: "kl720"}))
|
||
require.NoError(t, r.Save(ctx, &Model{ID: uuid.NewString(), OwnerUserID: owner2, Name: "c", StorageKey: "k3", Source: SourceUploaded, TargetChip: "kl520"}))
|
||
|
||
// 依 owner 過濾
|
||
list, err := r.List(ctx, ListFilter{OwnerUserID: owner})
|
||
require.NoError(t, err)
|
||
assert.Len(t, list, 2)
|
||
|
||
// 依 chip 過濾(owner+chip 複合)
|
||
list, err = r.List(ctx, ListFilter{OwnerUserID: owner, TargetChip: "kl520"})
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 1)
|
||
assert.Equal(t, id1, list[0].ID)
|
||
|
||
// 依 source 過濾(不限 owner)
|
||
list, err = r.List(ctx, ListFilter{Source: SourceConverted})
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 1)
|
||
assert.Equal(t, id2, list[0].ID)
|
||
|
||
// 無 filter(admin)
|
||
list, err = r.List(ctx, ListFilter{})
|
||
require.NoError(t, err)
|
||
assert.Len(t, list, 3)
|
||
|
||
// owner + source 複合
|
||
list, err = r.List(ctx, ListFilter{OwnerUserID: owner, Source: SourceUploaded})
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 1)
|
||
assert.Equal(t, id1, list[0].ID)
|
||
}
|
||
|
||
func TestPG_Delete_SoftDelete(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "x", StorageKey: "k"}))
|
||
require.NoError(t, r.Delete(ctx, id))
|
||
|
||
// Get 不到
|
||
_, err := r.Get(ctx, id)
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
|
||
// List 不含
|
||
list, _ := r.List(ctx, ListFilter{OwnerUserID: owner})
|
||
assert.Empty(t, list)
|
||
|
||
// 重複 Delete 回 ErrNotFound
|
||
assert.ErrorIs(t, r.Delete(ctx, id), ErrNotFound)
|
||
}
|
||
|
||
func TestPG_Save_RequiresID(t *testing.T) {
|
||
r, _, owner := newPGRepo(t)
|
||
assert.Error(t, r.Save(context.Background(), &Model{Name: "no-id", OwnerUserID: owner}))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1.6 integration/真 DB
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// array round-trip:input_shape INT[] / classes TEXT[] 寫入後讀回相等。
|
||
func TestPG_ArrayRoundTrip(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
id := uuid.NewString()
|
||
in := &Model{
|
||
ID: id,
|
||
OwnerUserID: owner,
|
||
Name: "with-arrays",
|
||
StorageKey: "k",
|
||
Source: SourceConverted,
|
||
InputShape: []int{1, 3, 224, 224},
|
||
Classes: []string{"face", "person", "car"},
|
||
Framework: "onnx",
|
||
}
|
||
require.NoError(t, r.Save(ctx, in))
|
||
|
||
got, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, []int{1, 3, 224, 224}, got.InputShape)
|
||
assert.Equal(t, []string{"face", "person", "car"}, got.Classes)
|
||
assert.Equal(t, "onnx", got.Framework)
|
||
}
|
||
|
||
// 空 / nil array:寫入 nil,讀回應為 nil(對齊 in-memory omitempty 語意)。
|
||
func TestPG_ArrayNilRoundTrip(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "no-arrays", StorageKey: "k", Source: SourceUploaded}))
|
||
|
||
got, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Nil(t, got.InputShape)
|
||
assert.Nil(t, got.Classes)
|
||
}
|
||
|
||
// 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, &Model{ID: id, OwnerUserID: owner, Name: "v1", StorageKey: "k", Source: SourceUploaded}))
|
||
first, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
|
||
// 稍候,確保 updated_at 會推進。
|
||
time.Sleep(10 * time.Millisecond)
|
||
|
||
// 第二次 Save:帶不同(更早 / 不同)的 CreatedAt,應被忽略而保留 first.CreatedAt。
|
||
require.NoError(t, r.Save(ctx, &Model{
|
||
ID: id,
|
||
OwnerUserID: owner,
|
||
Name: "v2",
|
||
StorageKey: "k2",
|
||
Source: SourceUploaded,
|
||
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, "k2", second.StorageKey)
|
||
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(非保留已刪除的舊值)。
|
||
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, &Model{ID: id, OwnerUserID: owner, Name: "v1", StorageKey: "k", Source: SourceUploaded, CreatedAt: oldCreated}))
|
||
require.NoError(t, r.Delete(ctx, id))
|
||
|
||
// 復活:deleted_at 仍在 DB(被 EXCLUDED.deleted_at=NULL 清掉),created_at 用新傳入值。
|
||
newCreated := time.Now().UTC()
|
||
require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "revived", StorageKey: "k", Source: SourceUploaded, 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 應採新值")
|
||
}
|
||
|
||
// faa_object_key nullable round-trip:有值寫入讀回相等;空值讀回空字串。
|
||
func TestPG_FAAObjectKey_NullableRoundTrip(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
// 有值(converted 類)
|
||
idWith := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Model{
|
||
ID: idWith, OwnerUserID: owner, Name: "converted", StorageKey: "k",
|
||
Source: SourceConverted, FAAObjectKey: "models/" + owner + "/job-1.nef",
|
||
}))
|
||
gotWith, err := r.Get(ctx, idWith)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "models/"+owner+"/job-1.nef", gotWith.FAAObjectKey)
|
||
|
||
// 空值(uploaded 類)— 讀回空字串。
|
||
idWithout := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Model{ID: idWithout, OwnerUserID: owner, Name: "uploaded", StorageKey: "k", Source: SourceUploaded}))
|
||
gotWithout, err := r.Get(ctx, idWithout)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "", gotWithout.FAAObjectKey)
|
||
}
|
||
|
||
// source_job_id(UUID nullable):有值 / 空值 round-trip。
|
||
func TestPG_SourceJobID_NullableRoundTrip(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
jobID := uuid.NewString()
|
||
idWith := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Model{ID: idWith, OwnerUserID: owner, Name: "j", StorageKey: "k", Source: SourceConverted, SourceJobID: jobID}))
|
||
gotWith, err := r.Get(ctx, idWith)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, jobID, gotWith.SourceJobID)
|
||
|
||
idWithout := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Model{ID: idWithout, OwnerUserID: owner, Name: "n", StorageKey: "k", Source: SourceUploaded}))
|
||
gotWithout, err := r.Get(ctx, idWithout)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "", gotWithout.SourceJobID)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1.7 邊界
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// 空 List:乾淨 DB 回空 slice(非 nil error)。
|
||
func TestPG_List_Empty(t *testing.T) {
|
||
r, _, owner := newPGRepo(t)
|
||
list, err := r.List(context.Background(), ListFilter{OwnerUserID: owner})
|
||
require.NoError(t, err)
|
||
assert.Empty(t, list)
|
||
assert.NotNil(t, list, "List 應回 non-nil 空 slice")
|
||
}
|
||
|
||
// 重複 Save 同 ID(未刪除):不報錯,最終為單一列、最後一次內容。
|
||
func TestPG_Save_Duplicate(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
id := uuid.NewString()
|
||
for i := 0; i < 5; i++ {
|
||
require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "dup", StorageKey: "k", Source: SourceUploaded}))
|
||
}
|
||
assert.Equal(t, 1, tdb.CountRows(t, "models"), "重複 upsert 同 ID 應只有一列")
|
||
}
|
||
|
||
// 併發 Save 同 ID:不應 panic / FK 衝突;最終仍為單一列。
|
||
func TestPG_Save_ConcurrentSameID(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, &Model{ID: id, OwnerUserID: owner, Name: "concurrent", StorageKey: "k", Source: SourceUploaded})
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
|
||
for i, e := range errs {
|
||
assert.NoError(t, e, "併發 Save #%d", i)
|
||
}
|
||
assert.Equal(t, 1, tdb.CountRows(t, "models"), "併發 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, &Model{ID: uuid.NewString(), OwnerUserID: owner, Name: "x", StorageKey: "k", Source: SourceUploaded})
|
||
assert.Error(t, err, "已取消 ctx 的 Save 應回 error")
|
||
|
||
_, err = r.Get(ctx, uuid.NewString())
|
||
assert.Error(t, err, "已取消 ctx 的 Get 應回 error")
|
||
|
||
_, err = r.List(ctx, ListFilter{OwnerUserID: owner})
|
||
assert.Error(t, err, "已取消 ctx 的 List 應回 error")
|
||
}
|