Files
bdrclone/internal/baidu/client_test.go
youbin c602698ce9
All checks were successful
Build / Test and build (push) Successful in 5m53s
Retry transient uploads and continue backups
2026-08-15 15:32:43 +08:00

318 lines
9.8 KiB
Go

package baidu
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path"
"reflect"
"strconv"
"strings"
"sync"
"testing"
"time"
"gitea.dddbg.com/youbin/bdrclone/internal/config"
)
func TestRemotePathWithRoot(t *testing.T) {
client := New(testConfig("token"))
for input, want := range map[string]string{
"/": "/apps/bdrclone", "docs/a.txt": "/apps/bdrclone/docs/a.txt", "/../a": "/apps/bdrclone/a",
} {
if got := client.RemotePath(input); got != want {
t.Errorf("RemotePath(%q) = %q, want %q", input, got, want)
}
}
if got := client.APIPath("/apps/bdrclone/docs/a.txt"); got != "/docs/a.txt" {
t.Fatalf("APIPath = %q", got)
}
}
func TestExpiredTokenRefreshesAndPersists(t *testing.T) {
var saved Token
var listCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth/2.0/token":
if r.URL.Query().Get("refresh_token") != "refresh-old" {
t.Errorf("unexpected refresh token: %q", r.URL.Query().Get("refresh_token"))
}
fmt.Fprint(w, `{"access_token":"access-new","refresh_token":"refresh-new","expires_in":3600}`)
case "/rest/2.0/xpan/file":
listCalls++
if r.URL.Query().Get("access_token") != "access-new" {
t.Errorf("unexpected access token: %q", r.URL.Query().Get("access_token"))
}
if got := r.Header.Get("User-Agent"); got != "pan.baidu.com" {
t.Errorf("User-Agent = %q", got)
}
fmt.Fprint(w, `{"errno":0,"list":[]}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
cfg := testConfig("expired")
cfg.RefreshToken = "refresh-old"
cfg.ExpiresAt = time.Now().Add(-time.Hour)
client := New(cfg, WithEndpoints(server.URL, server.URL, server.URL), WithTokenSaver(func(token Token, _ time.Time) error {
saved = token
return nil
}))
if _, err := client.List(context.Background(), "/"); err != nil {
t.Fatal(err)
}
if saved.AccessToken != "access-new" || saved.RefreshToken != "refresh-new" {
t.Fatalf("saved token = %+v", saved)
}
if listCalls != 1 {
t.Fatalf("list calls = %d", listCalls)
}
}
func TestOpenUsesOfficialUserAgentAndRange(t *testing.T) {
var server *httptest.Server
metadataCalls := 0
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/rest/2.0/xpan/multimedia":
metadataCalls++
fmt.Fprintf(w, `{"errno":0,"list":[{"fs_id":42,"dlink":%q}]}`, server.URL+"/download?x=1")
case "/download":
if got := r.Header.Get("User-Agent"); got != "pan.baidu.com" {
t.Errorf("User-Agent = %q", got)
}
if got := r.Header.Get("Range"); got != "bytes=2-5" {
t.Errorf("Range = %q", got)
}
if got := r.URL.Query().Get("access_token"); got != "token" {
t.Errorf("access_token = %q", got)
}
w.WriteHeader(http.StatusPartialContent)
fmt.Fprint(w, "2345")
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := New(testConfig("token"), WithEndpoints(server.URL, server.URL, server.URL))
body, err := client.Open(context.Background(), File{FSID: 42, Path: "/a"}, 2, 4)
if err != nil {
t.Fatal(err)
}
defer body.Close()
b, err := io.ReadAll(body)
if err != nil {
t.Fatal(err)
}
if string(b) != "2345" {
t.Fatalf("body = %q", b)
}
body, err = client.Open(context.Background(), File{FSID: 42, Path: "/a"}, 2, 4)
if err != nil {
t.Fatal(err)
}
body.Close()
if metadataCalls != 1 {
t.Fatalf("download metadata calls = %d, want cached URL to be reused", metadataCalls)
}
}
func TestUploadMultipartFlow(t *testing.T) {
content := append(bytes.Repeat([]byte("a"), int(defaultPartSize)), []byte("tail")...)
file, err := os.CreateTemp(t.TempDir(), "upload-*")
if err != nil {
t.Fatal(err)
}
if _, err := file.Write(content); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
parts := map[int][]byte{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/rest/2.0/xpan/file":
if err := r.ParseForm(); err != nil {
t.Error(err)
}
switch r.URL.Query().Get("method") {
case "precreate":
if r.Form.Get("path") != "/apps/bdrclone/remote.bin" {
t.Errorf("precreate path = %q", r.Form.Get("path"))
}
var blocks []string
if err := json.Unmarshal([]byte(r.Form.Get("block_list")), &blocks); err != nil || len(blocks) != 2 {
t.Errorf("block list = %q, err=%v", r.Form.Get("block_list"), err)
}
fmt.Fprint(w, `{"errno":0,"return_type":1,"uploadid":"upload-1","block_list":[0,1]}`)
case "create":
if r.Form.Get("uploadid") != "upload-1" {
t.Errorf("create uploadid = %q", r.Form.Get("uploadid"))
}
fmt.Fprint(w, `{"errno":0,"fs_id":99,"path":"/apps/bdrclone/remote.bin","server_filename":"remote.bin","size":4194308}`)
default:
http.Error(w, "unexpected method", http.StatusBadRequest)
}
case "/rest/2.0/pcs/superfile2":
part, _ := strconv.Atoi(r.URL.Query().Get("partseq"))
if r.URL.Query().Get("uploadid") != "upload-1" {
t.Errorf("part uploadid = %q", r.URL.Query().Get("uploadid"))
}
if err := r.ParseMultipartForm(defaultPartSize + 1024); err != nil {
t.Error(err)
return
}
partFile, _, err := r.FormFile("file")
if err != nil {
t.Error(err)
return
}
b, err := io.ReadAll(partFile)
partFile.Close()
if err != nil {
t.Error(err)
}
mu.Lock()
parts[part] = b
mu.Unlock()
fmt.Fprint(w, `{"md5":"ok"}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
cfg := testConfig("token")
cfg.PartSize = defaultPartSize
cfg.UploadParts = 2
client := New(cfg, WithEndpoints(server.URL, server.URL, server.URL))
entry, err := client.Upload(context.Background(), file, int64(len(content)), "/remote.bin", time.Unix(1_700_000_000, 0), nil)
if err != nil {
t.Fatal(err)
}
if entry.FSID != 99 {
t.Fatalf("entry = %+v", entry)
}
mu.Lock()
defer mu.Unlock()
if !bytes.Equal(parts[0], content[:defaultPartSize]) || !bytes.Equal(parts[1], content[defaultPartSize:]) {
t.Fatalf("uploaded parts do not match input: sizes %d, %d", len(parts[0]), len(parts[1]))
}
}
func TestUploadPartRetriesGatewayTimeout(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "retry-upload-*")
if err != nil {
t.Fatal(err)
}
defer file.Close()
content := []byte("retry payload")
if _, err := file.Write(content); err != nil {
t.Fatal(err)
}
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/rest/2.0/pcs/superfile2" {
http.NotFound(w, r)
return
}
attempts++
_, _ = io.Copy(io.Discard, r.Body)
if attempts < 4 {
http.Error(w, "gateway timeout", http.StatusGatewayTimeout)
return
}
fmt.Fprint(w, `{"md5":"ok"}`)
}))
defer server.Close()
client := New(testConfig("token"), WithEndpoints(server.URL, server.URL, server.URL))
client.uploadRetryDelay = func(int) time.Duration { return 0 }
if err := client.uploadPartWithRetry(context.Background(), file, "/apps/bdrclone/retry.bin", "upload-1", 0, 0, int64(len(content))); err != nil {
t.Fatal(err)
}
if attempts != 4 {
t.Fatalf("attempts = %d, want 4", attempts)
}
failureAttempts := 0
failureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
failureAttempts++
_, _ = io.Copy(io.Discard, r.Body)
http.Error(w, "gateway timeout", http.StatusGatewayTimeout)
}))
defer failureServer.Close()
failureClient := New(testConfig("token"), WithEndpoints(failureServer.URL, failureServer.URL, failureServer.URL))
failureClient.uploadRetryDelay = func(int) time.Duration { return 0 }
err = failureClient.uploadPartWithRetry(context.Background(), file, "/apps/bdrclone/retry.bin", "upload-2", 0, 0, int64(len(content)))
if err == nil || !strings.Contains(err.Error(), "after 6 attempts") {
t.Fatalf("error = %v", err)
}
if failureAttempts != maxUploadAttempts {
t.Fatalf("attempts = %d, want %d", failureAttempts, maxUploadAttempts)
}
}
func TestMkdirAllCreatesMissingRemoteDirectories(t *testing.T) {
directories := map[string]bool{"/apps/bdrclone": true}
var created []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/rest/2.0/xpan/file" {
http.NotFound(w, r)
return
}
switch r.URL.Query().Get("method") {
case "list":
parent := r.URL.Query().Get("dir")
entries := make([]File, 0)
for directory := range directories {
if directory != parent && path.Dir(directory) == parent {
entries = append(entries, File{Path: directory, ServerFilename: path.Base(directory), IsDir: 1})
}
}
_ = json.NewEncoder(w).Encode(map[string]any{"errno": 0, "list": entries})
case "create":
if err := r.ParseForm(); err != nil {
t.Error(err)
return
}
directory := r.Form.Get("path")
if r.Form.Get("isdir") != "1" {
t.Errorf("isdir = %q", r.Form.Get("isdir"))
}
directories[directory] = true
created = append(created, directory)
_ = json.NewEncoder(w).Encode(File{Path: directory, ServerFilename: path.Base(directory), IsDir: 1})
default:
http.Error(w, "unexpected method", http.StatusBadRequest)
}
}))
defer server.Close()
client := New(testConfig("token"), WithEndpoints(server.URL, server.URL, server.URL))
if err := client.MkdirAll(context.Background(), "/backup/mac/photos"); err != nil {
t.Fatal(err)
}
if err := client.MkdirAll(context.Background(), "/backup/mac/photos"); err != nil {
t.Fatal(err)
}
want := []string{"/apps/bdrclone/backup", "/apps/bdrclone/backup/mac", "/apps/bdrclone/backup/mac/photos"}
if !reflect.DeepEqual(created, want) {
t.Fatalf("created = %q, want %q", created, want)
}
}
func testConfig(token string) *config.Config {
return &config.Config{
ClientID: "client", ClientSecret: "secret", AccessToken: token,
Root: "/apps/bdrclone", UserAgent: "pan.baidu.com", UploadParts: 1,
}
}