visionA/visionA-backend/cmd/api-server/conversion_e2e_test.go
jim800121chen 6024c294d3 feat(visionA-backend): Phase 0.8b v0.6 T3 — 砍 faa_client + ErrFAA* + s-3/s-4/s-5
對齊 ADR-016:visionA backend 不再直連 FAA、download 改走 converter GetResult。T3 砍除 v0.5 階段為 FAA delegated token 路線留的 faa_client.go 整檔 + 對應 sentinel + flow / e2e 殘留。

砍除:
- internal/conversion/faa_client.go(整檔)
- internal/conversion/faa_client_test.go(整檔)
- errors.go: ErrFAAFileNotFound + ErrFAAAuthFailed 2 sentinel(+ ErrorCode/HTTPStatus mapping)
- flow.go: faa FAAClient 欄位 + FlowOpts.FAA 必填 + a-h T3 預期清單 godoc
- flow_test.go: flowStubFAA struct + newFlowStubFAA helper + fixture.faa
- internal/api/conversion_test.go: TestConversion_Download_FAAAuthFailed
- cmd/api-server/main.go: NewFAAClient wire + FAA: faaAPIClient field

保留:
- ErrFAAUnavailable(converter promote 仍 PUT FAA、502 透傳路徑需要)
- hashObjectKey helper 搬到 util.go(ownership 仍用)
- e2e mockFAA 精簡為 regression-only(保留 negative assertion: FAA 0 命中)— reviewer 推薦雙層防護

新增(T3 必補,T1/T2 reviewer 累積):
- s-3 TestDownloadStream_ConverterValidationFailed_Propagation(converter 4xx fallback → ErrValidationFailed 透傳)
- s-4 TestPromoteToModels_StorageError_StreamClosed(instrumented stream wrapper 驗 fd leak 防護)
- s-5 TestParseFilenameFromContentDisposition 9 個 sub-case(3 RFC 5987 + 5 hostile-input + 1 empty quoted)
  發現:Go stdlib 自動 percent-decode RFC 5987 並寫入 params["filename"]、RFC 5987 優先於 ASCII filename

T3 review M-1 修補(commit 內含):
- internal/api/conversion.go:51,56 godoc + 501 user-facing message 從「FAA_BASE_URL」改為「VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY」
- 對齊 ADR-016 visionA 端不再有 FAA 直連設計

驗證:
- B 層 verification 強制跑(reviewer 規定 T3 不接受暫緩):
  * 跨檔 grep: MC chain 0 / FAA functional refs 0 / TenantID 0
  * API contract test: TestConversionE2E_DownloadStream 6 斷言含 FAA negative
  * 安全 manual review: path traversal / unbounded read / secret in log / error mask 4 項
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸(v0.6-t3-review)⚠️ 通過(M-1 已修)

a-h 8 條清單 100% 達成(逐條 grep 驗收);mockFAA 選方案 1(保留 + negative assertion)— 雙層防護。

下一步:
- T4 砍 ConversionConfig.FAAAPIKey/FAABaseURL + load.go env 讀取 + .env*.example + m-2 i18n dead case 一併
- T5 main.go startup log 整理 + e2e regression 防護

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:56:01 +08:00

1238 lines
47 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.

