// conversion_test.go — handler-level unit tests for /api/conversion/*。 // // 用 in-package stub 實作 conversion.Service,測 handler 層轉接、路由註冊、 // 錯誤對應的正確性。實際 Service 行為(multipart 重組、ownership rebuild、 // promote → FAA pull → finalize)由 internal/conversion/*_test.go 覆蓋。 // // Phase 0.8 conversion (見 .autoflow/04-architecture/api/api-conversion.md) package api import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/conversion" ) // ========================================================================== // Service stub // ========================================================================== // stubConversionService 是 conversion.Service 的測試 stub。 // // 每個 method 都有對應的 InitJobFn / GetJobFn / ... 欄位,由 test case 注入想要的行為。 // 沒注入的 method 預設回 (nil, nil) — 對應 method 不被呼叫的 case。 // // goroutine-safe:所有欄位由 test setup 階段一次性寫入,handler 呼叫時只讀。 type stubConversionService struct { mu sync.Mutex // 紀錄上一次呼叫的參數,給 test 驗 user_id 注入正確(trust boundary) lastUserID string lastJobID string InitJobFn func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) GetJobFn func(ctx context.Context, userID, jobID string) (*conversion.Job, error) ActiveJobFn func(ctx context.Context, userID string) (*conversion.Job, error) PromoteFn func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) DownloadFn func(ctx context.Context, userID, jobID string) (string, error) } func (s *stubConversionService) InitJob(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) { s.mu.Lock() s.lastUserID = in.UserID s.mu.Unlock() if s.InitJobFn == nil { return nil, errors.New("stub: InitJobFn not set") } return s.InitJobFn(ctx, in) } func (s *stubConversionService) GetJob(ctx context.Context, userID, jobID string) (*conversion.Job, error) { s.mu.Lock() s.lastUserID = userID s.lastJobID = jobID s.mu.Unlock() if s.GetJobFn == nil { return nil, errors.New("stub: GetJobFn not set") } return s.GetJobFn(ctx, userID, jobID) } func (s *stubConversionService) ActiveJob(ctx context.Context, userID string) (*conversion.Job, error) { s.mu.Lock() s.lastUserID = userID s.mu.Unlock() if s.ActiveJobFn == nil { return nil, errors.New("stub: ActiveJobFn not set") } return s.ActiveJobFn(ctx, userID) } func (s *stubConversionService) PromoteToModels(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) { s.mu.Lock() s.lastUserID = userID s.lastJobID = jobID s.mu.Unlock() if s.PromoteFn == nil { return nil, errors.New("stub: PromoteFn not set") } return s.PromoteFn(ctx, userID, jobID, name) } func (s *stubConversionService) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) { s.mu.Lock() s.lastUserID = userID s.lastJobID = jobID s.mu.Unlock() if s.DownloadFn == nil { return "", errors.New("stub: DownloadFn not set") } return s.DownloadFn(ctx, userID, jobID) } // ========================================================================== // Fixture // ========================================================================== // newConversionFixture 建一個只裝 conversion routes 的 gin engine。 // // 所有 handler 都跑在 injectStaticUserContext("demo-user", ...) 之後 — // 模擬「user 已登入」場景;驗 AuthMiddleware 行為由 oidc_auth_test 負責。 func newConversionFixture(t *testing.T, svc conversion.Service) *gin.Engine { t.Helper() r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext("demo-user", "demo@example.com")) g := r.Group("/api") registerConversionRoutes(g, Deps{Conversion: svc}) return r } // sampleJob 是一個典型的成功 job — 給 happy path 用。 func sampleJob() *conversion.Job { now := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC) return &conversion.Job{ JobID: "job-abc-123", Status: "running", Stage: "onnx", Progress: 0, StageProgress: 0, CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(7 * 24 * time.Hour), SourceFilename: "yolov5s.onnx", TargetChip: "720", } } // ========================================================================== // 0. 共通:未啟用時 5 個 endpoint 全 501 // ========================================================================== // TestConversion_Disabled_All501 — 當 deps.Conversion = nil 時,5 個 endpoint 全回 501。 // // 對齊 main.go:cfg.Conversion.Enabled() == false 時 deps.Conversion 為 nil。 func TestConversion_Disabled_All501(t *testing.T) { r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext("demo-user", "")) g := r.Group("/api") registerConversionRoutes(g, Deps{Conversion: nil}) // 未啟用 cases := []struct { method string path string }{ {http.MethodPost, "/api/conversion/init"}, {http.MethodGet, "/api/conversion/active"}, {http.MethodGet, "/api/conversion/job-1"}, {http.MethodPost, "/api/conversion/job-1/promote-to-models"}, {http.MethodGet, "/api/conversion/job-1/download"}, } for _, c := range cases { t.Run(c.method+" "+c.path, func(t *testing.T) { req := httptest.NewRequest(c.method, c.path, nil) if c.method == http.MethodPost { req.Header.Set("Content-Type", "application/json") } w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusNotImplemented, w.Code, "%s %s should be 501 when Conversion=nil; body=%s", c.method, c.path, w.Body.String()) }) } } // ========================================================================== // 1. POST /api/conversion/init // ========================================================================== // TestConversion_Init_HappyPath — 成功 init 回 201 + Job。 func TestConversion_Init_HappyPath(t *testing.T) { job := sampleJob() svc := &stubConversionService{ InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) { // 驗 user_id 正確注入(trust boundary) require.Equal(t, "demo-user", in.UserID) require.NotEmpty(t, in.ContentType) require.NotNil(t, in.Body) // 驗 body 有內容(streaming reader 還沒被讀) b, err := io.ReadAll(in.Body) require.NoError(t, err) require.Contains(t, string(b), "fake-multipart") return job, nil }, } r := newConversionFixture(t, svc) body := strings.NewReader("--xyz\r\nContent-Disposition: form-data; name=\"fake-multipart\"\r\n\r\ndata\r\n--xyz--\r\n") req := httptest.NewRequest(http.MethodPost, "/api/conversion/init", body) req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz") w := httptest.NewRecorder() r.ServeHTTP(w, req) require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String()) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) data := sb.Data.(map[string]any) assert.Equal(t, "job-abc-123", data["job_id"]) assert.Equal(t, "running", data["status"]) assert.Equal(t, "yolov5s.onnx", data["source_filename"]) assert.Equal(t, "720", data["target_chip"]) } // TestConversion_Init_BadContentType — Content-Type 非 multipart/form-data 回 400。 // // 這擋下 client 傳 JSON 等錯誤格式(避免 Service 層白白讀完 body 才發現格式錯)。 func TestConversion_Init_BadContentType(t *testing.T) { svc := &stubConversionService{} // 不應該被呼叫 r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodPost, "/api/conversion/init", strings.NewReader(`{"foo":"bar"}`)) req.Header.Set("Content-Type", "application/json") // 錯誤 w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), ErrCodeValidationFailed) assert.Contains(t, w.Body.String(), "multipart/form-data") } // TestConversion_Init_ActiveJobError — ActiveJobError 回 409 + extra.active_job。 // // 這個 case 驗 handleConversionError 對 errors.As(*ActiveJobError) 的特殊處理。 func TestConversion_Init_ActiveJobError(t *testing.T) { existingJob := &conversion.Job{ JobID: "job-existing-456", Status: "running", Stage: "bie", Progress: 45, } svc := &stubConversionService{ InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) { return nil, &conversion.ActiveJobError{Job: existingJob} }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodPost, "/api/conversion/init", strings.NewReader("--xyz\r\n--xyz--\r\n")) req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz") w := httptest.NewRecorder() r.ServeHTTP(w, req) require.Equal(t, http.StatusConflict, w.Code, "body=%s", w.Body.String()) var eb ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &eb)) require.NotNil(t, eb.Error) assert.Equal(t, "active_job_exists", eb.Error.Code) require.NotNil(t, eb.Error.Extra) activeJob, ok := eb.Error.Extra["active_job"].(map[string]any) require.True(t, ok, "extra.active_job should be object; got %v", eb.Error.Extra) assert.Equal(t, "job-existing-456", activeJob["job_id"]) } // TestConversion_Init_ValidationError — ConverterValidationError 回 400 + details.fields。 func TestConversion_Init_ValidationError(t *testing.T) { svc := &stubConversionService{ InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) { return nil, &conversion.ConverterValidationError{ Fields: []conversion.ValidationFieldError{ {Field: "model_id", Message: "must be 1-65535"}, }, Message: "validation failed", } }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodPost, "/api/conversion/init", strings.NewReader("--xyz\r\n--xyz--\r\n")) req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz") w := httptest.NewRecorder() r.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code, "body=%s", w.Body.String()) var eb ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &eb)) assert.Equal(t, "validation_failed", eb.Error.Code) require.Len(t, eb.Error.Details, 1) assert.Equal(t, "model_id", eb.Error.Details[0].Field) } // TestConversion_Init_ConverterUnavailable — 502 mapping。 func TestConversion_Init_ConverterUnavailable(t *testing.T) { svc := &stubConversionService{ InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) { return nil, conversion.ErrConverterUnavailable }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodPost, "/api/conversion/init", strings.NewReader("--xyz\r\n--xyz--\r\n")) req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadGateway, w.Code) assert.Contains(t, w.Body.String(), "converter_unavailable") } // ========================================================================== // 2. GET /api/conversion/active // ========================================================================== func TestConversion_Active_HasActive(t *testing.T) { job := sampleJob() svc := &stubConversionService{ ActiveJobFn: func(ctx context.Context, userID string) (*conversion.Job, error) { require.Equal(t, "demo-user", userID) return job, nil }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodGet, "/api/conversion/active", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) data := sb.Data.(map[string]any) assert.Equal(t, true, data["has_active"]) jobMap, ok := data["job"].(map[string]any) require.True(t, ok) assert.Equal(t, "job-abc-123", jobMap["job_id"]) } func TestConversion_Active_NoActive(t *testing.T) { svc := &stubConversionService{ ActiveJobFn: func(ctx context.Context, userID string) (*conversion.Job, error) { return nil, nil }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodGet, "/api/conversion/active", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) data := sb.Data.(map[string]any) assert.Equal(t, false, data["has_active"]) assert.Nil(t, data["job"]) } func TestConversion_Active_ConverterUnavailable(t *testing.T) { svc := &stubConversionService{ ActiveJobFn: func(ctx context.Context, userID string) (*conversion.Job, error) { return nil, conversion.ErrConverterUnavailable }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodGet, "/api/conversion/active", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadGateway, w.Code) } // ========================================================================== // 3. GET /api/conversion/{job_id} // ========================================================================== func TestConversion_Get_HappyPath(t *testing.T) { job := sampleJob() svc := &stubConversionService{ GetJobFn: func(ctx context.Context, userID, jobID string) (*conversion.Job, error) { require.Equal(t, "demo-user", userID) require.Equal(t, "job-abc-123", jobID) return job, nil }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc-123", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) data := sb.Data.(map[string]any) assert.Equal(t, "job-abc-123", data["job_id"]) assert.Equal(t, "running", data["status"]) } func TestConversion_Get_NotFound(t *testing.T) { svc := &stubConversionService{ GetJobFn: func(ctx context.Context, userID, jobID string) (*conversion.Job, error) { return nil, conversion.ErrJobNotFound }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodGet, "/api/conversion/missing-job", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) assert.Contains(t, w.Body.String(), "not_found") } // ========================================================================== // 4. POST /api/conversion/{job_id}/promote-to-models // ========================================================================== func TestConversion_Promote_HappyPath(t *testing.T) { now := time.Date(2026, 4, 30, 12, 30, 0, 0, time.UTC) res := &conversion.PromoteResult{ ModelID: "model-xyz", Source: "converted", SourceJobID: "job-abc-123", Name: "yolo_kl720", TargetChip: "kl720", FileSize: 12345, Status: "ready", CreatedAt: now, } svc := &stubConversionService{ PromoteFn: func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) { require.Equal(t, "demo-user", userID) require.Equal(t, "job-abc-123", jobID) require.Equal(t, "yolo_kl720", name) return res, nil }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodPost, "/api/conversion/job-abc-123/promote-to-models", strings.NewReader(`{"name":"yolo_kl720"}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String()) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) data := sb.Data.(map[string]any) assert.Equal(t, "model-xyz", data["model_id"]) assert.Equal(t, "converted", data["source"]) assert.Equal(t, "ready", data["status"]) } // TestConversion_Promote_NoBody — 沒帶 body 也應該成功(name 可為空)。 func TestConversion_Promote_NoBody(t *testing.T) { svc := &stubConversionService{ PromoteFn: func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) { require.Equal(t, "", name) // body 沒帶 → name 為空,由 Service fallback return &conversion.PromoteResult{ModelID: "m1", Source: "converted", Status: "ready"}, nil }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodPost, "/api/conversion/job-abc/promote-to-models", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String()) } func TestConversion_Promote_BadJSON(t *testing.T) { svc := &stubConversionService{} // 不該被呼叫 r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodPost, "/api/conversion/job/promote-to-models", strings.NewReader(`{not valid json`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), ErrCodeValidationFailed) } func TestConversion_Promote_JobNotCompleted(t *testing.T) { svc := &stubConversionService{ PromoteFn: func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) { return nil, conversion.ErrJobNotCompleted }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodPost, "/api/conversion/job-abc/promote-to-models", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusConflict, w.Code) assert.Contains(t, w.Body.String(), "job_not_completed") } // ========================================================================== // 5. GET /api/conversion/{job_id}/download // ========================================================================== func TestConversion_Download_HappyPath302(t *testing.T) { target := "http://192.168.0.130:5081/files/models/u/job.nef?access_token=opaque-xyz" svc := &stubConversionService{ DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) { require.Equal(t, "demo-user", userID) require.Equal(t, "job-abc", jobID) return target, nil }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc/download", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusFound, w.Code) // 302 assert.Equal(t, target, w.Header().Get("Location")) // 防快取 header — token 不該被 browser cache(§10.4) assert.Contains(t, w.Header().Get("Cache-Control"), "no-store") assert.Equal(t, "no-cache", w.Header().Get("Pragma")) } func TestConversion_Download_JobNotCompleted(t *testing.T) { svc := &stubConversionService{ DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) { return "", conversion.ErrJobNotCompleted }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc/download", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) // 錯誤情況**不 redirect** — 回標準 JSON error assert.Equal(t, http.StatusConflict, w.Code) assert.Contains(t, w.Body.String(), "job_not_completed") assert.NotEqual(t, http.StatusFound, w.Code, "error case must not 302 redirect") } func TestConversion_Download_NotFound(t *testing.T) { svc := &stubConversionService{ DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) { return "", conversion.ErrJobNotFound }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodGet, "/api/conversion/missing/download", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) } func TestConversion_Download_MCTokenUnavailable(t *testing.T) { svc := &stubConversionService{ DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) { return "", conversion.ErrMCTokenUnavailable }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodGet, "/api/conversion/job/download", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadGateway, w.Code) assert.Contains(t, w.Body.String(), "mc_token_unavailable") } // ========================================================================== // User_id trust boundary // ========================================================================== // TestConversion_Init_IgnoresClientUserID — 即使 multipart form 帶 user_id,handler // 仍只把 cookie session 的 UserID 傳給 Service。 // // 這是 trust boundary 的回歸測試(conversion.md §7)。實際 multipart 重組 / 黑名單 // 邏輯在 Service 層做(flow.go rebuildMultipart),但 handler 必須確保傳給 Service 的 // InitJobInput.UserID 永遠是 UserContext 的,不是 client 提供的。 func TestConversion_Init_IgnoresClientUserID(t *testing.T) { svc := &stubConversionService{ InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) { // 即使 client 在 multipart 內塞了 user_id=attacker,handler 給 Service 的 UserID // 必須是 demo-user(從 UserContext 拿) require.Equal(t, "demo-user", in.UserID) return sampleJob(), nil }, } r := newConversionFixture(t, svc) // 一個包含 user_id=attacker 的 multipart body — 應被忽略 body := strings.NewReader( "--xyz\r\n" + "Content-Disposition: form-data; name=\"user_id\"\r\n\r\n" + "attacker\r\n" + "--xyz\r\n" + "Content-Disposition: form-data; name=\"model\"\r\n\r\n" + "data\r\n" + "--xyz--\r\n", ) req := httptest.NewRequest(http.MethodPost, "/api/conversion/init", body) req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz") w := httptest.NewRecorder() r.ServeHTTP(w, req) require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String()) } // TestConversion_GetJob_IgnoresQueryUserID — query 帶 user_id 不影響 handler // 傳給 Service 的 userID(仍是 UserContext 拿到的)。 func TestConversion_GetJob_IgnoresQueryUserID(t *testing.T) { svc := &stubConversionService{ GetJobFn: func(ctx context.Context, userID, jobID string) (*conversion.Job, error) { require.Equal(t, "demo-user", userID, "user_id from query must be ignored") return sampleJob(), nil }, } r := newConversionFixture(t, svc) req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc?user_id=attacker", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) }