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>
248 lines
8.2 KiB
Go
248 lines
8.2 KiB
Go
package device
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
func TestInMemoryRepository_SaveAndGet(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
|
||
d := &Device{
|
||
ID: "dev-1",
|
||
OwnerUserID: "user-1",
|
||
Name: "Lab KL520",
|
||
DeviceType: "kl520",
|
||
SerialNumber: "KL520-AAA",
|
||
RemoteStatus: RemoteStatusOffline,
|
||
Status: USBStatusUnknown,
|
||
}
|
||
require.NoError(t, r.Save(ctx, d))
|
||
|
||
got, err := r.Get(ctx, "dev-1")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "Lab KL520", got.Name)
|
||
assert.False(t, got.CreatedAt.IsZero())
|
||
assert.False(t, got.UpdatedAt.IsZero())
|
||
}
|
||
|
||
func TestInMemoryRepository_Get_NotFound(t *testing.T) {
|
||
r := NewInMemoryRepository()
|
||
_, err := r.Get(context.Background(), "nope")
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
}
|
||
|
||
func TestInMemoryRepository_Save_RequiresID(t *testing.T) {
|
||
r := NewInMemoryRepository()
|
||
err := r.Save(context.Background(), &Device{Name: "no-id"})
|
||
assert.Error(t, err)
|
||
}
|
||
|
||
func TestInMemoryRepository_GetBySerial(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: "dev-1", OwnerUserID: "user-A", SerialNumber: "S-1",
|
||
}))
|
||
require.NoError(t, r.Save(ctx, &Device{
|
||
ID: "dev-2", OwnerUserID: "user-B", SerialNumber: "S-1",
|
||
}))
|
||
|
||
got, err := r.GetBySerial(ctx, "user-A", "S-1")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "dev-1", got.ID)
|
||
|
||
_, err = r.GetBySerial(ctx, "user-C", "S-1")
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
}
|
||
|
||
func TestInMemoryRepository_List_ByOwner(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "a", OwnerUserID: "u1"}))
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "b", OwnerUserID: "u1"}))
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "c", OwnerUserID: "u2"}))
|
||
|
||
listU1, err := r.List(ctx, "u1")
|
||
require.NoError(t, err)
|
||
assert.Len(t, listU1, 2)
|
||
|
||
listU3, err := r.List(ctx, "u3")
|
||
require.NoError(t, err)
|
||
assert.Empty(t, listU3)
|
||
}
|
||
|
||
func TestInMemoryRepository_Delete_SoftDelete(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "dev-1", OwnerUserID: "u"}))
|
||
|
||
require.NoError(t, r.Delete(ctx, "dev-1"))
|
||
|
||
// Get 應該找不到
|
||
_, err := r.Get(ctx, "dev-1")
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
|
||
// List 也不該列出
|
||
list, _ := r.List(ctx, "u")
|
||
assert.Empty(t, list)
|
||
|
||
// 再次 Delete 應回 ErrNotFound(已軟刪除)
|
||
assert.ErrorIs(t, r.Delete(ctx, "dev-1"), ErrNotFound)
|
||
}
|
||
|
||
func TestInMemoryRepository_Save_PreservesCreatedAt(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "dev-1", OwnerUserID: "u"}))
|
||
first, err := r.Get(ctx, "dev-1")
|
||
require.NoError(t, err)
|
||
createdAt := first.CreatedAt
|
||
|
||
// 更新(應保留 CreatedAt)
|
||
updated := *first
|
||
updated.Name = "Updated"
|
||
require.NoError(t, r.Save(ctx, &updated))
|
||
|
||
got, err := r.Get(ctx, "dev-1")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "Updated", got.Name)
|
||
assert.Equal(t, createdAt, got.CreatedAt, "CreatedAt 應保留原值")
|
||
assert.True(t, got.UpdatedAt.After(createdAt) || got.UpdatedAt.Equal(createdAt))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// B4(List filter representative + Mi#4 空 serial + representative 查詢)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// List filter:representative device 不出現在清單(對齊 PG 版)。
|
||
func TestInMemoryRepository_List_FiltersRepresentative(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
owner := "owner-1"
|
||
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "rep", OwnerUserID: owner, Name: "rep", AgentID: "ag", IsRepresentative: true}))
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "usb1", OwnerUserID: owner, Name: "usb1", SerialNumber: "0x11111111", AgentID: "ag"}))
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "usb2", OwnerUserID: owner, Name: "usb2", SerialNumber: "0x22222222", AgentID: "ag"}))
|
||
|
||
list, err := r.List(ctx, owner)
|
||
require.NoError(t, err)
|
||
require.Len(t, list, 2, "List 只回真 USB(排除 representative)")
|
||
for _, d := range list {
|
||
assert.False(t, d.IsRepresentative)
|
||
}
|
||
}
|
||
|
||
// Mi#4:in-memory GetBySerial 空 serial → ErrNotFound(對齊 PG GetBySerialTx)。
|
||
func TestInMemoryRepository_GetBySerial_EmptyReturnsNotFound(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
owner := "owner-1"
|
||
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "null1", OwnerUserID: owner, Name: "null1"})) // serial 空
|
||
_, err := r.GetBySerial(ctx, owner, "")
|
||
assert.ErrorIs(t, err, ErrNotFound, "空 serial 查詢應回 ErrNotFound")
|
||
}
|
||
|
||
// GetRepresentativeByAgentTx:找該 agent 的 representative device。
|
||
func TestInMemoryRepository_GetRepresentativeByAgent(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
owner := "owner-1"
|
||
|
||
// 無 representative 時回 ErrNotFound。
|
||
_, err := r.GetRepresentativeByAgentTx(ctx, nil, "ag")
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "rep", OwnerUserID: owner, Name: "rep", AgentID: "ag", IsRepresentative: true}))
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "usb", OwnerUserID: owner, Name: "usb", SerialNumber: "0x11111111", AgentID: "ag"}))
|
||
|
||
got, err := r.GetRepresentativeByAgentTx(ctx, nil, "ag")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "rep", got.ID, "應回 representative device(非真 USB)")
|
||
assert.True(t, got.IsRepresentative)
|
||
|
||
// 不同 agent 查不到。
|
||
_, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent")
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SetRegistered(註冊軸單欄翻轉,feature-device-mgmt-tdd §3.3)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// SetRegistered set → registered_at 有值;set nil → 清空。
|
||
func TestInMemoryRepository_SetRegistered_SetAndClear(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
|
||
|
||
// 初始未註冊。
|
||
got, err := r.Get(ctx, "d1")
|
||
require.NoError(t, err)
|
||
require.Nil(t, got.RegisteredAt)
|
||
|
||
// set → 已註冊。
|
||
now := time.Now().UTC()
|
||
require.NoError(t, r.SetRegistered(ctx, "d1", &now))
|
||
got, err = r.Get(ctx, "d1")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, got.RegisteredAt)
|
||
assert.True(t, now.Equal(*got.RegisteredAt))
|
||
|
||
// set nil → 退回未註冊,列仍在。
|
||
require.NoError(t, r.SetRegistered(ctx, "d1", nil))
|
||
got, err = r.Get(ctx, "d1")
|
||
require.NoError(t, err, "unregister 不刪列")
|
||
assert.Nil(t, got.RegisteredAt)
|
||
}
|
||
|
||
// SetRegistered 冪等:對已 NULL 的列再 set nil → 成功(no-op)。
|
||
func TestInMemoryRepository_SetRegistered_ClearIdempotent(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
|
||
|
||
require.NoError(t, r.SetRegistered(ctx, "d1", nil), "未註冊清 nil 應冪等成功")
|
||
got, _ := r.Get(ctx, "d1")
|
||
assert.Nil(t, got.RegisteredAt)
|
||
}
|
||
|
||
// SetRegistered 對 representative device → ErrNotFound(縱深第三層)。
|
||
func TestInMemoryRepository_SetRegistered_RejectsRepresentative(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "rep", OwnerUserID: "u", AgentID: "ag", IsRepresentative: true}))
|
||
|
||
now := time.Now().UTC()
|
||
assert.ErrorIs(t, r.SetRegistered(ctx, "rep", &now), ErrNotFound,
|
||
"representative 不可註冊")
|
||
}
|
||
|
||
// SetRegistered 對已軟刪 device → ErrNotFound。
|
||
func TestInMemoryRepository_SetRegistered_RejectsDeleted(t *testing.T) {
|
||
ctx := context.Background()
|
||
r := NewInMemoryRepository()
|
||
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
|
||
require.NoError(t, r.Delete(ctx, "d1"))
|
||
|
||
now := time.Now().UTC()
|
||
assert.ErrorIs(t, r.SetRegistered(ctx, "d1", &now), ErrNotFound)
|
||
}
|
||
|
||
// SetRegistered 對不存在 device → ErrNotFound。
|
||
func TestInMemoryRepository_SetRegistered_NotFound(t *testing.T) {
|
||
r := NewInMemoryRepository()
|
||
now := time.Now().UTC()
|
||
assert.ErrorIs(t, r.SetRegistered(context.Background(), "ghost", &now), ErrNotFound)
|
||
}
|