visionA/visionA-backend/internal/model/postgres_repository_db_test.go
jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 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>
2026-06-20 18:28:04 +08:00

351 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build dbtest
// PostgresRepository 的真 DB 整合測試DB 接入塊 1子任務 1.51.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.goSaveAndGet、NotFound、List 三 filter、
// soft delete、Save 需 ID
// - 1.6 integration/真 DBarray 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)
// 無 filteradmin
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-tripinput_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_idUUID 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")
}