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>
162 lines
5.7 KiB
Go
162 lines
5.7 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"},
|
||
{"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-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},
|
||
{"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-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))
|
||
}
|