//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") }