// Converter Client 單元測試。 // // 測試策略: // - 用 httptest.Server mock task-scheduler 的 4 個 endpoint // - **Phase 0.8b**:直接用 string fake API key(不再注入 stub MCTokenClient)— 與 ADR-015 // pre-shared key 模式一致;驗 server 端確實收到 `Authorization: Bearer ` // - 用 atomic counter 驗 retry 行為(attempts 數對齊 conversion.md §9.1) // - 大 body streaming 用 io.LimitReader(不真的寫 100MB 進 RAM) // // 對應 task 規範必含 case: // - InitJob:Success / StreamingBody / ContentTypeHeader / Conflict409 / Validation400 / 5xx_NoRetry / AuthFailed401 / AuthFailed403 // - GetJob:Success / NotFound / 5xx_RetryThenSuccess / AuthFailed401_NoRetry // - Promote:Success / BadGateway / AuthFailed401_NoRetry // - List:Success / Empty / 5xxRetry / AuthFailed401_NoRetry // - Constructor:Panics_When_APIKey_Empty // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.5 + §9.1) // Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3) package conversion import ( "context" "errors" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ========================================================================== // Phase 0.8b:fake API key fixtures // ========================================================================== // // 取明顯 fake 字串、含 `do-not-use-in-prod` marker(grepable,避免被誤當真 key)。 // 長度 64 hex chars 對齊 ADR-015 §3.4 production key 規格(`openssl rand -hex 32`)— 即使 // 未來加 length validation 也不會 break 這個 fixture。 const ( fakeConverterAPIKey = "fake-converter-api-key-do-not-use-in-prod-aaaaaaaaaaaaaaaaaaaaaaaa" fakeFAAAPIKey = "fake-faa-api-key-do-not-use-in-prod-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ) // ========================================================================== // converter mock server helpers // ========================================================================== // newConverterClientForTest 建立指向 mock server 的 ConverterClient。 // // Phase 0.8b:直接傳 fakeConverterAPIKey;不再需要 MCTokenClient 注入。 // // 使用較短的 init/http timeout 加速 test;retry 退避保持原本(converterRetryBackoff 0.5s 起跳 // 對 retry test 有點久但仍可接受 — 5xx retry test 的 max 2 retries = 0.5s + 1s = 1.5s)。 func newConverterClientForTest(t *testing.T, baseURL string) ConverterClient { t.Helper() return NewConverterClient(ConverterClientOpts{ BaseURL: baseURL, APIKey: fakeConverterAPIKey, 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。 // // Phase 0.8b:驗 server 端確實收到 `Authorization: Bearer `(pre-shared // API key 直接 set header;不再透過 MCTokenClient 取 token)。 func TestInitJob_Success(t *testing.T) { t.Parallel() var serverContentType string var serverAuth string mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPost, r.Method) serverAuth = 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) 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, "Bearer "+fakeConverterAPIKey, serverAuth, "Phase 0.8b:必須直接帶 pre-shared API key(不經 MC token cache)") } // 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() 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), } // 對 streaming test 加長 timeout cc := NewConverterClient(ConverterClientOpts{ BaseURL: srv.URL, APIKey: fakeConverterAPIKey, 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() 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) _, 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() 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) _, 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() 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) _, 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() 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) _, 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_AuthFailed401:mock 回 401 → ErrConverterAuthFailed(Phase 0.8b 新 sentinel; // 對外 mask 成 converter_unavailable / 502,避免洩漏「API key 不對齊」內部運維狀態)。 func TestInitJob_AuthFailed401(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) _, _ = io.Copy(io.Discard, r.Body) w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) _, 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, ErrConverterAuthFailed), "Phase 0.8b:401 必須 mapping 到新 sentinel ErrConverterAuthFailed") // Phase 0.8b T3:舊 sentinel ErrServiceClientUnauthorized 已移除, // 改由 ErrConverterAuthFailed 接管 401/403 mapping。 assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry(API key 不對 retry 也是 401)") // 對外 ErrorCode mask 成 converter_unavailable(不洩漏「API key 不對」) assert.Equal(t, "converter_unavailable", ErrorCode(err)) assert.Equal(t, 502, HTTPStatus(err)) } // TestInitJob_AuthFailed403:對稱 — mock 回 403 → 同樣 ErrConverterAuthFailed、不 retry。 func TestInitJob_AuthFailed403(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) _, _ = io.Copy(io.Discard, r.Body) w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) _, 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, ErrConverterAuthFailed)) assert.Equal(t, int32(1), attempts.Load()) } // TestInitJob_RequiredFieldsValidation:本地參數驗證(不打網路)。 func TestInitJob_RequiredFieldsValidation(t *testing.T) { t.Parallel() cc := newConverterClientForTest(t, "http://unused") // 缺 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") } // TestNewConverterClient_Panics_When_APIKey_Empty:fail-fast 驗證 — Phase 0.8b // 不允許 server 在「未認證」狀態下啟動,建構式必須立即 panic。 // // 對齊 ADR-015 §3.5.3 部署檢查清單 #1。 func TestNewConverterClient_Panics_When_APIKey_Empty(t *testing.T) { t.Parallel() defer func() { r := recover() require.NotNil(t, r, "APIKey 為空時必須 panic(fail-fast)") err, ok := r.(error) require.True(t, ok, "panic value 應為 error 型別") assert.True(t, errors.Is(err, ErrConverterAPIKeyNotConfigured), "panic 應為 ErrConverterAPIKeyNotConfigured sentinel") }() _ = NewConverterClient(ConverterClientOpts{ BaseURL: "http://example.com", APIKey: "", // empty — 必須觸發 panic Logger: silentLogger(), }) } // ========================================================================== // GetJob tests // ========================================================================== // TestGetJob_Success:標準 happy path(含完整 Job shape 解析)。 func TestGetJob_Success(t *testing.T) { t.Parallel() 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 "+fakeConverterAPIKey, r.Header.Get("Authorization"), "Phase 0.8b:每個 GET 也要直接帶 pre-shared API key") // 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) 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() 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) _, 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() 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) 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() 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) _, 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() 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) 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") } // TestGetJob_AuthFailed401_NoRetry:401 → ErrConverterAuthFailed、不 retry(API key 不對齊)。 func TestGetJob_AuthFailed401_NoRetry(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) _, err := cc.GetJob(context.Background(), "j1") require.Error(t, err) assert.True(t, errors.Is(err, ErrConverterAuthFailed), "Phase 0.8b:GetJob 401 必須 mapping 到 ErrConverterAuthFailed") assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry — API key 不對 retry 100 次也不會自己變對") } // ========================================================================== // Promote tests // ========================================================================== // TestPromote_Success:promote response 含 target_object_key。 func TestPromote_Success(t *testing.T) { t.Parallel() 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) 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() 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) _, 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() 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) _, 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() 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) _, 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() 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) _, 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() cc := newConverterClientForTest(t, "http://unused") _, 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") } // TestPromote_AuthFailed401_NoRetry:401 → ErrConverterAuthFailed、不 retry。 func TestPromote_AuthFailed401_NoRetry(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) _, err := cc.Promote(context.Background(), "j1", PromoteReq{ UserID: "alice", TargetObjectKey: "x", }) require.Error(t, err) assert.True(t, errors.Is(err, ErrConverterAuthFailed), "Phase 0.8b:Promote 401 必須 mapping 到 ErrConverterAuthFailed") assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry") } // ========================================================================== // ListInProgressJobs tests // ========================================================================== // TestListInProgressJobs_Success:query string 含 user_id + status=in_progress。 func TestListInProgressJobs_Success(t *testing.T) { t.Parallel() 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) 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() 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) 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() 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) 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() cc := newConverterClientForTest(t, "http://unused") _, err := cc.ListInProgressJobs(context.Background(), "") require.Error(t, err) assert.Contains(t, err.Error(), "userID is required") } // TestListInProgressJobs_AuthFailed401_NoRetry:401 → ErrConverterAuthFailed、不 retry。 func TestListInProgressJobs_AuthFailed401_NoRetry(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) _, err := cc.ListInProgressJobs(context.Background(), "alice") require.Error(t, err) assert.True(t, errors.Is(err, ErrConverterAuthFailed), "Phase 0.8b:List 401 必須 mapping 到 ErrConverterAuthFailed") assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry") } // ========================================================================== // 共用:interface 契約 + helpers // ========================================================================== // 確保 converterClient 滿足 ConverterClient interface(compile-time check)。 var _ ConverterClient = (*converterClient)(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) }