// 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 應原樣往上傳") }