visionA/visionA-backend/internal/conversion/converter_client_test.go
jim800121chen 86b7175649 feat(visionA-backend): Phase 0.8b 步驟 2 — visionA → converter / FAA 改 API key 認證
對齊 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>
2026-05-15 09:45:45 +08:00

957 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

// Converter Client 單元測試。
//
// 測試策略:
// - 用 httptest.Server mock task-scheduler 的 4 個 endpoint
// - **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
// - InitJobSuccess / StreamingBody / ContentTypeHeader / Conflict409 / Validation400 / 5xx_NoRetry / AuthFailed401 / AuthFailed403
// - GetJobSuccess / NotFound / 5xx_RetryThenSuccess / AuthFailed401_NoRetry
// - PromoteSuccess / BadGateway / AuthFailed401_NoRetry
// - ListSuccess / Empty / 5xxRetry / AuthFailed401_NoRetry
// - ConstructorPanics_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.8bfake API key fixtures
// ==========================================================================
//
// 取明顯 fake 字串、含 `do-not-use-in-prod` markergrepable避免被誤當真 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 加速 testretry 退避保持原本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_Successmock 接受 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 含 boundaryconverter multer 解析依賴此)")
assert.Equal(t, "Bearer "+fakeConverterAPIKey, serverAuth,
"Phase 0.8b:必須直接帶 pre-shared API key不經 MC token cache")
}
// TestInitJob_StreamingBodydriver 寫 100MB 假資料給 io.Readerconfirm streaming不全 buffer RAM
//
// 用 io.LimitReader 包一個無限 readerserver side 也用 io.Discard 不存。
// 觀察peakReadBytes 不應接近 100MB確認 net/http 真的是 streaming— 但 peak 偵測在 Go 層級不易,
// 改驗reader 的 ReadCalls 數應遠大於 1如果 buffer 全進 RAMnet/http 會一次全讀)。
func TestInitJob_StreamingBody(t *testing.T) {
t.Parallel()
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 應該收到完整 bodystreaming proxy 不掉資料)")
// streaming 證據reader 應被多次呼叫 Read如果是 buffer 全 RAM 模式,會一次大讀)
calls := atomic.LoadInt64(&reader.calls)
assert.Greater(t, calls, int64(1), "streaming 必須多次 Read不能一次性 buffer 全 RAM")
}
// TestInitJob_ContentTypeHeadermultipart boundary 必須完整透傳。
func TestInitJob_ContentTypeHeader(t *testing.T) {
t.Parallel()
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_ActiveJobErrormock 回 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_Validation400mock 回 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_NoRetrymock 連續 500 → InitJob 不 retry立即 return。
//
// 設計理由multipart body 是 streamingio.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 5xxstreaming body 不可 replay")
}
// TestInitJob_AuthFailed401mock 回 401 → ErrConverterAuthFailedPhase 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.8b401 必須 mapping 到新 sentinel ErrConverterAuthFailed")
// Phase 0.8b T3舊 sentinel ErrServiceClientUnauthorized 已移除,
// 改由 ErrConverterAuthFailed 接管 401/403 mapping。
assert.Equal(t, int32(1), attempts.Load(),
"401 不應 retryAPI 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_Emptyfail-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 為空時必須 panicfail-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_NotFound404 → 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_RetryThenSuccess500/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_NoRetryctx 在 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_NoRetry401 → ErrConverterAuthFailed、不 retryAPI 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.8bGetJob 401 必須 mapping 到 ErrConverterAuthFailed")
assert.Equal(t, int32(1), attempts.Load(),
"401 不應 retry — API key 不對 retry 100 次也不會自己變對")
}
// ==========================================================================
// Promote tests
// ==========================================================================
// TestPromote_Successpromote 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 metadatatrust boundary 重申)")
assert.Contains(t, receivedBody, `"target_object_key":"visionA/models/alice/m-1001/v1.0.0/out.nef"`)
}
// TestPromote_DefaultSource未傳 Source 時預設 nef。
func TestPromote_DefaultSource(t *testing.T) {
t.Parallel()
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_BadGatewayFAA 不可達 → 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_NotCompleted409job_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_NotFound404404 → 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_NoRetry401 → 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.8bPromote 401 必須 mapping 到 ErrConverterAuthFailed")
assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry")
}
// ==========================================================================
// ListInProgressJobs tests
// ==========================================================================
// TestListInProgressJobs_Successquery 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_5xxRetry5xx 後成功;驗 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_NoRetry401 → 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.8bList 401 必須 mapping 到 ErrConverterAuthFailed")
assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry")
}
// ==========================================================================
// 共用interface 契約 + helpers
// ==========================================================================
// 確保 converterClient 滿足 ConverterClient interfacecompile-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)
}