package conversion import ( "errors" "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestErrorCode 確保所有 sentinel error 都對應到一個明確的 visionA error code, // 且未匹配的 error 走 internal_error fallback(對齊 api-conversion.md §錯誤碼總覽)。 func TestErrorCode(t *testing.T) { t.Parallel() cases := []struct { name string err error want string }{ {"forbidden", ErrForbidden, "forbidden"}, {"not_found", ErrJobNotFound, "not_found"}, {"job_not_completed", ErrJobNotCompleted, "job_not_completed"}, {"active_job_exists", ErrActiveJobExists, "active_job_exists"}, {"validation_failed", ErrValidationFailed, "validation_failed"}, {"payload_too_large", ErrPayloadTooLarge, "payload_too_large"}, {"converter_unavailable", ErrConverterUnavailable, "converter_unavailable"}, // ErrFAAUnavailable:v0.6 T3 後改由 converter promote 502 file_gateway_unavailable 透傳 // (visionA 端不再直接打 FAA、但 converter→FAA push 仍可能失敗;對外 code 仍 faa_unavailable) {"faa_unavailable_from_converter_promote", ErrFAAUnavailable, "faa_unavailable"}, {"service_busy", ErrServiceBusy, "service_busy"}, // Phase 0.8b T3:以下 sentinel 已移除,不再對外暴露對應 error code // ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured / // ErrIDPUnavailable / ErrServiceClientUnauthorized // 取代:401/403 改 ErrConverterAuthFailed(下方 wrapped) // Phase 0.8b v0.6 T3:ErrFAAAuthFailed / ErrFAAFileNotFound 已砍(visionA 端不再 // 直接打 FAA;ADR-016 撤回 FAA 直連設計、faa_client.go 整檔刪除) {"converter_auth_failed_masked_as_converter_unavailable", ErrConverterAuthFailed, "converter_unavailable"}, // Reviewer M-1:visionA 自身基礎設施失敗用獨立 code(與 FAA / converter 區分) {"storage_unavailable", ErrStorageUnavailable, "storage_unavailable"}, {"model_store_unavailable", ErrModelStoreUnavailable, "model_store_unavailable"}, {"unknown_falls_back_to_internal_error", errors.New("某個未預期錯誤"), "internal_error"}, {"nil_falls_back_to_internal_error", nil, "internal_error"}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tc.want, ErrorCode(tc.err)) }) } } // TestHTTPStatus 確保所有 sentinel error 對應到正確的 HTTP status, // 且未匹配的 error 走 500 fallback(對齊 conversion.md §6 mapping)。 func TestHTTPStatus(t *testing.T) { t.Parallel() cases := []struct { name string err error want int }{ {"forbidden_403", ErrForbidden, 403}, {"not_found_404", ErrJobNotFound, 404}, {"job_not_completed_409", ErrJobNotCompleted, 409}, {"active_job_exists_409", ErrActiveJobExists, 409}, {"validation_400", ErrValidationFailed, 400}, {"payload_too_large_413", ErrPayloadTooLarge, 413}, {"converter_unavailable_502", ErrConverterUnavailable, 502}, // ErrFAAUnavailable:v0.6 T3 後改由 converter promote 502 file_gateway_unavailable 透傳 {"faa_unavailable_502_from_converter_promote", ErrFAAUnavailable, 502}, {"service_busy_503", ErrServiceBusy, 503}, // Phase 0.8b T3:以下 sentinel 已移除,對外不再 mapping HTTP status // ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured / // ErrIDPUnavailable / ErrServiceClientUnauthorized // 取代:401/403 改 ErrConverterAuthFailed (HTTP 502) // Phase 0.8b v0.6 T3:ErrFAAAuthFailed 已砍(visionA 端不再直接打 FAA) {"converter_auth_failed_502", ErrConverterAuthFailed, 502}, // Reviewer M-1:visionA 自身基礎設施失敗 → 500(不是 502 gateway) {"storage_unavailable_500", ErrStorageUnavailable, 500}, {"model_store_unavailable_500", ErrModelStoreUnavailable, 500}, {"unknown_500", errors.New("未知錯誤"), 500}, {"nil_500", nil, 500}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tc.want, HTTPStatus(tc.err)) }) } } // TestActiveJobError 驗證 wrapped form 既能被 errors.Is 比對,又能用 errors.As 取出 Job。 // // 這是 frontend 顯示「你已有進行中任務」+ 跳轉到該 job 進度頁的關鍵:handler 用 errors.As // 取出 Job 帶到 response details。 func TestActiveJobError(t *testing.T) { t.Parallel() job := &Job{JobID: "job-abc", Status: "running"} err := &ActiveJobError{Job: job} // errors.Is 應命中 sentinel assert.True(t, errors.Is(err, ErrActiveJobExists)) // errors.As 應拿到 wrapped 結構 var ae *ActiveJobError assert.True(t, errors.As(err, &ae)) assert.NotNil(t, ae.Job) assert.Equal(t, "job-abc", ae.Job.JobID) // ErrorCode 應仍透過 sentinel 對應到 active_job_exists assert.Equal(t, "active_job_exists", ErrorCode(err)) assert.Equal(t, 409, HTTPStatus(err)) } // TestConverterValidationError 驗證 wrapped validation error 同樣行為。 func TestConverterValidationError(t *testing.T) { t.Parallel() verr := &ConverterValidationError{ Fields: []ValidationFieldError{ {Field: "platform", Message: "must be 520 or 720"}, }, Message: "platform invalid", } assert.True(t, errors.Is(verr, ErrValidationFailed)) var ve *ConverterValidationError assert.True(t, errors.As(verr, &ve)) require.Len(t, ve.Fields, 1) assert.Equal(t, "platform", ve.Fields[0].Field) assert.Equal(t, "must be 520 or 720", ve.Fields[0].Message) assert.Equal(t, "validation_failed", ErrorCode(verr)) assert.Equal(t, 400, HTTPStatus(verr)) // Error() 應包含 Message(給 log 用) assert.Contains(t, verr.Error(), "platform invalid") // Message 為空時退化到 sentinel 訊息 verr2 := &ConverterValidationError{} assert.Equal(t, ErrValidationFailed.Error(), verr2.Error()) } // TestErrorWrapping 驗證 fmt.Errorf("%w") wrapping 後仍能被 ErrorCode 抓對。 // // 這個測試模擬 flow.go 預期的 wrap pattern: // // if err := convClient.GetJob(...); err != nil { // return fmt.Errorf("flow: get job from converter: %w", err) // } func TestErrorWrapping(t *testing.T) { t.Parallel() wrapped := fmt.Errorf("flow: get job: %w", ErrJobNotFound) assert.True(t, errors.Is(wrapped, ErrJobNotFound)) assert.Equal(t, "not_found", ErrorCode(wrapped)) assert.Equal(t, 404, HTTPStatus(wrapped)) }