//go:build dbtest // PostgresRepository 模型共享方法(Library 查詢 + model_shares CRUD)的真 DB 整合測試。 // // build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。 // 執行: // // go test -tags=dbtest ./internal/model/... // DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ // go test -tags=dbtest ./internal/model/... // // 涵蓋:可見性 predicate(我的 ∪ public ∪ tenant同org ∪ shared)、enumeration 排除、 // filter / 搜尋 / cursor 分頁、share CRUD、tenant 邊界(空 org 不落 tenant)。 package model import ( "context" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/db/testsupport" ) // insertUserWithOrg 寫入一筆帶 org_id 的 user,回傳 user id。org 為空時 org_id=NULL。 func insertUserWithOrg(t *testing.T, tdb *testsupport.TestDB, org string) string { t.Helper() id := uuid.NewString() ctx := context.Background() if org == "" { _, err := tdb.Pool.Exec(ctx, `INSERT INTO users (id, email) VALUES ($1, $2)`, id, id+"@t.local") require.NoError(t, err) return id } _, err := tdb.Pool.Exec(ctx, `INSERT INTO users (id, email, org_id) VALUES ($1, $2, $3)`, id, id+"@t.local", org) require.NoError(t, err) return id } // saveReady 存一個 ready model(指定 owner + visibility),回傳其 id。 func saveReady(t *testing.T, r *PostgresRepository, owner, visibility, name string) string { t.Helper() now := time.Now().UTC() id := uuid.NewString() require.NoError(t, r.Save(context.Background(), &Model{ ID: id, OwnerUserID: owner, Name: name, StorageKey: "models/" + owner + "/" + id + ".nef", FileSize: 1024, Source: SourceUploaded, Visibility: visibility, UploadedAt: &now, })) return id } // TestPGShare_LibraryVisibility 驗證 Library predicate(我的 ∪ public ∪ shared,排除別人 private)。 func TestPGShare_LibraryVisibility(t *testing.T) { tdb := testsupport.SetupTestDB(t) tdb.Truncate(t, "model_shares", "models", "users") r := NewPostgresRepository(tdb.Pool) ctx := context.Background() me := insertUserWithOrg(t, tdb, "") other := insertUserWithOrg(t, tdb, "") mine := saveReady(t, r, me, VisibilityPrivate, "mine") saveReady(t, r, other, VisibilityPrivate, "otherPriv") pub := saveReady(t, r, other, VisibilityPublic, "otherPub") shared := saveReady(t, r, other, VisibilityPrivate, "otherShared") require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: shared, GranteeUserID: me, Role: "viewer", GrantedBy: other})) items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Limit: 100}) require.NoError(t, err) got := map[string]*LibraryItem{} for _, it := range items { got[it.Model.ID] = it } assert.Contains(t, got, mine) assert.Contains(t, got, pub) assert.Contains(t, got, shared) assert.Len(t, got, 3, "別人的 private 不應出現") assert.True(t, got[shared].SharedWithMe) assert.Equal(t, "viewer", got[shared].MyAccess) } // TestPGShare_LibraryTenantBoundary 驗證 tenant 可見性:同 org 命中、異 org / 空 org 不命中(SEC-4)。 func TestPGShare_LibraryTenantBoundary(t *testing.T) { tdb := testsupport.SetupTestDB(t) tdb.Truncate(t, "model_shares", "models", "users") r := NewPostgresRepository(tdb.Pool) ctx := context.Background() // org_id 是 UUID 欄,用真 UUID(不是 'org-1' 這種字面)。 org1 := uuid.NewString() org2 := uuid.NewString() orgOwner := insertUserWithOrg(t, tdb, org1) teammate := insertUserWithOrg(t, tdb, org1) outsider := insertUserWithOrg(t, tdb, org2) noOrg := insertUserWithOrg(t, tdb, "") tenantModel := saveReady(t, r, orgOwner, VisibilityTenant, "tenant") // 同 org → 可見。UserOrgID 傳 org_id 的 text 形式(對齊 UserContext.OrgID 為字串)。 items, _, err := r.Library(ctx, LibraryQuery{UserID: teammate, UserOrgID: org1, Limit: 100}) require.NoError(t, err) require.Len(t, items, 1) assert.Equal(t, tenantModel, items[0].Model.ID) // 異 org → 不可見。 items, _, err = r.Library(ctx, LibraryQuery{UserID: outsider, UserOrgID: org2, Limit: 100}) require.NoError(t, err) assert.Empty(t, items, "異 org 不應看到 tenant model") // 空 org(OIDC 現況)→ 不可見(安全預設,即使 model 是 tenant)。 items, _, err = r.Library(ctx, LibraryQuery{UserID: noOrg, UserOrgID: "", Limit: 100}) require.NoError(t, err) assert.Empty(t, items, "空 org 不應落 tenant 可見(SEC-4)") } // TestPGShare_LibraryFilters 驗證 filter(owned / target_chip / source / visibility / q)。 func TestPGShare_LibraryFilters(t *testing.T) { tdb := testsupport.SetupTestDB(t) tdb.Truncate(t, "model_shares", "models", "users") r := NewPostgresRepository(tdb.Pool) ctx := context.Background() me := insertUserWithOrg(t, tdb, "") other := insertUserWithOrg(t, tdb, "") mine := saveReady(t, r, me, VisibilityPrivate, "yolo-mine") pub := saveReady(t, r, other, VisibilityPublic, "resnet-pub") // owned=true → 只我的。 items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Owned: boolPtr(true), Limit: 100}) require.NoError(t, err) require.Len(t, items, 1) assert.Equal(t, mine, items[0].Model.ID) // owned=false → 只別人。 items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Owned: boolPtr(false), Limit: 100}) require.NoError(t, err) require.Len(t, items, 1) assert.Equal(t, pub, items[0].Model.ID) // visibility=public。 items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Visibility: VisibilityPublic, Limit: 100}) require.NoError(t, err) require.Len(t, items, 1) assert.Equal(t, pub, items[0].Model.ID) // q=yolo(搜尋 name)。 items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Q: "yolo", Limit: 100}) require.NoError(t, err) require.Len(t, items, 1) assert.Equal(t, mine, items[0].Model.ID) // q 含 LIKE 萬用字元應被 escape(不 match 全部)。 items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Q: "%", Limit: 100}) require.NoError(t, err) assert.Empty(t, items, "字面 '%' 不應 match 任何 model(萬用字元已 escape)") } // TestPGShare_LibraryCursorPagination 驗證 cursor 分頁不重不漏(真 DB keyset)。 func TestPGShare_LibraryCursorPagination(t *testing.T) { tdb := testsupport.SetupTestDB(t) tdb.Truncate(t, "model_shares", "models", "users") r := NewPostgresRepository(tdb.Pool) ctx := context.Background() me := insertUserWithOrg(t, tdb, "") for i := 0; i < 7; i++ { saveReady(t, r, me, VisibilityPrivate, "m"+string(rune('a'+i))) } seen := map[string]bool{} var cursor *Cursor for page := 0; page < 20; page++ { items, hasMore, err := r.Library(ctx, LibraryQuery{ UserID: me, Limit: 3, Sort: "name", Order: "asc", Cursor: cursor, }) require.NoError(t, err) for _, it := range items { assert.False(t, seen[it.Model.ID], "分頁重複 %s", it.Model.ID) seen[it.Model.ID] = true } if !hasMore { break } require.NotEmpty(t, items) last := items[len(items)-1].Model cursor = &Cursor{ID: last.ID, SortValue: last.Name} } assert.Len(t, seen, 7, "所有 model 應被分頁完整走過一次") } // TestPGShare_ShareCRUD 驗證 share Upsert / Get / List / Delete(真 DB)。 func TestPGShare_ShareCRUD(t *testing.T) { tdb := testsupport.SetupTestDB(t) tdb.Truncate(t, "model_shares", "models", "users") r := NewPostgresRepository(tdb.Pool) ctx := context.Background() owner := insertUserWithOrg(t, tdb, "") bob := insertUserWithOrg(t, tdb, "") alice := insertUserWithOrg(t, tdb, "") m := saveReady(t, r, owner, VisibilityPrivate, "m") require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: bob, Role: "viewer", GrantedBy: owner})) got, err := r.GetShare(ctx, m, bob) require.NoError(t, err) assert.Equal(t, "viewer", got.Role) // upsert 更新 role。 require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: bob, Role: "editor", GrantedBy: owner})) got, err = r.GetShare(ctx, m, bob) require.NoError(t, err) assert.Equal(t, "editor", got.Role) require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: alice, Role: "viewer", GrantedBy: owner})) shares, err := r.ListShares(ctx, m) require.NoError(t, err) assert.Len(t, shares, 2) require.NoError(t, r.DeleteShare(ctx, m, bob)) _, err = r.GetShare(ctx, m, bob) assert.ErrorIs(t, err, ErrNotFound) assert.ErrorIs(t, r.DeleteShare(ctx, m, uuid.NewString()), ErrNotFound) } // TestPGShare_LibraryExcludesSoftDeletedAndPending 驗證軟刪 / 未 ready 的 model 不進 Library。 func TestPGShare_LibraryExcludesSoftDeletedAndPending(t *testing.T) { tdb := testsupport.SetupTestDB(t) tdb.Truncate(t, "model_shares", "models", "users") r := NewPostgresRepository(tdb.Pool) ctx := context.Background() me := insertUserWithOrg(t, tdb, "") // pending(無 UploadedAt)。 pendingID := uuid.NewString() require.NoError(t, r.Save(ctx, &Model{ ID: pendingID, OwnerUserID: me, Name: "pending", StorageKey: "k", FileSize: 1, Source: SourceUploaded, Visibility: VisibilityPublic, })) // ready 然後軟刪。 deleted := saveReady(t, r, me, VisibilityPublic, "deleted") require.NoError(t, r.Delete(ctx, deleted)) // 正常 ready。 ok := saveReady(t, r, me, VisibilityPrivate, "ok") items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Limit: 100}) require.NoError(t, err) require.Len(t, items, 1, "只應列正常 ready 的 model") assert.Equal(t, ok, items[0].Model.ID) } // TestPGShare_GetWithOwner 驗證 GetWithOwner join 出 owner name(Minor-1,真 DB)。 func TestPGShare_GetWithOwner(t *testing.T) { tdb := testsupport.SetupTestDB(t) tdb.Truncate(t, "model_shares", "models", "users") r := NewPostgresRepository(tdb.Pool) ctx := context.Background() // 建帶 name 的 owner。 ownerID := uuid.NewString() _, err := tdb.Pool.Exec(ctx, `INSERT INTO users (id, email, name) VALUES ($1, $2, $3)`, ownerID, ownerID+"@t.local", "Alice") require.NoError(t, err) modelID := saveReady(t, r, ownerID, VisibilityPublic, "m") m, ownerName, err := r.GetWithOwner(ctx, modelID) require.NoError(t, err) assert.Equal(t, modelID, m.ID) assert.Equal(t, VisibilityPublic, m.Visibility) assert.Equal(t, "Alice", ownerName, "GetWithOwner 應 join 出 owner name") // owner 無 name → 空字串(COALESCE)。 noNameOwner := insertUserWithOrg(t, tdb, "") m2 := saveReady(t, r, noNameOwner, VisibilityPrivate, "m2") _, ownerName2, err := r.GetWithOwner(ctx, m2) require.NoError(t, err) assert.Equal(t, "", ownerName2, "owner 無 name 時 owner_name 應為空") // 不存在 / 已軟刪 → ErrNotFound。 _, _, err = r.GetWithOwner(ctx, uuid.NewString()) assert.ErrorIs(t, err, ErrNotFound) require.NoError(t, r.Delete(ctx, modelID)) _, _, err = r.GetWithOwner(ctx, modelID) assert.ErrorIs(t, err, ErrNotFound, "已軟刪應回 ErrNotFound") } func boolPtr(b bool) *bool { return &b }