jim800121chen 1231bf0ed2 feat(visionA-backend): Phase 0.8 conversion package — 5 endpoint + 8 個內部模組
Phase 0.8 把 kneron_model_converter 的轉檔功能整合進 visionA Cloud。
visionA backend 當 streaming proxy(upload)+ delegated download token broker(download)+
ownership trust boundary,converter / FAA / MC 三方零修改。

新增 internal/conversion/ 套件(8 個檔,~10,000 行 prod+test,117+ test cases,race -count=3 全綠):

- conversion.go:Service interface 5 method、Job/PromoteResult/InitJobInput types
- errors.go:13+ sentinel errors + ErrorCode/HTTPStatus mapping,對齊 conversion.md §6
- mc_token_client.go:service-to-service token (client_credentials grant) + DCL cache
  (exp - 15s 重取,per-scope cache),IssueDelegatedDownload(MC delegated download token)
  錯誤分 idp_misconfigured (4xx) / idp_unavailable (5xx) / download_token_failed / mc_token_unavailable
- converter_client.go:對 converter scheduler 4 method(InitJob multipart streaming /
  GetJob / Promote / ListInProgressJobs),InitJob 不 retry 5xx(streaming body 無法 replay)
- faa_client.go:對 FAA GET /files/{key} server-to-server pull,Phase A retry(GET 無 body
  可 replay)對齊 §9.1 retry 矩陣,streaming io.ReadCloser 透傳避 OOM
- ownership.go:in-memory job_id → user_id map + per-user mutex 防 thundering herd lazy rebuild
  (不同 user 平行 fetch,同 user 100 caller 收斂成 1 次),visionA 重啟靠 converter
  ListInProgressJobs(user) 重建
- flow.go:Service interface 整合層(5 method 串接 converter/FAA/MC/ownership)
  - InitJob 用 io.Pipe + multipart.Reader/Writer 重組 streaming proxy(黑名單 client user_id
    + 灌入 OIDC sub)
  - DownloadRedirectURL 自動觸發 promote(spec §1 Stage 3b),用 ensurePromoted helper
  - PromoteToModels 冪等(modelStore.FindBySourceJobID 為 source-of-truth)
  - OwnershipMismatch → ErrJobNotFound 不 forbidden(§7.2 防枚舉)
  - storage / modelStore 失敗包 ErrStorageUnavailable / ErrModelStoreUnavailable
    (視為 visionA 自身 500 而非 502 gateway,SRE alarm 才打對 team)

新增 internal/api/conversion.go(5 endpoint handler + main.go wire):
- POST /api/conversion/init(multipart streaming proxy,不呼叫 c.MultipartForm())
- GET  /api/conversion/active(lazy rebuild ownership)
- GET  /api/conversion/{job_id}(poll status)
- POST /api/conversion/{job_id}/promote-to-models(FAA pull → models 三段式)
- GET  /api/conversion/{job_id}/download(server-side HTTP 302 → FAA,token 不過 frontend
  JS,仿 FAA TestSite DownloadFileDirect pattern;Cache-Control: no-store)

5 個 endpoint 全部走 OIDC AuthMiddleware;user_id 從 cookie session 灌(trust boundary),
從不接受 client multipart form / JSON / query 的 user_id。
TestAllAPIEndpointsRequire401WithoutCookie 自動覆蓋新 5 endpoint regression 防呆。

新增 cmd/api-server/conversion_e2e_test.go(4 個 e2e 場景):
- TestConversionE2E_StreamingProxy(10MB body + trust boundary regression)
- TestConversionE2E_LazyRebuildAfterRestart(visionA 重啟仍能 /active)
- TestConversionE2E_Download302Redirect(驗 302 + Location header + token 不在 body)
- TestConversionE2E_ActiveJobConflict(409 + active_job 詳情)

修改 internal/config/{config,load}.go:新增 ConversionConfig 5 欄位
(ConverterBaseURL / FAABaseURL / TenantID / ServiceClientID / ServiceClientSecret)+
Enabled() helper(雙非空判定)。
修改 cmd/api-server/main.go:條件 wire(cfg.Conversion.Enabled() 為 true 才建 client + Service;
否則 Deps.Conversion=nil,handler 自動回 501)。
修改 .env.example:新增 Phase 0.8 區塊註解。
新增 cmd/api-server/conversion_adapters.go:narrow interface adapter(接既有
internal/model.Repository / internal/storage.Store → conversion.ModelStore / Storage,避免 import cycle)。

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning / go build 成功。

