//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 取 ".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 }