// 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_id(idempotent) // - PromoteToModels job 沒 succeeded → ErrJobNotCompleted // - DownloadStream 從 converter MinIO stream 拉到正確 metadata(v0.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 T4:DownloadRedirectURL → DownloadStream + 砍 flowStubMCToken // (見 ADR-015 §6 + conversion.md §3 / §4.1) // Phase 0.8b v0.6 T2:DownloadStream / PromoteToModels 改走 converter.GetResult // (見 ADR-016 + conversion.md §2.5 / §4.1 / §6) // Phase 0.8b v0.6 T3:flowStubFAA + 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 fields(initJobFunc 等)控制,testcase 寫起來直觀。 type flowStubConverter struct { mu sync.Mutex // 預設行為:jobsByID 用於 GetJob lookup;initJobFunc 用於控制 InitJob 結果 jobsByID map[string]*ConverterJob // 各 method 的 hook(nil → 走預設行為) 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.6(ADR-016 §1):GetResult 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」訊號)。 // // fixture(newFlowFixture)會在建立時自動安裝一個 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 stream(caller 若沒設 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 T5:mc_token_stub.go 整檔砍除;MCTokenClient interface 已不存在。 // Phase 0.8b v0.6 T3:flowStubFAA 整段砍除(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 T4:mcToken 欄位已移除(flow 不再依賴 MCTokenClient);FlowOpts 也砍 4 個欄位 // (MCToken / TenantID / FAABaseURL / DelegatedTTLSeconds)。 // // Phase 0.8b v0.6(ADR-016 / T2):DownloadStream / 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.6:default 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 / model(fake .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 T4:TenantID / FAABaseURL / MCToken 欄位已從 FlowOpts 砍除。 // Phase 0.8b v0.6 T3:FAA 欄位一併砍除(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_PassThrough:cache 中的 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_Propagation:converter 失敗應透傳 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_ConsumerSeesError:rebuild 中途 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) // 應透傳成 ErrConverterUnavailable(converter stub 回 5xx;或 rebuild 自身 wrap) assert.True(t, errors.Is(err, ErrConverterUnavailable), "rebuild + converter 雙失敗,最終應收斂成 ErrConverterUnavailable") // 關鍵 assert:converter 端讀 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 path:pipe 應正常 EOF(io.Copy 對 EOF 不報 error) assert.NoError(t, readErr, "正常完成時 converter 端 io.Copy(req.Body) 應 nil error(io.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_HappyPath:ownership 有 → 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 fallback:created_at + 7d assert.Equal(t, createdAt.Add(7*24*time.Hour), job.ExpiresAt) } // TestGetJob_OwnershipMismatch_ReturnsNotFound:ownership 不符回 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_ReturnsNotFound:cache 中沒對應 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_Propagation:converter 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_HappyPath:lazy 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_DeletesAndReturnsNil:cache 中有 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_Propagation:converter 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_ReturnsNil:cache 中是 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.GetResult(v0.6 取代 faa.GetFile)") // v0.6 T3:FAAClient interface 已整檔砍除(faa_client.go 不存在); // 「visionA 端不再直接打 FAA」改由「型別已不存在」的編譯期保證 + e2e mockFAA 端 negative // assertion 雙重防護(conversion_e2e_test.go:TestConversionE2E_DownloadStream) } // TestPromoteToModels_DefaultName:caller 傳空 name 應走 fallback `_kl`。 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_id(task 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.6:getResultCalls 取代原 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_JobNotCompleted:job 狀態 != completed → ErrJobNotCompleted(task 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_Propagation:converter.GetResult 失敗透傳。 // // v0.6(ADR-016 / T2):取代原 TestPromoteToModels_FAAError_Propagation。 // PromoteToModels 第 5 步改走 converter.GetResult、不再 pull FAA;對應的失敗 sentinel // 也從 ErrFAAUnavailable 改為 ErrConverterUnavailable(converter 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 T3:FAAClient 已整檔砍除(編譯期保證 visionA 端不再直接打 FAA); // 不再有 fix.faa.getCalls assertion 需要驗 } // TestPromoteToModels_ConverterGetResultExpired_Propagation:v0.6 新增—— // converter.GetResult 回 410 `result_expired`(job completed 但 NEF 已過 7 天 expires_at 被 GC) // 應透傳給 caller,handler 層會 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_StorageError:storage.Put 失敗 → 包成 ErrStorageUnavailable。 // // 對齊 Reviewer M-1:visionA 自家 storage(disk 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_ModelStoreError:modelStore.Save 失敗 → 包成 ErrModelStoreUnavailable。 // // 對齊 Reviewer M-1:visionA 自家 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)") } // ========================================================================== // DownloadStream(Phase 0.8b:取代原 DownloadRedirectURL) // ========================================================================== // // Phase 0.8b 變更(ADR-015 §7 + conversion.md §4.1): // - DownloadRedirectURL → DownloadStream(API key 模式下沒有 MC delegated token) // - 不再組「FAA URL + ?access_token=」;改成直接回 io.ReadCloser + DownloadMetadata // - 不再依賴 MCTokenClient(flowStubMCToken 已整段刪除) // - 對外仍由同一個 GET /api/conversion/{job_id}/download endpoint 觸發(handler 層改 stream proxy) // // 測試 case 對齊 happy / ownership / state / error propagation 路徑: // 1. HappyPath:成功拉到 stream + metadata 正確 // 2. FilenameFromConverterJob:filename 取自 cj.SourceFilename + Platform // 3. DefaultsContentType:converter 沒給 Content-Type 時 fallback application/octet-stream // 4. OwnershipMismatch:→ ErrJobNotFound // 5. JobNotCompleted:→ ErrJobNotCompleted // 6. PromoteError_Propagation:promote 5xx 透傳 // 7. ConverterGetResultError_Propagation(v0.6 取代 FAAError_Propagation) // 8. ConverterAuthFailed_Propagation(v0.6 取代 FAAAuthFailed_Propagation) // 9. ConverterResultExpired_Propagation(v0.6 新增;410 result_expired) // 10. ConverterValidationFailed_Propagation(v0.6 T3 / s-3 新增) // 11. StorageError_StreamClosed(v0.6 T3 / s-4 新增;驗 fd leak 防護) // TestDownloadStream_HappyPath:成功 → 拿到 io.ReadCloser + DownloadMetadata 正確。 // // v0.6(ADR-016 / T2):DownloadStream 改走 converter.GetResult;驗證點調整為: // - converter.GetResult 被叫 1 次、傳入正確 jobID // - stream byte-perfect 透傳(與 default getResultFunc 回的 body 一致) // - filename 仍由 visionA 端的 defaultDownloadFilename(cj) 覆寫(source-of-truth), // **不** 用 converter response Content-Disposition 給的值 // - visionA 端不再直接呼叫 FAA(faa.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() // metadata:filename 由 visionA defaultDownloadFilename(cj) 覆寫,**不**用 converter 給的 assert.Equal(t, "yolov5s_kl720.nef", meta.Filename, "filename = visionA 自己的 _.nef,覆寫 converter response 的 filename(source-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 T3:FAAClient interface + faa_client.go 已整檔砍除 → 編譯期保證「visionA 端不再 // 直接打 FAA」;e2e mockFAA 端的 negative assertion 提供 wire 層 regression 防護 } // TestDownloadStream_FilenameFromConverterJob:filename 取自 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_DefaultsContentType:converter.GetResult 回 empty Content-Type // → flow.DownloadStream 應 fallback 為 octet-stream(深防:converter_client 也有同樣 fallback)。 // // v0.6(ADR-016 / T2):改用 getResultFunc 模擬 converter 端缺 Content-Type;驗證 flow.go // 的雙層 fallback(converter_client doStreamOnce 已 fallback,flow.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_JobNotCompleted:still 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_Propagation:promote 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_Propagation:converter.GetResult 5xx 透傳 // (v0.6 取代原 TestDownloadStream_FAAError_Propagation;download path 改走 converter MinIO)。 // // v0.6 後 download path:visionA → converter(API 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_Propagation:converter 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 仍是 ErrConverterAuthFailed;handler 層才 mask 對外") // 驗對外 mask assert.Equal(t, "converter_unavailable", ErrorCode(err), "ErrorCode 對 ErrConverterAuthFailed 應 mask 成 converter_unavailable(不洩漏 auth_failed)") assert.Equal(t, 502, HTTPStatus(err)) } // TestDownloadStream_ConverterResultExpired_Propagation:v0.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_Propagation:v0.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 / 其他 4xx(converter_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 leak;flow.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_StreamClosed:v0.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 而不是 == 1:Go defer 行為下 stream.Close 應該被呼叫恰好 1 次,但容忍多次 // close(io.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 leak;flow.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")) }