visionA/visionA-backend/internal/device/inmemory_repository_test.go
jim800121chen 47a1d4d0ef feat(backend): 設備註冊 + 模型共享 backend(B 設備管理 + C 模型共享)
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>
2026-08-02 16:29:50 +08:00

248 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))
}
// ---------------------------------------------------------------------------
// B4List filter representative + Mi#4 空 serial + representative 查詢)
// ---------------------------------------------------------------------------
// List filterrepresentative 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#4in-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)
}