visionA/visionA-backend/internal/conversion/converter_client_test.go
jim800121chen ce6a657df4 feat(visionA-backend): Phase 0.8b v0.6 對齊 — T1+T2 download 改走 converter.GetResult
對齊 ADR-016:visionA download 不再經 FAA delegated token、改用 converter GET /api/v1/jobs/{id}/result 中轉。

T1 — converter_client.go 加 GetResult method:
- 新增 GetResult(ctx, jobID) (io.ReadCloser, *DownloadMetadata, error)
- 新增 ErrResultExpired sentinel + ErrorCode("result_expired") + HTTPStatus 410 mapping
- 獨立 StreamHTTPClient (無 timeout / dial+response header timeout) 給 streaming 大檔
- doStreamWithRetry / doStreamOnce / mapGetResultError / resultRetryBackoff helpers
- parseFilenameFromContentDisposition (RFC 5987 quoted/unquoted/encoded)
- 9 個 GetResult test + 6 個 parseFilename sub-test
- Reviewer 0 Critical / 0 Major / 3 Minor (M-1/M-2/M-3 全部 T2 順手修)

T2 — flow.go + e2e 改造:
- DownloadStream / PromoteToModels 移除 f.faa.GetFile(...) 改 f.converter.GetResult(ctx, jobID)
- filename 仍由 defaultDownloadFilename(cj) 覆寫 (visionA source-of-truth)
- 8 個 flow_test 既有 test 改寫 + 2 個改名 (FAA → Converter) + 2 個 410 透傳 test 新增
- e2e mock converter 加 GET /api/v1/jobs/{id}/result endpoint + 3 helper + 6 斷言更新 (含 negative: FAA 0 命中 / converter /result ≥1 命中)
- T1 reviewer 3 個 Minor 全處理 (mapGetResultError 設計取捨 godoc / 指數退避→線性退避 / 401+403 mask 驗證)
- 保留 faa FAAClient 欄位 + FlowOpts.FAA 必填 (T3 才砍 faa_client.go 整檔)

T2 修補 (architect + backend 平行):
- M-1 conversion.go Service interface DownloadStream/PromoteToModels godoc 對齊 v0.6 (從 flow.go layer 搬上來)
- M-2 conversion.md v0.6 → v0.6.1 — §2.5 ensurePromoted cache 描述「sync.Map cache」改為「Phase 0.8 簡化 (不實作 cache)」+ 4 簡化理由 + 3 Phase 1+ 升級選項 (in-memory / DB / model store 推論);連動修改 line 169 / 300 / 1187 cross-reference
- 3 Minor + 2 Suggestion 順手做 (resultRetryBaseDelay godoc / fixture 註解過渡狀態 / e2e route table 4→5 / flow.go struct T3 預期清單 / e2e negative assertion 強化)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸 (v0.6-t1-review + v0.6-t2-review + v0.6-t2-fix-review) 全  通過

對齊 ADR-016 §1 / conversion.md v0.6.1 §2.5 §4.1 / api-conversion.md v0.6 §4 / oidc-tdd.md v0.4 §13.1.3

下一步:
- T3 砍 faa_client.go + faa_client_test.go + 對應 ErrFAA* sentinel (B 層強制跑 / s-3/s-4/s-5 必補)
- T4 砍 ConversionConfig FAA* 欄位 + main.go wire 點 + .env*.example
- T5 main.go wire 點全切 + e2e regression 防護

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:09:20 +08:00

1327 lines
48 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")
}
// ==========================================================================
// GetResult testsPhase 0.8b v0.6ADR-016 §1
// ==========================================================================
// TestGetResult_Successmock 接受 GET /api/v1/jobs/{id}/result回 200 + binary body
// + Content-Length / Content-Type / Content-Disposition。
//
// Phase 0.8b v0.6:驗 visionA → converter 端送 `Authorization: Bearer <fakeKey>` +
// 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.8bserver 端應收到 pre-shared API keyvisionA → 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 假 bodycount 一次回應裡 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_AuthFailed401mock 回 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 到 ErrConverterAuthFailedAPI 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 到同一個 sentinelAPI 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_NotFound404mock 回 404 → ErrJobNotFound不 retry。
//
// 對應 ADR-016 §1.3job_id 不存在 / 已被 GCconverter 端 7d expires_at
// 對外 visionA 端用既有 ErrJobNotFoundi18n 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 到 ErrJobNotFoundjob_id 不存在 / 已被 GC")
assert.Equal(t, int32(1), attempts.Load(), "404 不應 retry")
}
// TestGetResult_NotCompleted409mock 回 409 → ErrJobNotCompleted不 retry。
//
// 對應 ADR-016 §1.3job 尚未 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 到 ErrJobNotCompletedjob 尚未 completed")
assert.Equal(t, int32(1), attempts.Load(), "409 不應 retry")
}
// TestGetResult_ResultExpired410mock 回 410 → ErrResultExpiredv0.6 新增 sentinel不 retry。
//
// 對應 ADR-016 §1.3job 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 到 ErrResultExpiredNEF 已被 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_RetryThenSuccessmock 前 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) = 1stest 接受。
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_Exhaustedmock 一直 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_EmptyJobIDjobID 空字串 → 立即 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")
}
// TestParseFilenameFromContentDispositioncover parser 的 happy / empty / malformed case。
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"},
}
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 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)
}