jim800121chen ce6a657df4 feat(visionA-backend): Phase 0.8b v0.6 對齊 — T1+T2 download 改走 converter.GetResult
對齊 ADR-016:visionA download 不再經 FAA delegated token、改用 converter GET /api/v1/jobs/{id}/result 中轉。

T1 — converter_client.go 加 GetResult method:
- 新增 GetResult(ctx, jobID) (io.ReadCloser, *DownloadMetadata, error)
- 新增 ErrResultExpired sentinel + ErrorCode("result_expired") + HTTPStatus 410 mapping
- 獨立 StreamHTTPClient (無 timeout / dial+response header timeout) 給 streaming 大檔
- doStreamWithRetry / doStreamOnce / mapGetResultError / resultRetryBackoff helpers
- parseFilenameFromContentDisposition (RFC 5987 quoted/unquoted/encoded)
- 9 個 GetResult test + 6 個 parseFilename sub-test
- Reviewer 0 Critical / 0 Major / 3 Minor (M-1/M-2/M-3 全部 T2 順手修)

T2 — flow.go + e2e 改造:
- DownloadStream / PromoteToModels 移除 f.faa.GetFile(...) 改 f.converter.GetResult(ctx, jobID)
- filename 仍由 defaultDownloadFilename(cj) 覆寫 (visionA source-of-truth)
- 8 個 flow_test 既有 test 改寫 + 2 個改名 (FAA → Converter) + 2 個 410 透傳 test 新增
- e2e mock converter 加 GET /api/v1/jobs/{id}/result endpoint + 3 helper + 6 斷言更新 (含 negative: FAA 0 命中 / converter /result ≥1 命中)
- T1 reviewer 3 個 Minor 全處理 (mapGetResultError 設計取捨 godoc / 指數退避→線性退避 / 401+403 mask 驗證)
- 保留 faa FAAClient 欄位 + FlowOpts.FAA 必填 (T3 才砍 faa_client.go 整檔)

T2 修補 (architect + backend 平行):
- M-1 conversion.go Service interface DownloadStream/PromoteToModels godoc 對齊 v0.6 (從 flow.go layer 搬上來)
- M-2 conversion.md v0.6 → v0.6.1 — §2.5 ensurePromoted cache 描述「sync.Map cache」改為「Phase 0.8 簡化 (不實作 cache)」+ 4 簡化理由 + 3 Phase 1+ 升級選項 (in-memory / DB / model store 推論);連動修改 line 169 / 300 / 1187 cross-reference
- 3 Minor + 2 Suggestion 順手做 (resultRetryBaseDelay godoc / fixture 註解過渡狀態 / e2e route table 4→5 / flow.go struct T3 預期清單 / e2e negative assertion 強化)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸 (v0.6-t1-review + v0.6-t2-review + v0.6-t2-fix-review) 全  通過

對齊 ADR-016 §1 / conversion.md v0.6.1 §2.5 §4.1 / api-conversion.md v0.6 §4 / oidc-tdd.md v0.4 §13.1.3

下一步:
- T3 砍 faa_client.go + faa_client_test.go + 對應 ErrFAA* sentinel (B 層強制跑 / s-3/s-4/s-5 必補)
- T4 砍 ConversionConfig FAA* 欄位 + main.go wire 點 + .env*.example
- T5 main.go wire 點全切 + e2e regression 防護

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

