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

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

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

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

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

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

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

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

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

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

896 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
// - InitJobSuccess / StreamingBody / ContentTypeHeader / Conflict409 / Validation400 / 5xx_NoRetry / AuthExpired
// - GetJobSuccess / NotFound / 5xx_RetryThenSuccess
// - PromoteSuccess / BadGateway
// - ListSuccess / 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 加速 testretry 退避保持原本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_Successmock 接受 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 含 boundaryconverter multer 解析依賴此)")
assert.Equal(t, 1, tokens.calls(scopeConverterWrite))
}
// TestInitJob_StreamingBodydriver 寫 100MB 假資料給 io.Readerconfirm streaming不全 buffer RAM
//
// 用 io.LimitReader 包一個無限 readerserver side 也用 io.Discard 不存。
// 觀察peakReadBytes 不應接近 100MB確認 net/http 真的是 streaming— 但 peak 偵測在 Go 層級不易,
// 改驗reader 的 ReadCalls 數應遠大於 1如果 buffer 全進 RAMnet/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 應該收到完整 bodystreaming proxy 不掉資料)")
// streaming 證據reader 應被多次呼叫 Read如果是 buffer 全 RAM 模式,會一次大讀)
calls := atomic.LoadInt64(&reader.calls)
assert.Greater(t, calls, int64(1), "streaming 必須多次 Read不能一次性 buffer 全 RAM")
}
// TestInitJob_ContentTypeHeadermultipart 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_ActiveJobErrormock 回 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_Validation400mock 回 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_NoRetrymock 連續 500 → InitJob 不 retry立即 return。
//
// 設計理由multipart body 是 streamingio.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 5xxstreaming body 不可 replay")
}
// TestInitJob_AuthExpiredmock 回 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_PropagatedMCTokenClient 取 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_NotFound404 → 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_RetryThenSuccess500/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_NoRetryctx 在 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_Successpromote 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 metadatatrust 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_BadGatewayFAA 不可達 → 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_NotCompleted409job_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_NotFound404404 → 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_Successquery 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_5xxRetry5xx 後成功;驗 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 interfacecompile-time check
var _ ConverterClient = (*converterClient)(nil)
// 確保 stubTokenClient 滿足 MCTokenClient interfacecompile-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)
}