// FAA Client 單元測試。 // // 測試策略: // - 用 httptest.Server mock FAA 的 GET /files/{key} 端點 // - **Phase 0.8b**:直接用 string fake API key(fakeFAAAPIKey;定義在 converter_client_test.go), // 不再注入 stub MCTokenClient // - 用 atomic counter 驗 retry 行為(Phase A retry:max 3 attempts = 1 + 2 retries) // - streaming 驗證用較大但合理大小(10MB)— 真 100MB 會拖慢 test runner 太多 // // 測試範疇對應 conversion.md §9.1(FAA GET /files retry max 2 次, 1s/2s)+ ADR-015 §3 認證: // - GetFile_Success / GetFile_Streaming / GetFile_AuthHeader // - GetFile_404_NoRetry / GetFile_AuthFailed401 / GetFile_AuthFailed403 // - GetFile_5xx_RetryThenSuccess / GetFile_5xx_Exhausted // - GetFile_Network_RetryThenSuccess / GetFile_Network_Exhausted // - GetFile_ContextCancel / GetFile_ContextCancel_DuringRetry // - GetFile_EmptyObjectKey / GetFile_400_GenericError / HashObjectKey_StableAndLength // - NewFAAClient_Panics_When_APIKey_Empty // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.3 + §9.1) // Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3) package conversion import ( "context" "errors" "io" "net" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ========================================================================== // FAA mock server helpers // ========================================================================== // newFAAClientForTest 建立指向 mock server 的 FAAClient。 // // Phase 0.8b:直接傳 fakeFAAAPIKey(定義在 converter_client_test.go),不再透過 MCTokenClient。 // 用較短 timeout 加速 test。注意 streaming test 不能用整體 Timeout,所以另外覆寫。 func newFAAClientForTest(t *testing.T, baseURL string) FAAClient { t.Helper() return NewFAAClient(FAAClientOpts{ BaseURL: baseURL, APIKey: fakeFAAAPIKey, HTTPClient: &http.Client{Timeout: 5 * time.Second}, Logger: silentLogger(), }) } // ========================================================================== // 成功路徑 // ========================================================================== // TestGetFile_Success:mock 回 200 + binary stream,驗 ContentLength / ETag / ContentType 解析。 func TestGetFile_Success(t *testing.T) { t.Parallel() payload := []byte("binary payload here") var receivedAuth string mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { receivedAuth = r.Header.Get("Authorization") require.Equal(t, http.MethodGet, r.Method) w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("ETag", "\"etag-abc-123\"") w.Header().Set("Content-Length", "19") w.WriteHeader(http.StatusOK) _, _ = w.Write(payload) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) fc := newFAAClientForTest(t, srv.URL) file, err := fc.GetFile(context.Background(), "tenant/jobs/abc/output.nef") require.NoError(t, err) require.NotNil(t, file) require.NotNil(t, file.Body) t.Cleanup(func() { _ = file.Body.Close() }) assert.Equal(t, "application/octet-stream", file.ContentType) assert.Equal(t, "\"etag-abc-123\"", file.ETag) assert.Equal(t, int64(19), file.ContentLength) // caller 確實能 streaming 讀到完整 body body, readErr := io.ReadAll(file.Body) require.NoError(t, readErr) assert.Equal(t, payload, body) assert.Equal(t, "Bearer "+fakeFAAAPIKey, receivedAuth, "Phase 0.8b:必須直接帶 pre-shared API key(不經 MC token cache)") } // TestGetFile_Streaming:mock 回 10MB body,confirm caller 能 streaming 讀(不 buffer 全 RAM)。 // // 與 InitJob streaming test 對稱:用 io.LimitReader + zerosReader,確認 reader 被多次 Read // (而非一次性全讀)。但 net/http 端 download 的 streaming 由 res.Body 提供,這裡的關鍵是: // - faa_client 必須**不 io.ReadAll** 把 body 提前讀完 // - caller 用 io.Copy 慢慢讀時,server 端不需要先把全部 buffer 完成 func TestGetFile_Streaming(t *testing.T) { t.Parallel() const totalSize = int64(10 * 1024 * 1024) // 10MB mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", "10485760") w.WriteHeader(http.StatusOK) // streaming write — 用 io.Copy from zerosReader(避免一次配 10MB buffer) _, _ = io.CopyN(w, zerosReader{}, totalSize) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) // streaming download 不能用 http.Client.Timeout(會中斷 body streaming) fc := NewFAAClient(FAAClientOpts{ BaseURL: srv.URL, APIKey: fakeFAAAPIKey, // 這裡用無 timeout 的 client(test 自己控) HTTPClient: &http.Client{}, Logger: silentLogger(), }) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() file, err := fc.GetFile(ctx, "big.nef") require.NoError(t, err) require.NotNil(t, file) t.Cleanup(func() { _ = file.Body.Close() }) assert.Equal(t, totalSize, file.ContentLength) // 用 countingReader 包 file.Body — 但 countingReader 是 io.Reader, // 這裡換成 wrap 一下:直接 io.Copy 到 io.Discard,confirm 全 download 完成。 written, copyErr := io.Copy(io.Discard, file.Body) require.NoError(t, copyErr) assert.Equal(t, totalSize, written, "streaming download 必須拿到完整 body") } // TestGetFile_AuthHeader:Phase 0.8b — 驗 pre-shared API key 直接帶在 Authorization header。 // // 用客製 APIKey(與 fakeFAAAPIKey 不同的字串),確認 client 真的透傳「建構時拿到的 key」、 // 而不是 hardcode 某個常數。 func TestGetFile_AuthHeader(t *testing.T) { t.Parallel() const customKey = "custom-faa-key-do-not-use-in-prod-ccccccccccccccccccccccccccccc" var receivedAuth string var receivedAccept string mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { receivedAuth = r.Header.Get("Authorization") receivedAccept = r.Header.Get("Accept") w.Header().Set("Content-Type", "application/octet-stream") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) fc := NewFAAClient(FAAClientOpts{ BaseURL: srv.URL, APIKey: customKey, HTTPClient: &http.Client{Timeout: 5 * time.Second}, Logger: silentLogger(), }) file, err := fc.GetFile(context.Background(), "key") require.NoError(t, err) defer file.Body.Close() _, _ = io.ReadAll(file.Body) assert.Equal(t, "Bearer "+customKey, receivedAuth, "必須透傳建構時拿到的 API key,不可 hardcode 或從別處取") assert.Equal(t, "application/octet-stream", receivedAccept) } // ========================================================================== // 失敗映射(不 retry 類) // ========================================================================== // TestGetFile_404_NoRetry:mock 回 404 → 立即 return ErrFAAFileNotFound,不 retry。 func TestGetFile_404_NoRetry(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":{"code":"file_not_found","message":"File not found."}}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) fc := newFAAClientForTest(t, srv.URL) file, err := fc.GetFile(context.Background(), "missing.nef") require.Error(t, err) require.Nil(t, file, "失敗時不應回 FAAFile(避免 caller 誤用 nil body)") assert.True(t, errors.Is(err, ErrFAAFileNotFound), "404 → ErrFAAFileNotFound(caller 可精細處理)") assert.Equal(t, int32(1), attempts.Load(), "404 不應 retry(object 不存在 retry 也沒用)") // 對外仍應 mask 成 faa_unavailable(避免揭露 object_key 不存在) assert.Equal(t, "faa_unavailable", ErrorCode(err)) assert.Equal(t, 502, HTTPStatus(err)) } // TestGetFile_AuthFailed401:Phase 0.8b — mock 回 401 → 不 retry,return ErrFAAAuthFailed。 // // 觸發情境:VISIONA_FAA_API_KEY 與 FAA 端 FAA_API_KEY 不對齊(rotate 未同步 / env 設錯)。 // 對外仍 mask 成 faa_unavailable / 502,避免洩漏「API key 不對」內部運維狀態。 func TestGetFile_AuthFailed401(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) fc := newFAAClientForTest(t, srv.URL) file, err := fc.GetFile(context.Background(), "k") require.Error(t, err) require.Nil(t, file) assert.True(t, errors.Is(err, ErrFAAAuthFailed), "Phase 0.8b:401 必須 mapping 到新 sentinel ErrFAAAuthFailed") // Phase 0.8b T3:舊 sentinel ErrServiceClientUnauthorized 已移除, // 改由 ErrFAAAuthFailed 接管 401/403 mapping。 assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry(API key 不對 retry 也是 401)") // 對外仍 mask 成 faa_unavailable assert.Equal(t, "faa_unavailable", ErrorCode(err)) assert.Equal(t, 502, HTTPStatus(err)) } // TestGetFile_AuthFailed403:對稱 — FAA 端 403 同樣 ErrFAAAuthFailed、不 retry。 func TestGetFile_AuthFailed403(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) fc := newFAAClientForTest(t, srv.URL) _, err := fc.GetFile(context.Background(), "k") require.Error(t, err) assert.True(t, errors.Is(err, ErrFAAAuthFailed)) assert.Equal(t, int32(1), attempts.Load(), "403 不應 retry") } // TestGetFile_400_GenericError:FAA 400(如 invalid_object_key)→ ErrFAAUnavailable,不 retry。 func TestGetFile_400_GenericError(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":{"code":"invalid_object_key"}}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) fc := newFAAClientForTest(t, srv.URL) _, err := fc.GetFile(context.Background(), "invalid//key") require.Error(t, err) assert.True(t, errors.Is(err, ErrFAAUnavailable), "400(非 401/403/404)→ ErrFAAUnavailable") // 應該不會被 mis-classified 成 ErrFAAFileNotFound assert.False(t, errors.Is(err, ErrFAAFileNotFound)) assert.Equal(t, int32(1), attempts.Load(), "400 不應 retry(visionA 端的 bug)") } // ========================================================================== // Phase A retry 驗證(5xx / network) // ========================================================================== // TestGetFile_5xx_RetryThenSuccess:mock 連續 500 兩次後回 200 → 共 3 次 attempt + 成功。 // // 對齊 §9.1:max 2 retries(1s, 2s)— 1 + 2 = 3 attempts;第 3 次成功就 return。 // 注意:test 用真實 backoff(1s + 2s = 3s)— 為了驗 §9.1 退避時序,可接受。 func TestGetFile_5xx_RetryThenSuccess(t *testing.T) { t.Parallel() var attempts atomic.Int32 payload := []byte("recovered after retry") mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { n := attempts.Add(1) if n < 3 { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`)) return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", "21") w.WriteHeader(http.StatusOK) _, _ = w.Write(payload) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) fc := newFAAClientForTest(t, srv.URL) start := time.Now() file, err := fc.GetFile(context.Background(), "k") duration := time.Since(start) require.NoError(t, err) require.NotNil(t, file) t.Cleanup(func() { _ = file.Body.Close() }) got, _ := io.ReadAll(file.Body) assert.Equal(t, payload, got, "第 3 次成功的 body 應正確透傳") assert.Equal(t, int32(3), attempts.Load(), "5xx 應 retry:max 2 retries → 3 attempts") // 驗時序:兩次 retry 退避 1s + 2s,至少花 3s(容忍輕微誤差用 ≥2.5s) assert.GreaterOrEqual(t, duration, 2500*time.Millisecond, "§9.1 退避序列 1s + 2s 應至少耗 2.5s") } // TestGetFile_5xx_Exhausted:mock 持續 500 → 用完 max retry 後 return ErrFAAUnavailable。 func TestGetFile_5xx_Exhausted(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) fc := newFAAClientForTest(t, srv.URL) _, err := fc.GetFile(context.Background(), "k") require.Error(t, err) assert.True(t, errors.Is(err, ErrFAAUnavailable), "5xx exhausted → ErrFAAUnavailable") assert.Equal(t, int32(faaMaxRetries+1), attempts.Load(), "5xx 應跑滿 max retries:1 + 2 = 3 attempts") } // TestGetFile_Network_RetryThenSuccess:前 2 次 connection refused,第 3 次成功。 // // 用 dynamic listener swap 實作:先用一個 free port 不開 listener(dial fail), // 第 3 次 attempt 之前才 swap 到真的 mock server。實作上比較複雜 — 改用 // proxy handler 在 mock server 內部對前 N 次「立刻 hijack 後 close」模擬 dial fail // 不行(連線已建好);改用「server 端 force-close connection 不送任何 byte」 // 來模擬 transport 層失敗。 // // 簡化版:用一個 proxy server,前 2 次直接 hijack + close 連線(client 看到 EOF), // 第 3 次正常回 200。 func TestGetFile_Network_RetryThenSuccess(t *testing.T) { t.Parallel() var attempts atomic.Int32 payload := []byte("recovered from net error") mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { n := attempts.Add(1) if n < 3 { // hijack + close 模擬 connection 中斷(client 端會看到 unexpected EOF / read error) hj, ok := w.(http.Hijacker) if !ok { t.Fatal("server does not support hijacking") } conn, _, err := hj.Hijack() if err != nil { t.Fatalf("hijack failed: %v", err) } _ = conn.Close() return } w.Header().Set("Content-Type", "application/octet-stream") w.WriteHeader(http.StatusOK) _, _ = w.Write(payload) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) fc := newFAAClientForTest(t, srv.URL) file, err := fc.GetFile(context.Background(), "k") require.NoError(t, err) require.NotNil(t, file) t.Cleanup(func() { _ = file.Body.Close() }) got, _ := io.ReadAll(file.Body) assert.Equal(t, payload, got) assert.Equal(t, int32(3), attempts.Load(), "network error 應 retry:max 2 retries → 3 attempts 後成功") } // TestGetFile_Network_Exhausted:dial 失敗持續發生 → 用完 max retry 後 ErrFAAUnavailable。 // // 用一個 listen 後立刻 close 的 socket 製造 connection refused(每次 attempt 都失敗)。 func TestGetFile_Network_Exhausted(t *testing.T) { t.Parallel() // 拿一個 free port 立刻關掉(dial 必失敗) ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) addr := ln.Addr().String() require.NoError(t, ln.Close()) fc := NewFAAClient(FAAClientOpts{ BaseURL: "http://" + addr, APIKey: fakeFAAAPIKey, // 用較短 timeout,但仍要大於 retry 退避總和(1s + 2s = 3s)— 設 10s 安全 HTTPClient: &http.Client{Timeout: 10 * time.Second}, Logger: silentLogger(), }) start := time.Now() _, err = fc.GetFile(context.Background(), "k") duration := time.Since(start) require.Error(t, err) assert.True(t, errors.Is(err, ErrFAAUnavailable), "network exhausted → ErrFAAUnavailable") // retry:1 + 2 retries = 3 attempts,2 次退避 = 1s + 2s = 3s 起跳 assert.GreaterOrEqual(t, duration, 2500*time.Millisecond, "network retry 應走完 §9.1 退避序列") } // ========================================================================== // Context cancel // ========================================================================== // TestGetFile_ContextCancel:caller cancel ctx → 立即 return ctx.Err()(不包成 sentinel)。 func TestGetFile_ContextCancel(t *testing.T) { t.Parallel() // mock server:handler 故意 sleep(讓 ctx cancel 在 server response 前發生) mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { select { case <-r.Context().Done(): case <-time.After(2 * time.Second): } w.WriteHeader(http.StatusOK) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) fc := newFAAClientForTest(t, srv.URL) ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(50 * time.Millisecond) cancel() }() _, err := fc.GetFile(ctx, "k") require.Error(t, err) // ctx cancel → 透傳 ctx.Err()(不包成 ErrFAAUnavailable) assert.True(t, errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), "ctx cancel 應透傳,不應包成 ErrFAAUnavailable") } // TestGetFile_ContextCancel_DuringRetry:ctx cancel 發生在 retry sleep 中 → 立即中斷。 // // 流程: // - mock server 持續 500(觸發 retry) // - 在第 1 次 retry 退避(1s)的中間(500ms)cancel ctx // - 期望:GetFile 立即 return ctx.Err(),不等完 1s 退避也不繼續第 2 次 retry func TestGetFile_ContextCancel_DuringRetry(t *testing.T) { t.Parallel() var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) fc := newFAAClientForTest(t, srv.URL) ctx, cancel := context.WithCancel(context.Background()) go func() { // 等第 1 次 attempt 跑完 + 進 retry sleep 後再 cancel // 第 1 次 attempt 約 < 100ms;第 1 次 retry 退避 1s,在 500ms cancel time.Sleep(500 * time.Millisecond) cancel() }() start := time.Now() _, err := fc.GetFile(ctx, "k") duration := time.Since(start) require.Error(t, err) assert.True(t, errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), "retry sleep 中 cancel → 透傳 ctx.Err()") // 應在 cancel 後立即中斷(< 1s 整體時間)— 不該等完 1s 退避或進入第 2 次 retry assert.Less(t, duration, 900*time.Millisecond, "ctx cancel 應立即中斷 retry sleep(不等完退避)") // attempts 應為 1(第 1 次 attempt 後進 retry sleep 就被 cancel) assert.Equal(t, int32(1), attempts.Load(), "cancel 後不應再嘗試第 2 次 attempt") } // ========================================================================== // Constructor fail-fast // ========================================================================== // TestNewFAAClient_Panics_When_APIKey_Empty:fail-fast 驗證 — Phase 0.8b // 不允許 server 在「未認證」狀態下啟動,建構式必須立即 panic。 // // 對齊 ADR-015 §3.5.3 部署檢查清單 #1。 // // (Phase 0.8b 之前的 `TestGetFile_ServiceTokenFailure_Propagated` 已移除: // API key 改造後 ServiceToken 不再被呼叫,「token 取不到」這個失敗路徑結構性消失, // 原測試的前提不存在;對應的失敗模式變成「建構時 fail-fast」由本測試覆蓋。) func TestNewFAAClient_Panics_When_APIKey_Empty(t *testing.T) { t.Parallel() defer func() { r := recover() require.NotNil(t, r, "APIKey 為空時必須 panic(fail-fast)") err, ok := r.(error) require.True(t, ok, "panic value 應為 error 型別") assert.True(t, errors.Is(err, ErrFAAAPIKeyNotConfigured), "panic 應為 ErrFAAAPIKeyNotConfigured sentinel") }() _ = NewFAAClient(FAAClientOpts{ BaseURL: "http://example.com", APIKey: "", // empty — 必須觸發 panic Logger: silentLogger(), }) } // ========================================================================== // 額外:empty object_key validation // ========================================================================== // TestGetFile_EmptyObjectKey:保護性 validation — 空字串 object_key 應立即 fail。 func TestGetFile_EmptyObjectKey(t *testing.T) { t.Parallel() fc := NewFAAClient(FAAClientOpts{ BaseURL: "http://invalid", APIKey: fakeFAAAPIKey, Logger: silentLogger(), }) _, err := fc.GetFile(context.Background(), "") require.Error(t, err) assert.Contains(t, err.Error(), "object_key is required", "empty object_key 應立即 fail(不打網路)") } // ========================================================================== // hashObjectKey unit test(log 用 hash 函式的穩定性) // ========================================================================== // TestHashObjectKey_StableAndLength:同 input 應產生同 output;長度固定 16。 func TestHashObjectKey_StableAndLength(t *testing.T) { t.Parallel() h1 := hashObjectKey("tenant/jobs/abc/output.nef") h2 := hashObjectKey("tenant/jobs/abc/output.nef") h3 := hashObjectKey("tenant/jobs/xyz/output.nef") assert.Equal(t, h1, h2, "同 object_key 應產生同 hash(log 可追蹤同一 request)") assert.NotEqual(t, h1, h3, "不同 object_key hash 應不同") assert.Len(t, h1, objectKeyHashLen, "hash 長度固定") }