DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
377 lines
14 KiB
Go
377 lines
14 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)
|
||
}
|
||
|
||
// List 排序:宣稱 ORDER BY created_at DESC(最新在前)。建多筆不同 created_at、驗回傳順序。
|
||
func TestPG_List_OrderByCreatedAtDesc(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
base := time.Now().UTC().Truncate(time.Microsecond)
|
||
// 刻意亂序插入(mid → oldest → newest),以證明排序來自查詢而非插入順序。
|
||
idMid := uuid.NewString()
|
||
idOld := uuid.NewString()
|
||
idNew := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Model{ID: idMid, OwnerUserID: owner, Name: "mid", StorageKey: "k", Source: SourceUploaded, CreatedAt: base.Add(-2 * time.Hour)}))
|
||
require.NoError(t, r.Save(ctx, &Model{ID: idOld, OwnerUserID: owner, Name: "old", StorageKey: "k", Source: SourceUploaded, CreatedAt: base.Add(-4 * time.Hour)}))
|
||
require.NoError(t, r.Save(ctx, &Model{ID: idNew, OwnerUserID: owner, Name: "new", StorageKey: "k", Source: SourceUploaded, CreatedAt: base}))
|
||
|
||
list, err := r.List(ctx, ListFilter{OwnerUserID: owner})
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 3)
|
||
// 最新在前(DESC):new → mid → old。
|
||
assert.Equal(t, []string{idNew, idMid, idOld}, []string{list[0].ID, list[1].ID, list[2].ID},
|
||
"List 應依 created_at DESC(最新在前)排序")
|
||
// 額外以時間單調遞減交叉驗證,避免只靠 ID 對齊。
|
||
assert.True(t, list[0].CreatedAt.After(list[1].CreatedAt), "list[0] 應較 list[1] 新")
|
||
assert.True(t, list[1].CreatedAt.After(list[2].CreatedAt), "list[1] 應較 list[2] 新")
|
||
}
|
||
|
||
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 應保留首次值")
|
||
// 已 sleep 10ms,updated_at 必嚴格推進(嚴格 After,非 After-OR-Equal)。
|
||
assert.True(t, second.UpdatedAt.After(first.UpdatedAt), "updated_at 應嚴格推進(sleep 10ms 後)")
|
||
}
|
||
|
||
// 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")
|
||
}
|