1406 lines
52 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不耦合 T3 / T4 / T5 真實邏輯,純驗 flow 整合行為)
// - 沿用 ownership_test.go 的 stubConverterClient補上 InitJob/GetJob/Promote 實作)
// - 用本檔案專屬的 stubFAAClient / 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)
// - flowStubFAA struct **保留**T3 才整檔砍 faa_client.go但 test 不再透過它走
// download / promote 路徑;改用 flowStubConverter.getResultFunc hook
// - fixture 自動安裝 default getResultFunc回 defaultStubNEFBody讓既有 happy-path
// test 不必個別設 hook需 override 行為的 test 直接覆寫 fix.converter.getResultFunc
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)
// flowStubFAA 是 FAAClient stub。
type flowStubFAA struct {
mu sync.Mutex
getFileFunc func(ctx context.Context, objectKey string) (*FAAFile, error)
getCalls atomic.Int32
lastKey string
}
func newFlowStubFAA() *flowStubFAA {
return &flowStubFAA{}
}
func (s *flowStubFAA) GetFile(ctx context.Context, objectKey string) (*FAAFile, error) {
s.getCalls.Add(1)
s.mu.Lock()
s.lastKey = objectKey
s.mu.Unlock()
if s.getFileFunc != nil {
return s.getFileFunc(ctx, objectKey)
}
body := io.NopCloser(strings.NewReader("nef-bytes-stub"))
return &FAAFile{
Body: body,
ContentLength: int64(len("nef-bytes-stub")),
ContentType: "application/octet-stream",
ETag: "stub-etag",
}, nil
}
var _ FAAClient = (*flowStubFAA)(nil)
// Phase 0.8b T4原 flowStubMCToken 已整段刪除MC 認證鏈取消、flow 不再依賴 MCTokenClient
// Phase 0.8b T5mc_token_stub.go 整檔砍除MCTokenClient interface 已不存在。
// 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
faa *flowStubFAA
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 (T2)** — FAA 欄位/stub 過渡狀態FAA 欄位保留作為 T3 過渡method 內無 caller、
// godoc flow.go:111-118 明示「T3 砍」);`flowStubFAA` 仍保留並維持 wire 進 FlowOpts.FAA
// 驗 e2e negative assertion「FAA 0 命中」conversion_e2e_test.go:1037-1040。T3 砍
// faa_client.go 整檔時同步砍:(a) `flowStubFAA` type(b) `flowFixture.faa` 欄位;
// (c) FlowOpts.FAA 必填;(d) e2e mockFAA + setupConversionFixture 對 FAA 的 wire。
func newFlowFixture(t *testing.T) *flowFixture {
t.Helper()
conv := newFlowStubConverter()
faa := newFlowStubFAA()
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,
FAA: faa,
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,
faa: faa,
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 砍除;
// 必填欄位降為 5 個Converter / FAA / Ownership / ModelStore / Storage
func TestNewService_RequiredFields(t *testing.T) {
t.Parallel()
conv := newFlowStubConverter()
faa := newFlowStubFAA()
own := NewOwnership(conv, newSilentLogger())
mod := newFlowStubModelStore()
st := newFlowStubStorage()
tests := []struct {
name string
opts FlowOpts
}{
{"missing converter", FlowOpts{FAA: faa, Ownership: own, ModelStore: mod, Storage: st}},
{"missing faa", FlowOpts{Converter: conv, Ownership: own, ModelStore: mod, Storage: st}},
{"missing ownership", FlowOpts{Converter: conv, FAA: faa, ModelStore: mod, Storage: st}},
{"missing modelstore", FlowOpts{Converter: conv, FAA: faa, Ownership: own, Storage: st}},
{"missing storage", FlowOpts{Converter: conv, FAA: faa, 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()
faa := newFlowStubFAA()
own := NewOwnership(conv, newSilentLogger())
mod := newFlowStubModelStore()
st := newFlowStubStorage()
svc, err := NewService(FlowOpts{
Converter: conv, FAA: faa, 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")
assert.Equal(t, int32(0), fix.faa.getCalls.Load(),
"v0.6visionA 端不再直接打 FAAfaa.GetFile 不該被呼叫")
}
// 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.6visionA 端不再直接打 FAA
assert.Equal(t, int32(0), fix.faa.getCalls.Load())
}
// 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不是 ErrFAAUnavailable")
// 確認沒被誤包成其他 sentinel
assert.False(t, errors.Is(err, ErrFAAUnavailable),
"storage 失敗不該被歸類為 FAA 問題Reviewer M-1")
assert.False(t, errors.Is(err, ErrConverterUnavailable))
// 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 對齊原 6 個 happy / ownership / state / error propagation 路徑:
// 1. HappyPath成功拉到 stream + metadata 正確
// 2. SpecialCharsuser_id / job_id 含特殊字元時 buildTargetObjectKey 正確 + filename 安全
// 3. OwnershipMismatch→ ErrJobNotFound
// 4. JobNotCompleted→ ErrJobNotCompleted
// 5. PromoteError_Propagationpromote 5xx 透傳
// 6. FAAError_Propagation取代 MCErrorFAA pull 失敗透傳
// 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.6visionA 端不再直接打 FAA
assert.Equal(t, int32(0), fix.faa.getCalls.Load(),
"v0.6DownloadStream 不該呼叫 faa.GetFile")
}
// 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 之前)
// v0.6:取代原 faa.getCalls assert
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 之前)
// v0.6:取代原 faa.getCalls assert
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))
}
// ==========================================================================
// 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"))
}