//go:build dbtest // PostgresRepository(device)的真 DB 整合測試(DB 接入塊 2,子任務 2.5–2.7)。 // // build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。 // 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。 // // 執行: // // go test -tags=dbtest ./internal/device/... // # 無本機 Docker 時,Orchestrator 在 130 補跑: // DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ // go test -tags=dbtest ./internal/device/... // // 涵蓋: // - 2.5 unit/邏輯:對齊既有 inmemory_repository_test.go(SaveAndGet、GetBySerial 跨 owner 不串、 // List by owner、soft delete、再刪回 NotFound、保留 CreatedAt、Save 需 ID)。 // - 2.6 integration/真 DB:partial unique 衝突(兩筆未刪除同 owner+serial)、partial unique 讓 // 已刪 serial 可重註冊、雙狀態欄位 + paired_at round-trip、upsert 保留 CreatedAt。 // - 2.7 邊界:空 List、併發註冊同 serial、context cancel。 package device import ( "context" "sync" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "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,滿足 devices.owner_user_id 的 FK。 func newPGRepo(t *testing.T) (*PostgresRepository, *testsupport.TestDB, string) { t.Helper() tdb := testsupport.SetupTestDB(t) tdb.Truncate(t, "devices", "users") owner := tdb.EnsureDemoUser(t) return NewPostgresRepository(tdb.Pool), tdb, owner } // --------------------------------------------------------------------------- // 2.5 unit/邏輯(對齊 inmemory_repository_test.go 的 case) // --------------------------------------------------------------------------- func TestPG_SaveAndGet(t *testing.T) { ctx := context.Background() r, _, owner := newPGRepo(t) d := &Device{ ID: uuid.NewString(), OwnerUserID: owner, Name: "Lab KL520", DeviceType: "kl520", SerialNumber: "KL520-AAA", RemoteStatus: RemoteStatusOffline, Status: USBStatusUnknown, } require.NoError(t, r.Save(ctx, d)) got, err := r.Get(ctx, d.ID) require.NoError(t, err) assert.Equal(t, "Lab KL520", got.Name) assert.Equal(t, owner, got.OwnerUserID) assert.Equal(t, "kl520", got.DeviceType) assert.Equal(t, "KL520-AAA", got.SerialNumber) assert.Equal(t, RemoteStatusOffline, got.RemoteStatus) assert.Equal(t, USBStatusUnknown, got.Status) 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_Save_RequiresID(t *testing.T) { r, _, owner := newPGRepo(t) assert.Error(t, r.Save(context.Background(), &Device{Name: "no-id", OwnerUserID: owner})) } // GetBySerial:跨 owner 同 serial 不互串(owner 過濾)。 func TestPG_GetBySerial(t *testing.T) { ctx := context.Background() r, tdb, ownerA := newPGRepo(t) ownerB := tdb.InsertUser(t, "", "") idA, idB := uuid.NewString(), uuid.NewString() require.NoError(t, r.Save(ctx, &Device{ID: idA, OwnerUserID: ownerA, Name: "a", SerialNumber: "S-1"})) require.NoError(t, r.Save(ctx, &Device{ID: idB, OwnerUserID: ownerB, Name: "b", SerialNumber: "S-1"})) gotA, err := r.GetBySerial(ctx, ownerA, "S-1") require.NoError(t, err) assert.Equal(t, idA, gotA.ID) gotB, err := r.GetBySerial(ctx, ownerB, "S-1") require.NoError(t, err) assert.Equal(t, idB, gotB.ID) // 不存在的 owner _, err = r.GetBySerial(ctx, uuid.NewString(), "S-1") assert.ErrorIs(t, err, ErrNotFound) } // GetBySerial:soft-delete 後查不到。 func TestPG_GetBySerial_SkipsDeleted(t *testing.T) { ctx := context.Background() r, _, owner := newPGRepo(t) id := uuid.NewString() require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "x", SerialNumber: "S-9"})) require.NoError(t, r.Delete(ctx, id)) _, err := r.GetBySerial(ctx, owner, "S-9") assert.ErrorIs(t, err, ErrNotFound) } func TestPG_List_ByOwner(t *testing.T) { ctx := context.Background() r, tdb, owner := newPGRepo(t) owner2 := tdb.InsertUser(t, "", "") require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "a", SerialNumber: "S-A"})) require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "b", SerialNumber: "S-B"})) require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner2, Name: "c", SerialNumber: "S-C"})) listOwner, err := r.List(ctx, owner) require.NoError(t, err) assert.Len(t, listOwner, 2) listOwner2, err := r.List(ctx, owner2) require.NoError(t, err) assert.Len(t, listOwner2, 1) // 不存在的 owner listNone, err := r.List(ctx, uuid.NewString()) require.NoError(t, err) assert.Empty(t, listNone) } func TestPG_Delete_SoftDelete(t *testing.T) { ctx := context.Background() r, _, owner := newPGRepo(t) id := uuid.NewString() require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "x", SerialNumber: "S-D"})) require.NoError(t, r.Delete(ctx, id)) // Get 不到 _, err := r.Get(ctx, id) assert.ErrorIs(t, err, ErrNotFound) // List 不含 list, _ := r.List(ctx, owner) assert.Empty(t, list) // 重複 Delete 回 ErrNotFound assert.ErrorIs(t, r.Delete(ctx, id), ErrNotFound) } // --------------------------------------------------------------------------- // 2.6 integration/真 DB // --------------------------------------------------------------------------- // partial unique 衝突:兩筆「未刪除」同 (owner, serial)、不同 id → 第二筆撞 unique(23505)。 func TestPG_PartialUnique_ActiveConflict(t *testing.T) { ctx := context.Background() r, _, owner := newPGRepo(t) require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "first", SerialNumber: "SN-DUP"})) // 不同 id、同 owner+serial、皆未刪除 → 違反 uq_devices_owner_serial_active。 err := r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "second", SerialNumber: "SN-DUP"}) require.Error(t, err, "兩筆未刪除同 owner+serial 應違反 partial unique") var pgErr *pgconn.PgError require.ErrorAs(t, err, &pgErr) assert.Equal(t, "23505", pgErr.Code, "應為 unique_violation") assert.Equal(t, "uq_devices_owner_serial_active", pgErr.ConstraintName) } // partial unique × soft-delete:已刪 serial 可重新註冊(核心決策 2.3)。 func TestPG_PartialUnique_ReRegisterAfterSoftDelete(t *testing.T) { ctx := context.Background() r, tdb, owner := newPGRepo(t) id1 := uuid.NewString() require.NoError(t, r.Save(ctx, &Device{ID: id1, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-RE"})) require.NoError(t, r.Delete(ctx, id1)) // 同 owner+serial、新 id → 因舊列已 soft-delete、退出 partial index,重註冊應成功。 id2 := uuid.NewString() require.NoError(t, r.Save(ctx, &Device{ID: id2, OwnerUserID: owner, Name: "v2", SerialNumber: "SN-RE"}), "已 soft-delete 的 serial 應可重新註冊") // GetBySerial 應回新註冊的那筆(未刪除)。 got, err := r.GetBySerial(ctx, owner, "SN-RE") require.NoError(t, err) assert.Equal(t, id2, got.ID) assert.Equal(t, "v2", got.Name) // DB 共有兩列(一筆 deleted、一筆 active)。 assert.Equal(t, 2, tdb.CountRows(t, "devices"), "重註冊後應有兩列:舊的 soft-deleted + 新的 active") // List(未刪除)只回新的一筆。 list, err := r.List(ctx, owner) require.NoError(t, err) require.Len(t, list, 1) assert.Equal(t, id2, list[0].ID) } // 雙狀態欄位 + paired_at + 時間欄位 round-trip。 func TestPG_DualStatus_RoundTrip(t *testing.T) { ctx := context.Background() r, _, owner := newPGRepo(t) lastSeen := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Microsecond) lastConnected := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Microsecond) paired := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond) id := uuid.NewString() in := &Device{ ID: id, OwnerUserID: owner, Name: "dual", DeviceType: "kl720", SerialNumber: "SN-DUAL", RemoteStatus: RemoteStatusReconnecting, LastSeenAt: &lastSeen, LastConnectedAt: &lastConnected, Status: USBStatusOnline, PairedAt: &paired, } require.NoError(t, r.Save(ctx, in)) got, err := r.Get(ctx, id) require.NoError(t, err) assert.Equal(t, RemoteStatusReconnecting, got.RemoteStatus) assert.Equal(t, USBStatusOnline, got.Status) require.NotNil(t, got.LastSeenAt) require.NotNil(t, got.LastConnectedAt) require.NotNil(t, got.PairedAt) assert.True(t, lastSeen.Equal(*got.LastSeenAt), "last_seen_at round-trip") assert.True(t, lastConnected.Equal(*got.LastConnectedAt), "last_connected_at round-trip") assert.True(t, paired.Equal(*got.PairedAt), "paired_at round-trip") } // nullable 時間欄位:不帶值寫入,讀回為 nil(對齊 in-memory omitempty 語意)。 func TestPG_NullableTimes_RoundTrip(t *testing.T) { ctx := context.Background() r, _, owner := newPGRepo(t) id := uuid.NewString() require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "no-times", SerialNumber: "SN-NT"})) got, err := r.Get(ctx, id) require.NoError(t, err) assert.Nil(t, got.LastSeenAt) assert.Nil(t, got.LastConnectedAt) assert.Nil(t, got.PairedAt) assert.Nil(t, got.DeletedAt) // 預設值(migration DEFAULT):remote_status='offline'、status='unknown'。 assert.Equal(t, RemoteStatusOffline, got.RemoteStatus) assert.Equal(t, USBStatusUnknown, got.Status) } // 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, &Device{ID: id, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-UP"})) first, err := r.Get(ctx, id) require.NoError(t, err) time.Sleep(10 * time.Millisecond) // 第二次 Save:帶不同(更早)的 CreatedAt,應被忽略而保留 first.CreatedAt。 require.NoError(t, r.Save(ctx, &Device{ ID: id, OwnerUserID: owner, Name: "v2", SerialNumber: "SN-UP", RemoteStatus: RemoteStatusOnline, Status: USBStatusOnline, 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, RemoteStatusOnline, second.RemoteStatus) 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、deleted_at 清回 nil。 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, &Device{ID: id, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-RV", CreatedAt: oldCreated})) require.NoError(t, r.Delete(ctx, id)) newCreated := time.Now().UTC() require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "revived", SerialNumber: "SN-RV", 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 應採新值") } // --------------------------------------------------------------------------- // 2.7 邊界 // --------------------------------------------------------------------------- // 空 List:乾淨 DB 回 non-nil 空 slice。 func TestPG_List_Empty(t *testing.T) { r, _, owner := newPGRepo(t) list, err := r.List(context.Background(), owner) require.NoError(t, err) assert.Empty(t, list) assert.NotNil(t, list, "List 應回 non-nil 空 slice") } // 併發註冊同 (owner, serial)、不同 id:partial unique 確保至多一筆成功,其餘撞 23505。 // 不應 panic;最終 active 列恰為一筆。 func TestPG_ConcurrentRegisterSameSerial(t *testing.T) { ctx := context.Background() r, tdb, owner := newPGRepo(t) 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, &Device{ ID: uuid.NewString(), OwnerUserID: owner, Name: "concurrent", SerialNumber: "SN-RACE", }) }(i) } wg.Wait() var ok, conflict int for _, e := range errs { if e == nil { ok++ continue } var pgErr *pgconn.PgError require.ErrorAs(t, e, &pgErr, "非 nil error 應為 PgError") assert.Equal(t, "23505", pgErr.Code, "衝突應為 unique_violation") conflict++ } assert.Equal(t, 1, ok, "恰一筆成功註冊") assert.Equal(t, n-1, conflict, "其餘皆撞 partial unique") // active(未刪除)列恰一筆。 list, err := r.List(ctx, owner) require.NoError(t, err) assert.Len(t, list, 1, "最終 active device 恰一筆") _ = tdb // tdb 保留供除錯(CountRows) } // 併發 Save 同 id:upsert by id,不應 panic;最終單一列、最後內容。 func TestPG_ConcurrentSaveSameID(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, &Device{ID: id, OwnerUserID: owner, Name: "same-id", SerialNumber: "SN-SID"}) }(i) } wg.Wait() for i, e := range errs { assert.NoError(t, e, "併發 Save 同 id #%d", i) } assert.Equal(t, 1, tdb.CountRows(t, "devices"), "併發 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, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "x", SerialNumber: "SN-CC"}) assert.Error(t, err, "已取消 ctx 的 Save 應回 error") _, err = r.Get(ctx, uuid.NewString()) assert.Error(t, err, "已取消 ctx 的 Get 應回 error") _, err = r.GetBySerial(ctx, owner, "SN-CC") assert.Error(t, err, "已取消 ctx 的 GetBySerial 應回 error") _, err = r.List(ctx, owner) assert.Error(t, err, "已取消 ctx 的 List 應回 error") }