jim800121chen 86b7175649 feat(visionA-backend): Phase 0.8b 步驟 2 — visionA → converter / FAA 改 API key 認證
對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。

實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):

T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清

T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)

T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go

T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test

T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)

不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動

下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試

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

1251 lines
42 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 從 FAA stream 拉到正確 metadataPhase 0.8b:取代原 DownloadRedirectURL URL 組裝)
// - ActiveJob converter 回 404 → ownership.Delete + (nil, nil)
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.7)
// Phase 0.8b T4DownloadRedirectURL → DownloadStream + 砍 flowStubMCToken
// (見 ADR-015 §6 + conversion.md §3 / §4.1)
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)
// 各 method 呼叫次數atomic
initJobCalls atomic.Int32
getJobCalls atomic.Int32
promoteCalls atomic.Int32
listInProgressJobsCalls 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
}
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
func newFlowFixture(t *testing.T) *flowFixture {
t.Helper()
conv := newFlowStubConverter()
faa := newFlowStubFAA()
models := newFlowStubModelStore()
storage := newFlowStubStorage()
own := NewOwnership(conv, newSilentLogger())
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,
}
}
// 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)
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 / faa 各被打 1 次
assert.Equal(t, int32(1), fix.converter.promoteCalls.Load())
assert.Equal(t, int32(1), fix.faa.getCalls.Load())
}
// 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 / faa.GetFile / storage.Put
convPromoteBefore := fix.converter.promoteCalls.Load()
faaCallsBefore := fix.faa.getCalls.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, faaCallsBefore, fix.faa.getCalls.Load(),
"二次 promote 不應再打 faa.GetFile")
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_FAAError_PropagationFAA 失敗透傳。
func TestPromoteToModels_FAAError_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.faa.getFileFunc = func(ctx context.Context, objectKey string) (*FAAFile, error) {
return nil, fmt.Errorf("%w: faa 502", ErrFAAUnavailable)
}
_, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrFAAUnavailable))
// model record 不應被建FAA 失敗在 storage 寫入前)
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 正確。
func TestDownloadStream_HappyPath(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
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 對齊 stub FAA 的 default 行為faa_client_test.go newFlowStubFAA
assert.Equal(t, "yolov5s_kl720.nef", meta.Filename,
"filename = <stem>_<chip>.nef對齊 wireframe §8.1 + defaultDownloadFilename")
assert.Equal(t, "application/octet-stream", meta.ContentType)
assert.Equal(t, int64(len("nef-bytes-stub")), meta.ContentLength)
// stream 內容與 stub FAA 預設 body 一致streaming pull
body, err := io.ReadAll(stream)
require.NoError(t, err)
assert.Equal(t, "nef-bytes-stub", string(body))
// 驗 faa.GetFile 帶到的 object_keybuildTargetObjectKey 規則models/<user>/<job>.nef
fix.faa.mu.Lock()
gotKey := fix.faa.lastKey
fix.faa.mu.Unlock()
assert.Equal(t, "models/user-alice/j1.nef", gotKey)
}
// 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_DefaultsContentTypeFAA 沒給 Content-Type → 預設 octet-stream
// (確保 browser download dialog 仍會觸發)。
func TestDownloadStream_DefaultsContentType(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.faa.getFileFunc = func(ctx context.Context, objectKey string) (*FAAFile, error) {
return &FAAFile{
Body: io.NopCloser(strings.NewReader("nef")),
ContentLength: 3,
ContentType: "", // 故意空白
ETag: "etag",
}, 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,
"FAA 沒給 Content-Type 時應 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)
// FAA 不該被打到ownership 不符在 FAA call 之前)
assert.Equal(t, int32(0), fix.faa.getCalls.Load())
}
// 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))
// FAA 不該被打到promote 失敗在 FAA call 之前)
assert.Equal(t, int32(0), fix.faa.getCalls.Load())
}
// TestDownloadStream_FAAError_PropagationFAA pull 5xx 透傳(取代原 MCError test
//
// Phase 0.8b 後 download path 不再經 MCFAA stream 失敗是最常見的失敗模式
// API key 不對齊 → ErrFAAAuthFailedFAA 服務不可達 → ErrFAAUnavailable
func TestDownloadStream_FAAError_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.faa.getFileFunc = func(ctx context.Context, objectKey string) (*FAAFile, error) {
return nil, fmt.Errorf("%w: faa 502", ErrFAAUnavailable)
}
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrFAAUnavailable))
assert.Nil(t, stream)
assert.Nil(t, meta)
}
// TestDownloadStream_FAAAuthFailed_PropagationFAA API key 不對齊 → ErrFAAAuthFailed
// 透傳handler 層會 mask 成 faa_unavailable 對外)。
func TestDownloadStream_FAAAuthFailed_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.faa.getFileFunc = func(ctx context.Context, objectKey string) (*FAAFile, error) {
return nil, fmt.Errorf("%w: faa 401", ErrFAAAuthFailed)
}
_, _, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrFAAAuthFailed),
"flow 層 sentinel 仍是 ErrFAAAuthFailedhandler 層才 mask 對外")
// 驗 sentinel 可被 errors.As 解出handler 用 conversion.HTTPStatus / ErrorCode 處理)
assert.Equal(t, "faa_unavailable", ErrorCode(err),
"ErrorCode helper 對 ErrFAAAuthFailed 應 mask 成 faa_unavailable對外不洩漏 auth_failed")
}
// ==========================================================================
// 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"))
}