@@ -0,0 +1,405 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
type osStubClient struct {
|
||||
*fakeClient
|
||||
|
||||
buckets []oci.Bucket
|
||||
createdPAR oci.CreatePARInput
|
||||
renamed [2]string
|
||||
|
||||
// 对象内容中转 stub 状态
|
||||
objectData []byte
|
||||
objectEtag string
|
||||
putContentType string
|
||||
putIfMatch string
|
||||
|
||||
// 后台清空删除桶的编排状态,goroutine 并发访问需加锁
|
||||
mu sync.Mutex
|
||||
versions []oci.ObjectVersion
|
||||
pars []oci.PAR
|
||||
bucketDeleted bool
|
||||
}
|
||||
|
||||
func (f *osStubClient) ListBuckets(ctx context.Context, cred oci.Credentials, region, compartmentID string) ([]oci.Bucket, error) {
|
||||
return f.buckets, nil
|
||||
}
|
||||
|
||||
func (f *osStubClient) CreateBucket(ctx context.Context, cred oci.Credentials, region string, in oci.CreateBucketInput) (oci.Bucket, error) {
|
||||
b := oci.Bucket{Name: in.Name, Namespace: "ns", VersioningOn: in.VersioningOn}
|
||||
f.buckets = append(f.buckets, b)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (f *osStubClient) RenameObject(ctx context.Context, cred oci.Credentials, region, bucket, src, dst string) error {
|
||||
f.renamed = [2]string{src, dst}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *osStubClient) DeleteBucket(ctx context.Context, cred oci.Credentials, region, name string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
// 模拟 OCI 真实行为:残留对象版本或活跃 PAR 都会拒绝删桶
|
||||
if len(f.versions) > 0 || len(f.pars) > 0 {
|
||||
return fmt.Errorf("delete bucket %s: %w", name, oci.ErrBucketNotEmpty)
|
||||
}
|
||||
f.bucketDeleted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *osStubClient) ListObjectVersions(ctx context.Context, cred oci.Credentials, region, bucket, page string) ([]oci.ObjectVersion, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]oci.ObjectVersion(nil), f.versions...), "", nil
|
||||
}
|
||||
|
||||
func (f *osStubClient) DeleteObjectVersion(ctx context.Context, cred oci.Credentials, region, bucket, object, versionID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
kept := f.versions[:0]
|
||||
for _, v := range f.versions {
|
||||
if !(v.Name == object && v.VersionID == versionID) {
|
||||
kept = append(kept, v)
|
||||
}
|
||||
}
|
||||
f.versions = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *osStubClient) ListPARs(ctx context.Context, cred oci.Credentials, region, bucket string) ([]oci.PAR, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]oci.PAR(nil), f.pars...), nil
|
||||
}
|
||||
|
||||
// ListPARsPage 以数组下标模拟游标:page 为起始下标,next 为下一段起始下标。
|
||||
func (f *osStubClient) ListPARsPage(ctx context.Context, cred oci.Credentials, region, bucket, page string, limit int) ([]oci.PAR, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
start, _ := strconv.Atoi(page)
|
||||
if start >= len(f.pars) || limit <= 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
end := start + limit
|
||||
next := ""
|
||||
if end < len(f.pars) {
|
||||
next = strconv.Itoa(end)
|
||||
} else {
|
||||
end = len(f.pars)
|
||||
}
|
||||
return append([]oci.PAR(nil), f.pars[start:end]...), next, nil
|
||||
}
|
||||
|
||||
func (f *osStubClient) DeletePAR(ctx context.Context, cred oci.Credentials, region, bucket, parID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
kept := f.pars[:0]
|
||||
for _, p := range f.pars {
|
||||
if p.ID != parID {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
f.pars = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *osStubClient) purgeState() (deleted bool, versions, pars int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.bucketDeleted, len(f.versions), len(f.pars)
|
||||
}
|
||||
|
||||
func (f *osStubClient) GetObject(ctx context.Context, cred oci.Credentials, region, bucket, object string, maxBytes int64) (oci.ObjectContent, error) {
|
||||
if int64(len(f.objectData)) > maxBytes {
|
||||
return oci.ObjectContent{}, fmt.Errorf("get object %s: %w", object, oci.ErrObjectTooLarge)
|
||||
}
|
||||
return oci.ObjectContent{Data: f.objectData, ContentType: "text/plain", Etag: f.objectEtag}, nil
|
||||
}
|
||||
|
||||
func (f *osStubClient) PutObject(ctx context.Context, cred oci.Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) {
|
||||
f.objectData = data
|
||||
f.putContentType = contentType
|
||||
f.putIfMatch = ifMatch
|
||||
return "etag-new", nil
|
||||
}
|
||||
|
||||
func (f *osStubClient) CreatePAR(ctx context.Context, cred oci.Credentials, region, bucket string, in oci.CreatePARInput) (oci.PAR, error) {
|
||||
f.createdPAR = in
|
||||
return oci.PAR{ID: "par-1", Name: in.Name, AccessType: in.AccessType, FullURL: "https://os.example/p/x/n/ns/b/o"}, nil
|
||||
}
|
||||
|
||||
func TestObjectStorageValidation(t *testing.T) {
|
||||
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() error
|
||||
wantErr string
|
||||
}{
|
||||
{name: "桶名非法字符", run: func() error {
|
||||
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: "有中文"})
|
||||
return err
|
||||
}, wantErr: "invalid character"},
|
||||
{name: "桶名空", run: func() error {
|
||||
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: ""})
|
||||
return err
|
||||
}, wantErr: "length"},
|
||||
{name: "合法桶名", run: func() error {
|
||||
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: "my-bucket_01"})
|
||||
return err
|
||||
}},
|
||||
{name: "重命名缺参", run: func() error {
|
||||
return svc.RenameObject(ctx, cfg.ID, "r", "b", "a.txt", "a.txt")
|
||||
}, wantErr: "distinct"},
|
||||
{name: "PAR 类型不支持", run: func() error {
|
||||
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "Whatever", ExpiresHours: 2})
|
||||
return err
|
||||
}, wantErr: "unsupported access type"},
|
||||
{name: "PAR 时长越界", run: func() error {
|
||||
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 800})
|
||||
return err
|
||||
}, wantErr: "1-720"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.run()
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBucketEmptySync(t *testing.T) {
|
||||
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
|
||||
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
|
||||
if err != nil || queued {
|
||||
t.Fatalf("DeleteBucket 空桶 = queued %v, %v, want 同步删除", queued, err)
|
||||
}
|
||||
if deleted, _, _ := client.purgeState(); !deleted {
|
||||
t.Error("空桶应同步删除")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBucketPurgeQueued(t *testing.T) {
|
||||
client := &osStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
versions: []oci.ObjectVersion{
|
||||
{Name: "a.txt", VersionID: "v1"},
|
||||
{Name: "a.txt", VersionID: ""},
|
||||
{Name: "dir/b.bin", VersionID: "v9"},
|
||||
},
|
||||
pars: []oci.PAR{{ID: "p1"}, {ID: "p2"}},
|
||||
}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
|
||||
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
|
||||
if err != nil || !queued {
|
||||
t.Fatalf("DeleteBucket 非空桶 = queued %v, %v, want queued", queued, err)
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if deleted, nv, np := client.purgeState(); deleted && nv == 0 && np == 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
deleted, nv, np := client.purgeState()
|
||||
t.Fatalf("purge 未完成: deleted=%v versions=%d pars=%d", deleted, nv, np)
|
||||
}
|
||||
|
||||
// TestDeleteBucketPAROnlyQueued 覆盖真实环境踩到的场景:桶内无对象,
|
||||
// 仅剩活跃 PAR 时 OCI 也拒绝删桶,应同样走后台清空后重删。
|
||||
func TestDeleteBucketPAROnlyQueued(t *testing.T) {
|
||||
client := &osStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
pars: []oci.PAR{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}},
|
||||
}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
|
||||
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
|
||||
if err != nil || !queued {
|
||||
t.Fatalf("DeleteBucket 仅 PAR 桶 = queued %v, %v, want queued", queued, err)
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if deleted, _, np := client.purgeState(); deleted && np == 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
deleted, _, np := client.purgeState()
|
||||
t.Fatalf("purge 未完成: deleted=%v pars=%d", deleted, np)
|
||||
}
|
||||
|
||||
func TestDeleteAllPARsConcurrent(t *testing.T) {
|
||||
pars := make([]oci.PAR, 40)
|
||||
for i := range pars {
|
||||
pars[i] = oci.PAR{ID: fmt.Sprintf("p-%d", i), Name: fmt.Sprintf("par-%d", i)}
|
||||
}
|
||||
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, pars: pars}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
|
||||
n, err := svc.DeleteAllPARs(context.Background(), cfg.ID, "r", "b")
|
||||
if err != nil || n != 40 {
|
||||
t.Fatalf("DeleteAllPARs = %d, %v, want 40 deleted", n, err)
|
||||
}
|
||||
if _, _, np := client.purgeState(); np != 0 {
|
||||
t.Errorf("remaining pars = %d, want 0", np)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPARsPage(t *testing.T) {
|
||||
pars := make([]oci.PAR, 5)
|
||||
for i := range pars {
|
||||
pars[i] = oci.PAR{ID: fmt.Sprintf("p-%d", i)}
|
||||
}
|
||||
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, pars: pars}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
page string
|
||||
limit int
|
||||
wantIDs int
|
||||
wantNext string
|
||||
}{
|
||||
{name: "首页带游标", page: "", limit: 2, wantIDs: 2, wantNext: "2"},
|
||||
{name: "中间页", page: "2", limit: 2, wantIDs: 2, wantNext: "4"},
|
||||
{name: "末页无游标", page: "4", limit: 2, wantIDs: 1, wantNext: ""},
|
||||
{name: "limit 0 归一为 100", page: "", limit: 0, wantIDs: 5, wantNext: ""},
|
||||
{name: "limit 越界归一为 100", page: "", limit: 5000, wantIDs: 5, wantNext: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
items, next, err := svc.PARsPage(context.Background(), cfg.ID, "r", "b", tt.page, tt.limit)
|
||||
if err != nil {
|
||||
t.Fatalf("PARsPage: %v", err)
|
||||
}
|
||||
if len(items) != tt.wantIDs || next != tt.wantNext {
|
||||
t.Fatalf("PARsPage = %d 条, next=%q; want %d 条, next=%q", len(items), next, tt.wantIDs, tt.wantNext)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForEachConcurrently(t *testing.T) {
|
||||
items := make([]int, 20)
|
||||
for i := range items {
|
||||
items[i] = i
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
items []int
|
||||
failOn int // 值为 -1 表示不注入失败
|
||||
wantErr bool
|
||||
wantDone int
|
||||
}{
|
||||
{name: "全部成功", items: items, failOn: -1, wantDone: 20},
|
||||
{name: "个别失败不中断其余", items: items, failOn: 3, wantErr: true, wantDone: 19},
|
||||
{name: "空列表", items: nil, failOn: -1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
done int
|
||||
)
|
||||
err := forEachConcurrently(4, tt.items, func(i int) error {
|
||||
if i == tt.failOn {
|
||||
return errors.New("boom")
|
||||
}
|
||||
mu.Lock()
|
||||
done++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
if (err != nil) != tt.wantErr || done != tt.wantDone {
|
||||
t.Fatalf("forEachConcurrently err=%v done=%d, want err=%v done=%d", err, done, tt.wantErr, tt.wantDone)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectContentRoundTrip(t *testing.T) {
|
||||
client := &osStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
objectData: []byte("hello"), objectEtag: "e1",
|
||||
}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := svc.ObjectContent(ctx, cfg.ID, "r", "b", "a.txt")
|
||||
if err != nil || string(got.Data) != "hello" || got.Etag != "e1" {
|
||||
t.Fatalf("ObjectContent = %q etag=%q, %v; want hello/e1", got.Data, got.Etag, err)
|
||||
}
|
||||
etag, err := svc.PutObjectContent(ctx, cfg.ID, "r", "b", "a.txt", []byte("world"), "text/plain", "e1")
|
||||
if err != nil || etag != "etag-new" {
|
||||
t.Fatalf("PutObjectContent = %q, %v; want etag-new", etag, err)
|
||||
}
|
||||
if client.putIfMatch != "e1" || client.putContentType != "text/plain" {
|
||||
t.Errorf("ifMatch=%q contentType=%q, want e1/text-plain 透传", client.putIfMatch, client.putContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectContentLimits(t *testing.T) {
|
||||
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.ObjectContent(ctx, cfg.ID, "r", "b", ""); err == nil {
|
||||
t.Error("空对象名应报错")
|
||||
}
|
||||
big := make([]byte, maxObjectPutBytes+1)
|
||||
if _, err := svc.PutObjectContent(ctx, cfg.ID, "r", "b", "a", big, "", ""); !errors.Is(err, oci.ErrObjectTooLarge) {
|
||||
t.Errorf("超限 PUT err = %v, want ErrObjectTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePARDefaultsName(t *testing.T) {
|
||||
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
|
||||
par, err := svc.CreatePAR(context.Background(), cfg.ID, "r", "b",
|
||||
oci.CreatePARInput{ObjectName: "db/app.sql.gz", AccessType: "ObjectRead", ExpiresHours: 24})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePAR: %v", err)
|
||||
}
|
||||
if client.createdPAR.Name != "par-db-app.sql.gz" {
|
||||
t.Errorf("default name = %q, want par-db-app.sql.gz", client.createdPAR.Name)
|
||||
}
|
||||
if par.FullURL == "" {
|
||||
t.Errorf("FullURL empty, want populated")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user