DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
373 lines
14 KiB
Go
373 lines
14 KiB
Go
// conversion_adapters_test.go — conversionModelStoreAdapter 的單元測試。
|
||
//
|
||
// 聚焦 B4 metadata(InputShape / Classes / Framework)的雙向 round-trip:
|
||
// - Save:conversion.ModelRecord → model.Model 時 metadata 要帶過去(adapter 曾遺漏這段,
|
||
// 導致 flow.go 寫進 ModelRecord 的 input_shape 在 adapter 被吞掉、進不了 DB)
|
||
// - modelToRecord(透過 FindBySourceJobID):model.Model → conversion.ModelRecord 帶回
|
||
//
|
||
// 用 model.NewInMemoryRepository(非 DB),不需 dbtest tag;DB 端真正落地由 PG repo 測試覆蓋。
|
||
//
|
||
// Phase 0.8 conversion B4 (見 .autoflow/04-architecture/conversion.md §2.5)
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/conversion"
|
||
"visiona-backend/internal/model"
|
||
"visiona-backend/internal/storage"
|
||
)
|
||
|
||
// TestConversionAdapter_Save_CarriesMetadata:Save 應把 ModelRecord 的 B4 metadata
|
||
// 帶進底層 model.Model(adapter 曾漏掉 InputShape/Classes/Framework 的 wiring)。
|
||
func TestConversionAdapter_Save_CarriesMetadata(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
repo := model.NewInMemoryRepository()
|
||
adapter := newConversionModelStoreAdapter(repo)
|
||
|
||
now := time.Now().UTC()
|
||
rec := &conversion.ModelRecord{
|
||
ID: "m-1",
|
||
OwnerUserID: "user-alice",
|
||
Name: "yolov5s_kl720",
|
||
StorageKey: "models/user-alice/m-1.nef",
|
||
FileSize: 12345,
|
||
TargetChip: "kl720",
|
||
InputShape: []int{1, 3, 224, 224},
|
||
Classes: []string{"face", "person"},
|
||
Framework: "onnx",
|
||
Source: "converted",
|
||
SourceJobID: "j-1",
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
require.NoError(t, adapter.Save(context.Background(), rec))
|
||
|
||
// 直接從底層 repo 撈回,驗 metadata 確實落進 model.Model
|
||
got, err := repo.Get(context.Background(), "m-1")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, got)
|
||
assert.Equal(t, []int{1, 3, 224, 224}, got.InputShape,
|
||
"adapter.Save 應把 ModelRecord.InputShape 帶進 model.Model(NCHW)")
|
||
assert.Equal(t, []string{"face", "person"}, got.Classes)
|
||
assert.Equal(t, "onnx", got.Framework)
|
||
}
|
||
|
||
// TestConversionAdapter_Save_NilMetadata_OK:metadata 為零值(轉檔端尚未串好)→
|
||
// adapter.Save 照常成功、model.Model metadata 留零值、不報錯。
|
||
func TestConversionAdapter_Save_NilMetadata_OK(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
repo := model.NewInMemoryRepository()
|
||
adapter := newConversionModelStoreAdapter(repo)
|
||
|
||
now := time.Now().UTC()
|
||
rec := &conversion.ModelRecord{
|
||
ID: "m-2",
|
||
OwnerUserID: "user-bob",
|
||
Name: "model_kl520",
|
||
StorageKey: "models/user-bob/m-2.nef",
|
||
TargetChip: "kl520",
|
||
Source: "converted",
|
||
SourceJobID: "j-2",
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
// 刻意不設 InputShape / Classes / Framework
|
||
}
|
||
require.NoError(t, adapter.Save(context.Background(), rec))
|
||
|
||
got, err := repo.Get(context.Background(), "m-2")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, got)
|
||
assert.Nil(t, got.InputShape, "缺 metadata → model.Model.InputShape 留 nil")
|
||
assert.Nil(t, got.Classes)
|
||
assert.Empty(t, got.Framework)
|
||
}
|
||
|
||
// TestConversionAdapter_FindBySourceJobID_RoundTripsMetadata:modelToRecord 應把
|
||
// model.Model 的 metadata 帶回 conversion.ModelRecord(冪等回傳路徑保持一致)。
|
||
func TestConversionAdapter_FindBySourceJobID_RoundTripsMetadata(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
repo := model.NewInMemoryRepository()
|
||
adapter := newConversionModelStoreAdapter(repo)
|
||
|
||
now := time.Now().UTC()
|
||
require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{
|
||
ID: "m-3",
|
||
OwnerUserID: "user-carol",
|
||
Name: "net_kl730",
|
||
StorageKey: "models/user-carol/m-3.nef",
|
||
TargetChip: "kl730",
|
||
InputShape: []int{1, 3, 640, 480},
|
||
Classes: []string{"dog"},
|
||
Framework: "tflite",
|
||
Source: "converted",
|
||
SourceJobID: "j-3",
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}))
|
||
|
||
rec, err := adapter.FindBySourceJobID(context.Background(), "user-carol", "j-3")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, rec)
|
||
assert.Equal(t, []int{1, 3, 640, 480}, rec.InputShape,
|
||
"modelToRecord 應把 model.Model.InputShape 帶回 ModelRecord")
|
||
assert.Equal(t, []string{"dog"}, rec.Classes)
|
||
assert.Equal(t, "tflite", rec.Framework)
|
||
}
|
||
|
||
// ─────────────────────── 補強:Save error / nil ───────────────────────
|
||
|
||
// Save(nil):error path —— nil record 應回明確 error,不 panic、不寫入。
|
||
func TestConversionAdapter_Save_NilRecord_Errors(t *testing.T) {
|
||
t.Parallel()
|
||
adapter := newConversionModelStoreAdapter(model.NewInMemoryRepository())
|
||
err := adapter.Save(context.Background(), nil)
|
||
require.Error(t, err, "Save(nil) 應回 error 而非 panic")
|
||
assert.Contains(t, err.Error(), "non-nil record")
|
||
}
|
||
|
||
// Save:UpdatedAt 為零值時 UploadedAt fallback 到 now(promote 完即 ready)。
|
||
// 對齊 toModelResponse:UploadedAt 非 nil → status "ready"。
|
||
func TestConversionAdapter_Save_ZeroUpdatedAt_SetsUploadedAtNow(t *testing.T) {
|
||
t.Parallel()
|
||
repo := model.NewInMemoryRepository()
|
||
adapter := newConversionModelStoreAdapter(repo)
|
||
|
||
before := time.Now().UTC()
|
||
require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{
|
||
ID: "m-zero",
|
||
OwnerUserID: "user-zero",
|
||
Name: "zero_updated_at",
|
||
StorageKey: "models/user-zero/m-zero.nef",
|
||
TargetChip: "kl520",
|
||
Source: "converted",
|
||
SourceJobID: "j-zero",
|
||
// 刻意不設 UpdatedAt(零值)
|
||
}))
|
||
after := time.Now().UTC()
|
||
|
||
got, err := repo.Get(context.Background(), "m-zero")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, got.UploadedAt, "UpdatedAt 為零時 UploadedAt 應 fallback 到 now,非 nil")
|
||
assert.False(t, got.UploadedAt.Before(before), "UploadedAt 應 >= 呼叫前時間")
|
||
assert.False(t, got.UploadedAt.After(after.Add(time.Second)), "UploadedAt 應 <= 呼叫後時間")
|
||
}
|
||
|
||
// Save:UpdatedAt 非零時 UploadedAt 沿用 rec.UpdatedAt(不覆蓋成 now)。
|
||
func TestConversionAdapter_Save_NonZeroUpdatedAt_UsesIt(t *testing.T) {
|
||
t.Parallel()
|
||
repo := model.NewInMemoryRepository()
|
||
adapter := newConversionModelStoreAdapter(repo)
|
||
|
||
fixed := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
|
||
require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{
|
||
ID: "m-fixed",
|
||
OwnerUserID: "user-fixed",
|
||
Name: "fixed_updated_at",
|
||
StorageKey: "models/user-fixed/m-fixed.nef",
|
||
TargetChip: "kl730",
|
||
Source: "converted",
|
||
SourceJobID: "j-fixed",
|
||
UpdatedAt: fixed,
|
||
}))
|
||
|
||
got, err := repo.Get(context.Background(), "m-fixed")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, got.UploadedAt)
|
||
assert.Equal(t, fixed, *got.UploadedAt, "UpdatedAt 非零時 UploadedAt 應沿用之")
|
||
}
|
||
|
||
// Save:完整欄位 round-trip —— 不只 B4 metadata,連 Description / FileChecksum /
|
||
// FAAObjectKey / Source / SourceJobID 都要正確帶進 model.Model。
|
||
func TestConversionAdapter_Save_AllFields_RoundTrip(t *testing.T) {
|
||
t.Parallel()
|
||
repo := model.NewInMemoryRepository()
|
||
adapter := newConversionModelStoreAdapter(repo)
|
||
|
||
now := time.Now().UTC()
|
||
rec := &conversion.ModelRecord{
|
||
ID: "m-full",
|
||
OwnerUserID: "user-full",
|
||
Name: "full_fields",
|
||
Description: "a full conversion record",
|
||
StorageKey: "models/user-full/m-full.nef",
|
||
FileSize: 987654,
|
||
FileChecksum: "sha256:deadbeef",
|
||
TargetChip: "kl720",
|
||
InputShape: []int{1, 3, 320, 320},
|
||
Classes: []string{"a", "b", "c"},
|
||
Framework: "onnx",
|
||
Source: "converted",
|
||
SourceJobID: "j-full",
|
||
FAAObjectKey: "faa/obj/full-key",
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
require.NoError(t, adapter.Save(context.Background(), rec))
|
||
|
||
got, err := repo.Get(context.Background(), "m-full")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, got)
|
||
assert.Equal(t, "a full conversion record", got.Description)
|
||
assert.Equal(t, int64(987654), got.FileSize)
|
||
assert.Equal(t, "sha256:deadbeef", got.FileChecksum)
|
||
assert.Equal(t, "kl720", got.TargetChip)
|
||
assert.Equal(t, model.Source("converted"), got.Source)
|
||
assert.Equal(t, "j-full", got.SourceJobID)
|
||
assert.Equal(t, "faa/obj/full-key", got.FAAObjectKey)
|
||
assert.Equal(t, now, got.CreatedAt)
|
||
}
|
||
|
||
// ─────────────────────── 補強:FindBySourceJobID edge ───────────────────────
|
||
|
||
// FindBySourceJobID:empty args(ownerUserID 或 sourceJobID 為空)→ (nil, nil),不查 repo。
|
||
func TestConversionAdapter_FindBySourceJobID_EmptyArgs_ReturnsNil(t *testing.T) {
|
||
t.Parallel()
|
||
adapter := newConversionModelStoreAdapter(model.NewInMemoryRepository())
|
||
|
||
cases := []struct{ owner, job string }{
|
||
{"", "j-1"},
|
||
{"user-1", ""},
|
||
{"", ""},
|
||
}
|
||
for _, tc := range cases {
|
||
rec, err := adapter.FindBySourceJobID(context.Background(), tc.owner, tc.job)
|
||
require.NoError(t, err)
|
||
assert.Nil(t, rec, "empty args 應回 (nil, nil)")
|
||
}
|
||
}
|
||
|
||
// FindBySourceJobID:repo 有資料但無 match 的 sourceJobID → (nil, nil)。
|
||
func TestConversionAdapter_FindBySourceJobID_NoMatch_ReturnsNil(t *testing.T) {
|
||
t.Parallel()
|
||
repo := model.NewInMemoryRepository()
|
||
adapter := newConversionModelStoreAdapter(repo)
|
||
|
||
now := time.Now().UTC()
|
||
require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{
|
||
ID: "m-nm",
|
||
OwnerUserID: "user-nm",
|
||
Name: "no_match",
|
||
StorageKey: "models/user-nm/m-nm.nef",
|
||
TargetChip: "kl520",
|
||
Source: "converted",
|
||
SourceJobID: "j-exists",
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}))
|
||
|
||
// 對的 owner、不存在的 job
|
||
rec, err := adapter.FindBySourceJobID(context.Background(), "user-nm", "j-does-not-exist")
|
||
require.NoError(t, err)
|
||
assert.Nil(t, rec)
|
||
|
||
// 不同 owner、存在的 job(owner 過濾應使其找不到)
|
||
rec2, err := adapter.FindBySourceJobID(context.Background(), "user-other", "j-exists")
|
||
require.NoError(t, err)
|
||
assert.Nil(t, rec2, "不同 owner 不應 match(List 已用 OwnerUserID 過濾)")
|
||
}
|
||
|
||
// ─────────────────────── 補強:GenerateID ───────────────────────
|
||
|
||
// GenerateID:每次回傳唯一非空值(uuid),快速重複呼叫不應碰撞(concurrency-ish / 唯一性)。
|
||
func TestConversionAdapter_GenerateID_Unique(t *testing.T) {
|
||
t.Parallel()
|
||
adapter := newConversionModelStoreAdapter(model.NewInMemoryRepository())
|
||
|
||
seen := make(map[string]struct{})
|
||
for i := 0; i < 1000; i++ {
|
||
id := adapter.GenerateID()
|
||
require.NotEmpty(t, id, "GenerateID 不應回空字串")
|
||
_, dup := seen[id]
|
||
require.False(t, dup, "GenerateID 不應碰撞,重複值: %s", id)
|
||
seen[id] = struct{}{}
|
||
}
|
||
}
|
||
|
||
// ─────────────────────── 補強:modelToRecord nil ───────────────────────
|
||
|
||
// modelToRecord(nil):edge —— nil model 應回 nil record,不 panic。
|
||
func TestModelToRecord_Nil_ReturnsNil(t *testing.T) {
|
||
t.Parallel()
|
||
assert.Nil(t, modelToRecord(nil))
|
||
}
|
||
|
||
// ─────────────────────── 補強:conversionStorageAdapter.Put ───────────────────────
|
||
|
||
// recordingStore 是只記下 Put 參數的 storage.Store fake(驗 adapter 透傳)。
|
||
type recordingStore struct {
|
||
gotKey string
|
||
gotSize int64
|
||
gotMeta map[string]string
|
||
gotBody []byte
|
||
putErr error
|
||
}
|
||
|
||
func (r *recordingStore) Put(ctx context.Context, key string, rd io.Reader, size int64, meta map[string]string) error {
|
||
r.gotKey = key
|
||
r.gotSize = size
|
||
r.gotMeta = meta
|
||
if rd != nil {
|
||
r.gotBody, _ = io.ReadAll(rd)
|
||
}
|
||
return r.putErr
|
||
}
|
||
|
||
// 其餘 storage.Store 方法非本測試關注點,回零值即可(adapter 只用到 Put)。
|
||
func (r *recordingStore) Get(ctx context.Context, key string) (io.ReadCloser, *storage.Object, error) {
|
||
return nil, nil, storage.ErrNotFound
|
||
}
|
||
func (r *recordingStore) Stat(ctx context.Context, key string) (*storage.Object, error) {
|
||
return nil, storage.ErrNotFound
|
||
}
|
||
func (r *recordingStore) Exists(ctx context.Context, key string) (bool, error) { return false, nil }
|
||
func (r *recordingStore) Delete(ctx context.Context, key string) error { return nil }
|
||
func (r *recordingStore) List(ctx context.Context, prefix string) ([]*storage.Object, error) {
|
||
return nil, nil
|
||
}
|
||
func (r *recordingStore) PresignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
|
||
return "", nil
|
||
}
|
||
func (r *recordingStore) PresignedPutURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
|
||
return "", nil
|
||
}
|
||
|
||
// conversionStorageAdapter.Put:happy —— 透傳 key / size / meta / body 給底層 store。
|
||
func TestConversionStorageAdapter_Put_PassesThrough(t *testing.T) {
|
||
t.Parallel()
|
||
rs := &recordingStore{}
|
||
adapter := &conversionStorageAdapter{store: rs}
|
||
|
||
body := []byte("converted-nef-bytes")
|
||
meta := map[string]string{"source": "converter"}
|
||
err := adapter.Put(context.Background(), "models/u/x.nef", bytes.NewReader(body), int64(len(body)), meta)
|
||
require.NoError(t, err)
|
||
|
||
assert.Equal(t, "models/u/x.nef", rs.gotKey)
|
||
assert.Equal(t, int64(len(body)), rs.gotSize)
|
||
assert.Equal(t, meta, rs.gotMeta, "meta 應原樣透傳")
|
||
assert.Equal(t, body, rs.gotBody, "body 應原樣透傳")
|
||
}
|
||
|
||
// conversionStorageAdapter.Put:error path —— 底層 store 回錯時 adapter 應原樣回傳該 error。
|
||
func TestConversionStorageAdapter_Put_PropagatesError(t *testing.T) {
|
||
t.Parallel()
|
||
sentinel := errors.New("disk full")
|
||
rs := &recordingStore{putErr: sentinel}
|
||
adapter := &conversionStorageAdapter{store: rs}
|
||
|
||
err := adapter.Put(context.Background(), "k", bytes.NewReader([]byte("x")), 1, nil)
|
||
assert.ErrorIs(t, err, sentinel, "底層 Put error 應原樣往上傳")
|
||
}
|