visionA/visionA-backend/internal/model/postgres_repository_db_test.go
jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
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>
2026-06-21 06:36:35 +08:00

377 lines
14 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)
}
// 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)
// 最新在前DESCnew → 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-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 應保留首次值")
// 已 sleep 10msupdated_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_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")
}