jim800121chen 1231bf0ed2 feat(visionA-backend): Phase 0.8 conversion package — 5 endpoint + 8 個內部模組
Phase 0.8 把 kneron_model_converter 的轉檔功能整合進 visionA Cloud。
visionA backend 當 streaming proxy(upload)+ delegated download token broker(download)+
ownership trust boundary,converter / FAA / MC 三方零修改。

新增 internal/conversion/ 套件(8 個檔,~10,000 行 prod+test,117+ test cases,race -count=3 全綠):

- conversion.go:Service interface 5 method、Job/PromoteResult/InitJobInput types
- errors.go:13+ sentinel errors + ErrorCode/HTTPStatus mapping,對齊 conversion.md §6
- mc_token_client.go:service-to-service token (client_credentials grant) + DCL cache
  (exp - 15s 重取,per-scope cache),IssueDelegatedDownload(MC delegated download token)
  錯誤分 idp_misconfigured (4xx) / idp_unavailable (5xx) / download_token_failed / mc_token_unavailable
- converter_client.go:對 converter scheduler 4 method(InitJob multipart streaming /
  GetJob / Promote / ListInProgressJobs),InitJob 不 retry 5xx(streaming body 無法 replay)
- faa_client.go:對 FAA GET /files/{key} server-to-server pull,Phase A retry(GET 無 body
  可 replay)對齊 §9.1 retry 矩陣,streaming io.ReadCloser 透傳避 OOM
- ownership.go:in-memory job_id → user_id map + per-user mutex 防 thundering herd lazy rebuild
  (不同 user 平行 fetch,同 user 100 caller 收斂成 1 次),visionA 重啟靠 converter
  ListInProgressJobs(user) 重建
- flow.go:Service interface 整合層(5 method 串接 converter/FAA/MC/ownership)
  - InitJob 用 io.Pipe + multipart.Reader/Writer 重組 streaming proxy(黑名單 client user_id
    + 灌入 OIDC sub)
  - DownloadRedirectURL 自動觸發 promote(spec §1 Stage 3b),用 ensurePromoted helper
  - PromoteToModels 冪等(modelStore.FindBySourceJobID 為 source-of-truth)
  - OwnershipMismatch → ErrJobNotFound 不 forbidden(§7.2 防枚舉)
  - storage / modelStore 失敗包 ErrStorageUnavailable / ErrModelStoreUnavailable
    (視為 visionA 自身 500 而非 502 gateway,SRE alarm 才打對 team)

新增 internal/api/conversion.go(5 endpoint handler + main.go wire):
- POST /api/conversion/init(multipart streaming proxy,不呼叫 c.MultipartForm())
- GET  /api/conversion/active(lazy rebuild ownership)
- GET  /api/conversion/{job_id}(poll status)
- POST /api/conversion/{job_id}/promote-to-models(FAA pull → models 三段式)
- GET  /api/conversion/{job_id}/download(server-side HTTP 302 → FAA,token 不過 frontend
  JS,仿 FAA TestSite DownloadFileDirect pattern;Cache-Control: no-store)

5 個 endpoint 全部走 OIDC AuthMiddleware;user_id 從 cookie session 灌(trust boundary),
從不接受 client multipart form / JSON / query 的 user_id。
TestAllAPIEndpointsRequire401WithoutCookie 自動覆蓋新 5 endpoint regression 防呆。

新增 cmd/api-server/conversion_e2e_test.go(4 個 e2e 場景):
- TestConversionE2E_StreamingProxy(10MB body + trust boundary regression)
- TestConversionE2E_LazyRebuildAfterRestart(visionA 重啟仍能 /active)
- TestConversionE2E_Download302Redirect(驗 302 + Location header + token 不在 body)
- TestConversionE2E_ActiveJobConflict(409 + active_job 詳情)

修改 internal/config/{config,load}.go:新增 ConversionConfig 5 欄位
(ConverterBaseURL / FAABaseURL / TenantID / ServiceClientID / ServiceClientSecret)+
Enabled() helper(雙非空判定)。
修改 cmd/api-server/main.go:條件 wire(cfg.Conversion.Enabled() 為 true 才建 client + Service;
否則 Deps.Conversion=nil,handler 自動回 501)。
修改 .env.example:新增 Phase 0.8 區塊註解。
新增 cmd/api-server/conversion_adapters.go:narrow interface adapter(接既有
internal/model.Repository / internal/storage.Store → conversion.ModelStore / Storage,避免 import cycle)。

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning / go build 成功。

對齊文件:
- .autoflow/04-architecture/adr/adr-014-conversion-integration.md
- .autoflow/04-architecture/conversion.md (TDD)
- .autoflow/04-architecture/api/api-conversion.md
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md

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

162 lines
5.7 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"},
{"faa_unavailable", ErrFAAUnavailable, "faa_unavailable"},
{"download_token_failed", ErrDownloadTokenFailed, "download_token_failed"},
{"mc_token_unavailable", ErrMCTokenUnavailable, "mc_token_unavailable"},
{"idp_misconfigured", ErrIDPMisconfigured, "idp_misconfigured"},
{"idp_unavailable", ErrIDPUnavailable, "idp_unavailable"},
{"service_busy", ErrServiceBusy, "service_busy"},
// ErrServiceClientUnauthorized 對外刻意 mask 成 idp_misconfigured不 leak「visionA secret 過期」內部狀態)
{"service_client_unauthorized_masked_as_idp_misconfig", ErrServiceClientUnauthorized, "idp_misconfigured"},
// 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},
{"faa_unavailable_502", ErrFAAUnavailable, 502},
{"download_token_failed_502", ErrDownloadTokenFailed, 502},
{"mc_token_unavailable_502", ErrMCTokenUnavailable, 502},
{"idp_misconfigured_500", ErrIDPMisconfigured, 500},
{"idp_unavailable_503", ErrIDPUnavailable, 503},
{"service_busy_503", ErrServiceBusy, 503},
{"service_client_unauthorized_500", ErrServiceClientUnauthorized, 500},
// 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))
}