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