visionA/visionA-backend/cmd/api-server/conversion_adapters_test.go
jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
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>
2026-06-21 06:36:35 +08:00

373 lines
14 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.

// conversion_adapters_test.go — conversionModelStoreAdapter 的單元測試。
//
// 聚焦 B4 metadataInputShape / Classes / Framework的雙向 round-trip
// - Saveconversion.ModelRecord → model.Model 時 metadata 要帶過去adapter 曾遺漏這段,
// 導致 flow.go 寫進 ModelRecord 的 input_shape 在 adapter 被吞掉、進不了 DB
// - modelToRecord透過 FindBySourceJobIDmodel.Model → conversion.ModelRecord 帶回
//
// 用 model.NewInMemoryRepository非 DB不需 dbtest tagDB 端真正落地由 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_CarriesMetadataSave 應把 ModelRecord 的 B4 metadata
// 帶進底層 model.Modeladapter 曾漏掉 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.ModelNCHW")
assert.Equal(t, []string{"face", "person"}, got.Classes)
assert.Equal(t, "onnx", got.Framework)
}
// TestConversionAdapter_Save_NilMetadata_OKmetadata 為零值(轉檔端尚未串好)→
// 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_RoundTripsMetadatamodelToRecord 應把
// 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")
}
// SaveUpdatedAt 為零值時 UploadedAt fallback 到 nowpromote 完即 ready
// 對齊 toModelResponseUploadedAt 非 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 應 <= 呼叫後時間")
}
// SaveUpdatedAt 非零時 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 ───────────────────────
// FindBySourceJobIDempty argsownerUserID 或 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)")
}
}
// FindBySourceJobIDrepo 有資料但無 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、存在的 jobowner 過濾應使其找不到)
rec2, err := adapter.FindBySourceJobID(context.Background(), "user-other", "j-exists")
require.NoError(t, err)
assert.Nil(t, rec2, "不同 owner 不應 matchList 已用 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.Puthappy —— 透傳 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.Puterror 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 應原樣往上傳")
}