接 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>
507 lines
21 KiB
Go
507 lines
21 KiB
Go
//go:build realconv
|
||
|
||
// real_chain_e2e_test.go — 完整鏈路 e2e:真轉檔 → PromoteToModels → 真 model 進 PG 模型庫。
|
||
//
|
||
// Owner: testing agent(realconv 完整鏈路 e2e — build tag 隔離,預設 CI 不跑)
|
||
//
|
||
// 與 real_converter_e2e_test.go 的差異:
|
||
// - real_converter_e2e_test.go:對「真轉檔服務」驗 visionA ConverterClient 的 **contract**
|
||
// (連線/認證/InitJob/GetJob/GetResult 解析、promote 失敗路徑)。當時 stage 是 stub。
|
||
// - 本檔:stage 已切 **real 模式**(真 KTC、真 nef 800KB、promote OAuth 已修),驗
|
||
// **整條 visionA 業務鏈路到底**:
|
||
// InitJob(真送 onnx+56 圖)→ poll completed(真轉檔)
|
||
// → flow.PromoteToModels(promote→converter MinIO pull NEF→storage.Put→建 model record)
|
||
// → **model 真的進了 PG 模型庫**(用 model.PostgresRepository 查、驗 owner/source_job_id/storage_key/faa_object_key)
|
||
// → 冪等(同 jobID 再 promote 回既有 model、不重複建)
|
||
//
|
||
// ⚠️ v0.6 架構事實(flow.go PromoteToModels):promote 與 download 都走
|
||
//
|
||
// `converter.GetResult`(converter MinIO),visionA 端**不再直接打 FAA**。所以本鏈路
|
||
// **不需要** wire FAA / MC client —— 只需要:真 ConverterClient(:9501 + API key)
|
||
// + 真 PG(建 model record)+ 一個 storage(streaming 寫 NEF;用本機 tmpdir LocalFS)。
|
||
// 這是「能在本檔組出真 conversion service」的關鍵:依賴比想像中少。
|
||
//
|
||
// 三個必要外部資源(缺任一 → t.Skip 並印啟用指令):
|
||
// 1. VISIONA_REAL_CONVERTER_URL + VISIONA_CONVERTER_API_KEY(與 real_converter_e2e_test.go 共用)
|
||
// 2. VISIONA_REAL_PG_DSN(已 migrate 的 PG;可指 stage 真 PG 或本機 docker PG)
|
||
// 3. onnx + ref images fixture(與 real_converter_e2e_test.go 共用 default 路徑 / env 覆寫)
|
||
//
|
||
// 本機通常連不到 stage(VPN/網路)+ 無 PG → 預設 Skip。由 Orchestrator 對 stage 跑
|
||
// (見檔尾「給 Orchestrator 對 stage 跑」段)。
|
||
package conversion
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"os"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"visiona-backend/internal/model"
|
||
"visiona-backend/internal/storage"
|
||
)
|
||
|
||
// ==========================================================================
|
||
// 環境 guard(PG DSN 是本檔額外需要的;converter / fixture 沿用 requireRealConvEnv)
|
||
// ==========================================================================
|
||
|
||
const (
|
||
// realChainPGDSNEnv 是已 migrate 的 PostgreSQL DSN(如
|
||
// postgres://visiona:pw@192.168.0.130:5432/visiona?sslmode=disable)。
|
||
// 缺 → 本檔的完整鏈路測試 Skip(contract 測試在 real_converter_e2e_test.go 仍可獨立跑)。
|
||
realChainPGDSNEnv = "VISIONA_REAL_PG_DSN"
|
||
|
||
// 真 KTC 轉檔比 stub 慢得多(onnx→bie→nef 三 stage、bie 量化要跑 56 張 ref 圖)。
|
||
// 給足 deadline;stub 經驗幾秒,real 端視機器可能數分鐘。
|
||
realChainPollTimeout = 10 * time.Minute
|
||
realChainPollInterval = 3 * time.Second
|
||
|
||
// 測試建立的 model name 前綴(可識別、cleanup 用)。
|
||
realChainModelNamePrefix = "e2e-realchain-"
|
||
)
|
||
|
||
// requireRealChainPG 解析 PG DSN、建 pool;缺 env → Skip。pool 在 t.Cleanup 關閉。
|
||
func requireRealChainPG(t *testing.T) *pgxpool.Pool {
|
||
t.Helper()
|
||
dsn := os.Getenv(realChainPGDSNEnv)
|
||
if dsn == "" {
|
||
t.Skipf(`real-chain e2e 跳過:未設 %s(PG DSN)。
|
||
本測試需要一個「已 migrate(含 users / models 表)」的 PostgreSQL:
|
||
- 指 stage 真 PG,或本機 docker PG(schema 來自 migrations/0001_create_users_models.up.sql)
|
||
範例:
|
||
VISIONA_REAL_PG_DSN="postgres://visiona:<pw>@192.168.0.130:5432/visiona?sslmode=disable"`, realChainPGDSNEnv)
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
pool, err := pgxpool.New(ctx, dsn)
|
||
if err != nil {
|
||
t.Skipf("real-chain e2e 跳過:PG pool 建立失敗(DSN 可達性問題?):%v", err)
|
||
}
|
||
// ping 確認連得上 + schema 存在(查 models 表)
|
||
pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer pingCancel()
|
||
if err := pool.Ping(pingCtx); err != nil {
|
||
pool.Close()
|
||
t.Skipf("real-chain e2e 跳過:PG ping 失敗:%v", err)
|
||
}
|
||
var reg int
|
||
if err := pool.QueryRow(pingCtx, `SELECT 1 FROM information_schema.tables
|
||
WHERE table_name = 'models'`).Scan(®); err != nil {
|
||
pool.Close()
|
||
t.Skipf("real-chain e2e 跳過:PG 缺 models 表(DSN 指到未 migrate 的 DB?):%v。"+
|
||
"請先對該 DB 跑 migrations/0001_create_users_models.up.sql。", err)
|
||
}
|
||
|
||
t.Cleanup(func() { pool.Close() })
|
||
return pool
|
||
}
|
||
|
||
// ensureTestUser upsert 一個合法 UUID user(滿足 models.owner_user_id FK)。
|
||
//
|
||
// 回傳該 user 的 UUID 字串。固定 UUID(deterministic)讓重跑時 idempotent;
|
||
// email 帶可識別前綴避免撞真 user。
|
||
func ensureTestUser(t *testing.T, pool *pgxpool.Pool) string {
|
||
t.Helper()
|
||
// 固定 namespace UUID(v5 不需要;這裡直接寫死一個明顯是測試用的 UUID)。
|
||
const testUserID = "e2e0c0de-0000-4000-8000-000000000001"
|
||
const testEmail = "e2e-realchain@example.invalid"
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
// upsert by id;email 用 functional unique index(lower(email)),ON CONFLICT (id) 即可。
|
||
_, err := pool.Exec(ctx, `
|
||
INSERT INTO users (id, email, name, roles)
|
||
VALUES ($1, $2, 'e2e realchain test user', '{}')
|
||
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, updated_at = now()`,
|
||
testUserID, testEmail)
|
||
if err != nil {
|
||
t.Fatalf("ensure test user(upsert users)失敗:%v\n"+
|
||
"(若是 email unique 衝突,代表有殘留同 email 的別的 user — 換 testEmail 或先清)", err)
|
||
}
|
||
return testUserID
|
||
}
|
||
|
||
// ==========================================================================
|
||
// in-test adapters:把 model.PostgresRepository / storage.LocalFSStore 包成
|
||
// conversion.ModelStore / conversion.Storage(對映 cmd/api-server/conversion_adapters.go,
|
||
// 但 main package 的 adapter 不可 import,這裡在 conversion package 內等價重寫)。
|
||
// ==========================================================================
|
||
|
||
// pgModelStore 把 model.Repository 包成 conversion.ModelStore(含 ModelRecord ↔ model.Model 轉換)。
|
||
type pgModelStore struct {
|
||
repo model.Repository
|
||
}
|
||
|
||
func (s *pgModelStore) Save(ctx context.Context, rec *ModelRecord) error {
|
||
if rec == nil {
|
||
return errors.New("pgModelStore.Save requires non-nil record")
|
||
}
|
||
now := time.Now().UTC()
|
||
uploadedAt := now
|
||
if !rec.UpdatedAt.IsZero() {
|
||
uploadedAt = rec.UpdatedAt
|
||
}
|
||
m := &model.Model{
|
||
ID: rec.ID,
|
||
OwnerUserID: rec.OwnerUserID,
|
||
Name: rec.Name,
|
||
Description: rec.Description,
|
||
StorageKey: rec.StorageKey,
|
||
FileSize: rec.FileSize,
|
||
FileChecksum: rec.FileChecksum,
|
||
TargetChip: rec.TargetChip,
|
||
InputShape: rec.InputShape,
|
||
Classes: rec.Classes,
|
||
Framework: rec.Framework,
|
||
Source: rec.Source,
|
||
SourceJobID: rec.SourceJobID,
|
||
FAAObjectKey: rec.FAAObjectKey,
|
||
CreatedAt: rec.CreatedAt,
|
||
UpdatedAt: rec.UpdatedAt,
|
||
UploadedAt: &uploadedAt,
|
||
}
|
||
return s.repo.Save(ctx, m)
|
||
}
|
||
|
||
func (s *pgModelStore) FindBySourceJobID(ctx context.Context, ownerUserID, sourceJobID string) (*ModelRecord, error) {
|
||
if ownerUserID == "" || sourceJobID == "" {
|
||
return nil, nil
|
||
}
|
||
models, err := s.repo.List(ctx, model.ListFilter{
|
||
OwnerUserID: ownerUserID,
|
||
Source: model.SourceConverted,
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("pgModelStore.FindBySourceJobID list: %w", err)
|
||
}
|
||
for _, m := range models {
|
||
if m.SourceJobID == sourceJobID {
|
||
return &ModelRecord{
|
||
ID: m.ID,
|
||
OwnerUserID: m.OwnerUserID,
|
||
Name: m.Name,
|
||
Description: m.Description,
|
||
StorageKey: m.StorageKey,
|
||
FileSize: m.FileSize,
|
||
FileChecksum: m.FileChecksum,
|
||
TargetChip: m.TargetChip,
|
||
InputShape: m.InputShape,
|
||
Classes: m.Classes,
|
||
Framework: m.Framework,
|
||
Source: m.Source,
|
||
SourceJobID: m.SourceJobID,
|
||
FAAObjectKey: m.FAAObjectKey,
|
||
CreatedAt: m.CreatedAt,
|
||
UpdatedAt: m.UpdatedAt,
|
||
}, nil
|
||
}
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
func (s *pgModelStore) GenerateID() string { return uuid.NewString() }
|
||
|
||
// localStorage 把 storage.Store 包成 conversion.Storage(只需 Put)。
|
||
type localStorage struct {
|
||
store storage.Store
|
||
}
|
||
|
||
func (s *localStorage) Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error {
|
||
return s.store.Put(ctx, key, r, size, meta)
|
||
}
|
||
|
||
// buildRealChainService 組一個「真 converter + 真 PG + 本機 LocalFS storage」的 conversion.Service。
|
||
//
|
||
// 回傳 service + 底層 pgRepo(測試直接用 repo 查 PG 驗證 model 落盤)。
|
||
func buildRealChainService(t *testing.T, env realConvEnv, pool *pgxpool.Pool) (Service, *model.PostgresRepository) {
|
||
t.Helper()
|
||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||
|
||
converterClient := NewConverterClient(ConverterClientOpts{
|
||
BaseURL: env.baseURL,
|
||
APIKey: env.apiKey,
|
||
Logger: logger,
|
||
})
|
||
ownership := NewOwnership(converterClient, logger)
|
||
|
||
pgRepo := model.NewPostgresRepository(pool)
|
||
modelStore := &pgModelStore{repo: pgRepo}
|
||
|
||
// 本機 tmpdir LocalFS storage(NEF streaming 寫進去;測完隨 t.TempDir 清)。
|
||
fsStore, err := storage.NewLocalFSStore(t.TempDir(), "http://localhost/files", "e2e-realchain-signing")
|
||
if err != nil {
|
||
t.Fatalf("建 LocalFSStore 失敗:%v", err)
|
||
}
|
||
storageAdapter := &localStorage{store: fsStore}
|
||
|
||
svc, err := NewService(FlowOpts{
|
||
Converter: converterClient,
|
||
Ownership: ownership,
|
||
ModelStore: modelStore,
|
||
Storage: storageAdapter,
|
||
Logger: logger,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewService 失敗:%v", err)
|
||
}
|
||
return svc, pgRepo
|
||
}
|
||
|
||
// ==========================================================================
|
||
// E2E:完整鏈路 — 真轉檔 → PromoteToModels → 真 model 進 PG → 冪等
|
||
// ==========================================================================
|
||
|
||
// TestRealChain_ConvertPromoteToPGModelLibrary 驗整條 visionA 業務鏈路到底。
|
||
//
|
||
// [1] flow.InitJob 真送 onnx + 56 圖(flow 內部重組 multipart、注入 user_id、寫 ownership)
|
||
// [2] flow.GetJob poll 到 completed(真 KTC 轉檔;給足 10 分鐘)
|
||
// [3] flow.PromoteToModels:promote(真 nef 推上 FAA + 保留 converter MinIO)
|
||
// → converter.GetResult 拉 NEF stream → storage.Put → model.PostgresRepository.Save
|
||
// [4] 驗 model 真的進 PG:用 pgRepo.Get(model_id) + List by owner,確認
|
||
// owner / source_job_id / storage_key / file_size / source=converted / faa_object_key 有值
|
||
// [5] 冪等:同 jobID 再 PromoteToModels → 回**同一個** model_id(不重複建)
|
||
func TestRealChain_ConvertPromoteToPGModelLibrary(t *testing.T) {
|
||
env := requireRealConvEnv(t)
|
||
pool := requireRealChainPG(t)
|
||
userID := ensureTestUser(t, pool)
|
||
svc, pgRepo := buildRealChainService(t, env, pool)
|
||
|
||
ctx := context.Background()
|
||
|
||
// ── [1] flow.InitJob(真送)─────────────────────────────────────────────
|
||
body, contentType, refCount := buildRealInitBody(t, env)
|
||
t.Logf("[1] InitJob:multipart %d bytes,ref_images=%d 張,user=%s", len(body), refCount, userID)
|
||
|
||
// 注意:flow.InitJob 內部會重組 multipart 並注入 user_id(黑名單 client 帶來的 user_id)。
|
||
// buildRealInitBody 已寫了 user_id=realConvTestUserID,但會被 flow 用本測 userID 蓋掉,
|
||
// 這正是要驗的安全行為(§4.2)。傳進去的 ContentType 必須含 boundary。
|
||
initCtx, initCancel := context.WithTimeout(ctx, 90*time.Second)
|
||
job, err := svc.InitJob(initCtx, InitJobInput{
|
||
UserID: userID,
|
||
ContentType: contentType,
|
||
Body: bytes.NewReader(body),
|
||
ContentLength: int64(len(body)),
|
||
})
|
||
initCancel()
|
||
if err != nil {
|
||
if errors.Is(err, ErrConverterAuthFailed) {
|
||
t.Fatalf("[1] InitJob 認證失敗(API key 未對齊?):%v", err)
|
||
}
|
||
t.Fatalf("[1] InitJob 失敗:%v", err)
|
||
}
|
||
if job.JobID == "" {
|
||
t.Fatalf("[1] InitJob 回的 job_id 為空:%+v", job)
|
||
}
|
||
jobID := job.JobID
|
||
t.Logf("[1] InitJob OK:job_id=%s status=%s stage=%s", jobID, job.Status, job.Stage)
|
||
|
||
// ── [2] flow.GetJob poll 到 completed(真轉檔,給足 10 分鐘)──────────────
|
||
final := pollChainUntilTerminal(t, svc, userID, jobID)
|
||
t.Logf("[2] 終態:status=%s stage=%q source_filename=%q target_chip=%q error_code=%q",
|
||
final.Status, final.Stage, final.SourceFilename, final.TargetChip, final.ErrorCode)
|
||
if final.Status != "completed" {
|
||
t.Fatalf("[2] 真轉檔未 completed(status=%s error_code=%q msg=%q)。"+
|
||
"確認 stage worker 已切 real 模式、且該 fixture 能轉成功。",
|
||
final.Status, final.ErrorCode, final.ErrorMessage)
|
||
}
|
||
|
||
// ── [3] flow.PromoteToModels(真 promote → MinIO pull → storage → 建 model record)──
|
||
modelName := realChainModelNamePrefix + jobID[:8]
|
||
promoteCtx, promoteCancel := context.WithTimeout(ctx, 2*time.Minute)
|
||
promoteRes, err := svc.PromoteToModels(promoteCtx, userID, jobID, modelName)
|
||
promoteCancel()
|
||
if err != nil {
|
||
t.Fatalf("[3] PromoteToModels 失敗:%v\n"+
|
||
"(promote OAuth 已修的前提下不該失敗;若回 ErrConverterUnavailable 代表 promote→FAA 仍有問題,"+
|
||
"回報 Orchestrator,勿自行改 production code。)", err)
|
||
}
|
||
if promoteRes == nil || promoteRes.ModelID == "" {
|
||
t.Fatalf("[3] PromoteToModels 回的 model_id 為空:%+v", promoteRes)
|
||
}
|
||
modelID := promoteRes.ModelID
|
||
// cleanup:測完軟刪除 model(pgRepo.Delete 寫 deleted_at;不留垃圾在模型庫)。
|
||
t.Cleanup(func() {
|
||
dctx, dcancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer dcancel()
|
||
if derr := pgRepo.Delete(dctx, modelID); derr != nil {
|
||
t.Logf("cleanup:軟刪除 model %s 失敗(殘留在 PG,需人工清):%v", modelID, derr)
|
||
} else {
|
||
t.Logf("cleanup:已軟刪除 model %s", modelID)
|
||
}
|
||
})
|
||
t.Logf("[3] PromoteToModels OK:model_id=%s name=%q source=%s source_job_id=%s file_size=%d status=%s",
|
||
modelID, promoteRes.Name, promoteRes.Source, promoteRes.SourceJobID, promoteRes.FileSize, promoteRes.Status)
|
||
|
||
// 基本一致性
|
||
if promoteRes.Source != "converted" {
|
||
t.Errorf("[3] promote source 預期 converted,得 %q", promoteRes.Source)
|
||
}
|
||
if promoteRes.SourceJobID != jobID {
|
||
t.Errorf("[3] promote source_job_id 預期 %s,得 %s", jobID, promoteRes.SourceJobID)
|
||
}
|
||
if promoteRes.FileSize <= 0 {
|
||
t.Errorf("[3] promote file_size 應 > 0(真 nef ~800KB),得 %d", promoteRes.FileSize)
|
||
}
|
||
|
||
// ── [4] 驗 model 真的進 PG(直接查 PostgresRepository)────────────────────
|
||
getCtx, getCancel := context.WithTimeout(ctx, 10*time.Second)
|
||
got, err := pgRepo.Get(getCtx, modelID)
|
||
getCancel()
|
||
if err != nil {
|
||
t.Fatalf("[4] 從 PG 查不到 model %s(model 未真正落盤?):%v", modelID, err)
|
||
}
|
||
t.Logf("[4] PG model 落盤:id=%s owner=%s name=%q storage_key=%q faa_object_key=%q "+
|
||
"file_size=%d source=%s source_job_id=%s target_chip=%q",
|
||
got.ID, got.OwnerUserID, got.Name, got.StorageKey, got.FAAObjectKey,
|
||
got.FileSize, got.Source, got.SourceJobID, got.TargetChip)
|
||
|
||
if got.OwnerUserID != userID {
|
||
t.Errorf("[4] PG model owner 預期 %s,得 %s", userID, got.OwnerUserID)
|
||
}
|
||
if got.SourceJobID != jobID {
|
||
t.Errorf("[4] PG model source_job_id 預期 %s,得 %s", jobID, got.SourceJobID)
|
||
}
|
||
if got.Source != model.SourceConverted {
|
||
t.Errorf("[4] PG model source 預期 converted,得 %q", got.Source)
|
||
}
|
||
if got.StorageKey == "" {
|
||
t.Errorf("[4] PG model storage_key 不該為空(promote 應寫 visionA storage key)")
|
||
}
|
||
if got.FileSize <= 0 {
|
||
t.Errorf("[4] PG model file_size 應 > 0,得 %d", got.FileSize)
|
||
}
|
||
// FAAObjectKey:v0.6 promote 仍寫此欄位(= converter promote 的 target_object_key)。
|
||
// 真 promote 成功路徑下應有值;若為空記下供判讀(不一定 fail — 視 converter promote response)。
|
||
if got.FAAObjectKey == "" {
|
||
t.Logf("[4] 注意:PG model faa_object_key 為空。v0.6 promote 應回 target_object_key;" +
|
||
"若 converter promote response 未帶 target_object_key 則 fallback 為 visionA 端組的 key," +
|
||
"理論上不該空。確認 converter promote 回傳格式。")
|
||
}
|
||
|
||
// List by owner 也應看得到(驗 List 路徑 + filter 正確)
|
||
listCtx, listCancel := context.WithTimeout(ctx, 10*time.Second)
|
||
models, err := pgRepo.List(listCtx, model.ListFilter{OwnerUserID: userID, Source: model.SourceConverted})
|
||
listCancel()
|
||
if err != nil {
|
||
t.Fatalf("[4] List by owner 失敗:%v", err)
|
||
}
|
||
if !containsModelID(models, modelID) {
|
||
t.Errorf("[4] List by owner=%s source=converted 結果未含 model %s(共 %d 筆)",
|
||
userID, modelID, len(models))
|
||
}
|
||
|
||
// ── [5] 冪等:同 jobID 再 PromoteToModels → 回同一個 model_id ──────────────
|
||
idemCtx, idemCancel := context.WithTimeout(ctx, 1*time.Minute)
|
||
promoteRes2, err := svc.PromoteToModels(idemCtx, userID, jobID, modelName)
|
||
idemCancel()
|
||
if err != nil {
|
||
t.Fatalf("[5] 第二次 PromoteToModels(冪等)失敗:%v", err)
|
||
}
|
||
if promoteRes2 == nil || promoteRes2.ModelID != modelID {
|
||
t.Errorf("[5] 冪等失敗:第二次 promote 應回同一 model_id=%s,得 %+v", modelID, promoteRes2)
|
||
} else {
|
||
t.Logf("[5] 冪等 OK:同 jobID 再 promote 回既有 model_id=%s(未重複建)", modelID)
|
||
}
|
||
|
||
// 再查一次 PG 確認只有一筆 converted model 對應此 jobID(冪等不該建第二筆)
|
||
cntCtx, cntCancel := context.WithTimeout(ctx, 10*time.Second)
|
||
all, err := pgRepo.List(cntCtx, model.ListFilter{OwnerUserID: userID, Source: model.SourceConverted})
|
||
cntCancel()
|
||
if err != nil {
|
||
t.Fatalf("[5] 冪等後 List 失敗:%v", err)
|
||
}
|
||
n := 0
|
||
for _, m := range all {
|
||
if m.SourceJobID == jobID {
|
||
n++
|
||
}
|
||
}
|
||
if n != 1 {
|
||
t.Errorf("[5] 冪等後對應 jobID=%s 的 converted model 應只有 1 筆,得 %d 筆", jobID, n)
|
||
}
|
||
|
||
t.Logf("完整鏈路驗證通過:真轉檔 → PromoteToModels → model 進 PG → 冪等,全部 OK。")
|
||
}
|
||
|
||
// pollChainUntilTerminal 用 flow.GetJob 對真服務 poll 到 completed/failed 或 timeout。
|
||
//
|
||
// 與 real_converter_e2e_test.go 的 pollUntilTerminal 不同:這裡走 **flow.GetJob**(含 ownership
|
||
// 檢查),驗的是 visionA 業務層的 poll,而非 raw client。回傳對外 *Job。
|
||
func pollChainUntilTerminal(t *testing.T, svc Service, userID, jobID string) *Job {
|
||
t.Helper()
|
||
deadline := time.Now().Add(realChainPollTimeout)
|
||
var last *Job
|
||
logEvery := 10 // 每 N 次 poll 印一次進度,避免長轉檔時 log 太吵
|
||
i := 0
|
||
for time.Now().Before(deadline) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
j, err := svc.GetJob(ctx, userID, jobID)
|
||
cancel()
|
||
if err != nil {
|
||
t.Logf("GetJob 暫時失敗(續 poll):%v", err)
|
||
time.Sleep(realChainPollInterval)
|
||
continue
|
||
}
|
||
last = j
|
||
i++
|
||
if i%logEvery == 0 {
|
||
t.Logf(" poll #%d:status=%s stage=%q progress=%d stage_progress=%d",
|
||
i, j.Status, j.Stage, j.Progress, j.StageProgress)
|
||
}
|
||
switch j.Status {
|
||
case "completed", "failed":
|
||
return j
|
||
default:
|
||
time.Sleep(realChainPollInterval)
|
||
}
|
||
}
|
||
if last == nil {
|
||
t.Fatalf("poll job %s 超時且從未成功 GetJob", jobID)
|
||
}
|
||
t.Logf("poll 超時(%s),回最後一次狀態:status=%s", realChainPollTimeout, last.Status)
|
||
return last
|
||
}
|
||
|
||
// containsModelID 檢查 model 清單是否含指定 id。
|
||
func containsModelID(models []*model.Model, id string) bool {
|
||
for _, m := range models {
|
||
if m.ID == id {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 給 Orchestrator 對 stage 跑(本機連不到 stage / 無 PG → 預設 Skip)
|
||
//
|
||
// 前置:
|
||
// 1. 取 converter API key(從 stage container env,不 hardcode):
|
||
// KEY=$(docker -H tcp://192.168.0.130:2375 exec \
|
||
// kneron_model_converter-scheduler-1 printenv CONVERTER_API_KEY)
|
||
// 2. 取 PG DSN:指 stage 真 PG(已 migrate,含 users / models 表)。
|
||
// 若 stage PG 連線資訊未知,可在 stage 機器上跑、用 localhost DSN;
|
||
// 或本機起一個 docker PG 並對它跑 migrations/0001_create_users_models.up.sql。
|
||
// 3. fixture:onnx + 56 張 ref 圖(default 路徑見 real_converter_e2e_test.go;
|
||
// env VISIONA_REAL_CONVERTER_ONNX / VISIONA_REAL_CONVERTER_IMAGES 可覆寫)。
|
||
//
|
||
// 跑:
|
||
// VISIONA_REAL_CONVERTER_URL=http://192.168.0.130:9501 \
|
||
// VISIONA_CONVERTER_API_KEY="$KEY" \
|
||
// VISIONA_REAL_PG_DSN="postgres://visiona:<pw>@<pg-host>:5432/visiona?sslmode=disable" \
|
||
// go test -tags=realconv ./internal/conversion/ \
|
||
// -run TestRealChain_ConvertPromoteToPGModelLibrary -count=1 -v -timeout=20m
|
||
//
|
||
// 注意:真 KTC 轉檔較慢,-timeout 給 20m(poll deadline 內建 10m)。
|
||
// 測完會軟刪除建立的 model(t.Cleanup);test user 留在 PG(無害、固定 UUID 可重用)。
|
||
// ==========================================================================
|