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

167 lines
6.2 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.

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"},
// ErrFAAUnavailablev0.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 T3ErrFAAAuthFailed / ErrFAAFileNotFound 已砍visionA 端不再
// 直接打 FAAADR-016 撤回 FAA 直連設計、faa_client.go 整檔刪除)
{"converter_auth_failed_masked_as_converter_unavailable", ErrConverterAuthFailed, "converter_unavailable"},
// Reviewer M-1visionA 自身基礎設施失敗用獨立 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},
// ErrFAAUnavailablev0.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 T3ErrFAAAuthFailed 已砍visionA 端不再直接打 FAA
{"converter_auth_failed_502", ErrConverterAuthFailed, 502},
// Reviewer M-1visionA 自身基礎設施失敗 → 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))
}