對齊文件:
- .autoflow/04-architecture/adr/adr-014-conversion-integration.md
- .autoflow/04-architecture/conversion.md (TDD)
- .autoflow/04-architecture/api/api-conversion.md
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md

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

1215 lines
40 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不耦合 T2-T5 真實邏輯,純驗 flow 整合行為)
// - 沿用 ownership_test.go 的 stubConverterClient補上 InitJob/GetJob/Promote 實作)
// - 用本檔案專屬的 stubFAAClient / stubMCTokenClient / stubModelStore / stubStorage
//
// 涵蓋 5 個 method × happy / ownership 失敗 / client 失敗 propagation +
// task spec 額外要求:
// - InitJob 同 user 已有 active → ActiveJobError
// - PromoteToModels 已 promote 過 → 回既有 model_ididempotent
// - PromoteToModels job 沒 succeeded → ErrJobNotCompleted
// - DownloadRedirectURL URL 組裝正確(含 url.PathEscape / url.QueryEscape
// - ActiveJob converter 回 404 → ownership.Delete + (nil, nil)
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.7)
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)
// flowStubMCToken 是 MCTokenClient stub。
type flowStubMCToken struct {
serviceTokenFunc func(ctx context.Context, scope string) (string, error)
issueDelegatedDownloadFunc func(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error)
// 紀錄最後一次 IssueDelegatedDownload 收到的 input
mu sync.Mutex
lastIssueInput *IssueDownloadReq
}
func newFlowStubMCToken() *flowStubMCToken {
return &flowStubMCToken{}
}
func (s *flowStubMCToken) ServiceToken(ctx context.Context, scope string) (string, error) {
if s.serviceTokenFunc != nil {
return s.serviceTokenFunc(ctx, scope)
}
return "stub-service-token", nil
}
func (s *flowStubMCToken) IssueDelegatedDownload(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) {
s.mu.Lock()
cp := in
s.lastIssueInput = &cp
s.mu.Unlock()
if s.issueDelegatedDownloadFunc != nil {
return s.issueDelegatedDownloadFunc(ctx, in)
}
return &DelegatedDownloadToken{
Token: "opaque-stub-token-xyz",
ExpiresAt: time.Now().Add(5 * time.Minute),
}, nil
}
var _ MCTokenClient = (*flowStubMCToken)(nil)
// 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
mcToken *flowStubMCToken
models *flowStubModelStore
storage *flowStubStorage
ownership Ownership
}
func newFlowFixture(t *testing.T) *flowFixture {
t.Helper()
conv := newFlowStubConverter()
faa := newFlowStubFAA()
mcToken := newFlowStubMCToken()
models := newFlowStubModelStore()
storage := newFlowStubStorage()
own := NewOwnership(conv, newSilentLogger())
svc, err := NewService(FlowOpts{
Converter: conv,
FAA: faa,
MCToken: mcToken,
Ownership: own,
ModelStore: models,
Storage: storage,
TenantID: "visiona-tenant",
FAABaseURL: "https://faa.example.com",
DefaultJobExpiryDuration: 7 * 24 * time.Hour,
DelegatedTTLSeconds: 300,
Logger: newSilentLogger(),
Now: time.Now,
})
require.NoError(t, err)
return &flowFixture{
svc: svc,
converter: conv,
faa: faa,
mcToken: mcToken,
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 — 缺欄位驗證
// ==========================================================================
func TestNewService_RequiredFields(t *testing.T) {
t.Parallel()
conv := newFlowStubConverter()
faa := newFlowStubFAA()
mc := newFlowStubMCToken()
own := NewOwnership(conv, newSilentLogger())
mod := newFlowStubModelStore()
st := newFlowStubStorage()
tests := []struct {
name string
opts FlowOpts
}{
{"missing converter", FlowOpts{FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}},
{"missing faa", FlowOpts{Converter: conv, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}},
{"missing mc", FlowOpts{Converter: conv, FAA: faa, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}},
{"missing ownership", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}},
{"missing modelstore", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, Storage: st, TenantID: "t", FAABaseURL: "https://x"}},
{"missing storage", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, TenantID: "t", FAABaseURL: "https://x"}},
{"missing tenant", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, FAABaseURL: "https://x"}},
{"missing faaurl", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t"}},
}
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()
mc := newFlowStubMCToken()
own := NewOwnership(conv, newSilentLogger())
mod := newFlowStubModelStore()
st := newFlowStubStorage()
svc, err := NewService(FlowOpts{
Converter: conv, FAA: faa, MCToken: mc, Ownership: own,
ModelStore: mod, Storage: st,
TenantID: "visiona", FAABaseURL: "https://faa.example.com/",
// DefaultJobExpiryDuration / DelegatedTTLSeconds 留空 → 應 fallback
})
require.NoError(t, err)
require.NotNil(t, svc)
f := svc.(*flow)
assert.Equal(t, 7*24*time.Hour, f.defaultJobExpiryDuration)
assert.Equal(t, 300, f.delegatedTTLSeconds)
assert.Equal(t, "https://faa.example.com", f.faaBaseURL, "trailing slash 應被 trim")
}
// ==========================================================================
// 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")
}
// ==========================================================================
// DownloadRedirectURL
// ==========================================================================
// TestDownloadRedirectURL_HappyPathURL 組裝正確task spec 要求)。
func TestDownloadRedirectURL_HappyPath(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")
url, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1")
require.NoError(t, err)
// FAA base + /files/<key>?access_token=<token>
// key = "models/user-alice/j1.nef"token = "opaque-stub-token-xyz"
assert.Equal(t,
"https://faa.example.com/files/models/user-alice/j1.nef?access_token=opaque-stub-token-xyz",
url,
)
// 驗 IssueDelegatedDownload 帶到的參數
fix.mcToken.mu.Lock()
in := fix.mcToken.lastIssueInput
fix.mcToken.mu.Unlock()
require.NotNil(t, in)
assert.Equal(t, "visiona-tenant", in.TenantID)
assert.Equal(t, "user-alice", in.UserID)
assert.Equal(t, "models/user-alice/j1.nef", in.ObjectKey)
assert.Equal(t, 300, in.ExpiresInSeconds)
}
// TestDownloadRedirectURL_EscapeSpecialChars特殊字元的 user_id / job_id 走 escape。
func TestDownloadRedirectURL_EscapeSpecialChars(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.mcToken.issueDelegatedDownloadFunc = func(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) {
// 模擬 token 含特殊字元
return &DelegatedDownloadToken{
Token: "abc def+/=",
ExpiresAt: time.Now().Add(5 * time.Minute),
}, nil
}
// 用合法但帶 special char 的 user_idOIDC sub 通常不會這樣,但要 defensive
userID := "user with space"
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", userID)
url, err := fix.svc.DownloadRedirectURL(context.Background(), userID, "j1")
require.NoError(t, err)
// path 段 user_id 應 escape' ' → %20
assert.Contains(t, url, "/files/models/user%20with%20space/j1.nef")
// token 段應 query escape'+' / '=' / '/' / ' '
assert.Contains(t, url, "?access_token=abc+def%2B%2F%3D")
}
// TestDownloadRedirectURL_OwnershipMismatchnot_found。
func TestDownloadRedirectURL_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.DownloadRedirectURL(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotFound))
}
// TestDownloadRedirectURL_JobNotCompletedstill running → ErrJobNotCompleted。
func TestDownloadRedirectURL_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.DownloadRedirectURL(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotCompleted))
}
// TestDownloadRedirectURL_PromoteError_Propagationpromote 5xx 透傳。
func TestDownloadRedirectURL_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.DownloadRedirectURL(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
}
// TestDownloadRedirectURL_MCError_PropagationMC delegated 5xx 透傳。
func TestDownloadRedirectURL_MCError_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.mcToken.issueDelegatedDownloadFunc = func(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) {
return nil, fmt.Errorf("%w: mc 5xx", ErrMCTokenUnavailable)
}
_, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrMCTokenUnavailable))
}
// ==========================================================================
// 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"))
}