B 設備管理(feature-device-mgmt-tdd): - POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋 + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪) - error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409) - 不需 migration(registered_at 欄/index/讀寫已在 0005) C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE): - migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表 - canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用 - GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email) / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬 - tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審) reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
851 lines
32 KiB
Go
851 lines
32 KiB
Go
//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()
|
||
|
||
// 注意:errs 收集在各 goroutine(只寫各自 index、無共享寫衝突),但所有斷言
|
||
// 都在 Wait 後的主 goroutine 進行。require.* 會走 t.FailNow → runtime.Goexit,
|
||
// 只能在測試主 goroutine 呼叫;若放進上面的 spawn goroutine 會是未定義行為。
|
||
// 這是 Reviewer 標示的易退化點 —— 保持 require.ErrorAs 留在此迴圈(Wait 後、主 goroutine)。
|
||
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 應只有一列")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// B1(A' 模型 scan 讀新欄;migration 0005)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// scanDevice 讀 17 欄回歸:既有 SaveTx 只寫舊 13 欄,新 4 欄(agent_id /
|
||
// agent_local_device_id / registered_at / is_representative)走 DB DEFAULT / nullable。
|
||
// B1 只擴充 scan(不寫新欄),此測驗證 Save→Get round-trip 後新欄讀回預設空值:
|
||
// - agent_id / agent_local_device_id → NULL → 空字串(derefString)
|
||
// - registered_at → NULL → nil
|
||
// - is_representative → DEFAULT false
|
||
func TestPG_ScanNewColumns_DefaultsAfterSave(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: "b1", SerialNumber: "SN-B1"}))
|
||
|
||
got, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Empty(t, got.AgentID, "既有 Save 不帶 agent_id → 讀回空字串(NULL)")
|
||
assert.Empty(t, got.AgentLocalDeviceID, "既有 Save 不帶 agent_local_device_id → 讀回空字串(NULL)")
|
||
assert.Nil(t, got.RegisteredAt, "既有 Save 不帶 registered_at → 讀回 nil(NULL)")
|
||
assert.False(t, got.IsRepresentative, "is_representative 應為 DEFAULT false")
|
||
}
|
||
|
||
// scanDevice 讀 17 欄:直接以 SQL 塞入帶新欄值的 device(模擬 migration 0005 data migration
|
||
// 產生的 representative device / 未來 B2 寫入的真 USB),驗證 scan 正確讀回四個新欄。
|
||
// 用 SQL 直塞而非 repo.Save,因 B1 的 Save 尚未寫新欄(B2 才寫)。
|
||
func TestPG_ScanNewColumns_ReadsPopulatedValues(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
// 先建一個 agent(供 devices.agent_id FK 參照)。
|
||
agentID := uuid.NewString()
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||
agentID, owner)
|
||
require.NoError(t, err)
|
||
|
||
registeredAt := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Microsecond)
|
||
devID := uuid.NewString()
|
||
_, err = tdb.Pool.Exec(ctx,
|
||
`INSERT INTO devices
|
||
(id, owner_user_id, name, agent_id, agent_local_device_id, registered_at, is_representative)
|
||
VALUES ($1, $2, 'usb-0', $3, 'kl520-0', $4, false)`,
|
||
devID, owner, agentID, registeredAt)
|
||
require.NoError(t, err)
|
||
|
||
got, err := r.Get(ctx, devID)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, agentID, got.AgentID, "agent_id 應讀回")
|
||
assert.Equal(t, "kl520-0", got.AgentLocalDeviceID, "agent_local_device_id 應讀回")
|
||
require.NotNil(t, got.RegisteredAt, "registered_at 應非 nil")
|
||
assert.True(t, registeredAt.Equal(*got.RegisteredAt), "registered_at round-trip")
|
||
assert.False(t, got.IsRepresentative, "is_representative=false 應讀回")
|
||
|
||
// 另塞一筆 representative device(is_representative=true),驗 bool 讀回。
|
||
repID := uuid.NewString()
|
||
_, err = tdb.Pool.Exec(ctx,
|
||
`INSERT INTO devices (id, owner_user_id, name, agent_id, is_representative)
|
||
VALUES ($1, $2, 'rep', $3, true)`,
|
||
repID, owner, agentID)
|
||
require.NoError(t, err)
|
||
|
||
rep, err := r.Get(ctx, repID)
|
||
require.NoError(t, err)
|
||
assert.True(t, rep.IsRepresentative, "representative device 的 is_representative=true 應讀回")
|
||
assert.Nil(t, rep.RegisteredAt, "未設 registered_at → nil")
|
||
}
|
||
|
||
// 0005-Mi#2:is_representative 為 NOT NULL 約束——直接 INSERT is_representative=NULL 應失敗。
|
||
// migration 0005 的 ALTER ... ADD COLUMN is_representative BOOLEAN NOT NULL DEFAULT false,
|
||
// 顯式寫 NULL 覆蓋 DEFAULT 時應被 NOT NULL 約束擋下(23502 not_null_violation)。
|
||
func TestPG_IsRepresentative_NotNullConstraint(t *testing.T) {
|
||
ctx := context.Background()
|
||
_, tdb, owner := newPGRepo(t)
|
||
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO devices (id, owner_user_id, name, is_representative)
|
||
VALUES ($1, $2, 'null-rep', NULL)`,
|
||
uuid.NewString(), owner)
|
||
require.Error(t, err, "is_representative=NULL 應違反 NOT NULL 約束")
|
||
|
||
var pgErr *pgconn.PgError
|
||
require.ErrorAs(t, err, &pgErr)
|
||
assert.Equal(t, "23502", pgErr.Code, "應為 not_null_violation")
|
||
assert.Equal(t, "is_representative", pgErr.ColumnName)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// B2(SaveTx 寫新欄;migration 0005)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// SaveTx 寫入 A' 新 4 欄:帶 agent_id / agent_local_device_id / registered_at /
|
||
// is_representative 的 device,Save→Get round-trip 後四欄都正確寫入 + 讀回。
|
||
func TestPG_SaveTx_WritesNewColumns(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
// 先建 agent 供 FK。
|
||
agentID := uuid.NewString()
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||
agentID, owner)
|
||
require.NoError(t, err)
|
||
|
||
registeredAt := time.Now().Add(-30 * time.Minute).UTC().Truncate(time.Microsecond)
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: id,
|
||
OwnerUserID: owner,
|
||
Name: "usb-real",
|
||
DeviceType: "kl520",
|
||
SerialNumber: "0x1A2B3C4D",
|
||
AgentID: agentID,
|
||
AgentLocalDeviceID: "kl520-0",
|
||
RegisteredAt: ®isteredAt,
|
||
IsRepresentative: false,
|
||
}))
|
||
|
||
got, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, agentID, got.AgentID)
|
||
assert.Equal(t, "kl520-0", got.AgentLocalDeviceID)
|
||
require.NotNil(t, got.RegisteredAt)
|
||
assert.True(t, registeredAt.Equal(*got.RegisteredAt), "registered_at round-trip")
|
||
assert.False(t, got.IsRepresentative)
|
||
}
|
||
|
||
// SaveTx 寫 representative device(is_representative=true、serial=NULL、掛 agent)。
|
||
func TestPG_SaveTx_WritesRepresentative(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
agentID := uuid.NewString()
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||
agentID, owner)
|
||
require.NoError(t, err)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: id,
|
||
OwnerUserID: owner,
|
||
Name: "local-agent (paired)",
|
||
DeviceType: "local-agent",
|
||
AgentID: agentID,
|
||
IsRepresentative: true,
|
||
// SerialNumber 留空 → NULL;RegisteredAt 留 nil → NULL
|
||
}))
|
||
|
||
got, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.True(t, got.IsRepresentative)
|
||
assert.Empty(t, got.SerialNumber)
|
||
assert.Nil(t, got.RegisteredAt)
|
||
assert.Equal(t, agentID, got.AgentID)
|
||
|
||
// 直接查 DB 確認 serial_number 為 NULL(representative 不佔 unique)。
|
||
var serialIsNull bool
|
||
require.NoError(t, tdb.Pool.QueryRow(ctx,
|
||
`SELECT serial_number IS NULL FROM devices WHERE id = $1`, id).Scan(&serialIsNull))
|
||
assert.True(t, serialIsNull, "representative device serial 應為 NULL")
|
||
}
|
||
|
||
// SaveTx upsert 更新新欄:第二次 Save 同 id 改 registered_at / is_representative,應更新。
|
||
// 這是註冊軸的關鍵路徑(WP-D 註冊會 UPDATE registered_at);B2 先確保 upsert 能改這些欄。
|
||
func TestPG_SaveTx_UpsertUpdatesNewColumns(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
agentID := uuid.NewString()
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||
agentID, owner)
|
||
require.NoError(t, err)
|
||
|
||
id := uuid.NewString()
|
||
// 初次:未註冊(registered_at nil)。
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0xAABBCCDD",
|
||
AgentID: agentID, IsRepresentative: false,
|
||
}))
|
||
first, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Nil(t, first.RegisteredAt)
|
||
|
||
// 二次:設 registered_at(模擬註冊)。
|
||
registeredAt := time.Now().UTC().Truncate(time.Microsecond)
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0xAABBCCDD",
|
||
AgentID: agentID, IsRepresentative: false, RegisteredAt: ®isteredAt,
|
||
}))
|
||
second, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, second.RegisteredAt, "upsert 應更新 registered_at")
|
||
assert.True(t, registeredAt.Equal(*second.RegisteredAt))
|
||
_ = tdb
|
||
}
|
||
|
||
// SaveTx 帶不存在的 agent_id → FK violation(23503)。守住 agent_id FK 完整性。
|
||
func TestPG_SaveTx_BadAgentIDFKViolation(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, _, owner := newPGRepo(t)
|
||
|
||
err := r.Save(ctx, &Device{
|
||
ID: uuid.NewString(), OwnerUserID: owner, Name: "orphan",
|
||
SerialNumber: "0x11223344", AgentID: uuid.NewString(), // 不存在的 agent
|
||
})
|
||
require.Error(t, err, "agent_id 指向不存在的 agent 應違反 FK")
|
||
|
||
var pgErr *pgconn.PgError
|
||
require.ErrorAs(t, err, &pgErr)
|
||
assert.Equal(t, "23503", pgErr.Code, "應為 foreign_key_violation")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// B4(List filter representative + Mi#4 空 serial)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// List filter:representative device 不出現在清單,只列真 USB device(is_representative=false)。
|
||
func TestPG_List_FiltersRepresentative(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
agentID := uuid.NewString()
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||
agentID, owner)
|
||
require.NoError(t, err)
|
||
|
||
// 1 representative + 2 真 USB。
|
||
repID := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: repID, OwnerUserID: owner, Name: "rep", AgentID: agentID, IsRepresentative: true,
|
||
}))
|
||
usb1 := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: usb1, OwnerUserID: owner, Name: "usb1", SerialNumber: "0x11111111", AgentID: agentID,
|
||
}))
|
||
usb2 := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: usb2, OwnerUserID: owner, Name: "usb2", SerialNumber: "0x22222222", AgentID: agentID,
|
||
}))
|
||
|
||
list, err := r.List(ctx, owner)
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 2, "List 應只回 2 顆真 USB(排除 representative)")
|
||
for _, d := range list {
|
||
assert.False(t, d.IsRepresentative, "List 不應含 representative device")
|
||
}
|
||
// DB 實際有 3 筆(含 representative),只是 List filter 掉。
|
||
assert.Equal(t, 3, tdb.CountRows(t, "devices"))
|
||
}
|
||
|
||
// List 回傳 registered_at / agent_id(供前端三色 / 分組)。
|
||
func TestPG_List_ReturnsAgentIDAndRegisteredAt(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
agentID := uuid.NewString()
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||
agentID, owner)
|
||
require.NoError(t, err)
|
||
|
||
registeredAt := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond)
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: uuid.NewString(), OwnerUserID: owner, Name: "registered-usb",
|
||
SerialNumber: "0x33333333", AgentID: agentID, RegisteredAt: ®isteredAt,
|
||
}))
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: uuid.NewString(), OwnerUserID: owner, Name: "unregistered-usb",
|
||
SerialNumber: "0x44444444", AgentID: agentID, // registered_at nil
|
||
}))
|
||
|
||
list, err := r.List(ctx, owner)
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 2)
|
||
|
||
byName := map[string]*Device{}
|
||
for _, d := range list {
|
||
byName[d.Name] = d
|
||
}
|
||
require.Contains(t, byName, "registered-usb")
|
||
require.Contains(t, byName, "unregistered-usb")
|
||
|
||
assert.Equal(t, agentID, byName["registered-usb"].AgentID)
|
||
require.NotNil(t, byName["registered-usb"].RegisteredAt)
|
||
assert.True(t, registeredAt.Equal(*byName["registered-usb"].RegisteredAt))
|
||
|
||
assert.Equal(t, agentID, byName["unregistered-usb"].AgentID)
|
||
assert.Nil(t, byName["unregistered-usb"].RegisteredAt, "未註冊 device registered_at 應為 nil")
|
||
}
|
||
|
||
// Mi#4:GetBySerialTx 空 serial → 直接回 ErrNotFound(不掃 serial IS NULL 的多筆)。
|
||
func TestPG_GetBySerialTx_EmptySerialReturnsNotFound(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
|
||
// 塞兩筆 serial=NULL 的 device(representative / 佔位)。
|
||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "null1"}))
|
||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "null2"}))
|
||
|
||
// 空 serial 查詢應直接回 ErrNotFound(Mi#4:不因多筆 NULL 而非決定性 / 誤命中)。
|
||
_, err := r.GetBySerialTx(ctx, tdb.Pool, owner, "")
|
||
assert.ErrorIs(t, err, ErrNotFound, "空 serial 查詢應回 ErrNotFound")
|
||
|
||
// 對照:非空 serial 正常命中。
|
||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "real", SerialNumber: "0x55555555"}))
|
||
got, err := r.GetBySerialTx(ctx, tdb.Pool, owner, "0x55555555")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "real", got.Name)
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SetRegistered(註冊軸單欄 UPDATE,feature-device-mgmt-tdd §3.3 / WS-BE)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// pgInsertAgent 建一筆 agent(滿足 devices.agent_id FK),回傳 agentID。
|
||
func pgInsertAgent(t *testing.T, tdb *testsupport.TestDB, owner string) string {
|
||
t.Helper()
|
||
agentID := uuid.NewString()
|
||
_, err := tdb.Pool.Exec(context.Background(),
|
||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||
agentID, owner)
|
||
require.NoError(t, err)
|
||
return agentID
|
||
}
|
||
|
||
// SetRegistered set → 已註冊;set nil → 退回未註冊(列保留)。
|
||
func TestPG_SetRegistered_SetAndClear(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
agentID := pgInsertAgent(t, tdb, owner)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x11111111", AgentID: agentID,
|
||
}))
|
||
|
||
// 初始未註冊。
|
||
got, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
require.Nil(t, got.RegisteredAt)
|
||
|
||
// set → 已註冊。
|
||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||
require.NoError(t, r.SetRegistered(ctx, id, &now))
|
||
got, err = r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, got.RegisteredAt, "register 後 registered_at 非 null")
|
||
assert.True(t, now.Equal(*got.RegisteredAt))
|
||
|
||
// set nil → 退回未註冊、列仍在(絕不軟刪)。
|
||
require.NoError(t, r.SetRegistered(ctx, id, nil))
|
||
got, err = r.Get(ctx, id)
|
||
require.NoError(t, err, "unregister 不軟刪,Get 應仍取得")
|
||
assert.Nil(t, got.RegisteredAt)
|
||
assert.Equal(t, 1, tdb.CountRows(t, "devices"), "unregister 不刪列,devices 仍 1 筆")
|
||
}
|
||
|
||
// SetRegistered 冪等:對已 NULL 的列 set nil → RowsAffected 命中、成功 no-op。
|
||
func TestPG_SetRegistered_ClearIdempotent(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
agentID := pgInsertAgent(t, tdb, owner)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x22222222", AgentID: agentID,
|
||
}))
|
||
|
||
// 未註冊再清 → 成功(WHERE 命中、RowsAffected=1、UPDATE 到相同 NULL)。
|
||
require.NoError(t, r.SetRegistered(ctx, id, nil), "未註冊清 nil 應冪等成功")
|
||
got, err := r.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Nil(t, got.RegisteredAt)
|
||
}
|
||
|
||
// SetRegistered 對 representative device → RowsAffected=0 → ErrNotFound(WHERE is_representative=false)。
|
||
func TestPG_SetRegistered_RejectsRepresentative(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
agentID := pgInsertAgent(t, tdb, owner)
|
||
|
||
repID := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: repID, OwnerUserID: owner, Name: "rep", AgentID: agentID, IsRepresentative: true,
|
||
}))
|
||
|
||
now := time.Now().UTC()
|
||
assert.ErrorIs(t, r.SetRegistered(ctx, repID, &now), ErrNotFound,
|
||
"representative device 應被 WHERE is_representative=false 擋成 ErrNotFound")
|
||
}
|
||
|
||
// SetRegistered 對已軟刪 device → RowsAffected=0 → ErrNotFound(WHERE deleted_at IS NULL)。
|
||
func TestPG_SetRegistered_RejectsDeleted(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, tdb, owner := newPGRepo(t)
|
||
agentID := pgInsertAgent(t, tdb, owner)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x33333333", AgentID: agentID,
|
||
}))
|
||
require.NoError(t, r.Delete(ctx, id)) // 軟刪
|
||
|
||
now := time.Now().UTC()
|
||
assert.ErrorIs(t, r.SetRegistered(ctx, id, &now), ErrNotFound,
|
||
"已軟刪 device 應回 ErrNotFound")
|
||
}
|
||
|
||
// SetRegistered 對不存在 device → ErrNotFound。
|
||
func TestPG_SetRegistered_NotFound(t *testing.T) {
|
||
r, _, _ := newPGRepo(t)
|
||
now := time.Now().UTC()
|
||
assert.ErrorIs(t, r.SetRegistered(context.Background(), uuid.NewString(), &now), ErrNotFound)
|
||
}
|