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>
896 lines
31 KiB
Go
896 lines
31 KiB
Go
// Converter Client 單元測試。
|
||
//
|
||
// 測試策略:
|
||
// - 用 httptest.Server mock task-scheduler 的 4 個 endpoint
|
||
// - 用 stub MCTokenClient(直接回 token / 注入錯誤),不耦合真實 mc_token_client 邏輯
|
||
// - 用 atomic counter 驗 retry 行為(attempts 數對齊 conversion.md §9.1)
|
||
// - 大 body streaming 用 io.LimitReader(不真的寫 100MB 進 RAM)
|
||
//
|
||
// 對應 task 規範必含 case:
|
||
// - InitJob:Success / StreamingBody / ContentTypeHeader / Conflict409 / Validation400 / 5xx_NoRetry / AuthExpired
|
||
// - GetJob:Success / NotFound / 5xx_RetryThenSuccess
|
||
// - Promote:Success / BadGateway
|
||
// - List:Success / Empty / 5xxRetry
|
||
//
|
||
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.5 + §9.1)
|
||
package conversion
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// ==========================================================================
|
||
// stub MCTokenClient — 解耦真實 mc_token_client 邏輯
|
||
// ==========================================================================
|
||
|
||
// stubTokenClient 是 test 用的 fake MCTokenClient。
|
||
type stubTokenClient struct {
|
||
mu sync.Mutex
|
||
token string
|
||
tokenErr error
|
||
callsByScope map[string]int
|
||
}
|
||
|
||
func newStubTokenClient(token string) *stubTokenClient {
|
||
return &stubTokenClient{
|
||
token: token,
|
||
callsByScope: make(map[string]int),
|
||
}
|
||
}
|
||
|
||
func (s *stubTokenClient) ServiceToken(ctx context.Context, scope string) (string, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.callsByScope[scope]++
|
||
if s.tokenErr != nil {
|
||
return "", s.tokenErr
|
||
}
|
||
return s.token, nil
|
||
}
|
||
|
||
func (s *stubTokenClient) IssueDelegatedDownload(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) {
|
||
// converter_client 不會呼叫;此處只是滿足 interface
|
||
return nil, fmt.Errorf("stubTokenClient.IssueDelegatedDownload should not be called from converter_client tests")
|
||
}
|
||
|
||
func (s *stubTokenClient) setError(err error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.tokenErr = err
|
||
}
|
||
|
||
func (s *stubTokenClient) calls(scope string) int {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.callsByScope[scope]
|
||
}
|
||
|
||
// ==========================================================================
|
||
// converter mock server helpers
|
||
// ==========================================================================
|
||
|
||
// newConverterClientForTest 建立指向 mock server 的 ConverterClient。
|
||
//
|
||
// 使用較短的 init/http timeout 加速 test;retry 退避保持原本(converterRetryBackoff 1s 起跳
|
||
// 對 retry test 有點久但仍可接受 — 5xx retry test 的 max 2 retries = 0.5s + 1s = 1.5s)。
|
||
func newConverterClientForTest(t *testing.T, baseURL string, tokens MCTokenClient) ConverterClient {
|
||
t.Helper()
|
||
return NewConverterClient(ConverterClientOpts{
|
||
BaseURL: baseURL,
|
||
Tokens: tokens,
|
||
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
||
InitHTTPClient: &http.Client{Timeout: 5 * time.Second},
|
||
Logger: silentLogger(),
|
||
})
|
||
}
|
||
|
||
// ==========================================================================
|
||
// InitJob tests
|
||
// ==========================================================================
|
||
|
||
// TestInitJob_Success:mock 接受 multipart,回 201 + job spec。
|
||
func TestInitJob_Success(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
var serverContentType string
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
require.Equal(t, http.MethodPost, r.Method)
|
||
require.Equal(t, "Bearer svc-tok", r.Header.Get("Authorization"))
|
||
serverContentType = r.Header.Get("Content-Type")
|
||
|
||
// drain body 確認 streaming 完成
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
"status": "created",
|
||
"stage": "onnx",
|
||
"progress": 0,
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:00:00Z",
|
||
"expires_at": "2026-05-02T12:00:00Z",
|
||
"user_id": "alice"
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
job, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
UserID: "alice",
|
||
Platform: "520",
|
||
SourceFilename: "model.onnx",
|
||
Body: strings.NewReader("--xyz\r\nContent-Disposition: form-data; name=\"user_id\"\r\n\r\nalice\r\n--xyz--\r\n"),
|
||
BodyContentType: "multipart/form-data; boundary=xyz",
|
||
})
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, job)
|
||
assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", job.JobID)
|
||
assert.Equal(t, "created", job.Status)
|
||
assert.Equal(t, "onnx", job.Stage)
|
||
assert.Equal(t, "multipart/form-data; boundary=xyz", serverContentType,
|
||
"InitJob 必須完整透傳 Content-Type 含 boundary(converter multer 解析依賴此)")
|
||
assert.Equal(t, 1, tokens.calls(scopeConverterWrite))
|
||
}
|
||
|
||
// TestInitJob_StreamingBody:driver 寫 100MB 假資料給 io.Reader,confirm streaming(不全 buffer RAM)。
|
||
//
|
||
// 用 io.LimitReader 包一個無限 reader,server side 也用 io.Discard 不存。
|
||
// 觀察:peakReadBytes 不應接近 100MB(確認 net/http 真的是 streaming)— 但 peak 偵測在 Go 層級不易,
|
||
// 改驗:reader 的 ReadCalls 數應遠大於 1(如果 buffer 全進 RAM,net/http 會一次全讀)。
|
||
func TestInitJob_StreamingBody(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
var serverBytesRead int64
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
// 不一次 ReadAll;用 Copy 到 io.Discard 強制 streaming
|
||
n, _ := io.Copy(io.Discard, r.Body)
|
||
atomic.AddInt64(&serverBytesRead, n)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "stream-test", "status": "created", "stage": "onnx", "progress": 0,
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:00:00Z",
|
||
"expires_at": "2026-05-02T12:00:00Z"
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
const totalSize = int64(10 * 1024 * 1024) // 10MB(測試成本與 streaming 驗證的平衡)
|
||
reader := &countingReader{
|
||
R: io.LimitReader(zerosReader{}, totalSize),
|
||
}
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
// 對 streaming test 加長 timeout
|
||
cc = NewConverterClient(ConverterClientOpts{
|
||
BaseURL: srv.URL,
|
||
Tokens: tokens,
|
||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||
InitHTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||
Logger: silentLogger(),
|
||
})
|
||
|
||
job, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
UserID: "alice",
|
||
Body: reader,
|
||
BodyContentType: "multipart/form-data; boundary=stream",
|
||
})
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, job)
|
||
assert.Equal(t, "stream-test", job.JobID)
|
||
assert.Equal(t, totalSize, atomic.LoadInt64(&serverBytesRead),
|
||
"server 應該收到完整 body(streaming proxy 不掉資料)")
|
||
|
||
// streaming 證據:reader 應被多次呼叫 Read(如果是 buffer 全 RAM 模式,會一次大讀)
|
||
calls := atomic.LoadInt64(&reader.calls)
|
||
assert.Greater(t, calls, int64(1), "streaming 必須多次 Read(不能一次性 buffer 全 RAM)")
|
||
}
|
||
|
||
// TestInitJob_ContentTypeHeader:multipart boundary 必須完整透傳。
|
||
func TestInitJob_ContentTypeHeader(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
var receivedCT string
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
receivedCT = r.Header.Get("Content-Type")
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "ct-test", "status": "created", "stage": "onnx", "progress": 0,
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:00:00Z",
|
||
"expires_at": "2026-05-02T12:00:00Z"
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
const customCT = "multipart/form-data; boundary=---xxx-very-specific-boundary-yyy---"
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("body content"),
|
||
BodyContentType: customCT,
|
||
})
|
||
require.NoError(t, err)
|
||
assert.Equal(t, customCT, receivedCT, "boundary 必須一字不差透傳(含特殊字元)")
|
||
}
|
||
|
||
// TestInitJob_Conflict409_ActiveJobError:mock 回 409 user_has_active_job → return *ActiveJobError。
|
||
func TestInitJob_Conflict409_ActiveJobError(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusConflict)
|
||
_, _ = w.Write([]byte(`{
|
||
"error": {
|
||
"code": "user_has_active_job",
|
||
"message": "使用者目前已有進行中的轉檔任務",
|
||
"details": {
|
||
"active_job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
"active_job_status": "running",
|
||
"active_job_stage": "bie",
|
||
"active_job_progress": 45,
|
||
"active_job_created_at": "2026-04-25T12:00:00Z"
|
||
},
|
||
"request_id": "req-123"
|
||
}
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
BodyContentType: "multipart/form-data; boundary=xxx",
|
||
})
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrActiveJobExists), "必須能透過 errors.Is 比對 sentinel")
|
||
|
||
var ae *ActiveJobError
|
||
require.True(t, errors.As(err, &ae), "必須能透過 errors.As 取出 ActiveJobError struct")
|
||
require.NotNil(t, ae.Job)
|
||
assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", ae.Job.JobID)
|
||
assert.Equal(t, "running", ae.Job.Status)
|
||
assert.Equal(t, "bie", ae.Job.Stage)
|
||
assert.Equal(t, 45, ae.Job.Progress)
|
||
}
|
||
|
||
// TestInitJob_Validation400:mock 回 400 + fields → return *ConverterValidationError,
|
||
// fields 對齊 openapi.yaml shape([]ValidationFieldError)。
|
||
func TestInitJob_Validation400(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
_, _ = w.Write([]byte(`{
|
||
"error": {
|
||
"code": "validation_error",
|
||
"message": "欄位驗證失敗",
|
||
"details": {
|
||
"fields": [
|
||
{"field": "model_id", "message": "model_id 範圍必須在 1 ~ 65535"},
|
||
{"field": "platform", "message": "platform 必須是 520 / 720 / 530 / 630 / 730"}
|
||
]
|
||
},
|
||
"request_id": "req-validation"
|
||
}
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
BodyContentType: "multipart/form-data; boundary=xxx",
|
||
})
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrValidationFailed))
|
||
|
||
var ve *ConverterValidationError
|
||
require.True(t, errors.As(err, &ve))
|
||
require.Len(t, ve.Fields, 2, "fields 必須對齊 converter openapi.yaml 的 array shape")
|
||
assert.Equal(t, "model_id", ve.Fields[0].Field)
|
||
assert.Equal(t, "model_id 範圍必須在 1 ~ 65535", ve.Fields[0].Message)
|
||
assert.Equal(t, "platform", ve.Fields[1].Field)
|
||
assert.Contains(t, ve.Message, "驗證失敗", "Message 應透傳 converter 原文供 log 用")
|
||
}
|
||
|
||
// TestInitJob_5xx_NoRetry:mock 連續 500 → InitJob 不 retry,立即 return。
|
||
//
|
||
// 設計理由:multipart body 是 streaming(io.Reader 一次性),retry 會傳到一半的爛資料。
|
||
func TestInitJob_5xx_NoRetry(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
var counter atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
counter.Add(1)
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"misconfiguration","message":"...","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
BodyContentType: "multipart/form-data; boundary=xxx",
|
||
})
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrConverterUnavailable))
|
||
assert.Equal(t, int32(1), counter.Load(),
|
||
"InitJob 不可 retry 5xx(streaming body 不可 replay)")
|
||
}
|
||
|
||
// TestInitJob_AuthExpired:mock 回 401 → return ErrServiceClientUnauthorized。
|
||
func TestInitJob_AuthExpired(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("expired-tok")
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = io.Copy(io.Discard, r.Body)
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"invalid_token","message":"...","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
BodyContentType: "multipart/form-data; boundary=xxx",
|
||
})
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized))
|
||
}
|
||
|
||
// TestInitJob_TokenFailure_Propagated:MCTokenClient 取 token 失敗時,錯誤透傳。
|
||
func TestInitJob_TokenFailure_Propagated(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("")
|
||
tokens.setError(ErrServiceClientUnauthorized)
|
||
|
||
cc := newConverterClientForTest(t, "http://unused", tokens)
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
BodyContentType: "multipart/form-data; boundary=xxx",
|
||
})
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized))
|
||
}
|
||
|
||
// TestInitJob_RequiredFieldsValidation:本地參數驗證(不打網路)。
|
||
func TestInitJob_RequiredFieldsValidation(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
cc := newConverterClientForTest(t, "http://unused", tokens)
|
||
|
||
// 缺 body
|
||
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
|
||
BodyContentType: "multipart/form-data; boundary=x",
|
||
})
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "body is required")
|
||
|
||
// 缺 content type
|
||
_, err = cc.InitJob(context.Background(), InitConverterJobReq{
|
||
Body: strings.NewReader("x"),
|
||
})
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "content type is required")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// GetJob tests
|
||
// ==========================================================================
|
||
|
||
// TestGetJob_Success:標準 happy path(含完整 Job shape 解析)。
|
||
func TestGetJob_Success(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
require.Equal(t, http.MethodGet, r.Method)
|
||
require.Equal(t, "Bearer svc-tok", r.Header.Get("Authorization"))
|
||
// path: /api/v1/jobs/{id}
|
||
assert.Contains(t, r.URL.Path, "550e8400")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
"user_id": "alice",
|
||
"status": "running",
|
||
"stage": "bie",
|
||
"progress": 45,
|
||
"stage_progress": 60,
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:05:30Z",
|
||
"expires_at": "2026-05-02T12:00:00Z",
|
||
"input": {"filename": "model.onnx", "size_bytes": 100, "ref_images_count": 0},
|
||
"parameters": {"model_id": 1001, "version": "v1.0.0", "platform": "520"},
|
||
"error": null
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
job, err := cc.GetJob(context.Background(), "550e8400-e29b-41d4-a716-446655440000")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, job)
|
||
assert.Equal(t, "running", job.Status)
|
||
assert.Equal(t, "bie", job.Stage)
|
||
require.NotNil(t, job.Progress)
|
||
assert.Equal(t, 45, *job.Progress)
|
||
require.NotNil(t, job.StageProgress)
|
||
assert.Equal(t, 60, *job.StageProgress)
|
||
assert.Equal(t, "model.onnx", job.SourceFilename)
|
||
assert.Equal(t, "520", job.Platform)
|
||
assert.False(t, job.ExpiresAt.IsZero())
|
||
}
|
||
|
||
// TestGetJob_NotFound:404 → ErrJobNotFound。
|
||
func TestGetJob_NotFound(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"job_not_found","message":"...","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
_, err := cc.GetJob(context.Background(), "missing-job")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrJobNotFound))
|
||
}
|
||
|
||
// TestGetJob_5xx_RetryThenSuccess:500/500/200 → atomic counter 驗 retry 3 次。
|
||
func TestGetJob_5xx_RetryThenSuccess(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
var counter atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
idx := counter.Add(1)
|
||
if idx <= 2 {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"...","request_id":"r"}}`))
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "j1", "status": "completed", "stage": null, "progress": 100,
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:08:30Z",
|
||
"expires_at": "2026-05-02T12:00:00Z"
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
job, err := cc.GetJob(context.Background(), "j1")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, job)
|
||
assert.Equal(t, "completed", job.Status)
|
||
assert.Equal(t, int32(3), counter.Load(), "GetJob 應 retry max 2 次(共 3 attempts)")
|
||
}
|
||
|
||
// TestGetJob_5xx_Exhausted:連續 5xx 用完 retry 仍失敗 → ErrConverterUnavailable。
|
||
func TestGetJob_5xx_Exhausted(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
var counter atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
counter.Add(1)
|
||
w.WriteHeader(http.StatusBadGateway)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"x","message":"x","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
_, err := cc.GetJob(context.Background(), "j1")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrConverterUnavailable))
|
||
assert.Equal(t, int32(3), counter.Load(), "用完 retry 仍 5xx 應該打 3 次")
|
||
}
|
||
|
||
// TestGetJob_ContextCancel_NoRetry:ctx 在 retry 等待中被 cancel → 立即 return。
|
||
func TestGetJob_ContextCancel_NoRetry(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
var counter atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
counter.Add(1)
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"x","message":"x","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
// 第一次 attempt 完後 cancel;第二次 retry 等待時應立即 return
|
||
go func() {
|
||
time.Sleep(50 * time.Millisecond)
|
||
cancel()
|
||
}()
|
||
|
||
_, err := cc.GetJob(ctx, "j1")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, context.Canceled))
|
||
// 至多 1 次(cancel 在退避時觸發)
|
||
assert.LessOrEqual(t, counter.Load(), int32(1),
|
||
"ctx cancel 應在第 1 次 attempt 後立即 return,不再打 server")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Promote tests
|
||
// ==========================================================================
|
||
|
||
// TestPromote_Success:promote response 含 target_object_key。
|
||
func TestPromote_Success(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
var receivedBody string
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
require.Equal(t, http.MethodPost, r.Method)
|
||
assert.Contains(t, r.URL.Path, "/promote")
|
||
body, _ := io.ReadAll(r.Body)
|
||
receivedBody = string(body)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "j1",
|
||
"promoted": [
|
||
{
|
||
"source": "nef",
|
||
"target_object_key": "visionA/models/alice/m-1001/v1.0.0/out.nef",
|
||
"size_bytes": 10485760,
|
||
"file_access_agent_etag": "abc123",
|
||
"promoted_at": "2026-04-25T12:30:00Z"
|
||
}
|
||
]
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
result, err := cc.Promote(context.Background(), "j1", PromoteReq{
|
||
UserID: "alice",
|
||
Source: "nef",
|
||
TargetObjectKey: "visionA/models/alice/m-1001/v1.0.0/out.nef",
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, result)
|
||
assert.Equal(t, "visionA/models/alice/m-1001/v1.0.0/out.nef", result.TargetObjectKey)
|
||
assert.Equal(t, int64(10485760), result.Size)
|
||
assert.Equal(t, "abc123", result.Checksum)
|
||
assert.Contains(t, receivedBody, `"user_id":"alice"`,
|
||
"promote body 應含 user_id metadata(trust boundary 重申)")
|
||
assert.Contains(t, receivedBody, `"target_object_key":"visionA/models/alice/m-1001/v1.0.0/out.nef"`)
|
||
}
|
||
|
||
// TestPromote_DefaultSource:未傳 Source 時預設 nef。
|
||
func TestPromote_DefaultSource(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
var receivedBody string
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
body, _ := io.ReadAll(r.Body)
|
||
receivedBody = string(body)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{
|
||
"job_id": "j1",
|
||
"promoted": [{"source":"nef","target_object_key":"x","size_bytes":1,"file_access_agent_etag":"","promoted_at":"2026-04-25T00:00:00Z"}]
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
|
||
UserID: "alice",
|
||
TargetObjectKey: "x",
|
||
})
|
||
require.NoError(t, err)
|
||
assert.Contains(t, receivedBody, `"source":"nef"`, "未傳 Source 時應預設 nef")
|
||
}
|
||
|
||
// TestPromote_BadGateway:FAA 不可達 → 502 → ErrFAAUnavailable。
|
||
func TestPromote_BadGateway(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusBadGateway)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"file_gateway_unavailable","message":"FAA 不可達","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
|
||
UserID: "alice",
|
||
TargetObjectKey: "x",
|
||
})
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrFAAUnavailable),
|
||
"converter 502 file_gateway_unavailable 必須對應到 ErrFAAUnavailable")
|
||
}
|
||
|
||
// TestPromote_NotCompleted409:job_not_ready_for_promote → ErrJobNotCompleted。
|
||
func TestPromote_NotCompleted409(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusConflict)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"job_not_ready_for_promote","message":"...","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
|
||
UserID: "alice",
|
||
TargetObjectKey: "x",
|
||
})
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrJobNotCompleted))
|
||
}
|
||
|
||
// TestPromote_NotFound404:404 → ErrJobNotFound。
|
||
func TestPromote_NotFound404(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"job_not_found","message":"...","request_id":"r"}}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
|
||
UserID: "alice",
|
||
TargetObjectKey: "x",
|
||
})
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrJobNotFound))
|
||
}
|
||
|
||
// TestPromote_RequiredFieldsValidation:本地參數驗證。
|
||
func TestPromote_RequiredFieldsValidation(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
cc := newConverterClientForTest(t, "http://unused", tokens)
|
||
|
||
_, err := cc.Promote(context.Background(), "", PromoteReq{TargetObjectKey: "x"})
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "jobID is required")
|
||
|
||
_, err = cc.Promote(context.Background(), "j1", PromoteReq{})
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "target_object_key is required")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// ListInProgressJobs tests
|
||
// ==========================================================================
|
||
|
||
// TestListInProgressJobs_Success:query string 含 user_id + status=in_progress。
|
||
func TestListInProgressJobs_Success(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
var receivedQuery string
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
// path 在 mux pattern 沒結尾 / 時 ServeMux 會匹配精確路徑(list 端點)
|
||
require.Equal(t, http.MethodGet, r.Method)
|
||
receivedQuery = r.URL.RawQuery
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{
|
||
"jobs": [
|
||
{
|
||
"job_id": "j-active",
|
||
"user_id": "alice",
|
||
"status": "running",
|
||
"stage": "bie",
|
||
"progress": 45,
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:05:30Z",
|
||
"expires_at": "2026-05-02T12:00:00Z",
|
||
"input": {"filename": "model.onnx", "size_bytes": 1, "ref_images_count": 0},
|
||
"parameters": {"model_id": 1, "version": "v1.0.0", "platform": "720"},
|
||
"error": null
|
||
}
|
||
],
|
||
"total": 1,
|
||
"next_cursor": null
|
||
}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
|
||
require.NoError(t, err)
|
||
require.Len(t, jobs, 1)
|
||
assert.Equal(t, "j-active", jobs[0].JobID)
|
||
assert.Equal(t, "running", jobs[0].Status)
|
||
assert.Equal(t, "bie", jobs[0].Stage)
|
||
assert.Equal(t, "720", jobs[0].Platform)
|
||
assert.Contains(t, receivedQuery, "user_id=alice")
|
||
assert.Contains(t, receivedQuery, "status=in_progress",
|
||
"必須帶 status=in_progress 給 lazy rebuild ownership 用")
|
||
}
|
||
|
||
// TestListInProgressJobs_Empty:[] response → 空 slice。
|
||
func TestListInProgressJobs_Empty(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{"jobs":[],"total":0,"next_cursor":null}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
|
||
require.NoError(t, err)
|
||
assert.Len(t, jobs, 0, "empty result 應回空 slice,不是 nil 也不是 error")
|
||
assert.NotNil(t, jobs, "應回非 nil 空 slice 給 caller 安全 range")
|
||
}
|
||
|
||
// TestListInProgressJobs_5xxRetry:5xx 後成功;驗 retry 1 次(共 2 attempts)。
|
||
func TestListInProgressJobs_5xxRetry(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
var counter atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||
idx := counter.Add(1)
|
||
if idx == 1 {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = w.Write([]byte(`{"error":{"code":"x","message":"x","request_id":"r"}}`))
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(`{"jobs":[],"total":0,"next_cursor":null}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL, tokens)
|
||
jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
|
||
require.NoError(t, err)
|
||
assert.Len(t, jobs, 0)
|
||
assert.Equal(t, int32(2), counter.Load(), "List 應 retry 1 次(共 2 attempts)")
|
||
}
|
||
|
||
// TestListInProgressJobs_RequiredUserID:本地參數驗證。
|
||
func TestListInProgressJobs_RequiredUserID(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tokens := newStubTokenClient("svc-tok")
|
||
cc := newConverterClientForTest(t, "http://unused", tokens)
|
||
|
||
_, err := cc.ListInProgressJobs(context.Background(), "")
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "userID is required")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 共用:interface 契約 + helpers
|
||
// ==========================================================================
|
||
|
||
// 確保 converterClient 滿足 ConverterClient interface(compile-time check)。
|
||
var _ ConverterClient = (*converterClient)(nil)
|
||
|
||
// 確保 stubTokenClient 滿足 MCTokenClient interface(compile-time check)。
|
||
var _ MCTokenClient = (*stubTokenClient)(nil)
|
||
|
||
// zerosReader 是無限產生 0 byte 的 reader(測 streaming 用)。
|
||
type zerosReader struct{}
|
||
|
||
func (zerosReader) Read(p []byte) (int, error) {
|
||
for i := range p {
|
||
p[i] = 0
|
||
}
|
||
return len(p), nil
|
||
}
|
||
|
||
// countingReader 包一個 reader 並計數 Read 呼叫次數(給 streaming 驗證用)。
|
||
type countingReader struct {
|
||
R io.Reader
|
||
calls int64 // atomic
|
||
}
|
||
|
||
func (c *countingReader) Read(p []byte) (int, error) {
|
||
atomic.AddInt64(&c.calls, 1)
|
||
return c.R.Read(p)
|
||
}
|