// conversion_e2e_test.go — Phase 0.8 / Phase 0.8b conversion 整合 e2e 測試。
//
// 涵蓋 4 個必含場景(原始範圍對齊 .autoflow/05-implementation/phase-0.8-T8.md
// Phase 0.8b T5 將場景 3 從「302 redirect」改為「server-side stream proxy」
//
// 1. Streaming proxy 完整跑通
// —— 驗 InitJob 真的 streaming不 buffer 整個 multipart body
// 用 io.Pipe 對 visionA 送 ~10MB body大小可控驗 mock converter 收到 byte-perfect copy。
//
// 2. 重啟恢復 lazy rebuild
// —— 模擬 visionA backend 剛啟動 ownership 全空 + converter 端 user X 有 in_progress job
// 驗 user 對 GET /active 觸發 lazy rebuild且後續 GET /active 走 cache 不再打 ListInProgressJobs。
//
// 3. Download server-side stream proxyPhase 0.8b 改造,原 302 redirect 已廢)
// —— 驗 visionA backend 用 Bearer <FAA_API_KEY> 拉 mock FAA、stream NEF binary 回 browser
// 驗 response 200 + Content-Type: application/octet-stream + Content-Disposition: attachment +
// Cache-Control: no-store + body bytes 與 mock FAA 寫的 NEF binary byte-perfect 一致;
// 驗結構性無 302 / Location headerAPI key 模式不再走 delegated download
// 對齊 ADR-015 §7 + conversion.md §4.1 + api-conversion.md §4。
//
// 4. Active job 409 衝突
// —— 同 user 第一個 init 成功 → 第二個 init 撞 409 + body 帶 active_job 詳情。
//
// 為什麼自建 fixture 而非擴充 setupFixture
//
// 既有 setupFixtureintegration_test.go是 B4/B5 的雛形(不含 conversion service
// T7 main.go 在 wire 時才 build conversion service。本檔保持 T1-T7 既有 code 不動,
// 自己組一個 conversion 專用 fixturefakeOIDC + apiServer + 2 個 mock servers
// converter / FAA完整模擬端到端。Phase 0.8b 取消 MC service token + delegated mock
// API key 模式不依賴 MC OAuthfixture 從 3 個 mock 收斂到 2 個。
//
// Phase 0.8 conversion e2e (見 docs/autoflow/04-architecture/conversion.md
// + adr/adr-015-server-to-server-api-key.md)
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
"visiona-backend/internal/conversion"
"visiona-backend/internal/converter"
"visiona-backend/internal/device"
"visiona-backend/internal/model"
"visiona-backend/internal/oidc"
"visiona-backend/internal/oidctest"
"visiona-backend/internal/session"
"visiona-backend/internal/storage"
"visiona-backend/internal/usersession"
)
// ==========================================================================
// Mock servers
// ==========================================================================
// mockConverter 模擬 kneron_model_converter 的 task-scheduler。
//
// 對應 5 個 endpointconverter_client.go 註解列表Phase 0.8b v0.6 新增 GetResult
// - POST /api/v1/jobs — InitJobmultipart streaming
// - GET /api/v1/jobs/{id} — GetJob
// - POST /api/v1/jobs/{id}/promote — Promote
// - GET /api/v1/jobs/{id}/result — GetResultv0.6 新增ADR-016 §1
// - GET /api/v1/jobs?user_id=&status=in_progress — ListInProgressJobs
//
// 透過 atomic.Int32 counter 計數每個 endpoint 被打幾次(給場景 #2 lazy rebuild 驗證用)。
type mockConverter struct {
srv *httptest.Server
mu sync.Mutex
// jobsjob_id → 當前狀態(給 GetJob / list 用)
jobs map[string]*conversion.ConverterJob
// userActiveuser_id → []job_id給 list endpoint 用)
userActive map[string][]string
// observed紀錄關鍵事件以便驗證
initCallCount atomic.Int32
getJobCallCount atomic.Int32
promoteCallCount atomic.Int32
listJobsCallCount atomic.Int32
getResultCallCount atomic.Int32 // v0.6ADR-016 §1
// initBodyBytes場景 #1 驗 streaming forward 收到的真實 bodymock 端 ReadAll 後保留)
initBodyMu sync.Mutex
initBody []byte
initBodyCT string
initBodyLen int64
// nextInitBehavior給場景 #4 用 — 若設為 conflictUserID第二次 init 對該 user
// 直接回 409 user_has_active_job
nextInitConflict atomic.Int32 // 0=正常;>0=回 409 / 後續 decrement
// v0.6getResult endpoint 用的 NEF binary payload 與 last auth header取代原 mock FAA
resultMu sync.Mutex
resultPayload []byte // nil → 預設一個小 marker payload
resultLastAuthHeader string
}
// initBodyMust 把 mock 收到的 init body 取出test caller 用)。
func (m *mockConverter) initBodySnapshot() ([]byte, string, int64) {
m.initBodyMu.Lock()
defer m.initBodyMu.Unlock()
return append([]byte(nil), m.initBody...), m.initBodyCT, m.initBodyLen
}
// addInProgressJob 預先在 mock 端註冊一個 user 的 in_progress job給場景 #2
func (m *mockConverter) addInProgressJob(userID, jobID string, createdAt time.Time) {
m.mu.Lock()
defer m.mu.Unlock()
job := &conversion.ConverterJob{
JobID: jobID,
Status: "running",
Stage: "bie",
SourceFilename: "yolov5s.onnx",
Platform: "720",
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
progress := 45
job.Progress = &progress
m.jobs[jobID] = job
m.userActive[userID] = append(m.userActive[userID], jobID)
}
// newMockConverter 建一個 mock converter server。
func newMockConverter(t *testing.T) *mockConverter {
t.Helper()
mc := &mockConverter{
jobs: make(map[string]*conversion.ConverterJob),
userActive: make(map[string][]string),
}
mux := http.NewServeMux()
// 解析 /api/v1/jobs 與 /api/v1/jobs/{id} / /promote — 依方法分流
mux.HandleFunc("/api/v1/jobs", mc.handleJobsRoot)
mux.HandleFunc("/api/v1/jobs/", mc.handleJobsByID)
mc.srv = httptest.NewServer(mux)
t.Cleanup(mc.srv.Close)
return mc
}
// handleJobsRoot 處理 POST /api/v1/jobsInitJob與 GET /api/v1/jobs?...List
func (m *mockConverter) handleJobsRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
m.handleInitJob(w, r)
case http.MethodGet:
m.handleListJobs(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// handleInitJob 模擬 POST /api/v1/jobs。
//
// 行為:
// - 若 nextInitConflict > 0 → 回 409 user_has_active_job + body 帶 active_job 詳情decrement
// - 否則streaming-read multipart body 全部(驗 visionA 真有 forward→ 解出 user_id / model_id 等
// → 建一個 running jobstatus=created → 對齊 converter API→ 回 201
func (m *mockConverter) handleInitJob(w http.ResponseWriter, r *http.Request) {
m.initCallCount.Add(1)
// 場景 #4第二次 init 撞 409
if m.nextInitConflict.Load() > 0 {
m.nextInitConflict.Add(-1)
// 找該 user 第一個 active job 帶回 details
// converter API 真實格式:見 conversion.go ActiveJobError 的 extractActiveJobFromDetails
var firstJobID string
m.mu.Lock()
for _, ids := range m.userActive {
if len(ids) > 0 {
firstJobID = ids[0]
break
}
}
m.mu.Unlock()
writeJSON(w, http.StatusConflict, map[string]any{
"error": map[string]any{
"code": "user_has_active_job",
"message": "user already has active job",
"details": map[string]any{
"active_job": map[string]any{
"job_id": firstJobID,
"status": "running",
"stage": "bie",
},
},
},
})
return
}
// streaming-read 真實 body驗 visionA 沒在記憶體 buffer
contentType := r.Header.Get("Content-Type")
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body error: "+err.Error(), http.StatusBadRequest)
return
}
m.initBodyMu.Lock()
m.initBody = bodyBytes
m.initBodyCT = contentType
m.initBodyLen = int64(len(bodyBytes))
m.initBodyMu.Unlock()
// 從 multipart 取 user_id / model_id驗 visionA 灌的 user_id 真有送到)
userID, ok := parseMultipartField(contentType, bodyBytes, "user_id")
if !ok {
http.Error(w, "user_id missing in multipart", http.StatusBadRequest)
return
}
jobID := fmt.Sprintf("job-%s-%d", userID, time.Now().UnixNano())
now := time.Now().UTC()
job := &conversion.ConverterJob{
JobID: jobID,
Status: "running",
Stage: "onnx",
SourceFilename: "yolov5s.onnx",
Platform: "720",
CreatedAt: now,
UpdatedAt: now,
}
zero := 0
job.Progress = &zero
m.mu.Lock()
m.jobs[jobID] = job
m.userActive[userID] = append(m.userActive[userID], jobID)
m.mu.Unlock()
writeJSON(w, http.StatusCreated, map[string]any{
"job_id": jobID,
"status": "running",
"stage": "onnx",
"progress": 0,
"stage_progress": 0,
"source_filename": "yolov5s.onnx",
"parameters": map[string]any{
"platform": "720",
},
"created_at": now.Format(time.RFC3339),
"updated_at": now.Format(time.RFC3339),
})
}
// handleListJobs 模擬 GET /api/v1/jobs?user_id=...&status=in_progress。
func (m *mockConverter) handleListJobs(w http.ResponseWriter, r *http.Request) {
m.listJobsCallCount.Add(1)
q := r.URL.Query()
userID := q.Get("user_id")
status := q.Get("status")
m.mu.Lock()
defer m.mu.Unlock()
jobs := make([]map[string]any, 0)
if status == "in_progress" {
for _, jobID := range m.userActive[userID] {
j := m.jobs[jobID]
if j == nil {
continue
}
if j.Status != "running" && j.Status != "created" {
continue
}
jobs = append(jobs, converterJobToMap(j))
}
}
writeJSON(w, http.StatusOK, map[string]any{
"jobs": jobs,
"total": len(jobs),
"page": 1,
"page_size": len(jobs),
"has_more": false,
})
}
// handleJobsByID 處理 /api/v1/jobs/{id} 與 /api/v1/jobs/{id}/promote。
func (m *mockConverter) handleJobsByID(w http.ResponseWriter, r *http.Request) {
// 路徑:/api/v1/jobs/{id} 或 /api/v1/jobs/{id}/promote
rest := strings.TrimPrefix(r.URL.Path, "/api/v1/jobs/")
if rest == "" {
http.NotFound(w, r)
return
}
parts := strings.SplitN(rest, "/", 2)
jobID := parts[0]
if len(parts) == 1 {
// /api/v1/jobs/{id}
switch r.Method {
case http.MethodGet:
m.handleGetJob(w, r, jobID)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
}
// 帶 sub-path
switch parts[1] {
case "promote":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
m.handlePromote(w, r, jobID)
case "result":
// v0.6ADR-016 §1GET /api/v1/jobs/{id}/result — visionA download path 改走此 endpoint
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
m.handleGetResult(w, r, jobID)
default:
http.NotFound(w, r)
}
}
// handleGetJob 模擬 GET /api/v1/jobs/{id}。
func (m *mockConverter) handleGetJob(w http.ResponseWriter, _ *http.Request, jobID string) {
m.getJobCallCount.Add(1)
m.mu.Lock()
j := m.jobs[jobID]
m.mu.Unlock()
if j == nil {
writeJSON(w, http.StatusNotFound, map[string]any{
"error": map[string]any{"code": "job_not_found", "message": "job not found"},
})
return
}
writeJSON(w, http.StatusOK, converterJobToMap(j))
}
// handlePromote 模擬 POST /api/v1/jobs/{id}/promote。
func (m *mockConverter) handlePromote(w http.ResponseWriter, r *http.Request, jobID string) {
m.promoteCallCount.Add(1)
var req struct {
Targets []struct {
Source string `json:"source"`
TargetObjectKey string `json:"target_object_key"`
} `json:"targets"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
m.mu.Lock()
j := m.jobs[jobID]
m.mu.Unlock()
if j == nil {
writeJSON(w, http.StatusNotFound, map[string]any{
"error": map[string]any{"code": "job_not_found"},
})
return
}
target := "models/promoted/" + jobID + ".nef"
if len(req.Targets) > 0 && req.Targets[0].TargetObjectKey != "" {
target = req.Targets[0].TargetObjectKey
}
writeJSON(w, http.StatusOK, map[string]any{
"job_id": jobID,
"promoted": []map[string]any{{
"source": "nef",
"target_object_key": target,
"size": int64(1024),
"file_access_agent_etag": "etag-mock",
}},
})
}
// handleGetResult 模擬 GET /api/v1/jobs/{id}/resultv0.6 / ADR-016 §1
//
// 行為:
// - 紀錄收到的 Authorization header給 E2E 驗證 visionA 帶上 converter API key
// - job 不存在 → 404 `job_not_found`
// - job 存在但 status != completed → 409 `job_not_completed`
// - 否則 200 + Content-Type/Length/Disposition + body
// payload 由 setResultPayload 控制nil 用 default marker
func (m *mockConverter) handleGetResult(w http.ResponseWriter, r *http.Request, jobID string) {
m.getResultCallCount.Add(1)
m.resultMu.Lock()
m.resultLastAuthHeader = r.Header.Get("Authorization")
payload := m.resultPayload
m.resultMu.Unlock()
if payload == nil {
payload = []byte("mock-converter-result-default-payload")
}
m.mu.Lock()
j := m.jobs[jobID]
m.mu.Unlock()
if j == nil {
writeJSON(w, http.StatusNotFound, map[string]any{
"error": map[string]any{"code": "job_not_found"},
})
return
}
if j.Status != "completed" {
writeJSON(w, http.StatusConflict, map[string]any{
"error": map[string]any{"code": "job_not_completed"},
"status": j.Status,
})
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
// converter 給的 filename 是 object_key 派生(對 user 不直觀visionA backend 端會用
// defaultDownloadFilename(cj) 覆寫成 <stem>_<chip>.nef 對齊 wireframe
w.Header().Set("Content-Disposition", `attachment; filename="`+jobID+`.nef"`)
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload)
}
// setResultPayload 設定 GET /result 回的 binary 內容(測試端控制 byte-perfect 比對)。
func (m *mockConverter) setResultPayload(payload []byte) {
m.resultMu.Lock()
defer m.resultMu.Unlock()
m.resultPayload = payload
}
// getResultLastAuthHeader 取最後一次 GET /result 收到的 Authorization header
// (測試驗 visionA 帶上正確 Bearer <CONVERTER_API_KEY>)。
func (m *mockConverter) getResultLastAuthHeader() string {
m.resultMu.Lock()
defer m.resultMu.Unlock()
return m.resultLastAuthHeader
}
// markJobCompleted 把 mock 端 jobID 推進到 completed 狀態(給場景 #3 download 用)。
func (m *mockConverter) markJobCompleted(jobID string) {
m.mu.Lock()
defer m.mu.Unlock()
if j := m.jobs[jobID]; j != nil {
j.Status = "completed"
j.Stage = ""
hundred := 100
j.Progress = &hundred
j.UpdatedAt = time.Now().UTC()
}
}
// converterJobToMap 把 mock 內部結構序列化成 converter API response shape。
func converterJobToMap(j *conversion.ConverterJob) map[string]any {
progress := 0
if j.Progress != nil {
progress = *j.Progress
}
stageProgress := 0
if j.StageProgress != nil {
stageProgress = *j.StageProgress
}
out := map[string]any{
"job_id": j.JobID,
"status": j.Status,
"stage": j.Stage,
"progress": progress,
"stage_progress": stageProgress,
"created_at": j.CreatedAt.Format(time.RFC3339),
"updated_at": j.UpdatedAt.Format(time.RFC3339),
"input": map[string]any{
"filename": j.SourceFilename,
},
"parameters": map[string]any{
"platform": j.Platform,
},
}
if j.Stage == "" {
out["stage"] = nil
}
return out
}
// ==========================================================================
// Phase 0.8b T5mockMC 已整段移除
// ==========================================================================
//
// 原 mockMC 提供 OAuth `client_credentials` service token 與 MC delegated download token
// Phase 0.8b 改 pre-shared API key 後ADR-015 §3 / §6 / §7visionA 完全不再呼叫 MC
// e2e fixture 不需要 mock MC server。
//
// 若未來 Phase 1+ 採 ADR-015 §7 選項 BvisionA 自簽 HMAC token + 302 redirect
// 也只需 visionA 端有 HMAC_KEY不需要 mock MC 端。
// ==========================================================================
// mockFAA — **Regression-only**Phase 0.8b v0.6 T3 起)。
//
// 用途已演進:
// - Phase 0.8b v0.4 / v0.5:模擬 FAA `GET /files/{key}` 回 NEF binary stream
// visionA backend 端真的會打它API key 認證)
// - **Phase 0.8b v0.6 T3 起(本 commit**visionA backend 端不再直接打 FAA
// ADR-016 撤回FAAClient interface + faa_client.go 整檔刪除。mockFAA server
// **保留**作為「e2e regression 防護」—— 若未來某 agent 不小心把 FAA 直連加回
// production code例如「optimize: 直接打 FAA 跳一層」、或誤從 git history copy 舊
// 程式碼),`getCallCount.Load() == 0` 的 negative assertion 會立即 fail
// TestConversionE2E_DownloadStream:1037-1048
//
// 為什麼**保留 mockFAA server**而不是純編譯期靜態斷言:
// - 純編譯期斷言(`var _ = (*conversion.FAAClient)(nil)`已透過「FAAClient interface
// 不存在」自動成立 — production code 不可能再 import 不存在的 type
// - 但若有人**直接用 net/http 手寫**對 FAA 的 request不透過 conversion package
// interface、編譯期斷言抓不到。mockFAA server 在 e2e 層提供 wire-level 防護
// (只要 visionA 端對 mockFAA URL 發任何 GET request、getCallCount > 0 → test fail
//
// 為什麼選方案 1保留 mockFAA + negative assertion而非方案 2純結構性保證
// - 方案 1 額外維護成本50 行 mock + 1 行 assertion功能性影響 0mock 不會被 visionA
// 端打到)
// - 方案 2 強度只到「FAAClient interface 不存在」、抓不到 raw net/http 手寫的 regression
// - 採方案 1 給未來 ADR-016 設計約束多一層 wire-level 防護、cost-benefit 划算
// ==========================================================================
type mockFAA struct {
srv *httptest.Server
// **regression-only**v0.6 T3 後 visionA 端不應再對 mockFAA 發任何 request
// 此 counter > 0 → 設計約束被破壞、立即 fail e2e
getCallCount atomic.Int32
}
func newMockFAA(t *testing.T) *mockFAA {
t.Helper()
m := &mockFAA{}
m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// **v0.6 T3 起**:任何 request 進來都應視為設計違規;用 counter 記下,讓 e2e 斷言抓到
m.getCallCount.Add(1)
// 仍照舊處理 GET /files/... 以避免 visionA 端因連線失敗發生 panic讓 e2e 拿到具體
// 「不該有的呼叫」而非「連線錯誤」)—— 但 production code 不應走到這條 path
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/files/") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(m.srv.Close)
return m
}
// ==========================================================================
// conversionFixture — 把所有 server 拼起來並提供 OIDC 登入 helper
// ==========================================================================
type conversionFixture struct {
server *httptest.Server // visionA backend
fakeOIDC *oidctest.Server // 給 user 走 OIDC cookie session 登入用
conv *mockConverter
faa *mockFAA
// Phase 0.8b T5mc *mockMC 已從 fixture 移除(服務間認證改 API key、不再依賴 MC
// 重啟模擬:場景 #2 需要在 instance A 不註冊 ownership 直接 instance B 起,
// 所以保留 lazy 把 conversion service rebuild 進新 router 的 hook。
router *gin.Engine
}
func (f *conversionFixture) Close() {
if f.server != nil {
f.server.Close()
}
// fakeOIDC / mocks 由 t.Cleanup 自動關
}
// setupConversionFixture 建立完整的 e2e 環境:
// - mock converterAPI key 模式)
// - mock FAA**Phase 0.8b v0.6 T3 起為 regression-only**visionA 端不應再對它發 request
// 若 getCallCount > 0 表示設計約束被破壞,由 e2e negative assertion 抓出)
// - fake OIDC給 user 走 cookie session 登入)
// - visionA-backend router含 conversion service wired仿 main.go wire 邏輯)
//
// **不影響 T1-T7 既有 code**:本 fixture 完全獨立,不重用 setupFixture後者沒 wire conversion
//
// Phase 0.8b T5服務間認證改 pre-shared API keyADR-015mock MC / mcTokenClient
// 已從 fixture 移除。
// Phase 0.8b v0.6 T3FAA wire 從 conversion.FlowOpts 移除FAAClient interface 已砍);
// mockFAA server 保留作為「visionA 端不再直接打 FAA」的 regression 防護
// TestConversionE2E_DownloadStream:1037-1048 的 negative assertion 仍生效)。
func setupConversionFixture(t *testing.T) *conversionFixture {
t.Helper()
gin.SetMode(gin.TestMode)
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
conv := newMockConverter(t)
faa := newMockFAA(t)
fakeOIDC := oidctest.NewServer(t,
oidctest.WithClientCredentials(fixtureOIDCClientID, fixtureOIDCClientSecret),
)
// 用 lazyHandler既有 helper因為 storage baseURL 需要 apiServer.URL
// 而 storage 又是 router 的依賴 — 必須先 Start server 拿 URL。
lazy := &lazyHandler{}
apiTS := httptest.NewServer(lazy)
t.Cleanup(apiTS.Close)
storeDir := t.TempDir()
storeStore, err := storage.NewLocalFSStore(storeDir, apiTS.URL+"/storage", "test-secret")
require.NoError(t, err)
// OIDC provider指向 fakeOIDCuser cookie session 登入用)
callbackURL := apiTS.URL + "/api/auth/callback"
oidcCtx, oidcCancel := context.WithTimeout(context.Background(), 5*time.Second)
oidcProvider, err := oidc.NewProvider(oidcCtx, oidc.ProviderConfig{
IssuerURL: fakeOIDC.URL,
ClientID: fakeOIDC.ClientID,
ClientSecret: fakeOIDC.ClientSecret,
RedirectURL: callbackURL,
})
oidcCancel()
require.NoError(t, err)
sessionMgr := usersession.NewManager(usersession.NewInMemoryStore(), usersession.CookieConfig{
Name: "visiona_session",
Path: "/",
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
SigningKey: []byte(fixtureSessionSecret),
})
// === 組 conversion service模擬 main.go wire 邏輯mocks 替換真實 endpoint ===
//
// Phase 0.8b T5服務間認證改 pre-shared API keyADR-015
// - 不再 wire MCTokenClient / Tokens 欄位
// - converter client 帶 fixture 用的 API key
// - mock converter 端不驗 key測試重點是 visionA 端的 wire 行為與 stream proxy
//
// Phase 0.8b v0.6 T3visionA 端不再 wire FAA clientFAAClient interface 已砍);
// download / promote 都走 converter.GetResult。mockFAA server 仍 spin up 作為
// regression-only 防護(見 newMockFAA godoc
//
// 注意converter_client 用 5s timeout HTTPClient 避免測試卡死;
// 對 mock servers 來說連線秒回timeout 不會觸發。
fastHTTP := &http.Client{Timeout: 5 * time.Second}
const fixtureConverterAPIKey = "fixture-converter-api-key-do-not-use-in-prod-aaaaaaaaaaaaaaaaaa"
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
BaseURL: conv.srv.URL,
APIKey: fixtureConverterAPIKey,
HTTPClient: fastHTTP,
InitHTTPClient: &http.Client{Timeout: 60 * time.Second}, // 場景 #1 大 body 給寬一點
Logger: logger,
})
ownership := conversion.NewOwnership(converterAPIClient, logger)
modelRepo := model.NewInMemoryRepository()
modelStoreAdapter := newConversionModelStoreAdapter(modelRepo)
storageAdapter := newConversionStorageAdapter(storeStore)
conversionService, err := conversion.NewService(conversion.FlowOpts{
Converter: converterAPIClient,
Ownership: ownership,
ModelStore: modelStoreAdapter,
Storage: storageAdapter,
Logger: logger,
})
require.NoError(t, err)
// === Build router含 conversion ===
pairingStore := auth.NewInMemoryPairingStore()
router := api.NewRouter(api.Deps{
Logger: logger,
PairingStore: pairingStore,
SessionTokenStore: auth.NewInMemorySessionTokenStore(),
// 不需要真實 SessionStore / Forwarderconversion endpoint 不依賴);
// 但 NewRouter validate 需要某些非 nil 欄位 — 用 stub。
SessionStore: session.NewProxyClientStore(
session.NewHTTPProxyClient("http://127.0.0.1:1", logger),
session.NewForwarder("http://127.0.0.1:1", logger),
),
Forwarder: session.NewForwarder("http://127.0.0.1:1", logger),
DeviceRepo: device.NewInMemoryRepository(),
ModelRepo: modelRepo,
Storage: storeStore,
Converter: converter.NewStubClient(),
Conversion: conversionService,
MaxUploadSizeMB: 0,
OIDCProvider: oidcProvider,
SessionManager: sessionMgr,
OIDCPostLoginURL: apiTS.URL,
})
lazy.Set(router)
return &conversionFixture{
server: apiTS,
fakeOIDC: fakeOIDC,
conv: conv,
faa: faa,
router: router,
}
}
// AuthenticatedClient 走完整 OIDC login flow複製自 oidc_test_helper_test.go 的 pattern
// 但綁本檔的 conversionFixturefakeOIDC / apiServer
//
// 不直接 reuse testFixture.AuthenticatedClient因為那個綁的是 setupFixture 的 testFixture
// 結構;我們的 conversionFixture 是獨立 type。
func (f *conversionFixture) AuthenticatedClient(t *testing.T, userID, email string) *http.Client {
t.Helper()
f.fakeOIDC.SetNextIDTokenClaims(map[string]any{
"sub": userID,
"email": email,
"name": userID,
})
jar, err := cookiejar.New(nil)
require.NoError(t, err)
flowClient := &http.Client{
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 10 * time.Second,
}
loc := getExpect302(t, flowClient, f.server.URL+"/api/auth/login")
require.True(t, strings.HasPrefix(loc, f.fakeOIDC.URL+"/authorize"),
"login 應 302 to fakeOIDC /authorize得 %s", loc)
cb := f.fakeOIDC.SimulateAuthorizationFlow(t, loc)
_ = getExpect302(t, flowClient, cb)
u, err := url.Parse(f.server.URL)
require.NoError(t, err)
var sess *http.Cookie
for _, c := range jar.Cookies(u) {
if c.Name == "visiona_session" {
sess = c
break
}
}
require.NotNil(t, sess, "expected visiona_session cookie")
return &http.Client{Jar: jar, Timeout: 30 * time.Second}
}
// ==========================================================================
// E2E #1Streaming proxy 完整跑通
// ==========================================================================
// TestConversionE2E_StreamingProxy 驗 visionA 的 InitJob 真的 streaming —
// 用 io.Pipe 對 visionA 送大量 multipart body~10MB
//
// 1. mock converter 收到的 body 解析後能取出 visionA 灌的 user_idOIDC sub
// 2. mock converter 收到的 model file 內容與 client 端送的 byte-perfect 一致
// 3. response 201 + job_id
// 4. visionA backend 沒在記憶體 buffer 整個 body透過 streaming 行為 + 沒 OOM 隱含驗證)
//
// 體積 10MB 而非 100MBCI 上跑 race -count=3每次都建 100MB buffer 太貴;
// 10MB 已能驗 streaming 行為(若 visionA 有 buffer 全 RAM10MB 也會被測出來:
// io.Pipe 的 reader 卡住 → mock converter 永遠收不到完整 body → handler 200ms 內失敗)。
func TestConversionE2E_StreamingProxy(t *testing.T) {
f := setupConversionFixture(t)
defer f.Close()
const wantSub = "user-streaming-001"
client := f.AuthenticatedClient(t, wantSub, "stream@e2e.local")
// 產生 ~10MB 隨機 model file contentmock 收到後比對 byte-perfect
modelBytes := make([]byte, 10*1024*1024)
_, err := rand.Read(modelBytes)
require.NoError(t, err)
// 用 io.Pipe + multipart.Writer 邊產 body 邊送streaming沒在記憶體組整個 body
pr, pw := io.Pipe()
mw := multipart.NewWriter(pw)
contentType := mw.FormDataContentType()
go func() {
defer pw.Close()
defer mw.Close()
// 順序:先寫 form fields再寫 fileconverter multer 慣例)
_ = mw.WriteField("model_id", "12345")
_ = mw.WriteField("version", "v1.0.0")
_ = mw.WriteField("platform", "720")
// 嘗試塞 user_id攻擊者場景— 驗 visionA 黑名單
_ = mw.WriteField("user_id", "ATTACKER-OVERRIDE")
fw, _ := mw.CreateFormFile("model", "yolov5s.onnx")
// chunked write每次寫 64KB確保走 streaming 路徑)
for i := 0; i < len(modelBytes); i += 64 * 1024 {
end := i + 64*1024
if end > len(modelBytes) {
end = len(modelBytes)
}
if _, werr := fw.Write(modelBytes[i:end]); werr != nil {
return
}
}
}()
req, err := http.NewRequest(http.MethodPost, f.server.URL+"/api/conversion/init", pr)
require.NoError(t, err)
req.Header.Set("Content-Type", contentType)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode, "body=%s", string(bodyBytes))
// 驗 response shape
var apiResp map[string]any
require.NoError(t, json.Unmarshal(bodyBytes, &apiResp))
require.Equal(t, true, apiResp["success"])
data := apiResp["data"].(map[string]any)
jobID, _ := data["job_id"].(string)
require.NotEmpty(t, jobID)
require.Equal(t, "running", data["status"])
// 驗 mock converter 收到的 body 真的有解出 user_id且為 visionA 灌的 OIDC sub
gotBody, gotCT, gotLen := f.conv.initBodySnapshot()
require.NotZero(t, gotLen, "mock converter 應收到 non-empty body")
require.Greater(t, gotLen, int64(10*1024*1024), "body 至少 10MB含 multipart overhead 應略大)")
gotUserID, ok := parseMultipartField(gotCT, gotBody, "user_id")
require.True(t, ok, "mock converter 收到的 multipart 應含 user_id field")
require.Equal(t, wantSub, gotUserID,
"visionA 必須注入 OIDC sub 為 user_id不能採用 client 端塞的 ATTACKER-OVERRIDE")
// 驗 model file 內容 byte-perfectstreaming forward 沒掉 byte / 沒亂改)
gotModel, ok := parseMultipartFile(gotCT, gotBody, "model")
require.True(t, ok, "mock converter 收到的 multipart 應含 model file")
require.Equal(t, len(modelBytes), len(gotModel), "model file 長度應 byte-perfect 一致")
require.True(t, bytes.Equal(modelBytes, gotModel), "model file content 應 byte-perfect 一致")
require.Equal(t, int32(1), f.conv.initCallCount.Load(), "converter init 應被打 1 次")
}
// ==========================================================================
// E2E #2重啟恢復 lazy rebuild
// ==========================================================================
// TestConversionE2E_LazyRebuildAfterRestart 驗 visionA backend 重啟後in-memory
// ownership 全空user 對 GET /active 仍能拿到 in_progress job — 透過對 converter
// ListInProgressJobs 觸發 lazy rebuild。
//
// 流程:
// 1. 起 instance A、預先在 mock converter 端註冊 user X 有 1 個 in_progress job
// 模擬user X 之前 init 過,但 visionA-backend 重啟導致 in-memory ownership 丟失)
// 2. user X 透過 instance A 對 /active 打第一次 → 回 has_active:truelazy rebuild
// 3. 驗 mock converter 的 ListInProgressJobs 被打 1 次
// 4. user X 對 instance A 對 /active 打第二次 → 仍回 has_active:true
// ListInProgressJobs **沒有**再被打cache hit / rebuilt flag set
//
// 注意:題目說「啟動 instance B模擬重啟— 沿用同一個 mock converter」實作上
// 「instance B」就是「重新 setupConversionFixture 但共用 mock converter」。但 instance A
// 從來沒有 init 過任何 job題目 #2 的前提就是 in-memory ownership 全空),所以 instance A
// 本身已等同「重啟後的乾淨 instance」— 不需要真的開兩個 server這樣場景測得更乾淨。
func TestConversionE2E_LazyRebuildAfterRestart(t *testing.T) {
f := setupConversionFixture(t)
defer f.Close()
const wantSub = "user-rebuild-002"
// 預先在 mock converter 端註冊 user X 有 1 個 in_progress job模擬 visionA 重啟前的狀態)
preexistingJobID := "job-preexisting-001"
createdAt := time.Now().Add(-1 * time.Hour).UTC()
f.conv.addInProgressJob(wantSub, preexistingJobID, createdAt)
client := f.AuthenticatedClient(t, wantSub, "rebuild@e2e.local")
// 第一次 /active → 觸發 lazy rebuildvisionA 對 converter 打 ListInProgressJobs
resp1 := getJSONReq(t, client, f.server.URL+"/api/conversion/active")
require.Equal(t, http.StatusOK, resp1.status, "body=%v", resp1.body)
require.Equal(t, true, resp1.body["success"])
data1 := resp1.body["data"].(map[string]any)
assert.Equal(t, true, data1["has_active"],
"lazy rebuild 後應拿到 active jobvisionA 從 converter 重建 ownership")
job1 := data1["job"].(map[string]any)
assert.Equal(t, preexistingJobID, job1["job_id"],
"應拿回 mock 端那個預先註冊的 job_id")
// list 應被打 1 次
listCount1 := f.conv.listJobsCallCount.Load()
require.Equal(t, int32(1), listCount1, "lazy rebuild 應打 ListInProgressJobs 1 次")
// 第二次 /active → 走 cacherebuilt flag set不再打 list
resp2 := getJSONReq(t, client, f.server.URL+"/api/conversion/active")
require.Equal(t, http.StatusOK, resp2.status)
data2 := resp2.body["data"].(map[string]any)
assert.Equal(t, true, data2["has_active"], "第二次仍應有 active")
listCount2 := f.conv.listJobsCallCount.Load()
assert.Equal(t, listCount1, listCount2,
"第二次 /active 應走 cache不再打 ListInProgressJobs實際多了 %d 次)",
listCount2-listCount1)
}
// ==========================================================================
// E2E #3Download server-side stream proxyPhase 0.8b
// ==========================================================================
// TestConversionE2E_DownloadStream 驗 Phase 0.8b v0.6 後 download 端對端行為:
//
// 1. user X 對 completed job 打 /download → status 200 OK
// 2. response header
// - Content-Type: application/octet-stream
// - Content-Disposition: attachment; filename="..."filename 經 sanitize
// - Cache-Control: no-store, no-cache, must-revalidate, max-age=0
// 3. response body bytes 與 mock converter `/result` 寫的 binary 一致byte-perfect
// 4. mock converter 端 `/api/v1/jobs/{id}/result` 收到 visionA 帶的 Authorization:
// Bearer <fixture converter API key>(驗 visionA 端真的用 API key wire 對 converter 發 request
// 5. **沒有** 302 / Location header / token 結構性流經 frontend
// Phase 0.8b 設計核心server-side proxy 取代 delegated token redirect
// 6. **v0.6 新增**visionA 端**不再直接打 FAA**mock FAA `/files` 的 getCallCount 應為 0
// —— 對應 ADR-016 撤回 visionA → FAA 直接呼叫的設計
//
// 對齊 api-conversion.md §4 (Phase 0.8b v0.6) + conversion.md §4.1 + ADR-016 §1。
//
// 流程:
// - 起 fixturemock converter `/result` 預設回 small NEF binary marker
// - user X init 一個 job → mock converter 自動建 running job
// - markJobCompleted(jobID) 把 mock job 推進 completed
// - 對 /download 打 GET — client 設 ErrUseLastResponse 防止意外 follow雖預期非 302
// - 驗以上 6 點
func TestConversionE2E_DownloadStream(t *testing.T) {
f := setupConversionFixture(t)
defer f.Close()
// v0.6:設定 mock converter `/result` 回的 NEF binary取代原 mock FAA setNEFPayload
const wantNEFContent = "PHASE-0.8b-v0.6-MOCK-NEF-BINARY-PAYLOAD-FROM-CONVERTER-RESULT-12345"
f.conv.setResultPayload([]byte(wantNEFContent))
const wantSub = "user-download-003"
client := f.AuthenticatedClient(t, wantSub, "download@e2e.local")
// 1. 先 init 一個 job讓 visionA 端寫 ownership + mock converter 建 running job
jobID := initSimpleJob(t, client, f.server.URL)
// 2. 把 mock converter 端 job 推進到 completed給 download 用)
f.conv.markJobCompleted(jobID)
// 3. 對 /download 打 GET — 設 ErrUseLastResponse 防意外 follow預期非 302
noRedirectClient := &http.Client{
Jar: client.Jar,
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := noRedirectClient.Get(f.server.URL + "/api/conversion/" + jobID + "/download")
require.NoError(t, err)
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// === 斷言 1status 200 OKPhase 0.8b 不再回 302===
require.Equal(t, http.StatusOK, resp.StatusCode,
"Phase 0.8b 後 /download 回 200server-side stream proxy不再 302body=%s",
string(bodyBytes))
// === 斷言 2response header 對齊 api-conversion.md §4 ===
assert.Equal(t, "application/octet-stream", resp.Header.Get("Content-Type"),
"Content-Type 應為 application/octet-stream觸發 browser download dialog")
cd := resp.Header.Get("Content-Disposition")
assert.True(t, strings.HasPrefix(cd, "attachment; filename="),
"Content-Disposition 應為 attachment; filename=...,得 %s", cd)
// filename 應已被 sanitize不含控制字元 / path sep / quote
assert.NotContains(t, cd, "\r", "Content-Disposition 不應含 \\rCRLF injection 防護)")
assert.NotContains(t, cd, "\n", "Content-Disposition 不應含 \\nCRLF injection 防護)")
// filename 應對齊 wireframe §8.1<source_filename_stem>_<chip_lower>.nef
// mock converter 建的 job source_filename=yolov5s.onnx + platform=720 → yolov5s_kl720.nef
// — v0.6mock converter `/result` 給的 filename 對 user 不直觀visionA backend 用
// defaultDownloadFilename(cj) 覆寫成 <stem>_<chip>.nef
assert.Contains(t, cd, ".nef",
"filename 應以 .nef 結尾NEF 結果檔),得 %s", cd)
assert.Contains(t, cd, "yolov5s_kl720.nef",
"v0.6filename 應由 visionA defaultDownloadFilename(cj) 覆寫成 <stem>_<chip>.nef得 %s", cd)
cc := resp.Header.Get("Cache-Control")
assert.Contains(t, cc, "no-store",
"Cache-Control 應含 no-store避免 browser cache private NEF")
assert.Contains(t, cc, "no-cache",
"Cache-Control 應含 no-cache得 %s", cc)
assert.Contains(t, cc, "must-revalidate",
"Cache-Control 應含 must-revalidate得 %s", cc)
// === 斷言 3response body byte-perfect 對齊 mock converter `/result` 寫的 binary ===
assert.Equal(t, wantNEFContent, string(bodyBytes),
"response body 應等於 mock converter `/result` 寫的 NEF binarybyte-perfect stream proxy")
// === 斷言 4mock converter `/result` 收到 visionA 帶的 Authorization Bearer <CONVERTER API key> ===
// v0.6:取代原本驗 mock FAA 收到 FAA API key 的斷言
authHeader := f.conv.getResultLastAuthHeader()
assert.True(t, strings.HasPrefix(authHeader, "Bearer "),
"mock converter `/result` 應收到 Bearer 開頭的 Authorization header得 %q", authHeader)
assert.Contains(t, authHeader, "fixture-converter-api-key-do-not-use-in-prod",
"mock converter `/result` 應收到 fixture converter API key驗 visionA 端 wire 正確)")
// === 斷言 5沒有 302 / Location headerPhase 0.8b 結構性無 redirect===
assert.NotEqual(t, http.StatusFound, resp.StatusCode,
"Phase 0.8b 不再回 302 Found取消 delegated token 機制)")
assert.Empty(t, resp.Header.Get("Location"),
"Phase 0.8b 不應有 Location header無 redirect 流程)")
// === 斷言 6visionA 端不再直接打 FAAv0.6 設計約束 + T3 雙層防護)===
//
// 這條斷言是 **ADR-016 §1 設計約束的 regression 防護**visionA 端不再直接呼叫 FAA
// 整條 download path 改走 converter MinIO。萬一未來某 agent 不小心加回 FAA 直連
// 例如「optimize: 直接打 FAA 跳一層」、或從 git history 誤 copy 舊程式碼),此 e2e
// 立即 fail。
//
// **T3 後的雙層防護**reviewer 推薦方案 1保留 mockFAA + negative assertion
// 1. **編譯期保證**v0.6 T3 新加):`FAAClient` interface + `faa_client.go` 整檔已砍除;
// production code 不可能再 import `conversion.FAAClient` 或 `conversion.NewFAAClient`
// type 不存在 → 編譯 fail
// 2. **執行期保證**本斷言mockFAA server 仍 spin up若有人直接用 net/http 手寫
// 對 FAA URL 的 request繞過 conversion package interfacegetCallCount > 0 →
// e2e fail。涵蓋編譯期斷言無法抓的「raw HTTP 直連」regression
assert.Equal(t, int32(0), f.faa.getCallCount.Load(),
"v0.6 T3visionA 端不應再直接打 FAAdownload path 改走 converter `/result`"+
"FAAClient interface 已整檔砍除作為第一層編譯期防護,此斷言為第二層執行期防護)")
// 驗 mock converter `/result` 真的被打到(防 wire 路徑錯)
assert.GreaterOrEqual(t, int(f.conv.getResultCallCount.Load()), 1,
"mock converter GET /api/v1/jobs/{id}/result 應至少被打一次")
}
// ==========================================================================
// E2E #4Active job 409 衝突
// ==========================================================================
// TestConversionE2E_ActiveJobConflict 驗:同一 user 在 visionA 端有 active job 時,
// 第二個 init 應回 409 + body 含 active_job 詳情。
//
// 流程:
// 1. user X 第一個 init → 200 + 取得 jobID1visionA 寫 ownership
// 2. user X 第二個 init → visionA pre-check 命中 ownership.ActiveJobOf(userID) 不為空
// → flow.checkActiveJob 對 mock converter GetJob jobID1 → status=runningactive
// → 回 *ActiveJobErrorhandler 包成 409 + extra.active_job
// 3. 驗 status 409 + body.error.code == "active_job_exists" + extra.active_job.job_id == jobID1
//
// 注意題目原本要求「mock converter 第二次回 409 user_has_active_job」 —
// 但實際上 visionA pre-check 會在打 converter 之前就 short-circuit§9.3 流程圖):
// 因此第二個 init 根本不會打到 converter init endpoint。這個行為更安全少一次浪費 round-trip
// 我們驗 visionA 自己的 pre-check 有效,並驗 active_job extra payload。
//
// 若要驗「converter 端也有同樣保護」由 internal/conversion/converter_client_test.go
// 的 ActiveJobError mapping test 涵蓋T3 已驗)。
func TestConversionE2E_ActiveJobConflict(t *testing.T) {
f := setupConversionFixture(t)
defer f.Close()
const wantSub = "user-conflict-004"
client := f.AuthenticatedClient(t, wantSub, "conflict@e2e.local")
// 第一次 init → 應 201
jobID1 := initSimpleJob(t, client, f.server.URL)
require.NotEmpty(t, jobID1)
// 第二次 init → 應撞 409
resp2 := postSimpleInit(t, client, f.server.URL)
defer resp2.Body.Close()
body2, _ := io.ReadAll(resp2.Body)
require.Equal(t, http.StatusConflict, resp2.StatusCode,
"第二次 init 應 409body=%s", string(body2))
var apiResp map[string]any
require.NoError(t, json.Unmarshal(body2, &apiResp))
require.Equal(t, false, apiResp["success"])
errObj := apiResp["error"].(map[string]any)
assert.Equal(t, "active_job_exists", errObj["code"])
// extra.active_job.job_id 應為 jobID1
extra, ok := errObj["extra"].(map[string]any)
require.True(t, ok, "error.extra 應存在(帶 active_job 詳情),實際 error=%v", errObj)
activeJob, ok := extra["active_job"].(map[string]any)
require.True(t, ok, "extra.active_job 應為 object")
assert.Equal(t, jobID1, activeJob["job_id"],
"active_job.job_id 應指向第一個 init 建立的 job")
// 驗第二次 init **沒有真的打到** mock converter init endpointvisionA pre-check 短路)
assert.Equal(t, int32(1), f.conv.initCallCount.Load(),
"第二次 init 應被 visionA pre-check 短路mock converter init 應只被打 1 次(實際 %d 次)",
f.conv.initCallCount.Load())
}
// ==========================================================================
// 共用 helper
// ==========================================================================
// initSimpleJob 對 visionA 送一個 minimal multipart init request回 jobID。
func initSimpleJob(t *testing.T, client *http.Client, baseURL string) string {
t.Helper()
resp := postSimpleInit(t, client, baseURL)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode, "init body=%s", string(body))
var apiResp map[string]any
require.NoError(t, json.Unmarshal(body, &apiResp))
data := apiResp["data"].(map[string]any)
id, _ := data["job_id"].(string)
require.NotEmpty(t, id)
return id
}
// postSimpleInit 送一個 minimal multipart init回 raw responsecaller defer Close
func postSimpleInit(t *testing.T, client *http.Client, baseURL string) *http.Response {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("model_id", "12345")
_ = mw.WriteField("version", "v1.0.0")
_ = mw.WriteField("platform", "720")
fw, _ := mw.CreateFormFile("model", "yolov5s.onnx")
_, _ = fw.Write([]byte("dummy-onnx-bytes"))
_ = mw.Close()
req, err := http.NewRequest(http.MethodPost, baseURL+"/api/conversion/init", &buf)
require.NoError(t, err)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := client.Do(req)
require.NoError(t, err)
return resp
}
// getJSONReq 對 client 打 GET 並 parse JSON。複製自 oidc_e2e_test 的 getJSON
// 但獨立命名避免 helper 命名衝突。
func getJSONReq(t *testing.T, client *http.Client, target string) jsonResp {
t.Helper()
resp, err := client.Get(target)
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
out := jsonResp{status: resp.StatusCode, body: map[string]any{}}
if len(body) > 0 {
_ = json.Unmarshal(body, &out.body)
}
return out
}
// parseMultipartField 從 raw multipart body 取 form field 的值。
func parseMultipartField(contentType string, body []byte, fieldName string) (string, bool) {
parts, ok := iterMultipart(contentType, body)
if !ok {
return "", false
}
for _, p := range parts {
if p.name == fieldName && p.filename == "" {
return string(p.body), true
}
}
return "", false
}
// parseMultipartFile 從 raw multipart body 取 file part 的內容。
func parseMultipartFile(contentType string, body []byte, fieldName string) ([]byte, bool) {
parts, ok := iterMultipart(contentType, body)
if !ok {
return nil, false
}
for _, p := range parts {
if p.name == fieldName && p.filename != "" {
return p.body, true
}
}
return nil, false
}
// multipartPart 是 iterMultipart 的中間結構。
type multipartPart struct {
name string
filename string
body []byte
}
// iterMultipart 解 raw multipart body → []multipartPart。
func iterMultipart(contentType string, body []byte) ([]multipartPart, bool) {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, false
}
boundary := params["boundary"]
if boundary == "" {
return nil, false
}
mr := multipart.NewReader(bytes.NewReader(body), boundary)
out := make([]multipartPart, 0, 4)
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
return out, true
}
if err != nil {
return nil, false
}
raw, _ := io.ReadAll(part)
_ = part.Close()
out = append(out, multipartPart{
name: part.FormName(),
filename: part.FileName(),
body: raw,
})
}
}
// writeJSON 是 mock server handler 共用的 JSON 回應 helper。
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}