visionA/visionA-backend/internal/conversion/real_converter_e2e_test.go
jim800121chen c2f0b1549e feat: OIDC 登入修復(email fallback / prompt=login / logout 連動)+ 真轉檔鏈路 e2e
接 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>
2026-06-27 03:18:59 +08:00

492 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

//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` 都**不**編譯本檔(避免外部服務依賴污染主測試集)。
// - 本機通常連不到 stageVPN/網路),由 Orchestrator 對 stage 192.168.0.130 跑(見檔尾「給
// Orchestrator 對 stage 跑」段)。
//
// 測什麼mock 測不到的部分):直接用 visionA 的 ConverterClientconverter_client.go
// httptest mock打 stage 真轉檔服務,驗 visionA 端對「真服務實際回的格式」解析正確:
// 1. 連線 + 認證(真 API key 過認證)
// 2. InitJob 真送 onnx + 56 張 ref 圖multipart→ 拿 job_id / status=created / stage=onnx
// 3. GetJob poll 到 completedstub 模式幾秒)→ 解析真服務 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 三個 workeronnx/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_idstub 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 不存在:%serr=%v。"+
"用 %s 指定路徑。", onnxPath, err, realConvOnnxEnv)
}
if fi, err := os.Stat(imagesPath); err != nil || !fi.IsDir() {
t.Skipf("real-converter e2e 跳過ref images 目錄不存在:%serr=%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 bodyonnx + 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 fieldsconverter 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.pymodel_id=10
writeField("model_id", "9999")
// version 為字串、無型別限制;用簡單值對齊手動成功("v1")。
writeField("version", "v1")
writeField("platform", "520")
writeField("enable_evaluate", "false")
// model fileonnx
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 pollcontract 主路徑)
// ==========================================================================
// TestRealConverter_InitAndPollContract 驗 visionA ConverterClient 對真 stage 服務的主路徑:
//
// InitJob真送 onnx + 56 圖)→ status=created/running + stage=onnx
// → GetJob poll 到 completedstub 幾秒)
// → visionA 正確解析真服務 completed response即使 result_object_keys=null、無 analysis_info
// 也不報錯、不 panicInputShape/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 bytesref_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 透傳。亦容忍 runningrace
if cj.Status != "created" && cj.Status != "running" {
t.Errorf("InitJob status 預期 created/running得 %qjob=%+v", cj.Status, cj)
}
if cj.Stage != "onnx" {
t.Errorf("InitJob stage 預期 onnx得 %q", cj.Stage)
}
t.Logf("InitJob OKjob_id=%s status=%s stage=%s", cj.JobID, cj.Status, cj.Stage)
// ── GetJob poll 到 completed ───────────────────────────────────────
final := pollUntilTerminal(t, client, cj.JobID)
t.Logf("終態 jobstatus=%s stage=%q progress=%v input_filename=%q platform=%q",
final.Status, final.Stage, derefInt(final.Progress), final.SourceFilename, final.Platform)
// stub 模式預期 completed若 failed 也不該 paniccontract 仍須能解析)
if final.Status != "completed" {
t.Errorf("poll 終態預期 completedstub 模式幾秒完成),得 %qerror_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=%vstub 模式預期 nil"+
"若轉檔端已串 analysis_info 則此為正常)", final.InputShape)
}
if len(final.Classes) != 0 {
t.Logf("注意:真服務回了 classes=%vstub 模式預期空)", 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 #2promote 失敗路徑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 正確包裝成 ErrConverterUnavailablemapPromoteError 預設 5xx 分支)
//
// 這是有價值的 contract 測試:驗 visionA 對真服務 5xx 的錯誤處理鏈正確。
func TestRealConverter_PromoteFailsGracefullyOnStub(t *testing.T) {
env := requireRealConvEnv(t)
client := newRealConverterClient(t, env)
// 先 init + poll 到 completedpromote 前提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 未 completedstatus=%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記 logcontract 仍成立)
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)
}
// 關鍵 contractvisionA 回了「分類好的 error」而非 nil result + nil err不崩
if res != nil {
t.Errorf("Promote 失敗時 result 應為 nil得 %+v", res)
}
}
// ==========================================================================
// E2E #3GetResult 解析真 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 未 completedstatus=%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 bytescontent_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 bytesstub 預期回 15 bytes 佔位輸出)")
}
}
// ==========================================================================
// helpers
// ==========================================================================
func derefInt(p *int) int {
if p == nil {
return -1
}
return *p
}