// 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) }