// 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") } // ========================================================================== // GetResult tests(Phase 0.8b v0.6,ADR-016 §1) // ========================================================================== // TestGetResult_Success:mock 接受 GET /api/v1/jobs/{id}/result,回 200 + binary body // + Content-Length / Content-Type / Content-Disposition。 // // Phase 0.8b v0.6:驗 visionA → converter 端送 `Authorization: Bearer ` + // metadata 正確解出 + body 透傳成功(不被 ReadAll 進 RAM)。 func TestGetResult_Success(t *testing.T) { t.Parallel() const wantBody = "FAKE_NEF_BINARY_CONTENT_FOR_TEST" var serverAuth string var serverPath string mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/job-xyz/result", func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) serverAuth = r.Header.Get("Authorization") serverPath = r.URL.Path w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", "32") w.Header().Set("Content-Disposition", `attachment; filename="yolov5s_kl720.nef"`) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(wantBody)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) stream, meta, err := cc.GetResult(context.Background(), "job-xyz") require.NoError(t, err) require.NotNil(t, stream) require.NotNil(t, meta) defer stream.Close() assert.Equal(t, "Bearer "+fakeConverterAPIKey, serverAuth, "Phase 0.8b:server 端應收到 pre-shared API key(visionA → converter API key 路徑)") assert.Equal(t, "/api/v1/jobs/job-xyz/result", serverPath) assert.Equal(t, int64(32), meta.ContentLength) assert.Equal(t, "application/octet-stream", meta.ContentType) assert.Equal(t, "yolov5s_kl720.nef", meta.Filename) body, err := io.ReadAll(stream) require.NoError(t, err) assert.Equal(t, wantBody, string(body)) } // TestGetResult_StreamingBody_NotBuffered:用 8MB body,驗 client 拿到 stream // 而不是 ReadAll 進 RAM。 // // 策略:用 countingReader 包 8MB 假 body,count 一次回應裡 reader 被 Read 的次數; // 然後在 caller 端只讀前 1KB 就 close,驗 client 端對 body 是 lazy read(剩下的不會被讀完)。 func TestGetResult_StreamingBody_NotBuffered(t *testing.T) { t.Parallel() const totalBytes = 8 * 1024 * 1024 // 8MB var serverReadCalls int64 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/job-big/result", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", "8388608") w.WriteHeader(http.StatusOK) // 用 countingReader 包來源(驗 server 端 io.Copy 是 streaming) src := &countingReader{R: io.LimitReader(zerosReader{}, totalBytes)} _, _ = io.Copy(w, src) atomic.AddInt64(&serverReadCalls, src.calls) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) stream, meta, err := cc.GetResult(context.Background(), "job-big") require.NoError(t, err) require.NotNil(t, stream) require.NotNil(t, meta) // caller 只讀前 1KB 就 close — 驗 client 確實是 streaming // (非 ReadAll,否則 client 收到 8MB 完整 body 後才回給 caller) buf := make([]byte, 1024) n, _ := io.ReadFull(stream, buf) assert.Equal(t, 1024, n, "caller 應能在 client 還在 streaming 時就拿到部分 byte") require.NoError(t, stream.Close()) assert.Equal(t, int64(8*1024*1024), meta.ContentLength, "meta.ContentLength 應正確反映 server 端 Content-Length(不是 -1)") } // TestGetResult_AuthFailed401:mock 回 401 → ErrConverterAuthFailed,不 retry, // 且對外 mask 為 converter_unavailable / 502。 // // 對齊 T1 Reviewer Minor #M-3:補 mask 行為驗證(ErrorCode / HTTPStatus); // 結構與 TestInitJob_AuthFailed401 對稱。 func TestGetResult_AuthFailed401(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/job-401/result", 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) stream, meta, err := cc.GetResult(context.Background(), "job-401") require.Error(t, err) assert.Nil(t, stream) assert.Nil(t, meta) assert.True(t, errors.Is(err, ErrConverterAuthFailed), "401 必須 mapping 到 ErrConverterAuthFailed(API key 不對齊運維事件;對外 mask 成 converter_unavailable)") assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry") // 對外 ErrorCode mask 成 converter_unavailable(不洩漏「API key 不對」內部運維狀態) assert.Equal(t, "converter_unavailable", ErrorCode(err), "ErrorCode 應 mask 成 converter_unavailable(與 TestInitJob_AuthFailed401 對稱)") assert.Equal(t, 502, HTTPStatus(err), "HTTPStatus 應為 502 — auth_failed 與『服務不可達』對外同層") } // TestGetResult_AuthFailed403:對稱 — mock 回 403 → 同樣 ErrConverterAuthFailed、不 retry, // 對外仍 mask 為 converter_unavailable / 502。 // // mapGetResultError 將 401 / 403 mapping 到同一個 sentinel(API key 不對齊運維事件), // 此 test 對稱補上 403 case,比照 TestInitJob_AuthFailed403 結構。 func TestGetResult_AuthFailed403(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/job-403/result", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) stream, meta, err := cc.GetResult(context.Background(), "job-403") require.Error(t, err) assert.Nil(t, stream) assert.Nil(t, meta) assert.True(t, errors.Is(err, ErrConverterAuthFailed), "403 必須與 401 共用 ErrConverterAuthFailed sentinel") assert.Equal(t, int32(1), attempts.Load(), "403 不應 retry") assert.Equal(t, "converter_unavailable", ErrorCode(err), "ErrorCode 對 403 也 mask 成 converter_unavailable") assert.Equal(t, 502, HTTPStatus(err)) } // TestGetResult_NotFound404:mock 回 404 → ErrJobNotFound,不 retry。 // // 對應 ADR-016 §1.3:job_id 不存在 / 已被 GC(converter 端 7d expires_at)。 // 對外 visionA 端用既有 ErrJobNotFound(i18n key `conversion.error.not_found`, // 與 ownership 找不到共用文字「任務不存在」)。 func TestGetResult_NotFound404(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/job-404/result", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"job_not_found"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) _, _, err := cc.GetResult(context.Background(), "job-404") require.Error(t, err) assert.True(t, errors.Is(err, ErrJobNotFound), "404 必須 mapping 到 ErrJobNotFound(job_id 不存在 / 已被 GC)") assert.Equal(t, int32(1), attempts.Load(), "404 不應 retry") } // TestGetResult_NotCompleted409:mock 回 409 → ErrJobNotCompleted,不 retry。 // // 對應 ADR-016 §1.3:job 尚未 completed(理論上 visionA flow.go ensurePromoted 已先 // 確認 completed 才打 GetResult,不應發生;保留 mapping 防 converter 端 race)。 func TestGetResult_NotCompleted409(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/job-409/result", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusConflict) _, _ = w.Write([]byte(`{"error":"job_not_completed","status":"running"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) _, _, err := cc.GetResult(context.Background(), "job-409") require.Error(t, err) assert.True(t, errors.Is(err, ErrJobNotCompleted), "409 必須 mapping 到 ErrJobNotCompleted(job 尚未 completed)") assert.Equal(t, int32(1), attempts.Load(), "409 不應 retry") } // TestGetResult_ResultExpired410:mock 回 410 → ErrResultExpired(v0.6 新增 sentinel),不 retry。 // // 對應 ADR-016 §1.3:job completed 但 converter MinIO 內 NEF 已過 7d expires_at 被 GC。 // 對外 HTTP 410 / code `result_expired`,frontend 顯示「轉檔結果已過期,請重新轉檔」CTA。 func TestGetResult_ResultExpired410(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/job-410/result", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusGone) _, _ = w.Write([]byte(`{"error":"result_expired"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) _, _, err := cc.GetResult(context.Background(), "job-410") require.Error(t, err) assert.True(t, errors.Is(err, ErrResultExpired), "410 必須 mapping 到 ErrResultExpired(NEF 已被 converter MinIO GC)") assert.Equal(t, "result_expired", ErrorCode(err), "ErrorCode 應回 result_expired(與 converter_unavailable 區分,給 frontend 顯示精確過期訊息)") assert.Equal(t, 410, HTTPStatus(err), "HTTPStatus 應回 410 Gone(語意:曾經有、現在永遠沒了)") assert.Equal(t, int32(1), attempts.Load(), "410 不應 retry(過期不會自己變回來)") } // TestGetResult_5xx_RetryThenSuccess:mock 前 1 次回 503、第 2 次 200。 // 驗 Phase A retry 機制:5xx 可 retry。 func TestGetResult_5xx_RetryThenSuccess(t *testing.T) { t.Parallel() var attempts atomic.Int32 const wantBody = "RECOVERED_NEF" mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/job-503-then-200/result", func(w http.ResponseWriter, r *http.Request) { n := attempts.Add(1) if n == 1 { w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"error":"storage_unavailable"}`)) return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", "13") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(wantBody)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) // 用較短 retry base 加速 test(注入 fake stream client 不適用,因為 transport // 行為與 retry base 不同 — 改用 short-circuit ctx 或直接接受 1s 等候)。 // resultRetryBackoff(1) = 1s;test 接受。 cc := newConverterClientForTest(t, srv.URL) stream, meta, err := cc.GetResult(context.Background(), "job-503-then-200") require.NoError(t, err) require.NotNil(t, stream) defer stream.Close() body, err := io.ReadAll(stream) require.NoError(t, err) assert.Equal(t, wantBody, string(body)) assert.Equal(t, int64(13), meta.ContentLength) assert.Equal(t, int32(2), attempts.Load(), "5xx 應 retry 1 次後成功(共 2 attempts)") } // TestGetResult_5xx_Exhausted:mock 一直 503 → 用完 retry 額度後回 ErrConverterUnavailable。 func TestGetResult_5xx_Exhausted(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/job-always-503/result", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"error":"storage_unavailable"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) _, _, err := cc.GetResult(context.Background(), "job-always-503") require.Error(t, err) assert.True(t, errors.Is(err, ErrConverterUnavailable), "5xx exhausted 必須 mapping 到 ErrConverterUnavailable") assert.Equal(t, int32(converterMaxRetriesResult+1), attempts.Load(), "應該打滿 max retry 額度(1 + converterMaxRetriesResult = 3 attempts)") } // TestGetResult_ContextCancel:開 ctx 然後 cancel → 收 ctx.Err(不 retry)。 // // 場景:server 一開始就 503,但在第 1 次 retry 退避(1s)期間 caller cancel ctx // → 應該立即 return ctx.Err(),不再進下一輪 attempt。 func TestGetResult_ContextCancel(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/job-cancel/result", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusServiceUnavailable) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) cc := newConverterClientForTest(t, srv.URL) ctx, cancel := context.WithCancel(context.Background()) // 100ms 後 cancel — 在第 1 次 503 後、第 1 次 retry 退避(1s)期間觸發 time.AfterFunc(100*time.Millisecond, cancel) _, _, err := cc.GetResult(ctx, "job-cancel") require.Error(t, err) assert.True(t, errors.Is(err, context.Canceled), "ctx cancel 應立即透傳,不繼續 retry") // attempts 可能是 1(第 1 次 503 後在 backoff sleep 收到 cancel) assert.LessOrEqual(t, attempts.Load(), int32(2), "ctx cancel 後不應繼續 retry 到打滿額度") } // TestGetResult_EmptyJobID:jobID 空字串 → 立即 return error,不打網路。 func TestGetResult_EmptyJobID(t *testing.T) { t.Parallel() cc := newConverterClientForTest(t, "http://this-should-never-be-called") _, _, err := cc.GetResult(context.Background(), "") require.Error(t, err) assert.Contains(t, err.Error(), "jobID is required") } // TestParseFilenameFromContentDisposition:cover parser 的 happy / empty / malformed case。 // // v0.6 T3 s-5 補強(reviewer T1 提的 RFC 5987 encoded form + hostile-input sub-case): // - RFC 5987 encoded form(`filename*=UTF-8''...`)— 驗 Go stdlib `mime.ParseMediaType` // 對 charset-encoded `filename*` 參數的 transparent 解碼行為 // - Hostile-input:CRLF injection / path traversal / null byte / extreme length // // **重要發現**:Go stdlib `mime.ParseMediaType` 對 `filename*=UTF-8''...` 形式 // **自動 percent-decode** 並寫入 `params["filename"]`(不需 caller 端額外讀 `filename*`)。 // 即 parser 取回的字串會是 UTF-8 解碼後的值(如 `foo_✓.nef`),而非 raw URL-encoded // (`foo_%E2%9C%93.nef`)。**且 RFC 5987 form 優先於 ASCII filename**(當兩者並存時)。 // 此行為對 visionA 無影響: // - flow.DownloadStream 對 filename 不依賴此 parser;用 defaultDownloadFilename(cj) 覆寫 // - converter 端 ADR-016 §1.2 給的 filename 是 ASCII(`_.nef`)、不會走到 RFC 5987 path // // Hostile-input 防護:mime.ParseMediaType 對 malformed input(CRLF)回 error → parser 回 // 空字串、不 panic、不洩漏 raw input;visionA-backend handler (api/conversion.go) 對 // filename 另有 sanitize 層(CRLF / quote 移除),不依賴此 parser 做 sanitize。 func TestParseFilenameFromContentDisposition(t *testing.T) { t.Parallel() tests := []struct { name string cd string want string }{ {"happy_path", `attachment; filename="yolov5s_kl720.nef"`, "yolov5s_kl720.nef"}, {"no_quotes", `attachment; filename=yolov5s_kl720.nef`, "yolov5s_kl720.nef"}, {"empty_header", ``, ""}, {"malformed_no_attachment", `;;;`, ""}, {"missing_filename_param", `attachment`, ""}, {"inline_disposition", `inline; filename="foo.bin"`, "foo.bin"}, // === v0.6 T3 s-5 補強 === // RFC 5987 encoded form — Go stdlib mime.ParseMediaType 自動 percent-decode 為 UTF-8 // 並寫進 params["filename"](透明行為);對 ASCII-only 值結果就是原字串 {"rfc5987_utf8_ascii_only", `attachment; filename*=UTF-8''yolov5s_kl720.nef`, "yolov5s_kl720.nef"}, // RFC 5987 含 lang tag(`UTF-8'en'foo.nef`)— 同上、結果為解碼後 ASCII {"rfc5987_with_lang", `attachment; filename*=UTF-8'en'foo.nef`, "foo.nef"}, // RFC 5987 含 percent-encoded UTF-8(✓ 字元)— stdlib 解碼後回 UTF-8 字串 // 且 RFC 5987 form **優先於** ASCII fallback(當兩者並存時,stdlib 取 `filename*` 值) {"rfc5987_utf8_with_unicode", `attachment; filename="foo.nef"; filename*=UTF-8''foo_%E2%9C%93.nef`, "foo_✓.nef"}, // Hostile-input 1:CRLF injection(HTTP response splitting 攻擊向量) // `mime.ParseMediaType` 對含 CR/LF 的 header 回 error → parser 回空字串 {"hostile_crlf_injection", "attachment; filename=\"foo.nef\r\nSet-Cookie: evil=1\"", ""}, // Hostile-input 2:path traversal — parser 端不做 sanitize(responsibility 在 handler); // 此處只驗 parser 不 panic、不丟錯,把 raw `../../etc/passwd` 字串原樣傳出(caller 端 // 後續的 defaultDownloadFilename / handler sanitize 會處理) {"hostile_path_traversal", `attachment; filename="../../etc/passwd"`, "../../etc/passwd"}, // Hostile-input 3:null byte injection — `mime.ParseMediaType` 容忍 null byte // (規範未明禁),parser 把 raw 字串傳出(同上、由後續 layer sanitize) {"hostile_null_byte", "attachment; filename=\"foo\x00.nef\"", "foo\x00.nef"}, // Hostile-input 4:extreme length(4KB filename)— parser 不限制長度、回 raw 字串 // 對 visionA 影響:0(response header 4KB 在 Content-Disposition 內早就被 net/http // 拒絕;此 case 純驗 parser 不 OOM / 不 panic) {"hostile_extreme_length", `attachment; filename="` + strings.Repeat("A", 4096) + `"`, strings.Repeat("A", 4096)}, // Hostile-input 5:empty quoted string — 合法格式、回空字串 {"empty_quoted_filename", `attachment; filename=""`, ""}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() got := parseFilenameFromContentDisposition(tc.cd) assert.Equal(t, tc.want, got) }) } } // ========================================================================== // 共用: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) }