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 }