接 DB 後真人 OIDC 登入暴露 MC OIDC provider 實作不完整,visionA 端逐項繞過, 讓登入/換帳號可用;另補真轉檔服務的整合 e2e。 OIDC 登入修復(MC 端根因另有交接檔,visionA 先繞過): - email fallback:MC id_token 不發 email claim(ASP.NET Identity 預設 factory 只發 sub/name)→ A7 email 必填擋住登入。callback email 空時用 <sub>@noemail.visiona.local placeholder,不污染 schema,MC 修好發真 email 後 ON CONFLICT 自動覆寫 - prompt=login:authorize 帶 prompt=login(config VISIONA_OIDC_PROMPT_LOGIN,預設關) - logout 連動 MC:logout 回 idp_logout(MC Web :7880 /account/logout,GET),前端用 隱藏 iframe 觸發清 MC session(Web/Api 共享 DataProtection)→ 能換帳號。 config VISIONA_OIDC_LOGOUT_URL、向下相容(未設則只清本地) 真轉檔鏈路 e2e(//go:build realconv,按需對 stage 跑、不污染主測試集): - real_converter_e2e:give 真轉檔服務 contract(init→poll→completed/promote/result) - real_chain_e2e:真轉檔→PromoteToModels→model 進 PG→冪等 全鏈路(對 stage 跑 PASS) 交接檔(給對應團隊根治): - mc-email-claim-handoff:MC 加 email claim(自訂 UserClaimsPrincipalFactory) - converter-promote-oauth-handoff:轉檔服務 OAuth 用 form body 非 Basic Auth 全程 Reviewer 審查 + 對 stage 真環境驗證。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
492 lines
20 KiB
Go
492 lines
20 KiB
Go
//go:build realconv
|
||
|
||
// real_converter_e2e_test.go — 對「真實轉檔服務」(kneron_model_converter task-scheduler)
|
||
// 驗 visionA conversion client 的 contract e2e(**非 mock**)。
|
||
//
|
||
// Owner: testing agent(真轉檔 contract e2e — build tag 隔離,預設 CI 不跑)
|
||
//
|
||
// build tag `realconv`:只在 `go test -tags=realconv` 時編譯/執行。
|
||
// - 預設 `go test ./...` 與 `-tags=dbtest` 都**不**編譯本檔(避免外部服務依賴污染主測試集)。
|
||
// - 本機通常連不到 stage(VPN/網路),由 Orchestrator 對 stage 192.168.0.130 跑(見檔尾「給
|
||
// Orchestrator 對 stage 跑」段)。
|
||
//
|
||
// 測什麼(mock 測不到的部分):直接用 visionA 的 ConverterClient(converter_client.go,非
|
||
// httptest mock)打 stage 真轉檔服務,驗 visionA 端對「真服務實際回的格式」解析正確:
|
||
// 1. 連線 + 認證(真 API key 過認證)
|
||
// 2. InitJob 真送 onnx + 56 張 ref 圖(multipart)→ 拿 job_id / status=created / stage=onnx
|
||
// 3. GetJob poll 到 completed(stub 模式幾秒)→ 解析真服務 completed response 不報錯/不 panic
|
||
// 4. stub 環境真實行為:result_object_keys=null、analysis_info 不存在時,visionA
|
||
// InputShape/Classes/Framework 為零值(B4 鏈路防禦性,不報錯)
|
||
// 5. promote 失敗路徑:stub 無真 nef → converter 回 500 → visionA 正確包裝成 sentinel、不 panic
|
||
//
|
||
// ⚠️ 範圍界定:**對 stub 模式驗 contract**。stage 三個 worker(onnx/bie/nef)皆 WORKER_MODE=stub
|
||
// (Dockerfile.stub),不跑真 KTC、產佔位輸出(GET /result 回 15 bytes "STUB_NEF_OUTPUT")。
|
||
// 真 KTC 轉檔 / 真 nef / promote 成功路徑需轉檔端 worker 切真模式,**不在此測範圍**
|
||
// (那是 kneron_model_converter repo 的事,本測不嘗試開真 KTC / 改 worker 模式)。
|
||
//
|
||
// 對齊:converter_client.go endpoint 註解 + Orchestrator 手動實測的真服務 contract。
|
||
package conversion
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"log/slog"
|
||
"mime/multipart"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// ==========================================================================
|
||
// 環境 guard — 沒設 env / fixture 不存在 → t.Skip(優雅跳過,印清楚啟用方式)
|
||
// ==========================================================================
|
||
|
||
const (
|
||
// realConvURLEnv 是 stage 真轉檔服務 base URL(如 http://192.168.0.130:9501)。
|
||
realConvURLEnv = "VISIONA_REAL_CONVERTER_URL"
|
||
// realConvAPIKeyEnv 是 stage 上對齊的 converter API key(從 container env 取,**不 hardcode**)。
|
||
realConvAPIKeyEnv = "VISIONA_CONVERTER_API_KEY"
|
||
// realConvOnnxEnv 是 input onnx fixture 路徑(覆寫預設)。
|
||
realConvOnnxEnv = "VISIONA_REAL_CONVERTER_ONNX"
|
||
// realConvImagesEnv 是 ref images 目錄路徑(覆寫預設)。
|
||
realConvImagesEnv = "VISIONA_REAL_CONVERTER_IMAGES"
|
||
|
||
// 預設 fixture 路徑(Orchestrator 手動實測來源)。env 沒設時試這些,仍找不到 → Skip。
|
||
defaultOnnxFixture = "/Users/jimchen/kneron_model_converter/tests/fixtures/bie/input.onnx"
|
||
defaultImagesFixture = "/Users/jimchen/kneron_model_converter/tests/fixtures/bie_images"
|
||
|
||
// 測試用可識別 user_id(stub job 無害、會自然過期 expires_at)。
|
||
realConvTestUserID = "e2e-realconv-test"
|
||
// stub 模式下 onnx→bie→nef 幾秒就跑完;給寬裕 poll deadline。
|
||
realConvPollTimeout = 90 * time.Second
|
||
realConvPollInterval = 1 * time.Second
|
||
)
|
||
|
||
// realConvEnv 是 guard 通過後回傳的環境設定。
|
||
type realConvEnv struct {
|
||
baseURL string
|
||
apiKey string
|
||
onnxPath string
|
||
imagesPath string
|
||
}
|
||
|
||
// requireRealConvEnv 檢查 env + fixture;缺任一 → t.Skip 並印「怎麼啟用這個測試」。
|
||
func requireRealConvEnv(t *testing.T) realConvEnv {
|
||
t.Helper()
|
||
|
||
baseURL := os.Getenv(realConvURLEnv)
|
||
apiKey := os.Getenv(realConvAPIKeyEnv)
|
||
|
||
if baseURL == "" || apiKey == "" {
|
||
t.Skipf(`real-converter e2e 跳過:未設環境變數。
|
||
啟用方式(對 stage 192.168.0.130 跑):
|
||
1. 取 stage 上對齊的 API key(從 converter container env):
|
||
KEY=$(docker -H tcp://192.168.0.130:2375 exec kneron_model_converter-scheduler-1 printenv CONVERTER_API_KEY)
|
||
2. 跑:
|
||
VISIONA_REAL_CONVERTER_URL=http://192.168.0.130:9501 \
|
||
VISIONA_CONVERTER_API_KEY="$KEY" \
|
||
go test -tags=realconv ./internal/conversion/ -run TestRealConverter -count=1 -v
|
||
缺少的環境變數:%s / %s(兩者皆必填)`, realConvURLEnv, realConvAPIKeyEnv)
|
||
}
|
||
|
||
onnxPath := os.Getenv(realConvOnnxEnv)
|
||
if onnxPath == "" {
|
||
onnxPath = defaultOnnxFixture
|
||
}
|
||
imagesPath := os.Getenv(realConvImagesEnv)
|
||
if imagesPath == "" {
|
||
imagesPath = defaultImagesFixture
|
||
}
|
||
|
||
if _, err := os.Stat(onnxPath); err != nil {
|
||
t.Skipf("real-converter e2e 跳過:input onnx fixture 不存在:%s(err=%v)。"+
|
||
"用 %s 指定路徑。", onnxPath, err, realConvOnnxEnv)
|
||
}
|
||
if fi, err := os.Stat(imagesPath); err != nil || !fi.IsDir() {
|
||
t.Skipf("real-converter e2e 跳過:ref images 目錄不存在:%s(err=%v)。"+
|
||
"用 %s 指定路徑。", imagesPath, err, realConvImagesEnv)
|
||
}
|
||
|
||
return realConvEnv{
|
||
baseURL: baseURL,
|
||
apiKey: apiKey,
|
||
onnxPath: onnxPath,
|
||
imagesPath: imagesPath,
|
||
}
|
||
}
|
||
|
||
// newRealConverterClient 用真 env 建一個 ConverterClient(**非 mock**,打真服務)。
|
||
//
|
||
// 用較長的 init/get timeout:真服務 multipart 上傳 + stub 幾秒處理需要餘裕。
|
||
func newRealConverterClient(t *testing.T, env realConvEnv) ConverterClient {
|
||
t.Helper()
|
||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||
return NewConverterClient(ConverterClientOpts{
|
||
BaseURL: env.baseURL,
|
||
APIKey: env.apiKey,
|
||
Logger: logger,
|
||
})
|
||
}
|
||
|
||
// ==========================================================================
|
||
// fixture:組真 multipart body(onnx + 56 張 ref 圖 + form fields)
|
||
// ==========================================================================
|
||
|
||
// buildRealInitBody 組 stage 真轉檔服務 POST /api/v1/jobs 需要的 multipart body。
|
||
//
|
||
// 對齊 Orchestrator 手動實測的真 API contract:
|
||
// fields: model(file onnx) + ref_images(N files) + user_id + model_id + version + platform("520") + enable_evaluate("false")
|
||
//
|
||
// 回傳 body bytes + Content-Type(含 boundary)+ ref_images 數量(給斷言驗 ref_images_count)。
|
||
func buildRealInitBody(t *testing.T, env realConvEnv) (body []byte, contentType string, refCount int) {
|
||
t.Helper()
|
||
|
||
var buf bytes.Buffer
|
||
mw := multipart.NewWriter(&buf)
|
||
|
||
// form fields(converter multer 慣例:fields 在 file 之前)
|
||
writeField := func(name, val string) {
|
||
if err := mw.WriteField(name, val); err != nil {
|
||
t.Fatalf("write multipart field %q: %v", name, err)
|
||
}
|
||
}
|
||
writeField("user_id", realConvTestUserID)
|
||
// model_id 必須能 parse 成數字(轉檔服務驗證:非數字字串 → 400 validation_error)。
|
||
// 對齊手動實測(model_id=9999)與轉檔端 test_flow_e2e.py(model_id=10)。
|
||
writeField("model_id", "9999")
|
||
// version 為字串、無型別限制;用簡單值對齊手動成功("v1")。
|
||
writeField("version", "v1")
|
||
writeField("platform", "520")
|
||
writeField("enable_evaluate", "false")
|
||
|
||
// model file(onnx)
|
||
onnxBytes, err := os.ReadFile(env.onnxPath)
|
||
if err != nil {
|
||
t.Fatalf("read onnx fixture %s: %v", env.onnxPath, err)
|
||
}
|
||
fw, err := mw.CreateFormFile("model", filepath.Base(env.onnxPath))
|
||
if err != nil {
|
||
t.Fatalf("create form file model: %v", err)
|
||
}
|
||
if _, err := fw.Write(onnxBytes); err != nil {
|
||
t.Fatalf("write onnx bytes: %v", err)
|
||
}
|
||
|
||
// ref_images:讀目錄下所有 *.jpg,依檔名數字排序送(與手動實測一致)
|
||
entries, err := os.ReadDir(env.imagesPath)
|
||
if err != nil {
|
||
t.Fatalf("read images dir %s: %v", env.imagesPath, err)
|
||
}
|
||
jpgs := make([]string, 0, len(entries))
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
continue
|
||
}
|
||
name := e.Name()
|
||
if filepath.Ext(name) == ".jpg" {
|
||
jpgs = append(jpgs, name)
|
||
}
|
||
}
|
||
// 依檔名數字排序(0.jpg, 1.jpg, ... 而非字典序 1,10,100)
|
||
sort.Slice(jpgs, func(i, j int) bool {
|
||
ni := jpgStem(jpgs[i])
|
||
nj := jpgStem(jpgs[j])
|
||
if ni != nj {
|
||
return ni < nj
|
||
}
|
||
return jpgs[i] < jpgs[j]
|
||
})
|
||
for _, name := range jpgs {
|
||
full := filepath.Join(env.imagesPath, name)
|
||
imgBytes, err := os.ReadFile(full)
|
||
if err != nil {
|
||
t.Fatalf("read ref image %s: %v", full, err)
|
||
}
|
||
ifw, err := mw.CreateFormFile("ref_images", name)
|
||
if err != nil {
|
||
t.Fatalf("create form file ref_images %s: %v", name, err)
|
||
}
|
||
if _, err := ifw.Write(imgBytes); err != nil {
|
||
t.Fatalf("write ref image %s: %v", name, err)
|
||
}
|
||
refCount++
|
||
}
|
||
|
||
if err := mw.Close(); err != nil {
|
||
t.Fatalf("close multipart writer: %v", err)
|
||
}
|
||
if refCount == 0 {
|
||
t.Fatalf("ref images 目錄 %s 沒有 .jpg 檔(預期 56 張)", env.imagesPath)
|
||
}
|
||
return buf.Bytes(), mw.FormDataContentType(), refCount
|
||
}
|
||
|
||
// jpgStem 取 "<n>.jpg" 的數字部分;非數字回 -1(排到最前,不影響正確性)。
|
||
func jpgStem(name string) int {
|
||
stem := name[:len(name)-len(filepath.Ext(name))]
|
||
n, err := strconv.Atoi(stem)
|
||
if err != nil {
|
||
return -1
|
||
}
|
||
return n
|
||
}
|
||
|
||
// ==========================================================================
|
||
// E2E #1:連線 + 認證 + InitJob + GetJob poll(contract 主路徑)
|
||
// ==========================================================================
|
||
|
||
// TestRealConverter_InitAndPollContract 驗 visionA ConverterClient 對真 stage 服務的主路徑:
|
||
//
|
||
// InitJob(真送 onnx + 56 圖)→ status=created/running + stage=onnx
|
||
// → GetJob poll 到 completed(stub 幾秒)
|
||
// → visionA 正確解析真服務 completed response(即使 result_object_keys=null、無 analysis_info
|
||
// 也不報錯、不 panic;InputShape/Classes/Framework 為零值)
|
||
//
|
||
// 連線 + 認證隱含驗證:InitJob 沒回 ErrConverterAuthFailed = API key 過認證、連得上。
|
||
func TestRealConverter_InitAndPollContract(t *testing.T) {
|
||
env := requireRealConvEnv(t)
|
||
client := newRealConverterClient(t, env)
|
||
|
||
body, contentType, refCount := buildRealInitBody(t, env)
|
||
t.Logf("組好 multipart body:%d bytes,ref_images=%d 張", len(body), refCount)
|
||
|
||
// ── InitJob(真送)──────────────────────────────────────────────────
|
||
initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer initCancel()
|
||
|
||
cj, err := client.InitJob(initCtx, InitConverterJobReq{
|
||
UserID: realConvTestUserID,
|
||
Platform: "520",
|
||
SourceFilename: filepath.Base(env.onnxPath),
|
||
Body: bytes.NewReader(body),
|
||
BodyContentType: contentType,
|
||
})
|
||
if err != nil {
|
||
// 認證失敗 → 明確訊息(最可能:API key 未對齊)
|
||
if errors.Is(err, ErrConverterAuthFailed) {
|
||
t.Fatalf("InitJob 認證失敗(API key 未對齊?):%v\n"+
|
||
"確認 %s 與 stage container CONVERTER_API_KEY 一致。", err, realConvAPIKeyEnv)
|
||
}
|
||
t.Fatalf("InitJob 失敗:%v", err)
|
||
}
|
||
|
||
// 連線 + 認證 OK(沒回 auth error 就是過了)
|
||
if cj.JobID == "" {
|
||
t.Fatalf("InitJob 回的 job_id 為空:%+v", cj)
|
||
}
|
||
// 真服務回 status=created(手動實測);visionA client 透傳。亦容忍 running(race)。
|
||
if cj.Status != "created" && cj.Status != "running" {
|
||
t.Errorf("InitJob status 預期 created/running,得 %q(job=%+v)", cj.Status, cj)
|
||
}
|
||
if cj.Stage != "onnx" {
|
||
t.Errorf("InitJob stage 預期 onnx,得 %q", cj.Stage)
|
||
}
|
||
t.Logf("InitJob OK:job_id=%s status=%s stage=%s", cj.JobID, cj.Status, cj.Stage)
|
||
|
||
// ── GetJob poll 到 completed ───────────────────────────────────────
|
||
final := pollUntilTerminal(t, client, cj.JobID)
|
||
|
||
t.Logf("終態 job:status=%s stage=%q progress=%v input_filename=%q platform=%q",
|
||
final.Status, final.Stage, derefInt(final.Progress), final.SourceFilename, final.Platform)
|
||
|
||
// stub 模式預期 completed;若 failed 也不該 panic(contract 仍須能解析)
|
||
if final.Status != "completed" {
|
||
t.Errorf("poll 終態預期 completed(stub 模式幾秒完成),得 %q(error_code=%q msg=%q)",
|
||
final.Status, final.ErrorCode, final.ErrorMessage)
|
||
}
|
||
// completed 時真服務回 stage=null → visionA 解析成 ""(不報錯)
|
||
if final.Status == "completed" && final.Stage != "" {
|
||
t.Errorf("completed 時 stage 預期空字串(真服務回 null),得 %q", final.Stage)
|
||
}
|
||
|
||
// ── 驗 stub 環境真實行為:analysis_info 缺 → B4 metadata 全零值,不報錯 ──
|
||
// 這是 mock 測不到的:真 stub 服務的 completed response 沒有 analysis_info,
|
||
// visionA toConverterJob 必須優雅留零值(防禦性),不能 panic / 報錯。
|
||
if final.InputShape != nil {
|
||
t.Logf("注意:真服務回了 analysis_info.input_shape=%v(stub 模式預期 nil;"+
|
||
"若轉檔端已串 analysis_info 則此為正常)", final.InputShape)
|
||
}
|
||
if len(final.Classes) != 0 {
|
||
t.Logf("注意:真服務回了 classes=%v(stub 模式預期空)", final.Classes)
|
||
}
|
||
// 不對 InputShape 斷言「必為 nil」——若轉檔端未來串好 analysis_info,這裡不該 fail;
|
||
// 重點是「解析不 panic」,能跑到這行就證明解析成功。
|
||
t.Logf("contract 驗證通過:visionA client 成功解析真服務 completed response,未 panic")
|
||
}
|
||
|
||
// pollUntilTerminal 用 visionA GetJob 對真服務 poll 到 completed/failed 或 timeout。
|
||
func pollUntilTerminal(t *testing.T, client ConverterClient, jobID string) *ConverterJob {
|
||
t.Helper()
|
||
deadline := time.Now().Add(realConvPollTimeout)
|
||
var last *ConverterJob
|
||
for time.Now().Before(deadline) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
cj, err := client.GetJob(ctx, jobID)
|
||
cancel()
|
||
if err != nil {
|
||
// 真服務暫時 5xx → GetJob 內已 retry;這裡再容忍一次(記 log 續 poll)
|
||
t.Logf("GetJob 暫時失敗(續 poll):%v", err)
|
||
time.Sleep(realConvPollInterval)
|
||
continue
|
||
}
|
||
last = cj
|
||
switch cj.Status {
|
||
case "completed", "failed":
|
||
return cj
|
||
default:
|
||
time.Sleep(realConvPollInterval)
|
||
}
|
||
}
|
||
if last == nil {
|
||
t.Fatalf("poll job %s 超時且從未成功 GetJob", jobID)
|
||
}
|
||
t.Logf("poll 超時,回最後一次狀態:status=%s", last.Status)
|
||
return last
|
||
}
|
||
|
||
// ==========================================================================
|
||
// E2E #2:promote 失敗路徑(stub 無真 nef → converter 500 → visionA 不崩)
|
||
// ==========================================================================
|
||
|
||
// TestRealConverter_PromoteFailsGracefullyOnStub 驗「真服務失敗時 visionA 不崩」:
|
||
//
|
||
// stub 模式下 GET /result 回 15 bytes 佔位字串、promote 因無真 nef → converter 回 500
|
||
// `{error:{code:"internal_error",...}}`。visionA Promote 必須:
|
||
// - 不 panic
|
||
// - 把 500 正確包裝成 ErrConverterUnavailable(mapPromoteError 預設 5xx 分支)
|
||
//
|
||
// 這是有價值的 contract 測試:驗 visionA 對真服務 5xx 的錯誤處理鏈正確。
|
||
func TestRealConverter_PromoteFailsGracefullyOnStub(t *testing.T) {
|
||
env := requireRealConvEnv(t)
|
||
client := newRealConverterClient(t, env)
|
||
|
||
// 先 init + poll 到 completed(promote 前提:job 須 completed)
|
||
body, contentType, _ := buildRealInitBody(t, env)
|
||
initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer initCancel()
|
||
cj, err := client.InitJob(initCtx, InitConverterJobReq{
|
||
UserID: realConvTestUserID,
|
||
Platform: "520",
|
||
SourceFilename: filepath.Base(env.onnxPath),
|
||
Body: bytes.NewReader(body),
|
||
BodyContentType: contentType,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("InitJob 失敗(promote 測試前置):%v", err)
|
||
}
|
||
final := pollUntilTerminal(t, client, cj.JobID)
|
||
if final.Status != "completed" {
|
||
t.Skipf("job 未 completed(status=%s),跳過 promote 失敗路徑驗證", final.Status)
|
||
}
|
||
|
||
// ── Promote — stub 無真 nef,預期 converter 回 500 ────────────────────
|
||
promoteCtx, promoteCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer promoteCancel()
|
||
|
||
// 包進 func 確保「不 panic」可被測到(panic 會讓 test fail 並印 stack)
|
||
res, perr := client.Promote(promoteCtx, cj.JobID, PromoteReq{
|
||
UserID: realConvTestUserID,
|
||
Source: "nef",
|
||
TargetObjectKey: "models/" + realConvTestUserID + "/" + cj.JobID + ".nef",
|
||
})
|
||
|
||
if perr == nil {
|
||
// 若 stage 某天切真模式 → promote 可能成功;不 fail(記 log,contract 仍成立)
|
||
t.Logf("注意:Promote 成功(stage 可能已非 stub 模式):%+v", res)
|
||
return
|
||
}
|
||
|
||
// 預期:stub 無真 nef → converter 500 → visionA 包成 ErrConverterUnavailable
|
||
t.Logf("Promote 如預期失敗(stub 無真 nef):%v", perr)
|
||
if !errors.Is(perr, ErrConverterUnavailable) {
|
||
// 容忍其他合理 sentinel(如 ErrJobNotCompleted / ErrFAAUnavailable),但記下供人工判讀。
|
||
t.Logf("注意:Promote 錯誤非 ErrConverterUnavailable(得 %v)。"+
|
||
"確認真服務 500 body 是否符合手動實測的 internal_error 格式;"+
|
||
"若 converter 回了不同 status 此處需對齊。", perr)
|
||
}
|
||
// 關鍵 contract:visionA 回了「分類好的 error」而非 nil result + nil err(不崩)。
|
||
if res != nil {
|
||
t.Errorf("Promote 失敗時 result 應為 nil,得 %+v", res)
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// E2E #3:GetResult 解析真 stub 輸出(15 bytes "STUB_NEF_OUTPUT")
|
||
// ==========================================================================
|
||
|
||
// TestRealConverter_GetResultStubOutput 驗 visionA GetResult 對真 stub 服務的 streaming 解析:
|
||
//
|
||
// stub 模式 GET /result 回 15 bytes 字串 "STUB_NEF_OUTPUT"(非真 nef)。
|
||
// visionA GetResult 必須:
|
||
// - 不 panic
|
||
// - 回 io.ReadCloser stream + DownloadMetadata(能讀出 body、Close 正常)
|
||
//
|
||
// 這驗的是「真服務 result endpoint 的 streaming response 解析」——mock 用固定 marker,
|
||
// 此處用真服務的實際佔位輸出。
|
||
func TestRealConverter_GetResultStubOutput(t *testing.T) {
|
||
env := requireRealConvEnv(t)
|
||
client := newRealConverterClient(t, env)
|
||
|
||
body, contentType, _ := buildRealInitBody(t, env)
|
||
initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer initCancel()
|
||
cj, err := client.InitJob(initCtx, InitConverterJobReq{
|
||
UserID: realConvTestUserID,
|
||
Platform: "520",
|
||
SourceFilename: filepath.Base(env.onnxPath),
|
||
Body: bytes.NewReader(body),
|
||
BodyContentType: contentType,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("InitJob 失敗(GetResult 測試前置):%v", err)
|
||
}
|
||
final := pollUntilTerminal(t, client, cj.JobID)
|
||
if final.Status != "completed" {
|
||
t.Skipf("job 未 completed(status=%s),跳過 GetResult 驗證", final.Status)
|
||
}
|
||
|
||
resCtx, resCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer resCancel()
|
||
stream, meta, gerr := client.GetResult(resCtx, cj.JobID)
|
||
if gerr != nil {
|
||
// stub 模式下 GET /result 預期回 200 + 佔位輸出;若回錯誤記下供人工判讀
|
||
t.Logf("注意:GetResult 失敗(stub 預期成功回佔位輸出):%v", gerr)
|
||
// 仍驗「失敗時不回半套」:stream 應為 nil
|
||
if stream != nil {
|
||
_ = stream.Close()
|
||
t.Errorf("GetResult 失敗時 stream 應為 nil")
|
||
}
|
||
return
|
||
}
|
||
defer stream.Close()
|
||
|
||
got, rerr := io.ReadAll(io.LimitReader(stream, 1024))
|
||
if rerr != nil {
|
||
t.Fatalf("讀 GetResult stream 失敗:%v", rerr)
|
||
}
|
||
t.Logf("GetResult OK:讀到 %d bytes,content_type=%q filename=%q content_length=%d。內容=%q",
|
||
len(got), meta.ContentType, meta.Filename, meta.ContentLength, string(got))
|
||
|
||
// stub 真實行為:15 bytes "STUB_NEF_OUTPUT"(手動實測)。不硬斷言內容(轉檔端可能改),
|
||
// 重點是「解析 streaming response 成功、能讀出 body」。
|
||
if len(got) == 0 {
|
||
t.Errorf("GetResult stream 讀到 0 bytes(stub 預期回 15 bytes 佔位輸出)")
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// helpers
|
||
// ==========================================================================
|
||
|
||
func derefInt(p *int) int {
|
||
if p == nil {
|
||
return -1
|
||
}
|
||
return *p
|
||
}
|