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