DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1512 lines
56 KiB
Go
1512 lines
56 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")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// GetResult tests(Phase 0.8b v0.6,ADR-016 §1)
|
||
// ==========================================================================
|
||
|
||
// TestGetResult_Success:mock 接受 GET /api/v1/jobs/{id}/result,回 200 + binary body
|
||
// + Content-Length / Content-Type / Content-Disposition。
|
||
//
|
||
// Phase 0.8b v0.6:驗 visionA → converter 端送 `Authorization: Bearer <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.8b:server 端應收到 pre-shared API key(visionA → converter API key 路徑)")
|
||
assert.Equal(t, "/api/v1/jobs/job-xyz/result", serverPath)
|
||
assert.Equal(t, int64(32), meta.ContentLength)
|
||
assert.Equal(t, "application/octet-stream", meta.ContentType)
|
||
assert.Equal(t, "yolov5s_kl720.nef", meta.Filename)
|
||
|
||
body, err := io.ReadAll(stream)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, wantBody, string(body))
|
||
}
|
||
|
||
// TestGetResult_StreamingBody_NotBuffered:用 8MB body,驗 client 拿到 stream
|
||
// 而不是 ReadAll 進 RAM。
|
||
//
|
||
// 策略:用 countingReader 包 8MB 假 body,count 一次回應裡 reader 被 Read 的次數;
|
||
// 然後在 caller 端只讀前 1KB 就 close,驗 client 端對 body 是 lazy read(剩下的不會被讀完)。
|
||
func TestGetResult_StreamingBody_NotBuffered(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const totalBytes = 8 * 1024 * 1024 // 8MB
|
||
var serverReadCalls int64
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-big/result", func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Length", "8388608")
|
||
w.WriteHeader(http.StatusOK)
|
||
|
||
// 用 countingReader 包來源(驗 server 端 io.Copy 是 streaming)
|
||
src := &countingReader{R: io.LimitReader(zerosReader{}, totalBytes)}
|
||
_, _ = io.Copy(w, src)
|
||
atomic.AddInt64(&serverReadCalls, src.calls)
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
stream, meta, err := cc.GetResult(context.Background(), "job-big")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, stream)
|
||
require.NotNil(t, meta)
|
||
|
||
// caller 只讀前 1KB 就 close — 驗 client 確實是 streaming
|
||
// (非 ReadAll,否則 client 收到 8MB 完整 body 後才回給 caller)
|
||
buf := make([]byte, 1024)
|
||
n, _ := io.ReadFull(stream, buf)
|
||
assert.Equal(t, 1024, n, "caller 應能在 client 還在 streaming 時就拿到部分 byte")
|
||
require.NoError(t, stream.Close())
|
||
|
||
assert.Equal(t, int64(8*1024*1024), meta.ContentLength,
|
||
"meta.ContentLength 應正確反映 server 端 Content-Length(不是 -1)")
|
||
}
|
||
|
||
// TestGetResult_AuthFailed401:mock 回 401 → ErrConverterAuthFailed,不 retry,
|
||
// 且對外 mask 為 converter_unavailable / 502。
|
||
//
|
||
// 對齊 T1 Reviewer Minor #M-3:補 mask 行為驗證(ErrorCode / HTTPStatus);
|
||
// 結構與 TestInitJob_AuthFailed401 對稱。
|
||
func TestGetResult_AuthFailed401(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-401/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
stream, meta, err := cc.GetResult(context.Background(), "job-401")
|
||
|
||
require.Error(t, err)
|
||
assert.Nil(t, stream)
|
||
assert.Nil(t, meta)
|
||
assert.True(t, errors.Is(err, ErrConverterAuthFailed),
|
||
"401 必須 mapping 到 ErrConverterAuthFailed(API key 不對齊運維事件;對外 mask 成 converter_unavailable)")
|
||
assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry")
|
||
// 對外 ErrorCode mask 成 converter_unavailable(不洩漏「API key 不對」內部運維狀態)
|
||
assert.Equal(t, "converter_unavailable", ErrorCode(err),
|
||
"ErrorCode 應 mask 成 converter_unavailable(與 TestInitJob_AuthFailed401 對稱)")
|
||
assert.Equal(t, 502, HTTPStatus(err),
|
||
"HTTPStatus 應為 502 — auth_failed 與『服務不可達』對外同層")
|
||
}
|
||
|
||
// TestGetResult_AuthFailed403:對稱 — mock 回 403 → 同樣 ErrConverterAuthFailed、不 retry,
|
||
// 對外仍 mask 為 converter_unavailable / 502。
|
||
//
|
||
// mapGetResultError 將 401 / 403 mapping 到同一個 sentinel(API key 不對齊運維事件),
|
||
// 此 test 對稱補上 403 case,比照 TestInitJob_AuthFailed403 結構。
|
||
func TestGetResult_AuthFailed403(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-403/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusForbidden)
|
||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
stream, meta, err := cc.GetResult(context.Background(), "job-403")
|
||
|
||
require.Error(t, err)
|
||
assert.Nil(t, stream)
|
||
assert.Nil(t, meta)
|
||
assert.True(t, errors.Is(err, ErrConverterAuthFailed),
|
||
"403 必須與 401 共用 ErrConverterAuthFailed sentinel")
|
||
assert.Equal(t, int32(1), attempts.Load(), "403 不應 retry")
|
||
assert.Equal(t, "converter_unavailable", ErrorCode(err),
|
||
"ErrorCode 對 403 也 mask 成 converter_unavailable")
|
||
assert.Equal(t, 502, HTTPStatus(err))
|
||
}
|
||
|
||
// TestGetResult_NotFound404:mock 回 404 → ErrJobNotFound,不 retry。
|
||
//
|
||
// 對應 ADR-016 §1.3:job_id 不存在 / 已被 GC(converter 端 7d expires_at)。
|
||
// 對外 visionA 端用既有 ErrJobNotFound(i18n key `conversion.error.not_found`,
|
||
// 與 ownership 找不到共用文字「任務不存在」)。
|
||
func TestGetResult_NotFound404(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-404/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusNotFound)
|
||
_, _ = w.Write([]byte(`{"error":"job_not_found"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, _, err := cc.GetResult(context.Background(), "job-404")
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrJobNotFound),
|
||
"404 必須 mapping 到 ErrJobNotFound(job_id 不存在 / 已被 GC)")
|
||
assert.Equal(t, int32(1), attempts.Load(), "404 不應 retry")
|
||
}
|
||
|
||
// TestGetResult_NotCompleted409:mock 回 409 → ErrJobNotCompleted,不 retry。
|
||
//
|
||
// 對應 ADR-016 §1.3:job 尚未 completed(理論上 visionA flow.go ensurePromoted 已先
|
||
// 確認 completed 才打 GetResult,不應發生;保留 mapping 防 converter 端 race)。
|
||
func TestGetResult_NotCompleted409(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-409/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusConflict)
|
||
_, _ = w.Write([]byte(`{"error":"job_not_completed","status":"running"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, _, err := cc.GetResult(context.Background(), "job-409")
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrJobNotCompleted),
|
||
"409 必須 mapping 到 ErrJobNotCompleted(job 尚未 completed)")
|
||
assert.Equal(t, int32(1), attempts.Load(), "409 不應 retry")
|
||
}
|
||
|
||
// TestGetResult_ResultExpired410:mock 回 410 → ErrResultExpired(v0.6 新增 sentinel),不 retry。
|
||
//
|
||
// 對應 ADR-016 §1.3:job completed 但 converter MinIO 內 NEF 已過 7d expires_at 被 GC。
|
||
// 對外 HTTP 410 / code `result_expired`,frontend 顯示「轉檔結果已過期,請重新轉檔」CTA。
|
||
func TestGetResult_ResultExpired410(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-410/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusGone)
|
||
_, _ = w.Write([]byte(`{"error":"result_expired"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, _, err := cc.GetResult(context.Background(), "job-410")
|
||
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrResultExpired),
|
||
"410 必須 mapping 到 ErrResultExpired(NEF 已被 converter MinIO GC)")
|
||
assert.Equal(t, "result_expired", ErrorCode(err),
|
||
"ErrorCode 應回 result_expired(與 converter_unavailable 區分,給 frontend 顯示精確過期訊息)")
|
||
assert.Equal(t, 410, HTTPStatus(err),
|
||
"HTTPStatus 應回 410 Gone(語意:曾經有、現在永遠沒了)")
|
||
assert.Equal(t, int32(1), attempts.Load(), "410 不應 retry(過期不會自己變回來)")
|
||
}
|
||
|
||
// TestGetResult_5xx_RetryThenSuccess:mock 前 1 次回 503、第 2 次 200。
|
||
// 驗 Phase A retry 機制:5xx 可 retry。
|
||
func TestGetResult_5xx_RetryThenSuccess(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
const wantBody = "RECOVERED_NEF"
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-503-then-200/result", func(w http.ResponseWriter, r *http.Request) {
|
||
n := attempts.Add(1)
|
||
if n == 1 {
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
_, _ = w.Write([]byte(`{"error":"storage_unavailable"}`))
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Length", "13")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte(wantBody))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
// 用較短 retry base 加速 test(注入 fake stream client 不適用,因為 transport
|
||
// 行為與 retry base 不同 — 改用 short-circuit ctx 或直接接受 1s 等候)。
|
||
// resultRetryBackoff(1) = 1s;test 接受。
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
stream, meta, err := cc.GetResult(context.Background(), "job-503-then-200")
|
||
require.NoError(t, err)
|
||
require.NotNil(t, stream)
|
||
defer stream.Close()
|
||
|
||
body, err := io.ReadAll(stream)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, wantBody, string(body))
|
||
assert.Equal(t, int64(13), meta.ContentLength)
|
||
assert.Equal(t, int32(2), attempts.Load(), "5xx 應 retry 1 次後成功(共 2 attempts)")
|
||
}
|
||
|
||
// TestGetResult_5xx_Exhausted:mock 一直 503 → 用完 retry 額度後回 ErrConverterUnavailable。
|
||
func TestGetResult_5xx_Exhausted(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-always-503/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
_, _ = w.Write([]byte(`{"error":"storage_unavailable"}`))
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
_, _, err := cc.GetResult(context.Background(), "job-always-503")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, ErrConverterUnavailable),
|
||
"5xx exhausted 必須 mapping 到 ErrConverterUnavailable")
|
||
assert.Equal(t, int32(converterMaxRetriesResult+1), attempts.Load(),
|
||
"應該打滿 max retry 額度(1 + converterMaxRetriesResult = 3 attempts)")
|
||
}
|
||
|
||
// TestGetResult_ContextCancel:開 ctx 然後 cancel → 收 ctx.Err(不 retry)。
|
||
//
|
||
// 場景:server 一開始就 503,但在第 1 次 retry 退避(1s)期間 caller cancel ctx
|
||
// → 應該立即 return ctx.Err(),不再進下一輪 attempt。
|
||
func TestGetResult_ContextCancel(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var attempts atomic.Int32
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/v1/jobs/job-cancel/result", func(w http.ResponseWriter, r *http.Request) {
|
||
attempts.Add(1)
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
})
|
||
srv := httptest.NewServer(mux)
|
||
t.Cleanup(srv.Close)
|
||
|
||
cc := newConverterClientForTest(t, srv.URL)
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
// 100ms 後 cancel — 在第 1 次 503 後、第 1 次 retry 退避(1s)期間觸發
|
||
time.AfterFunc(100*time.Millisecond, cancel)
|
||
|
||
_, _, err := cc.GetResult(ctx, "job-cancel")
|
||
require.Error(t, err)
|
||
assert.True(t, errors.Is(err, context.Canceled),
|
||
"ctx cancel 應立即透傳,不繼續 retry")
|
||
// attempts 可能是 1(第 1 次 503 後在 backoff sleep 收到 cancel)
|
||
assert.LessOrEqual(t, attempts.Load(), int32(2),
|
||
"ctx cancel 後不應繼續 retry 到打滿額度")
|
||
}
|
||
|
||
// TestGetResult_EmptyJobID:jobID 空字串 → 立即 return error,不打網路。
|
||
func TestGetResult_EmptyJobID(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cc := newConverterClientForTest(t, "http://this-should-never-be-called")
|
||
_, _, err := cc.GetResult(context.Background(), "")
|
||
require.Error(t, err)
|
||
assert.Contains(t, err.Error(), "jobID is required")
|
||
}
|
||
|
||
// TestParseFilenameFromContentDisposition:cover parser 的 happy / empty / malformed case。
|
||
//
|
||
// v0.6 T3 s-5 補強(reviewer T1 提的 RFC 5987 encoded form + hostile-input sub-case):
|
||
// - RFC 5987 encoded form(`filename*=UTF-8”...`)— 驗 Go stdlib `mime.ParseMediaType`
|
||
// 對 charset-encoded `filename*` 參數的 transparent 解碼行為
|
||
// - Hostile-input:CRLF injection / path traversal / null byte / extreme length
|
||
//
|
||
// **重要發現**:Go stdlib `mime.ParseMediaType` 對 `filename*=UTF-8”...` 形式
|
||
// **自動 percent-decode** 並寫入 `params["filename"]`(不需 caller 端額外讀 `filename*`)。
|
||
// 即 parser 取回的字串會是 UTF-8 解碼後的值(如 `foo_✓.nef`),而非 raw URL-encoded
|
||
// (`foo_%E2%9C%93.nef`)。**且 RFC 5987 form 優先於 ASCII filename**(當兩者並存時)。
|
||
// 此行為對 visionA 無影響:
|
||
// - flow.DownloadStream 對 filename 不依賴此 parser;用 defaultDownloadFilename(cj) 覆寫
|
||
// - converter 端 ADR-016 §1.2 給的 filename 是 ASCII(`<stem>_<chip>.nef`)、不會走到 RFC 5987 path
|
||
//
|
||
// Hostile-input 防護:mime.ParseMediaType 對 malformed input(CRLF)回 error → parser 回
|
||
// 空字串、不 panic、不洩漏 raw input;visionA-backend handler (api/conversion.go) 對
|
||
// filename 另有 sanitize 層(CRLF / quote 移除),不依賴此 parser 做 sanitize。
|
||
func TestParseFilenameFromContentDisposition(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
tests := []struct {
|
||
name string
|
||
cd string
|
||
want string
|
||
}{
|
||
{"happy_path", `attachment; filename="yolov5s_kl720.nef"`, "yolov5s_kl720.nef"},
|
||
{"no_quotes", `attachment; filename=yolov5s_kl720.nef`, "yolov5s_kl720.nef"},
|
||
{"empty_header", ``, ""},
|
||
{"malformed_no_attachment", `;;;`, ""},
|
||
{"missing_filename_param", `attachment`, ""},
|
||
{"inline_disposition", `inline; filename="foo.bin"`, "foo.bin"},
|
||
// === v0.6 T3 s-5 補強 ===
|
||
// RFC 5987 encoded form — Go stdlib mime.ParseMediaType 自動 percent-decode 為 UTF-8
|
||
// 並寫進 params["filename"](透明行為);對 ASCII-only 值結果就是原字串
|
||
{"rfc5987_utf8_ascii_only", `attachment; filename*=UTF-8''yolov5s_kl720.nef`, "yolov5s_kl720.nef"},
|
||
// RFC 5987 含 lang tag(`UTF-8'en'foo.nef`)— 同上、結果為解碼後 ASCII
|
||
{"rfc5987_with_lang", `attachment; filename*=UTF-8'en'foo.nef`, "foo.nef"},
|
||
// RFC 5987 含 percent-encoded UTF-8(✓ 字元)— stdlib 解碼後回 UTF-8 字串
|
||
// 且 RFC 5987 form **優先於** ASCII fallback(當兩者並存時,stdlib 取 `filename*` 值)
|
||
{"rfc5987_utf8_with_unicode", `attachment; filename="foo.nef"; filename*=UTF-8''foo_%E2%9C%93.nef`, "foo_✓.nef"},
|
||
// Hostile-input 1:CRLF injection(HTTP response splitting 攻擊向量)
|
||
// `mime.ParseMediaType` 對含 CR/LF 的 header 回 error → parser 回空字串
|
||
{"hostile_crlf_injection", "attachment; filename=\"foo.nef\r\nSet-Cookie: evil=1\"", ""},
|
||
// Hostile-input 2:path traversal — parser 端不做 sanitize(responsibility 在 handler);
|
||
// 此處只驗 parser 不 panic、不丟錯,把 raw `../../etc/passwd` 字串原樣傳出(caller 端
|
||
// 後續的 defaultDownloadFilename / handler sanitize 會處理)
|
||
{"hostile_path_traversal", `attachment; filename="../../etc/passwd"`, "../../etc/passwd"},
|
||
// Hostile-input 3:null byte injection — `mime.ParseMediaType` 容忍 null byte
|
||
// (規範未明禁),parser 把 raw 字串傳出(同上、由後續 layer sanitize)
|
||
{"hostile_null_byte", "attachment; filename=\"foo\x00.nef\"", "foo\x00.nef"},
|
||
// Hostile-input 4:extreme length(4KB filename)— parser 不限制長度、回 raw 字串
|
||
// 對 visionA 影響:0(response header 4KB 在 Content-Disposition 內早就被 net/http
|
||
// 拒絕;此 case 純驗 parser 不 OOM / 不 panic)
|
||
{"hostile_extreme_length", `attachment; filename="` + strings.Repeat("A", 4096) + `"`, strings.Repeat("A", 4096)},
|
||
// Hostile-input 5:empty quoted string — 合法格式、回空字串
|
||
{"empty_quoted_filename", `attachment; filename=""`, ""},
|
||
}
|
||
for _, tc := range tests {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
got := parseFilenameFromContentDisposition(tc.cd)
|
||
assert.Equal(t, tc.want, got)
|
||
})
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// B4:analysis_info → ConverterJob metadata 對映(parse 層)
|
||
// ==========================================================================
|
||
|
||
// TestParseConverterJob_AnalysisInfo_InputShape:GET /jobs/{id} response 帶 analysis_info
|
||
// 含明確 input_shape 陣列 → ConverterJob.InputShape / Classes / Framework 正確對映。
|
||
//
|
||
// 對齊 converterJobJSON.AnalysisInfo 假設 schema:input_shape 為 NCHW [1,3,224,224]。
|
||
func TestParseConverterJob_AnalysisInfo_InputShape(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
body := []byte(`{
|
||
"job_id": "j-meta-1",
|
||
"status": "completed",
|
||
"stage": null,
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:05:30Z",
|
||
"input": {"filename": "yolov5s.onnx"},
|
||
"parameters": {"platform": "720"},
|
||
"analysis_info": {
|
||
"input_shape": [1, 3, 224, 224],
|
||
"classes": ["face", "person"],
|
||
"framework": "onnx"
|
||
}
|
||
}`)
|
||
|
||
cj, err := parseConverterJob(body)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, cj)
|
||
assert.Equal(t, []int{1, 3, 224, 224}, cj.InputShape,
|
||
"明確 input_shape 陣列應直接對映(NCHW,對齊 PG INT[] 既有測試 [1,3,224,224])")
|
||
assert.Equal(t, []string{"face", "person"}, cj.Classes)
|
||
assert.Equal(t, "onnx", cj.Framework)
|
||
}
|
||
|
||
// TestParseConverterJob_AnalysisInfo_DimsFallback:analysis_info 沒給明確 input_shape,
|
||
// 但給拆開的 batch_size/channels/height/width 四維 → 組成 [batch, channel, height, width](NCHW)。
|
||
//
|
||
// 對映 bie worker 原生輸出(input_name/batch_size/channels/height/width)。
|
||
func TestParseConverterJob_AnalysisInfo_DimsFallback(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
body := []byte(`{
|
||
"job_id": "j-meta-2",
|
||
"status": "completed",
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:05:30Z",
|
||
"analysis_info": {
|
||
"batch_size": 1,
|
||
"channels": 3,
|
||
"height": 640,
|
||
"width": 480,
|
||
"framework": "tflite"
|
||
}
|
||
}`)
|
||
|
||
cj, err := parseConverterJob(body)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, cj)
|
||
assert.Equal(t, []int{1, 3, 640, 480}, cj.InputShape,
|
||
"拆開的四維應組成 NCHW [batch, channel, height, width]")
|
||
assert.Equal(t, "tflite", cj.Framework)
|
||
assert.Nil(t, cj.Classes, "未給 classes → nil")
|
||
}
|
||
|
||
// TestParseConverterJob_AnalysisInfo_PartialDims_NoGuess:四維只給一部分(缺 width)→
|
||
// 不亂組半套維度,InputShape 留 nil(防呆)。
|
||
func TestParseConverterJob_AnalysisInfo_PartialDims_NoGuess(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
body := []byte(`{
|
||
"job_id": "j-meta-3",
|
||
"status": "completed",
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:05:30Z",
|
||
"analysis_info": {
|
||
"batch_size": 1,
|
||
"channels": 3,
|
||
"height": 224
|
||
}
|
||
}`)
|
||
|
||
cj, err := parseConverterJob(body)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, cj)
|
||
assert.Nil(t, cj.InputShape, "四維缺一(缺 width)→ 不亂組,InputShape 留 nil")
|
||
}
|
||
|
||
// TestParseConverterJob_AnalysisInfo_Missing:完全沒有 analysis_info(轉檔端尚未串好的目前狀態)
|
||
// → metadata 全留零值、不報錯、不 panic。這是 B4 防禦性的核心 case。
|
||
func TestParseConverterJob_AnalysisInfo_Missing(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
body := []byte(`{
|
||
"job_id": "j-meta-4",
|
||
"status": "completed",
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:05:30Z",
|
||
"input": {"filename": "model.onnx"},
|
||
"parameters": {"platform": "520"}
|
||
}`)
|
||
|
||
cj, err := parseConverterJob(body)
|
||
require.NoError(t, err, "缺 analysis_info 不應報錯(轉檔端尚未串好)")
|
||
require.NotNil(t, cj)
|
||
assert.Nil(t, cj.InputShape, "缺 analysis_info → InputShape 留 nil")
|
||
assert.Nil(t, cj.Classes)
|
||
assert.Empty(t, cj.Framework)
|
||
// 其餘欄位照常解析、不受影響
|
||
assert.Equal(t, "completed", cj.Status)
|
||
assert.Equal(t, "model.onnx", cj.SourceFilename)
|
||
}
|
||
|
||
// TestParseConverterJob_AnalysisInfo_EmptyObject:analysis_info 物件存在但所有欄位皆缺
|
||
// → 同樣全零值、不報錯(防禦:轉檔端送了空殼物件)。
|
||
func TestParseConverterJob_AnalysisInfo_EmptyObject(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
body := []byte(`{
|
||
"job_id": "j-meta-5",
|
||
"status": "completed",
|
||
"created_at": "2026-04-25T12:00:00Z",
|
||
"updated_at": "2026-04-25T12:05:30Z",
|
||
"analysis_info": {}
|
||
}`)
|
||
|
||
cj, err := parseConverterJob(body)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, cj)
|
||
assert.Nil(t, cj.InputShape, "analysis_info 空物件 → InputShape 留 nil")
|
||
assert.Nil(t, cj.Classes)
|
||
assert.Empty(t, cj.Framework)
|
||
}
|
||
|
||
// TestConverterAnalysisInfoJSON_ToInputShape_NilReceiver:nil *converterAnalysisInfoJSON
|
||
// 呼叫 toInputShape 不 panic、回 nil(nil-safe 保證)。
|
||
func TestConverterAnalysisInfoJSON_ToInputShape_NilReceiver(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var a *converterAnalysisInfoJSON
|
||
assert.Nil(t, a.toInputShape(), "nil receiver 應回 nil、不 panic")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 共用: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)
|
||
}
|