對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。
實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):
T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清
T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)
T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go
T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test
T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)
驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)
不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動
下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
957 lines
34 KiB
Go
957 lines
34 KiB
Go
// 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 <fakeAPIKey>`
|
||
// - 用 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 <fakeConverterAPIKey>`(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)
|
||
}
|