jim800121chen 6024c294d3 feat(visionA-backend): Phase 0.8b v0.6 T3 — 砍 faa_client + ErrFAA* + s-3/s-4/s-5
對齊 ADR-016:visionA backend 不再直連 FAA、download 改走 converter GetResult。T3 砍除 v0.5 階段為 FAA delegated token 路線留的 faa_client.go 整檔 + 對應 sentinel + flow / e2e 殘留。

砍除:
- internal/conversion/faa_client.go(整檔)
- internal/conversion/faa_client_test.go(整檔)
- errors.go: ErrFAAFileNotFound + ErrFAAAuthFailed 2 sentinel(+ ErrorCode/HTTPStatus mapping)
- flow.go: faa FAAClient 欄位 + FlowOpts.FAA 必填 + a-h T3 預期清單 godoc
- flow_test.go: flowStubFAA struct + newFlowStubFAA helper + fixture.faa
- internal/api/conversion_test.go: TestConversion_Download_FAAAuthFailed
- cmd/api-server/main.go: NewFAAClient wire + FAA: faaAPIClient field

保留:
- ErrFAAUnavailable(converter promote 仍 PUT FAA、502 透傳路徑需要)
- hashObjectKey helper 搬到 util.go(ownership 仍用)
- e2e mockFAA 精簡為 regression-only(保留 negative assertion: FAA 0 命中)— reviewer 推薦雙層防護

新增(T3 必補,T1/T2 reviewer 累積):
- s-3 TestDownloadStream_ConverterValidationFailed_Propagation(converter 4xx fallback → ErrValidationFailed 透傳)
- s-4 TestPromoteToModels_StorageError_StreamClosed(instrumented stream wrapper 驗 fd leak 防護)
- s-5 TestParseFilenameFromContentDisposition 9 個 sub-case(3 RFC 5987 + 5 hostile-input + 1 empty quoted)
  發現:Go stdlib 自動 percent-decode RFC 5987 並寫入 params["filename"]、RFC 5987 優先於 ASCII filename

T3 review M-1 修補(commit 內含):
- internal/api/conversion.go:51,56 godoc + 501 user-facing message 從「FAA_BASE_URL」改為「VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY」
- 對齊 ADR-016 visionA 端不再有 FAA 直連設計

驗證:
- B 層 verification 強制跑(reviewer 規定 T3 不接受暫緩):
  * 跨檔 grep: MC chain 0 / FAA functional refs 0 / TenantID 0
  * API contract test: TestConversionE2E_DownloadStream 6 斷言含 FAA negative
  * 安全 manual review: path traversal / unbounded read / secret in log / error mask 4 項
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸(v0.6-t3-review)⚠️ 通過(M-1 已修)

a-h 8 條清單 100% 達成(逐條 grep 驗收);mockFAA 選方案 1(保留 + negative assertion)— 雙層防護。

下一步:
- T4 砍 ConversionConfig.FAAAPIKey/FAABaseURL + load.go env 讀取 + .env*.example + m-2 i18n dead case 一併
- T5 main.go startup log 整理 + e2e regression 防護

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:56:01 +08:00

1456 lines
55 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.

