github.com/qiniu/go-sdk/v7@v7.27.0/storage/bucket_get_unit_test.go (about)

     1  //go:build unit
     2  // +build unit
     3  
     4  package storage
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"io"
    10  	"net/http"
    11  	"net/http/httptest"
    12  	"strconv"
    13  	"strings"
    14  	"sync"
    15  	"testing"
    16  	"time"
    17  
    18  	"github.com/qiniu/go-sdk/v7/auth"
    19  )
    20  
    21  // getTestGetServers 创建 mock 下载服务器和 mock UC 服务器,
    22  // 并将 UC hosts 指向 mock UC 服务器,返回 cleanup 函数。
    23  //
    24  // mock UC 在 /v4/query 响应中将 io_src.domains 设置为 mock 下载服务器的 host:port,
    25  // 让 defaultSrcURLsProvider 查询后直接命中 mock 下载服务器,无需真实凭据。
    26  func getTestGetServers(
    27  	t *testing.T,
    28  	key, content string,
    29  	withContentLength bool,
    30  	extraHeaders http.Header,
    31  ) (dlServer *httptest.Server, cleanup func()) {
    32  	t.Helper()
    33  
    34  	// --- mock 下载服务器 ---
    35  	dlMux := http.NewServeMux()
    36  	dlMux.HandleFunc("/"+key, func(w http.ResponseWriter, r *http.Request) {
    37  		w.Header().Set("Content-Type", "text/plain")
    38  		w.Header().Set("ETag", `"testetag"`)
    39  		w.Header().Set("Last-Modified", time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC).UTC().Format(http.TimeFormat))
    40  		w.Header().Set("Accept-Ranges", "bytes")
    41  		w.Header().Add("X-Reqid", "fakereqid")
    42  		for k, vs := range extraHeaders {
    43  			for _, v := range vs {
    44  				w.Header().Set(k, v)
    45  			}
    46  		}
    47  		if withContentLength {
    48  			w.Header().Set("Content-Length", strconv.Itoa(len(content)))
    49  		}
    50  		switch r.Method {
    51  		case http.MethodHead:
    52  		case http.MethodGet:
    53  			w.Write([]byte(content))
    54  		default:
    55  			t.Errorf("unexpected method: %s", r.Method)
    56  		}
    57  	})
    58  	dlServer = httptest.NewServer(dlMux)
    59  
    60  	// 从 "http://127.0.0.1:PORT" 提取 "127.0.0.1:PORT",
    61  	// 写入 UC 响应的 io_src.domains(无 scheme),
    62  	// 由 staticDomainBasedURLsProvider 根据 UseInsecureProtocol 补 http://。
    63  	dlHost := strings.TrimPrefix(dlServer.URL, "http://")
    64  
    65  	// --- mock UC 服务器,响应 /v4/query ---
    66  	ucMux := http.NewServeMux()
    67  	ucMux.HandleFunc("/v4/query", func(w http.ResponseWriter, r *http.Request) {
    68  		w.Header().Set("Content-Type", "application/json")
    69  		w.Header().Add("X-Reqid", "fakereqid")
    70  		// 将 io_src.domains 指向 mock 下载服务器,
    71  		// 使 defaultSrcURLsProvider 拿到 mock 下载服务器地址。
    72  		fmt.Fprintf(w, `{
    73  			"hosts": [{
    74  				"region": "test",
    75  				"ttl": 86400,
    76  				"io_src": {"domains": [%q]},
    77  				"io":     {"domains": [%q]},
    78  				"up":     {"domains": ["upload.fake.com"]},
    79  				"uc":     {"domains": ["uc.fake.com"]},
    80  				"rs":     {"domains": ["rs.fake.com"]},
    81  				"rsf":    {"domains": ["rsf.fake.com"]},
    82  				"api":    {"domains": ["api.fake.com"]}
    83  			}]
    84  		}`, dlHost, dlHost)
    85  	})
    86  	ucServer := httptest.NewServer(ucMux)
    87  
    88  	// 保存原始 UC hosts,覆盖为 mock UC,测试结束后还原。
    89  	origUCHosts := getUCHosts()
    90  	SetUcHosts(ucServer.URL)
    91  
    92  	cleanup = func() {
    93  		SetUcHosts(origUCHosts...)
    94  		ucServer.Close()
    95  		dlServer.Close()
    96  	}
    97  	return dlServer, cleanup
    98  }
    99  
   100  // newGetTestBucketManager 创建使用假凭据的 BucketManager,仅供单元测试使用。
   101  func newGetTestBucketManager() *BucketManager {
   102  	return NewBucketManager(auth.New("fake-ak", "fake-sk"), &Config{UseHTTPS: false})
   103  }
   104  
   105  // TestGet_ContentLengthSetFromHeaderBeforeBodyRead 验证关键修复:
   106  // ContentLength 在 Get() 返回时就已从 HEAD 响应头解析完毕,调用方无需等待 Body
   107  // 读取完成即可获得准确的文件大小;配合 -race 可验证不存在数据竞争。
   108  func TestGet_ContentLengthSetFromHeaderBeforeBodyRead(t *testing.T) {
   109  	const content = "hello world"
   110  	_, cleanup := getTestGetServers(t, "testfile", content, true, nil)
   111  	defer cleanup()
   112  
   113  	resp, err := newGetTestBucketManager().Get("fakebucket", "testfile", &GetObjectInput{})
   114  	if err != nil {
   115  		t.Fatalf("Get() error: %v", err)
   116  	}
   117  	defer resp.Close()
   118  
   119  	// 在读取 Body 之前断言:ContentLength 来自响应头,不是下载结束后赋值。
   120  	if want := int64(len(content)); resp.ContentLength != want {
   121  		t.Errorf("ContentLength before body read = %d, want %d", resp.ContentLength, want)
   122  	}
   123  
   124  	// Body 内容仍然正确。
   125  	got, err := io.ReadAll(resp.Body)
   126  	if err != nil {
   127  		t.Fatalf("ReadAll error: %v", err)
   128  	}
   129  	if string(got) != content {
   130  		t.Errorf("Body = %q, want %q", string(got), content)
   131  	}
   132  }
   133  
   134  // TestGet_ContentLengthNegativeOneWhenHeaderAbsent 验证当服务端未返回 Content-Length 时,
   135  // ContentLength 被设置为 -1,与 GetObjectOutput 注释约定(-1 表示未知)一致。
   136  func TestGet_ContentLengthNegativeOneWhenHeaderAbsent(t *testing.T) {
   137  	const content = "hello world"
   138  	// withContentLength=false:HEAD 响应头不包含 Content-Length。
   139  	_, cleanup := getTestGetServers(t, "testfile", content, false, nil)
   140  	defer cleanup()
   141  
   142  	resp, err := newGetTestBucketManager().Get("fakebucket", "testfile", &GetObjectInput{})
   143  	if err != nil {
   144  		t.Fatalf("Get() error: %v", err)
   145  	}
   146  	defer resp.Close()
   147  
   148  	if resp.ContentLength != -1 {
   149  		t.Errorf("ContentLength = %d, want -1 when Content-Length header is absent", resp.ContentLength)
   150  	}
   151  }
   152  
   153  // TestGet_AllHeaderFieldsPopulatedBeforeReturn 验证 Get() 返回时所有响应头字段
   154  // (ContentType、ETag、LastModified、ContentLength、x-qn-meta-* Metadata)
   155  // 均已完整填充,不依赖 Body 读取完成。
   156  func TestGet_AllHeaderFieldsPopulatedBeforeReturn(t *testing.T) {
   157  	const content = "response body"
   158  	wantLastModified := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
   159  	extraHeaders := http.Header{
   160  		"X-Qn-Meta-Sync-Index": []string{"99"},
   161  		"X-Qn-Meta-Author":     []string{"alice"},
   162  	}
   163  	_, cleanup := getTestGetServers(t, "testfile", content, true, extraHeaders)
   164  	defer cleanup()
   165  
   166  	resp, err := newGetTestBucketManager().Get("fakebucket", "testfile", &GetObjectInput{})
   167  	if err != nil {
   168  		t.Fatalf("Get() error: %v", err)
   169  	}
   170  	defer resp.Close()
   171  
   172  	// 以下全部断言在读取 Body 之前执行,确认字段在 Get() 返回时就已就绪。
   173  	if resp.ContentType != "text/plain" {
   174  		t.Errorf("ContentType = %q, want %q", resp.ContentType, "text/plain")
   175  	}
   176  	if resp.ETag != "testetag" {
   177  		t.Errorf("ETag = %q, want %q (quotes should be stripped)", resp.ETag, "testetag")
   178  	}
   179  	if !resp.LastModified.Equal(wantLastModified) {
   180  		t.Errorf("LastModified = %v, want %v", resp.LastModified, wantLastModified)
   181  	}
   182  	if want := int64(len(content)); resp.ContentLength != want {
   183  		t.Errorf("ContentLength = %d, want %d", resp.ContentLength, want)
   184  	}
   185  	// x-qn-meta-* 头以 Go canonical 形式(首字母大写)存储于 Metadata。
   186  	for wantKey, wantVal := range map[string]string{
   187  		"X-Qn-Meta-Sync-Index": "99",
   188  		"X-Qn-Meta-Author":     "alice",
   189  	} {
   190  		if gotVal, ok := resp.Metadata[wantKey]; !ok {
   191  			t.Errorf("Metadata missing key %q", wantKey)
   192  		} else if gotVal != wantVal {
   193  			t.Errorf("Metadata[%q] = %q, want %q", wantKey, gotVal, wantVal)
   194  		}
   195  	}
   196  }
   197  
   198  // TestGet_NoPanicOnConcurrentCalls 以高并发方式验证修复后的 Get() 不再触发
   199  // "send on closed channel" panic;配合 -race 标志可同时检测数据竞争。
   200  func TestGet_NoPanicOnConcurrentCalls(t *testing.T) {
   201  	const content = "hello world"
   202  	_, cleanup := getTestGetServers(t, "testfile", content, true, nil)
   203  	defer cleanup()
   204  
   205  	bm := newGetTestBucketManager()
   206  
   207  	const (
   208  		concurrency = 32
   209  		loops       = 64
   210  	)
   211  	ctx, cancel := context.WithCancel(context.Background())
   212  	defer cancel()
   213  
   214  	var (
   215  		wg      sync.WaitGroup
   216  		panicCh = make(chan interface{}, 1)
   217  	)
   218  	for i := 0; i < concurrency; i++ {
   219  		wg.Add(1)
   220  		go func() {
   221  			defer wg.Done()
   222  			for j := 0; j < loops; j++ {
   223  				select {
   224  				case <-ctx.Done():
   225  					return
   226  				default:
   227  				}
   228  				func() {
   229  					defer func() {
   230  						if r := recover(); r != nil {
   231  							if strings.Contains(fmt.Sprint(r), "send on closed channel") {
   232  								select {
   233  								case panicCh <- r:
   234  								default:
   235  								}
   236  								cancel()
   237  							} else {
   238  								panic(r)
   239  							}
   240  						}
   241  					}()
   242  					resp, err := bm.Get("fakebucket", "testfile", &GetObjectInput{Context: ctx})
   243  					if resp != nil {
   244  						resp.Close()
   245  					}
   246  					_ = err
   247  				}()
   248  			}
   249  		}()
   250  	}
   251  
   252  	done := make(chan struct{})
   253  	go func() {
   254  		wg.Wait()
   255  		close(done)
   256  	}()
   257  
   258  	select {
   259  	case p := <-panicCh:
   260  		t.Fatalf("Get() triggered 'send on closed channel' panic: %v", p)
   261  	case <-done:
   262  		// 全部并发请求正常结束,无 panic,测试通过。
   263  	case <-time.After(60 * time.Second):
   264  		t.Fatal("timeout: concurrent Get() calls did not complete in time")
   265  	}
   266  }