對齊 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>
167 lines
6.2 KiB
Go
167 lines
6.2 KiB
Go
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))
|
||
}
|