// flow_test.go — Service interface 整合層的單元測試。
//
// 測試策略:
// - 各 client 用 in-package stub不耦合 ConverterClient / Ownership 真實邏輯,純驗 flow 整合行為)
// - 沿用 ownership_test.go 的 stubConverterClient補上 InitJob/GetJob/Promote 實作)
// - 用本檔案專屬的 stubModelStore / stubStorage
//
// 涵蓋 5 個 method × happy / ownership 失敗 / client 失敗 propagation +
// task spec 額外要求:
// - InitJob 同 user 已有 active → ActiveJobError
// - PromoteToModels 已 promote 過 → 回既有 model_ididempotent
// - PromoteToModels job 沒 succeeded → ErrJobNotCompleted
// - DownloadStream 從 converter MinIO stream 拉到正確 metadatav0.6 取代原 FAA stream
// - ActiveJob converter 回 404 → ownership.Delete + (nil, nil)
//
// Phase 0.8 conversion (見 docs/autoflow/04-architecture/conversion.md §2.7)
// Phase 0.8b T4DownloadRedirectURL → DownloadStream + 砍 flowStubMCToken
// (見 ADR-015 §6 + conversion.md §3 / §4.1)
// Phase 0.8b v0.6 T2DownloadStream / PromoteToModels 改走 converter.GetResult
// (見 ADR-016 + conversion.md §2.5 / §4.1 / §6)
// Phase 0.8b v0.6 T3flowStubFAA + flowFixture.faa 欄位整段砍除ADR-016 撤回 FAA 直連、
// faa_client.go 整檔刪除FlowOpts.FAA 必填校驗一併移除。
package conversion
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ==========================================================================
// stubs — 補齊 ownership_test.go 沒實作的 method
// ==========================================================================
// flowStubConverter 是 flow_test 專用的 ConverterClient stub。
//
// 與 ownership_test.go 的 stubConverterClient 區隔:
// - ownership_test 只用 ListInProgressJobs其他 method panic
// - flow_test 需要 InitJob / GetJob / Promote / List 全套
//
// 設計:行為由 functional fieldsinitJobFunc 等控制testcase 寫起來直觀。
type flowStubConverter struct {
mu sync.Mutex
// 預設行為jobsByID 用於 GetJob lookupinitJobFunc 用於控制 InitJob 結果
jobsByID map[string]*ConverterJob
// 各 method 的 hooknil → 走預設行為)
initJobFunc func(ctx context.Context, req InitConverterJobReq) (*ConverterJob, error)
getJobFunc func(ctx context.Context, jobID string) (*ConverterJob, error)
promoteFunc func(ctx context.Context, jobID string, req PromoteReq) (*ConverterPromoteResult, error)
listInProgressJobsFunc func(ctx context.Context, userID string) ([]*ConverterJob, error)
// Phase 0.8b v0.6ADR-016 §1GetResult stream hook
getResultFunc func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error)
// 各 method 呼叫次數atomic
initJobCalls atomic.Int32
getJobCalls atomic.Int32
promoteCalls atomic.Int32
listInProgressJobsCalls atomic.Int32
getResultCalls atomic.Int32
// 紀錄 InitJob 收到的 body驗證 multipart user_id 注入)
lastInitBody []byte
lastInitBodyType string
}
func newFlowStubConverter() *flowStubConverter {
return &flowStubConverter{
jobsByID: make(map[string]*ConverterJob),
}
}
func (s *flowStubConverter) setJob(j *ConverterJob) {
s.mu.Lock()
defer s.mu.Unlock()
s.jobsByID[j.JobID] = j
}
func (s *flowStubConverter) InitJob(ctx context.Context, req InitConverterJobReq) (*ConverterJob, error) {
s.initJobCalls.Add(1)
// 把 body 讀完(模擬 converter 收到 streaming body
if req.Body != nil {
buf, _ := io.ReadAll(req.Body)
s.mu.Lock()
s.lastInitBody = buf
s.lastInitBodyType = req.BodyContentType
s.mu.Unlock()
}
if s.initJobFunc != nil {
return s.initJobFunc(ctx, req)
}
// 預設:回一個 created job
return &ConverterJob{
JobID: "stub-job-1",
Status: "created",
Stage: "onnx",
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
SourceFilename: req.SourceFilename,
Platform: req.Platform,
}, nil
}
func (s *flowStubConverter) GetJob(ctx context.Context, jobID string) (*ConverterJob, error) {
s.getJobCalls.Add(1)
if s.getJobFunc != nil {
return s.getJobFunc(ctx, jobID)
}
s.mu.Lock()
defer s.mu.Unlock()
if j, ok := s.jobsByID[jobID]; ok {
jc := *j
return &jc, nil
}
return nil, fmt.Errorf("%w: get_job 404 (not_found)", ErrJobNotFound)
}
func (s *flowStubConverter) Promote(ctx context.Context, jobID string, req PromoteReq) (*ConverterPromoteResult, error) {
s.promoteCalls.Add(1)
if s.promoteFunc != nil {
return s.promoteFunc(ctx, jobID, req)
}
return &ConverterPromoteResult{
TargetObjectKey: req.TargetObjectKey,
Size: 12345,
Checksum: "stub-etag",
}, nil
}
func (s *flowStubConverter) ListInProgressJobs(ctx context.Context, userID string) ([]*ConverterJob, error) {
s.listInProgressJobsCalls.Add(1)
if s.listInProgressJobsFunc != nil {
return s.listInProgressJobsFunc(ctx, userID)
}
return nil, nil
}
// GetResult 是 Phase 0.8b v0.6 新增ADR-016 §1— flow.DownloadStream / PromoteToModels
// 在 T2 起改走此 method。
//
// 預設行為:若 getResultFunc 為 nil回 ErrConverterUnavailable wrapped error避免 silent nil
// 觸發 caller NPE讓 test 立即看到「忘設 hook」訊號
//
// fixturenewFlowFixture會在建立時自動安裝一個 default getResultFunc 回 defaultStubNEFBody
// 所以多數 happy-path test 不必個別設 hook需 override 的 test 直接覆寫 fix.converter.getResultFunc。
func (s *flowStubConverter) GetResult(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
s.getResultCalls.Add(1)
if s.getResultFunc != nil {
return s.getResultFunc(ctx, jobID)
}
// 預設:回 error streamcaller 若沒設 hook 表示不該觸發 GetResult
return nil, nil, fmt.Errorf("%w: flowStubConverter.GetResult called without getResultFunc hook", ErrConverterUnavailable)
}
var _ ConverterClient = (*flowStubConverter)(nil)
// Phase 0.8b T4原 flowStubMCToken 已整段刪除MC 認證鏈取消、flow 不再依賴 MCTokenClient
// Phase 0.8b T5mc_token_stub.go 整檔砍除MCTokenClient interface 已不存在。
// Phase 0.8b v0.6 T3flowStubFAA 整段砍除ADR-016 撤回 FAA 直連FAAClient interface
// + faa_client.go 整檔已刪download / promote 改驗 converter.GetResult。
// flowStubModelStore 是 ModelStore stub。
type flowStubModelStore struct {
mu sync.Mutex
// records: model_id → ModelRecord
records map[string]*ModelRecord
// idCounter 給 GenerateID 用
idCounter atomic.Int32
// hook 控制(測試 model save 失敗用)
saveErr error
findErr error
}
func newFlowStubModelStore() *flowStubModelStore {
return &flowStubModelStore{
records: make(map[string]*ModelRecord),
}
}
func (s *flowStubModelStore) Save(ctx context.Context, m *ModelRecord) error {
if s.saveErr != nil {
return s.saveErr
}
s.mu.Lock()
defer s.mu.Unlock()
cp := *m
s.records[m.ID] = &cp
return nil
}
func (s *flowStubModelStore) FindBySourceJobID(ctx context.Context, ownerUserID, sourceJobID string) (*ModelRecord, error) {
if s.findErr != nil {
return nil, s.findErr
}
s.mu.Lock()
defer s.mu.Unlock()
for _, r := range s.records {
if r.OwnerUserID == ownerUserID && r.SourceJobID == sourceJobID {
cp := *r
return &cp, nil
}
}
return nil, nil
}
func (s *flowStubModelStore) GenerateID() string {
n := s.idCounter.Add(1)
return fmt.Sprintf("model-%03d", n)
}
var _ ModelStore = (*flowStubModelStore)(nil)
// flowStubStorage 是 Storage stub。
type flowStubStorage struct {
mu sync.Mutex
// objects: key → bytes驗證 streaming write 正確)
objects map[string][]byte
putErr error
putCalls atomic.Int32
}
func newFlowStubStorage() *flowStubStorage {
return &flowStubStorage{
objects: make(map[string][]byte),
}
}
func (s *flowStubStorage) Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error {
s.putCalls.Add(1)
if s.putErr != nil {
// 仍 read 防 io.Pipe 寫端 block
_, _ = io.Copy(io.Discard, r)
return s.putErr
}
buf, err := io.ReadAll(r)
if err != nil {
return err
}
s.mu.Lock()
s.objects[key] = buf
s.mu.Unlock()
return nil
}
var _ Storage = (*flowStubStorage)(nil)
// ==========================================================================
// helper: 建立 flow service + 全套 stub
// ==========================================================================
type flowFixture struct {
svc Service
converter *flowStubConverter
models *flowStubModelStore
storage *flowStubStorage
ownership Ownership
}
// Phase 0.8b T4mcToken 欄位已移除flow 不再依賴 MCTokenClientFlowOpts 也砍 4 個欄位
// MCToken / TenantID / FAABaseURL / DelegatedTTLSeconds
//
// Phase 0.8b v0.6ADR-016 / T2DownloadStream / PromoteToModels 改走 converter.GetResult
// fixture 自動安裝 default getResultFunc回 `defaultStubNEFBody`)讓既有 happy-path test 不必
// 個別設 hook。需要 override 行為specific Content-Length / error的 test 直接覆寫
// `fix.converter.getResultFunc` 即可。
//
// **Phase 0.8b v0.6 T3**FAA 欄位/stub 整段砍除ADR-016 撤回 FAA 直連。FlowOpts.FAA 必填
// 校驗一併移除e2e negative assertion 仍由 conversion_e2e_test.go 端 mockFAA + getCallCount
// 保留作為 regression 防護(驗 visionA 端不再直接打 FAA
func newFlowFixture(t *testing.T) *flowFixture {
t.Helper()
conv := newFlowStubConverter()
models := newFlowStubModelStore()
storage := newFlowStubStorage()
own := NewOwnership(conv, newSilentLogger())
// v0.6default getResultFunc — 回固定 NEF stub bytes + octet-stream + filename
// (與 v0.5 之前 flowStubFAA default 行為對等test 仍可覆寫)
conv.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
return io.NopCloser(strings.NewReader(defaultStubNEFBody)), &DownloadMetadata{
Filename: "converter-stub.nef",
ContentType: "application/octet-stream",
ContentLength: int64(len(defaultStubNEFBody)),
}, nil
}
svc, err := NewService(FlowOpts{
Converter: conv,
Ownership: own,
ModelStore: models,
Storage: storage,
DefaultJobExpiryDuration: 7 * 24 * time.Hour,
Logger: newSilentLogger(),
Now: time.Now,
})
require.NoError(t, err)
return &flowFixture{
svc: svc,
converter: conv,
models: models,
storage: storage,
ownership: own,
}
}
// defaultStubNEFBody 是 fixture default getResultFunc 回的 stub NEF body 內容(穩定 byte sequence
// 讓 test 可比對 streaming 正確性)。沿用 v0.5 之前 flowStubFAA default 行為("nef-bytes-stub")。
const defaultStubNEFBody = "nef-bytes-stub"
// makeMultipartBody 建一個合法的 multipart/form-data body 給 InitJob 測試用。
//
// 包含model_id / version / platform / modelfake .onnx file+ 故意塞一個 client user_id測黑名單
func makeMultipartBody(t *testing.T, clientUserID string) (body io.Reader, contentType string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
require.NoError(t, mw.WriteField("model_id", "1024"))
require.NoError(t, mw.WriteField("version", "v1.0.0"))
require.NoError(t, mw.WriteField("platform", "720"))
if clientUserID != "" {
require.NoError(t, mw.WriteField("user_id", clientUserID)) // 應被黑名單
}
fw, err := mw.CreateFormFile("model", "yolov5s.onnx")
require.NoError(t, err)
_, err = fw.Write([]byte("fake-onnx-bytes"))
require.NoError(t, err)
require.NoError(t, mw.Close())
return &buf, mw.FormDataContentType()
}
// ==========================================================================
// Constructor — 缺欄位驗證
// ==========================================================================
// Phase 0.8b T4TenantID / FAABaseURL / MCToken 欄位已從 FlowOpts 砍除。
// Phase 0.8b v0.6 T3FAA 欄位一併砍除ADR-016必填欄位降為 4 個
// Converter / Ownership / ModelStore / Storage
func TestNewService_RequiredFields(t *testing.T) {
t.Parallel()
conv := newFlowStubConverter()
own := NewOwnership(conv, newSilentLogger())
mod := newFlowStubModelStore()
st := newFlowStubStorage()
tests := []struct {
name string
opts FlowOpts
}{
{"missing converter", FlowOpts{Ownership: own, ModelStore: mod, Storage: st}},
{"missing ownership", FlowOpts{Converter: conv, ModelStore: mod, Storage: st}},
{"missing modelstore", FlowOpts{Converter: conv, Ownership: own, Storage: st}},
{"missing storage", FlowOpts{Converter: conv, Ownership: own, ModelStore: mod}},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := NewService(tt.opts)
require.Error(t, err)
})
}
}
func TestNewService_DefaultsApplied(t *testing.T) {
t.Parallel()
conv := newFlowStubConverter()
own := NewOwnership(conv, newSilentLogger())
mod := newFlowStubModelStore()
st := newFlowStubStorage()
svc, err := NewService(FlowOpts{
Converter: conv, Ownership: own,
ModelStore: mod, Storage: st,
// DefaultJobExpiryDuration 留空 → 應 fallback 7d
})
require.NoError(t, err)
require.NotNil(t, svc)
f := svc.(*flow)
assert.Equal(t, 7*24*time.Hour, f.defaultJobExpiryDuration)
}
// ==========================================================================
// InitJob
// ==========================================================================
// TestInitJob_HappyPath標準 init flow黑名單 user_id 注入正確。
func TestInitJob_HappyPath(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
body, ct := makeMultipartBody(t, "fake-client-userid")
job, err := fix.svc.InitJob(context.Background(), InitJobInput{
UserID: "user-alice",
ContentType: ct,
Body: body,
})
require.NoError(t, err)
require.NotNil(t, job)
assert.Equal(t, "stub-job-1", job.JobID)
assert.Equal(t, "created", job.Status)
assert.Equal(t, int32(1), fix.converter.initJobCalls.Load())
// 驗 ownership 已寫入
uid, ok := fix.ownership.Get("stub-job-1")
assert.True(t, ok)
assert.Equal(t, "user-alice", uid)
// 驗 multipart body 中 user_id 是 visionA 灌的client 帶的被黑名單
fix.converter.mu.Lock()
gotBody := string(fix.converter.lastInitBody)
fix.converter.mu.Unlock()
assert.Contains(t, gotBody, "user-alice", "visionA-backend 注入的 user_id 應在 body 中")
// fake-client-userid 不該出現(被黑名單)
assert.NotContains(t, gotBody, "fake-client-userid",
"client 帶的 user_id 應被黑名單,不應出現在送給 converter 的 body")
}
// TestInitJob_ActiveJobExists同 user 已有 active job → ActiveJobError。
//
// 這個 case 來自 task spec「額外要測」。
func TestInitJob_ActiveJobExists(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
// 預先在 cache 注入一個 active job
createdAt := time.Now().UTC()
fix.converter.setJob(&ConverterJob{
JobID: "existing-job",
Status: "running",
Stage: "bie",
CreatedAt: createdAt,
})
fix.ownership.Set("existing-job", "user-alice")
body, ct := makeMultipartBody(t, "")
_, err := fix.svc.InitJob(context.Background(), InitJobInput{
UserID: "user-alice",
ContentType: ct,
Body: body,
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrActiveJobExists))
var ae *ActiveJobError
require.True(t, errors.As(err, &ae))
require.NotNil(t, ae.Job)
assert.Equal(t, "existing-job", ae.Job.JobID)
assert.Equal(t, "running", ae.Job.Status)
// converter.InitJob 不該被呼叫pre-check 攔截)
assert.Equal(t, int32(0), fix.converter.initJobCalls.Load())
}
// TestInitJob_ActiveJob_AlreadyCompleted_PassThroughcache 中的 job 已 completed
// → 視為無 active正常 init。
func TestInitJob_ActiveJob_AlreadyCompleted_PassThrough(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "old-job",
Status: "completed",
CreatedAt: time.Now().UTC(),
})
fix.ownership.Set("old-job", "user-alice")
body, ct := makeMultipartBody(t, "")
job, err := fix.svc.InitJob(context.Background(), InitJobInput{
UserID: "user-alice",
ContentType: ct,
Body: body,
})
require.NoError(t, err)
assert.Equal(t, "stub-job-1", job.JobID)
}
// TestInitJob_ConverterError_Propagationconverter 失敗應透傳 sentinel。
func TestInitJob_ConverterError_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.initJobFunc = func(ctx context.Context, req InitConverterJobReq) (*ConverterJob, error) {
// 仍 drain body 以免 io.Pipe 寫端 block
_, _ = io.Copy(io.Discard, req.Body)
return nil, fmt.Errorf("%w: simulated 502", ErrConverterUnavailable)
}
body, ct := makeMultipartBody(t, "")
_, err := fix.svc.InitJob(context.Background(), InitJobInput{
UserID: "user-alice",
ContentType: ct,
Body: body,
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
// 失敗時 ownership 不應寫入
_, ok := fix.ownership.Get("stub-job-1")
assert.False(t, ok)
}
// TestInitJob_RebuildBodyError_ConsumerSeesErrorrebuild 中途 reader 失敗
// → converter 端從 pipe 讀時應拿到該 error而非空的 EOF / 截斷 multipart
//
// 對齊 Reviewer M-2原本 `defer pw.Close()` 配 `pw.CloseWithError(err)` 的寫法
// 因 defer LIFO 會把錯誤訊號蓋成 nil EOF。修法後 converter 端應能透過 pipe 讀到
// rebuild 階段拋出的錯誤(例如 io.ErrUnexpectedEOF / 自訂錯誤)。
func TestInitJob_RebuildBodyError_ConsumerSeesError(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
// 在 converter stub 的 InitJob 中,主動讀 body — 驗證讀到的是「帶 rebuild error 的 pipe」
// 而不是「截斷的 EOF」
var readErr error
fix.converter.initJobFunc = func(ctx context.Context, req InitConverterJobReq) (*ConverterJob, error) {
// 讀完 body若 rebuild 失敗pipe 應拿到非 nil error不是 EOF
_, readErr = io.Copy(io.Discard, req.Body)
// 模擬 converter 因為收不完 body 回 5xx
return nil, fmt.Errorf("%w: simulated bad multipart from rebuild", ErrConverterUnavailable)
}
// 故意給一個會在 rebuild 中失敗的 body合法 boundary 但 part 內容讀到一半就 error
body := &errReader{
// 先給足以讓 multipart.NewReader 找到第一個 boundary 的內容
content: []byte("--boundary123\r\nContent-Disposition: form-data; name=\"x\"\r\n\r\n"),
errAt: 1024, // 讀到第 N byte 後拋錯
err: errors.New("simulated reader failure mid-stream"),
}
contentType := "multipart/form-data; boundary=boundary123"
_, err := fix.svc.InitJob(context.Background(), InitJobInput{
UserID: "user-alice",
ContentType: contentType,
Body: body,
})
require.Error(t, err)
// 應透傳成 ErrConverterUnavailableconverter stub 回 5xx或 rebuild 自身 wrap
assert.True(t, errors.Is(err, ErrConverterUnavailable),
"rebuild + converter 雙失敗,最終應收斂成 ErrConverterUnavailable")
// 關鍵 assertconverter 端讀 body 時,應拿到「非 nil error」而不是空 EOF
// (原本 defer 順序錯時 readErr 會是 nil — 因為 pw.Close() 蓋掉 CloseWithError
assert.Error(t, readErr,
"converter 端 io.Copy(req.Body) 應拿到 rebuild 階段的錯誤訊號,而不是 nil EOF")
}
// TestInitJob_RebuildHappyPath_ConsumerSeesEOF正常完成時consumer 端應拿到 EOF非 error
//
// 對齊 Reviewer M-2 的反向 case成功路徑 pipe 應正常 EOF。
func TestInitJob_RebuildHappyPath_ConsumerSeesEOF(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
var readErr error
fix.converter.initJobFunc = func(ctx context.Context, req InitConverterJobReq) (*ConverterJob, error) {
_, readErr = io.Copy(io.Discard, req.Body)
return &ConverterJob{
JobID: "stub-job-1", Status: "created", CreatedAt: time.Now(),
}, nil
}
body, ct := makeMultipartBody(t, "")
_, err := fix.svc.InitJob(context.Background(), InitJobInput{
UserID: "user-alice",
ContentType: ct,
Body: body,
})
require.NoError(t, err)
// happy pathpipe 應正常 EOFio.Copy 對 EOF 不報 error
assert.NoError(t, readErr,
"正常完成時 converter 端 io.Copy(req.Body) 應 nil errorio.Copy 把 EOF 視為正常結束)")
}
// errReader 在讀到 errAt bytes 後拋錯,用於模擬 rebuild 中途失敗。
type errReader struct {
content []byte
pos int
read int
errAt int
err error
}
func (r *errReader) Read(p []byte) (int, error) {
if r.read >= r.errAt {
return 0, r.err
}
if r.pos >= len(r.content) {
// 把剩餘 byte 補 0 直到 errAt — 模擬「讀到一半才出錯」
n := r.errAt - r.read
if n > len(p) {
n = len(p)
}
for i := 0; i < n; i++ {
p[i] = 0
}
r.read += n
return n, nil
}
n := copy(p, r.content[r.pos:])
r.pos += n
r.read += n
return n, nil
}
// TestInitJob_RequiredFields缺 UserID / Body / ContentType return error。
func TestInitJob_RequiredFields(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
_, err := fix.svc.InitJob(context.Background(), InitJobInput{ContentType: "x", Body: strings.NewReader("y")})
assert.Error(t, err)
_, err = fix.svc.InitJob(context.Background(), InitJobInput{UserID: "u", ContentType: "x"})
assert.Error(t, err)
_, err = fix.svc.InitJob(context.Background(), InitJobInput{UserID: "u", Body: strings.NewReader("y")})
assert.Error(t, err)
}
// ==========================================================================
// GetJob
// ==========================================================================
// TestGetJob_HappyPathownership 有 → converter.GetJob → 回 *Job。
func TestGetJob_HappyPath(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
createdAt := time.Now().UTC()
fix.converter.setJob(&ConverterJob{
JobID: "j1",
Status: "running",
Stage: "bie",
CreatedAt: createdAt,
UpdatedAt: createdAt,
SourceFilename: "yolov5s.onnx",
Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
job, err := fix.svc.GetJob(context.Background(), "user-alice", "j1")
require.NoError(t, err)
assert.Equal(t, "j1", job.JobID)
assert.Equal(t, "yolov5s.onnx", job.SourceFilename)
assert.Equal(t, "720", job.TargetChip)
// expires_at fallbackcreated_at + 7d
assert.Equal(t, createdAt.Add(7*24*time.Hour), job.ExpiresAt)
}
// TestGetJob_OwnershipMismatch_ReturnsNotFoundownership 不符回 ErrJobNotFound避免洩漏
func TestGetJob_OwnershipMismatch_ReturnsNotFound(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "running", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-bob")
_, err := fix.svc.GetJob(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotFound),
"ownership mismatch 應回 not_found 而非 forbidden§7.2 防枚舉)")
// converter.GetJob 不該被呼叫
assert.Equal(t, int32(0), fix.converter.getJobCalls.Load())
}
// TestGetJob_OwnershipMissing_ReturnsNotFoundcache 中沒對應 jobID → not_found。
func TestGetJob_OwnershipMissing_ReturnsNotFound(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
_, err := fix.svc.GetJob(context.Background(), "user-alice", "ghost-job")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotFound))
}
// TestGetJob_ConverterError_Propagationconverter 5xx 透傳。
func TestGetJob_ConverterError_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.ownership.Set("j1", "user-alice")
fix.converter.getJobFunc = func(ctx context.Context, jobID string) (*ConverterJob, error) {
return nil, fmt.Errorf("%w: simulated", ErrConverterUnavailable)
}
_, err := fix.svc.GetJob(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
}
// ==========================================================================
// ActiveJob
// ==========================================================================
// TestActiveJob_HappyPathlazy rebuild → ActiveJobOf → converter.GetJob → 回 *Job。
func TestActiveJob_HappyPath(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
createdAt := time.Now().UTC()
fix.converter.listInProgressJobsFunc = func(ctx context.Context, userID string) ([]*ConverterJob, error) {
if userID != "user-alice" {
return nil, nil
}
return []*ConverterJob{
{JobID: "j-active", Status: "running", CreatedAt: createdAt},
}, nil
}
fix.converter.setJob(&ConverterJob{
JobID: "j-active",
Status: "running",
Stage: "bie",
CreatedAt: createdAt,
})
job, err := fix.svc.ActiveJob(context.Background(), "user-alice")
require.NoError(t, err)
require.NotNil(t, job)
assert.Equal(t, "j-active", job.JobID)
assert.Equal(t, "running", job.Status)
}
// TestActiveJob_NoActive沒 active job 回 (nil, nil)。
func TestActiveJob_NoActive(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
job, err := fix.svc.ActiveJob(context.Background(), "user-alice")
require.NoError(t, err)
assert.Nil(t, job)
}
// TestActiveJob_ConverterReturns404_DeletesAndReturnsNilcache 中有 job 但 converter 回 404
// → 清 ownership + (nil, nil)。task spec 額外要測 case。
func TestActiveJob_ConverterReturns404_DeletesAndReturnsNil(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
// 預先在 cache 中放一個 — 模擬 visionA 重啟 + lazy rebuild 從 converter 拉到,
// 但中間 converter 又 GC 了
fix.converter.listInProgressJobsFunc = func(ctx context.Context, userID string) ([]*ConverterJob, error) {
return []*ConverterJob{{JobID: "j-stale", Status: "running", CreatedAt: time.Now()}}, nil
}
fix.converter.getJobFunc = func(ctx context.Context, jobID string) (*ConverterJob, error) {
return nil, fmt.Errorf("%w: simulated 404", ErrJobNotFound)
}
job, err := fix.svc.ActiveJob(context.Background(), "user-alice")
require.NoError(t, err)
assert.Nil(t, job, "converter 404 應視為無 active")
// ownership 已清掉
_, ok := fix.ownership.Get("j-stale")
assert.False(t, ok, "converter 404 後應呼叫 ownership.Delete")
}
// TestActiveJob_ConverterError_Propagationconverter 5xx 透傳給 caller不 fail-soft
func TestActiveJob_ConverterError_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.listInProgressJobsFunc = func(ctx context.Context, userID string) ([]*ConverterJob, error) {
return nil, fmt.Errorf("%w: list 5xx", ErrConverterUnavailable)
}
_, err := fix.svc.ActiveJob(context.Background(), "user-alice")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
}
// TestActiveJob_CompletedJob_ReturnsNilcache 中是 completed job → 不算 active。
func TestActiveJob_CompletedJob_ReturnsNil(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.listInProgressJobsFunc = func(ctx context.Context, userID string) ([]*ConverterJob, error) {
return []*ConverterJob{{JobID: "j-done", Status: "running", CreatedAt: time.Now()}}, nil
}
// converter 即時狀態 = completed
fix.converter.setJob(&ConverterJob{
JobID: "j-done",
Status: "completed",
CreatedAt: time.Now(),
})
job, err := fix.svc.ActiveJob(context.Background(), "user-alice")
require.NoError(t, err)
assert.Nil(t, job)
}
// ==========================================================================
// PromoteToModels
// ==========================================================================
// TestPromoteToModels_HappyPath完整 pipeline。
func TestPromoteToModels_HappyPath(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
createdAt := time.Now().UTC()
fix.converter.setJob(&ConverterJob{
JobID: "j1",
Status: "completed",
CreatedAt: createdAt,
SourceFilename: "yolov5s.onnx",
Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
res, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "my-model")
require.NoError(t, err)
require.NotNil(t, res)
assert.NotEmpty(t, res.ModelID)
assert.Equal(t, "converted", res.Source)
assert.Equal(t, "j1", res.SourceJobID)
assert.Equal(t, "my-model", res.Name)
assert.Equal(t, "kl720", res.TargetChip)
assert.Equal(t, "ready", res.Status)
assert.Equal(t, int64(12345), res.FileSize)
// 驗 storage 真的有寫
assert.Equal(t, int32(1), fix.storage.putCalls.Load())
fix.storage.mu.Lock()
expectedKey := fmt.Sprintf("models/user-alice/%s.nef", res.ModelID)
assert.Contains(t, fix.storage.objects, expectedKey)
// 驗 storage 寫進去的內容是 default getResultFunc 回的 NEF stream
// v0.6:取代原本 flowStubFAA default body 驗證;確保 stream byte-perfect 透傳)
assert.Equal(t, defaultStubNEFBody, string(fix.storage.objects[expectedKey]),
"storage 寫進去的 byte 應與 converter.GetResult 回的 stream 完全一致")
fix.storage.mu.Unlock()
// 驗 model store 真的有寫
rec, _ := fix.models.FindBySourceJobID(context.Background(), "user-alice", "j1")
require.NotNil(t, rec)
assert.Equal(t, res.ModelID, rec.ID)
// 驗 promote / converter.GetResult 各被打 1 次v0.6:取代原 faa.getCalls
assert.Equal(t, int32(1), fix.converter.promoteCalls.Load())
assert.Equal(t, int32(1), fix.converter.getResultCalls.Load(),
"PromoteToModels 應呼叫 1 次 converter.GetResultv0.6 取代 faa.GetFile")
// v0.6 T3FAAClient interface 已整檔砍除faa_client.go 不存在);
// 「visionA 端不再直接打 FAA」改由「型別已不存在」的編譯期保證 + e2e mockFAA 端 negative
// assertion 雙重防護conversion_e2e_test.go:TestConversionE2E_DownloadStream
}
// TestPromoteToModels_DefaultNamecaller 傳空 name 應走 fallback `<stem>_kl<chip>`。
func TestPromoteToModels_DefaultName(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j1",
Status: "completed",
CreatedAt: time.Now(),
SourceFilename: "yolov5s.onnx",
Platform: "520",
})
fix.ownership.Set("j1", "user-alice")
res, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "")
require.NoError(t, err)
assert.Equal(t, "yolov5s_kl520", res.Name)
}
// TestPromoteToModels_Idempotent同 jobID 二次 promote 應回既有 model_idtask spec 要求)。
func TestPromoteToModels_Idempotent(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j1", Status: "completed", CreatedAt: time.Now(),
SourceFilename: "x.onnx", Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
first, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "v1")
require.NoError(t, err)
require.NotNil(t, first)
// 第二次:應該不再打 converter.Promote / converter.GetResult / storage.Put
// v0.6getResultCalls 取代原 faaCallsBefore
convPromoteBefore := fix.converter.promoteCalls.Load()
getResultBefore := fix.converter.getResultCalls.Load()
storagePutBefore := fix.storage.putCalls.Load()
second, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "v2")
require.NoError(t, err)
require.NotNil(t, second)
assert.Equal(t, first.ModelID, second.ModelID, "二次 promote 應回既有 model_id")
assert.Equal(t, convPromoteBefore, fix.converter.promoteCalls.Load(),
"二次 promote 不應再打 converter.Promote")
assert.Equal(t, getResultBefore, fix.converter.getResultCalls.Load(),
"二次 promote 不應再打 converter.GetResult冪等 hit 在 FindBySourceJobID 階段短路)")
assert.Equal(t, storagePutBefore, fix.storage.putCalls.Load(),
"二次 promote 不應再寫 storage")
}
// TestPromoteToModels_JobNotCompletedjob 狀態 != completed → ErrJobNotCompletedtask spec 要求)。
func TestPromoteToModels_JobNotCompleted(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j1", Status: "running", CreatedAt: time.Now(),
})
fix.ownership.Set("j1", "user-alice")
_, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotCompleted))
}
// TestPromoteToModels_OwnershipMismatch別 user 的 job → not_found。
func TestPromoteToModels_OwnershipMismatch(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j1", Status: "completed", CreatedAt: time.Now(),
})
fix.ownership.Set("j1", "user-bob")
_, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotFound))
}
// TestPromoteToModels_ConverterGetResultError_Propagationconverter.GetResult 失敗透傳。
//
// v0.6ADR-016 / T2取代原 TestPromoteToModels_FAAError_Propagation。
// PromoteToModels 第 5 步改走 converter.GetResult、不再 pull FAA對應的失敗 sentinel
// 也從 ErrFAAUnavailable 改為 ErrConverterUnavailableconverter MinIO 5xx
func TestPromoteToModels_ConverterGetResultError_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j1", Status: "completed", CreatedAt: time.Now(),
SourceFilename: "x.onnx", Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
return nil, nil, fmt.Errorf("%w: converter result 502", ErrConverterUnavailable)
}
_, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
// model record 不應被建converter.GetResult 失敗在 storage 寫入前)
rec, _ := fix.models.FindBySourceJobID(context.Background(), "user-alice", "j1")
assert.Nil(t, rec)
// v0.6 T3FAAClient 已整檔砍除(編譯期保證 visionA 端不再直接打 FAA
// 不再有 fix.faa.getCalls assertion 需要驗
}
// TestPromoteToModels_ConverterGetResultExpired_Propagationv0.6 新增——
// converter.GetResult 回 410 `result_expired`job completed 但 NEF 已過 7 天 expires_at 被 GC
// 應透傳給 callerhandler 層會 mapping 到 HTTP 410 / code `result_expired`,給 frontend 顯示
// 「轉檔結果已過期請重新轉檔」CTA。
//
// 對齊 ADR-016 §1.3 + conversion.md §6 + api-conversion.md §4。
func TestPromoteToModels_ConverterGetResultExpired_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j1", Status: "completed", CreatedAt: time.Now(),
SourceFilename: "x.onnx", Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
return nil, nil, fmt.Errorf("%w: get_result 410", ErrResultExpired)
}
_, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrResultExpired),
"converter 410 → ErrResultExpired 必須透傳給 handler")
assert.Equal(t, "result_expired", ErrorCode(err))
assert.Equal(t, 410, HTTPStatus(err))
// model record 不應被建
rec, _ := fix.models.FindBySourceJobID(context.Background(), "user-alice", "j1")
assert.Nil(t, rec)
}
// TestPromoteToModels_StorageErrorstorage.Put 失敗 → 包成 ErrStorageUnavailable。
//
// 對齊 Reviewer M-1visionA 自家 storagedisk full / S3 5xx / 權限錯誤)失敗
// 不該被歸類為 FAA 或 converter 問題,避免 SRE alarm 打錯 team / i18n 訊息誤導。
func TestPromoteToModels_StorageError(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j1", Status: "completed", CreatedAt: time.Now(),
SourceFilename: "x.onnx", Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
fix.storage.putErr = errors.New("disk full")
_, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrStorageUnavailable),
"storage.Put 失敗應歸類為 ErrStorageUnavailable不是 ErrConverterUnavailable")
// 確認沒被誤包成其他 sentinel
assert.False(t, errors.Is(err, ErrConverterUnavailable),
"storage 失敗不該被歸類為 converter 問題Reviewer M-1")
// model record 不應被建storage 失敗在 modelStore.Save 前)
rec, _ := fix.models.FindBySourceJobID(context.Background(), "user-alice", "j1")
assert.Nil(t, rec)
}
// TestPromoteToModels_ModelStoreErrormodelStore.Save 失敗 → 包成 ErrModelStoreUnavailable。
//
// 對齊 Reviewer M-1visionA 自家 model store 失敗不該被歸類為 converter 問題。
func TestPromoteToModels_ModelStoreError(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j1", Status: "completed", CreatedAt: time.Now(),
SourceFilename: "x.onnx", Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
fix.models.saveErr = errors.New("postgres connection refused")
_, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrModelStoreUnavailable),
"modelStore.Save 失敗應歸類為 ErrModelStoreUnavailable不是 ErrConverterUnavailable")
assert.False(t, errors.Is(err, ErrConverterUnavailable),
"modelStore 失敗不該被歸類為 converter 問題Reviewer M-1")
}
// ==========================================================================
// DownloadStreamPhase 0.8b:取代原 DownloadRedirectURL
// ==========================================================================
//
// Phase 0.8b 變更ADR-015 §7 + conversion.md §4.1
// - DownloadRedirectURL → DownloadStreamAPI key 模式下沒有 MC delegated token
// - 不再組「FAA URL + ?access_token=」;改成直接回 io.ReadCloser + DownloadMetadata
// - 不再依賴 MCTokenClientflowStubMCToken 已整段刪除)
// - 對外仍由同一個 GET /api/conversion/{job_id}/download endpoint 觸發handler 層改 stream proxy
//
// 測試 case 對齊 happy / ownership / state / error propagation 路徑:
// 1. HappyPath成功拉到 stream + metadata 正確
// 2. FilenameFromConverterJobfilename 取自 cj.SourceFilename + Platform
// 3. DefaultsContentTypeconverter 沒給 Content-Type 時 fallback application/octet-stream
// 4. OwnershipMismatch→ ErrJobNotFound
// 5. JobNotCompleted→ ErrJobNotCompleted
// 6. PromoteError_Propagationpromote 5xx 透傳
// 7. ConverterGetResultError_Propagationv0.6 取代 FAAError_Propagation
// 8. ConverterAuthFailed_Propagationv0.6 取代 FAAAuthFailed_Propagation
// 9. ConverterResultExpired_Propagationv0.6 新增410 result_expired
// 10. ConverterValidationFailed_Propagationv0.6 T3 / s-3 新增)
// 11. StorageError_StreamClosedv0.6 T3 / s-4 新增;驗 fd leak 防護)
// TestDownloadStream_HappyPath成功 → 拿到 io.ReadCloser + DownloadMetadata 正確。
//
// v0.6ADR-016 / T2DownloadStream 改走 converter.GetResult驗證點調整為
// - converter.GetResult 被叫 1 次、傳入正確 jobID
// - stream byte-perfect 透傳(與 default getResultFunc 回的 body 一致)
// - filename 仍由 visionA 端的 defaultDownloadFilename(cj) 覆寫source-of-truth
// **不** 用 converter response Content-Disposition 給的值
// - visionA 端不再直接呼叫 FAAfaa.getCalls == 0
func TestDownloadStream_HappyPath(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
// 覆寫 getResultFunc 紀錄收到的 jobID並驗 converter response Content-Disposition 給的
// filename 會被 visionA 端覆寫掉
var capturedJobID string
fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
capturedJobID = jobID
return io.NopCloser(strings.NewReader(defaultStubNEFBody)), &DownloadMetadata{
// converter response Content-Disposition 給的 filename — 預期會被 visionA 覆寫
Filename: "converter-raw-object-key.nef",
ContentType: "application/octet-stream",
ContentLength: int64(len(defaultStubNEFBody)),
}, nil
}
fix.converter.setJob(&ConverterJob{
JobID: "j1",
Status: "completed",
CreatedAt: time.Now(),
SourceFilename: "yolov5s.onnx",
Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.NoError(t, err)
require.NotNil(t, stream)
require.NotNil(t, meta)
defer stream.Close()
// metadatafilename 由 visionA defaultDownloadFilename(cj) 覆寫,**不**用 converter 給的
assert.Equal(t, "yolov5s_kl720.nef", meta.Filename,
"filename = visionA 自己的 <stem>_<chip>.nef覆寫 converter response 的 filenamesource-of-truth")
assert.Equal(t, "application/octet-stream", meta.ContentType)
assert.Equal(t, int64(len(defaultStubNEFBody)), meta.ContentLength)
// stream 內容與 default getResultFunc 回的 body 一致byte-perfect 透傳)
body, err := io.ReadAll(stream)
require.NoError(t, err)
assert.Equal(t, defaultStubNEFBody, string(body))
// converter.GetResult 收到的 jobID 正確
assert.Equal(t, "j1", capturedJobID, "converter.GetResult 應收到 jobID不再用 object_key")
assert.Equal(t, int32(1), fix.converter.getResultCalls.Load(),
"DownloadStream 應呼叫 1 次 converter.GetResult")
// v0.6 T3FAAClient interface + faa_client.go 已整檔砍除 → 編譯期保證「visionA 端不再
// 直接打 FAA」e2e mockFAA 端的 negative assertion 提供 wire 層 regression 防護
}
// TestDownloadStream_FilenameFromConverterJobfilename 取自 cj.SourceFilename + Platform
// 而非從 FAA metadata 拿API key 模式下 FAA URL 不再含原檔名)。
func TestDownloadStream_FilenameFromConverterJob(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j1",
Status: "completed",
CreatedAt: time.Now(),
SourceFilename: "/path/to/my_model.tflite", // 有 path prefix → 應只取 stem
Platform: "520",
})
fix.ownership.Set("j1", "user-alice")
_, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.NoError(t, err)
require.NotNil(t, meta)
assert.Equal(t, "my_model_kl520.nef", meta.Filename)
}
// TestDownloadStream_DefaultsContentTypeconverter.GetResult 回 empty Content-Type
// → flow.DownloadStream 應 fallback 為 octet-stream深防converter_client 也有同樣 fallback
//
// v0.6ADR-016 / T2改用 getResultFunc 模擬 converter 端缺 Content-Type驗證 flow.go
// 的雙層 fallbackconverter_client doStreamOnce 已 fallbackflow.go DownloadStream 再保險一次)。
func TestDownloadStream_DefaultsContentType(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
return io.NopCloser(strings.NewReader("nef")), &DownloadMetadata{
Filename: "ignored.nef",
ContentLength: 3,
ContentType: "", // 故意空白 — 模擬下游缺 Content-Type 的 edge case
}, nil
}
fix.converter.setJob(&ConverterJob{
JobID: "j1", Status: "completed", CreatedAt: time.Now(),
SourceFilename: "x.onnx", Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
_, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.NoError(t, err)
assert.Equal(t, "application/octet-stream", meta.ContentType,
"converter.GetResult 沒給 Content-Type 時 flow.DownloadStream 應 fallback 為 application/octet-stream")
}
// TestDownloadStream_OwnershipMismatch別 user 的 job → ErrJobNotFound防枚舉
func TestDownloadStream_OwnershipMismatch(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-bob")
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotFound))
assert.Nil(t, stream)
assert.Nil(t, meta)
// converter.GetResult 不該被打到ownership 不符在 GetResult 之前)
assert.Equal(t, int32(0), fix.converter.getResultCalls.Load(),
"ownership 不符應在 converter.GetResult 之前短路")
}
// TestDownloadStream_JobNotCompletedstill running → ErrJobNotCompleted。
func TestDownloadStream_JobNotCompleted(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "running", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-alice")
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotCompleted))
assert.Nil(t, stream)
assert.Nil(t, meta)
}
// TestDownloadStream_PromoteError_Propagationpromote 5xx 透傳。
func TestDownloadStream_PromoteError_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-alice")
fix.converter.promoteFunc = func(ctx context.Context, jobID string, req PromoteReq) (*ConverterPromoteResult, error) {
return nil, fmt.Errorf("%w: promote 502", ErrConverterUnavailable)
}
_, _, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
// converter.GetResult 不該被打到promote 失敗在 GetResult 之前)
assert.Equal(t, int32(0), fix.converter.getResultCalls.Load(),
"promote 失敗應在 converter.GetResult 之前短路")
}
// TestDownloadStream_ConverterGetResultError_Propagationconverter.GetResult 5xx 透傳
// v0.6 取代原 TestDownloadStream_FAAError_Propagationdownload path 改走 converter MinIO
//
// v0.6 後 download pathvisionA → converterAPI key→ converter MinIO
// 失敗模式從 FAA 失敗變成 converter / MinIO 失敗。
func TestDownloadStream_ConverterGetResultError_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-alice")
fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
return nil, nil, fmt.Errorf("%w: get_result 502", ErrConverterUnavailable)
}
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
assert.Nil(t, stream)
assert.Nil(t, meta)
}
// TestDownloadStream_ConverterAuthFailed_Propagationconverter API key 不對齊
// → ErrConverterAuthFailed 透傳handler 層會 mask 成 converter_unavailable 對外)。
//
// v0.6 取代原 TestDownloadStream_FAAAuthFailed_Propagation。
func TestDownloadStream_ConverterAuthFailed_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-alice")
fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
return nil, nil, fmt.Errorf("%w: get_result 401", ErrConverterAuthFailed)
}
_, _, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterAuthFailed),
"flow 層 sentinel 仍是 ErrConverterAuthFailedhandler 層才 mask 對外")
// 驗對外 mask
assert.Equal(t, "converter_unavailable", ErrorCode(err),
"ErrorCode 對 ErrConverterAuthFailed 應 mask 成 converter_unavailable不洩漏 auth_failed")
assert.Equal(t, 502, HTTPStatus(err))
}
// TestDownloadStream_ConverterResultExpired_Propagationv0.6 新增——
// converter.GetResult 回 410 ErrResultExpired 應透傳給 handler對外 HTTP 410 + code result_expired。
//
// 對齊 ADR-016 §1.3「job completed 但 NEF 已過 7 天 expires_at 被 GC」場景
// frontend 收到 410 後顯示「轉檔結果已過期請重新轉檔」CTA與 404 not_found 區分)。
func TestDownloadStream_ConverterResultExpired_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-alice")
fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
return nil, nil, fmt.Errorf("%w: get_result 410", ErrResultExpired)
}
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrResultExpired))
assert.Nil(t, stream)
assert.Nil(t, meta)
// 對外 ErrorCode / HTTPStatus 對齊 conversion.md §6 + api-conversion.md §4
assert.Equal(t, "result_expired", ErrorCode(err))
assert.Equal(t, 410, HTTPStatus(err))
}
// TestDownloadStream_ConverterValidationFailed_Propagationv0.6 T3 s-3 補強——
// converter `GET /api/v1/jobs/{id}/result` 端回 4xx非 401/403/404/409/410
// converter_client.mapGetResultError mapping 到 ErrValidationFailed
// flow.DownloadStream 透傳handler 層對外 HTTP 400 + code `validation_failed`。
//
// 設計動機T1/T2 reviewer s-3 要求mapGetResultError 內 `case status >= 400 && status < 500`
// 的 fallback 路徑沒測試覆蓋,補上避免 silent fallback regression。
func TestDownloadStream_ConverterValidationFailed_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-alice")
fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
// 模擬 converter 端 422 / 其他 4xxconverter_client 會收斂到 ErrValidationFailed
return nil, nil, fmt.Errorf("%w: get_result 422", ErrValidationFailed)
}
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrValidationFailed),
"converter 端 4xx fallback 必須透傳 ErrValidationFailed 給 handler")
assert.Nil(t, stream)
assert.Nil(t, meta)
// 對外 ErrorCode / HTTPStatus 對齊 conversion.md §6 + api-conversion.md §錯誤碼總覽
assert.Equal(t, "validation_failed", ErrorCode(err))
assert.Equal(t, 400, HTTPStatus(err))
}
// instrumentedReadCloser 是 s-4 用的 io.ReadCloser wrapper計數 Close 被呼叫次數。
//
// 用於驗 flow.PromoteToModels 在 storage.Put 失敗時仍有 close converter.GetResult
// 回的 stream避免 fd / goroutine leakflow.go `defer stream.Close()` 已實作,
// 但缺乏 explicit test 驗證行為)。
type instrumentedReadCloser struct {
io.Reader
closeCalls atomic.Int32
}
func (r *instrumentedReadCloser) Close() error {
r.closeCalls.Add(1)
return nil
}
// TestPromoteToModels_StorageError_StreamClosedv0.6 T3 s-4 補強——
// PromoteToModels 第 5 步從 converter.GetResult 拿到 stream第 6 步 storage.Put 失敗時,
// 必須仍 close 該 stream避免 fd / goroutine leak
//
// 設計動機T2 reviewer s-4 要求flow.go:635 `defer stream.Close()` 行為缺乏 explicit
// regression 防護;本 test 用 instrumented stream wrapper 計數 Close 呼叫次數,驗值 ≥ 1。
//
// 為什麼用 ≥ 1 而不是 == 1Go defer 行為下 stream.Close 應該被呼叫恰好 1 次,但容忍多次
// closeio.NopCloser 等 wrapper 對重複 close 是安全的);用 ≥ 1 避免測試對 close 次數
// 過度耦合(未來改寫成 defer + explicit close 的 cleanup pattern 也不破壞此 test
func TestPromoteToModels_StorageError_StreamClosed(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j1", Status: "completed", CreatedAt: time.Now(),
SourceFilename: "x.onnx", Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
// 注入 instrumented stream — 驗 storage.Put 失敗時仍會被 Close
instrumented := &instrumentedReadCloser{Reader: strings.NewReader("nef-bytes")}
fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
return instrumented, &DownloadMetadata{
Filename: "x.nef",
ContentType: "application/octet-stream",
ContentLength: int64(len("nef-bytes")),
}, nil
}
// storage.Put 設為失敗 — 觸發 ErrStorageUnavailable 並驗 stream 仍被 close
fix.storage.putErr = errors.New("disk full")
_, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrStorageUnavailable),
"storage.Put 失敗應歸類為 ErrStorageUnavailable")
// **核心斷言**:即使 storage.Put 失敗stream.Close 仍應被呼叫flow.go defer 保護)
assert.GreaterOrEqual(t, int(instrumented.closeCalls.Load()), 1,
"storage.Put 失敗時converter.GetResult 回的 stream 必須仍被 Close避免 fd / goroutine leakflow.go:635 defer 保護)")
}
// ==========================================================================
// helper functions tests
// ==========================================================================
func TestNormalizeTargetChip(t *testing.T) {
t.Parallel()
cases := []struct {
in, want string
}{
{"720", "kl720"},
{"520", "kl520"},
{"KL630", "kl630"},
{"kl730", "kl730"},
{"", ""},
{" 720 ", "kl720"},
}
for _, c := range cases {
assert.Equal(t, c.want, normalizeTargetChip(c.in), "input=%q", c.in)
}
}
func TestDefaultModelName(t *testing.T) {
t.Parallel()
assert.Equal(t, "yolov5s_kl720", defaultModelName(&ConverterJob{
SourceFilename: "yolov5s.onnx", Platform: "720",
}))
assert.Equal(t, "yolov5s_kl520", defaultModelName(&ConverterJob{
SourceFilename: "/path/to/yolov5s.onnx", Platform: "520",
}))
// 沒 chip
assert.Equal(t, "x", defaultModelName(&ConverterJob{SourceFilename: "x.tflite"}))
// 沒 stem
assert.Equal(t, "converted_kl720", defaultModelName(&ConverterJob{Platform: "720"}))
}
func TestEscapeObjectKeyPath(t *testing.T) {
t.Parallel()
assert.Equal(t, "models/user/file.nef", escapeObjectKeyPath("models/user/file.nef"))
// space 在 path 中需 escape
assert.Equal(t, "models/user%20space/file.nef", escapeObjectKeyPath("models/user space/file.nef"))
// '/' 保留path separator其他 path-reserved 字元正常 escape
assert.Equal(t, "a%3Fb/c", escapeObjectKeyPath("a?b/c"))
// '+' 在 path 段是 valid不會被 escape與 query string 不同)
assert.Equal(t, "a+b/c", escapeObjectKeyPath("a+b/c"))
}
func TestBuildTargetObjectKey(t *testing.T) {
t.Parallel()
assert.Equal(t, "models/u1/j1.nef", buildTargetObjectKey("u1", "j1"))
}
func TestBuildStorageKey(t *testing.T) {
t.Parallel()
assert.Equal(t, "models/u1/m1.nef", buildStorageKey("u1", "m1"))
}