271 lines
8.7 KiB
Go
271 lines
8.7 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/tamakyi/TamaBox/internal/conf"
|
|
"github.com/tamakyi/TamaBox/internal/db"
|
|
)
|
|
|
|
type fakeR2OutboxFailure struct {
|
|
nextRetry time.Time
|
|
lastError string
|
|
}
|
|
|
|
type fakeR2OutboxStore struct {
|
|
tasks []*db.R2DeleteTask
|
|
references map[string]bool
|
|
referenceErrs map[string]error
|
|
referenceCalls []string
|
|
deleted map[uint]bool
|
|
deleteErr error
|
|
failures map[uint]fakeR2OutboxFailure
|
|
markFailedErr error
|
|
}
|
|
|
|
func newFakeR2OutboxStore(tasks ...*db.R2DeleteTask) *fakeR2OutboxStore {
|
|
return &fakeR2OutboxStore{
|
|
tasks: tasks,
|
|
references: make(map[string]bool),
|
|
referenceErrs: make(map[string]error),
|
|
deleted: make(map[uint]bool),
|
|
failures: make(map[uint]fakeR2OutboxFailure),
|
|
}
|
|
}
|
|
|
|
func (s *fakeR2OutboxStore) ListDue(ctx context.Context, limit int) ([]*db.R2DeleteTask, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
tasks := s.tasks
|
|
if limit >= 0 && len(tasks) > limit {
|
|
tasks = tasks[:limit]
|
|
}
|
|
return append([]*db.R2DeleteTask(nil), tasks...), nil
|
|
}
|
|
|
|
func (s *fakeR2OutboxStore) Delete(id uint) error {
|
|
if s.deleteErr != nil {
|
|
return s.deleteErr
|
|
}
|
|
s.deleted[id] = true
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeR2OutboxStore) MarkFailed(id uint, nextRetry time.Time, lastError string) error {
|
|
if s.markFailedErr != nil {
|
|
return s.markFailedErr
|
|
}
|
|
s.failures[id] = fakeR2OutboxFailure{nextRetry: nextRetry, lastError: lastError}
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeR2OutboxStore) HasReferences(ctx context.Context, _, key string) (bool, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
s.referenceCalls = append(s.referenceCalls, key)
|
|
return s.references[key], s.referenceErrs[key]
|
|
}
|
|
|
|
type fakeR2DeleteCall struct {
|
|
bucket string
|
|
key string
|
|
}
|
|
|
|
type fakeR2ObjectDeleter struct {
|
|
calls []fakeR2DeleteCall
|
|
errs map[string]error
|
|
onDelete func(context.Context, string, string) error
|
|
}
|
|
|
|
func (d *fakeR2ObjectDeleter) DeleteObject(ctx context.Context, bucket, key string) error {
|
|
d.calls = append(d.calls, fakeR2DeleteCall{bucket: bucket, key: key})
|
|
if d.onDelete != nil {
|
|
return d.onDelete(ctx, bucket, key)
|
|
}
|
|
return d.errs[key]
|
|
}
|
|
|
|
type fakeR2HTTPStatusError int
|
|
|
|
func (e fakeR2HTTPStatusError) Error() string {
|
|
return fmt.Sprintf("R2 returned HTTP %d", int(e))
|
|
}
|
|
|
|
func (e fakeR2HTTPStatusError) HTTPStatusCode() int {
|
|
return int(e)
|
|
}
|
|
|
|
func setR2OutboxTestConfig(t *testing.T) {
|
|
t.Helper()
|
|
old := conf.Upload
|
|
conf.Upload.ImageEndpoint = "https://r2.example.test"
|
|
conf.Upload.ImageAccessID = "test-access-id"
|
|
conf.Upload.ImageAccessSecret = "test-secret"
|
|
conf.Upload.ImageBucket = "configured-bucket"
|
|
conf.Upload.ImageBucketCDNHost = "cdn.example.test"
|
|
t.Cleanup(func() {
|
|
conf.Upload = old
|
|
})
|
|
}
|
|
|
|
func TestProcessR2OutboxSuccessAndNotFound(t *testing.T) {
|
|
setR2OutboxTestConfig(t)
|
|
for _, tt := range []struct {
|
|
name string
|
|
deleteErr error
|
|
}{
|
|
{name: "success"},
|
|
{name: "not found is success", deleteErr: fakeR2HTTPStatusError(404)},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
first := &db.R2DeleteTask{ID: 1, Bucket: conf.Upload.ImageBucket, Key: "first.webp"}
|
|
second := &db.R2DeleteTask{ID: 2, Bucket: conf.Upload.ImageBucket, Key: "second.webp"}
|
|
store := newFakeR2OutboxStore(first, second)
|
|
deleter := &fakeR2ObjectDeleter{errs: map[string]error{"second.webp": tt.deleteErr}}
|
|
|
|
result, err := processR2Outbox(context.Background(), store, deleter, 100)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 2, result.Listed)
|
|
require.Equal(t, 2, result.Removed)
|
|
require.Equal(t, 2, result.ObjectsDeleted)
|
|
require.Zero(t, result.Failed)
|
|
require.Zero(t, result.Remaining)
|
|
require.Equal(t, []fakeR2DeleteCall{
|
|
{bucket: conf.Upload.ImageBucket, key: "first.webp"},
|
|
{bucket: conf.Upload.ImageBucket, key: "second.webp"},
|
|
}, deleter.calls, "任务必须按 ListDue 顺序串行处理")
|
|
require.True(t, store.deleted[first.ID])
|
|
require.True(t, store.deleted[second.ID])
|
|
require.Empty(t, store.failures)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProcessR2OutboxReferencedTaskDeletesNoObject(t *testing.T) {
|
|
setR2OutboxTestConfig(t)
|
|
task := &db.R2DeleteTask{ID: 11, Bucket: conf.Upload.ImageBucket, Key: "shared.webp"}
|
|
store := newFakeR2OutboxStore(task)
|
|
store.references[task.Key] = true
|
|
deleter := &fakeR2ObjectDeleter{}
|
|
|
|
result, err := processR2Outbox(context.Background(), store, deleter, 100)
|
|
require.NoError(t, err)
|
|
require.Empty(t, deleter.calls, "仍有引用时绝不能调用 R2 删除")
|
|
require.True(t, store.deleted[task.ID], "过期任务本身应删除,最后引用移除时会重新入队")
|
|
require.Equal(t, 1, result.ReferenceOnly)
|
|
require.Equal(t, 1, result.Removed)
|
|
require.Zero(t, result.ObjectsDeleted)
|
|
require.Zero(t, result.Remaining)
|
|
}
|
|
|
|
func TestProcessR2OutboxReferenceErrorSchedulesRetry(t *testing.T) {
|
|
setR2OutboxTestConfig(t)
|
|
task := &db.R2DeleteTask{ID: 21, Bucket: conf.Upload.ImageBucket, Key: "unknown.webp"}
|
|
store := newFakeR2OutboxStore(task)
|
|
store.referenceErrs[task.Key] = errors.New("reference query failed")
|
|
deleter := &fakeR2ObjectDeleter{}
|
|
before := time.Now()
|
|
|
|
result, err := processR2Outbox(context.Background(), store, deleter, 100)
|
|
require.NoError(t, err)
|
|
require.Empty(t, deleter.calls)
|
|
require.False(t, store.deleted[task.ID])
|
|
require.Contains(t, store.failures, task.ID)
|
|
failure := store.failures[task.ID]
|
|
require.Equal(t, "reference query failed", failure.lastError)
|
|
require.WithinDuration(t, before.Add(time.Minute), failure.nextRetry, 2*time.Second)
|
|
require.Equal(t, 1, result.Failed)
|
|
require.Equal(t, 1, result.Remaining)
|
|
}
|
|
|
|
func TestProcessR2OutboxDeleteFailureUsesBackoffAndBoundsLastError(t *testing.T) {
|
|
setR2OutboxTestConfig(t)
|
|
task := &db.R2DeleteTask{ID: 31, Bucket: conf.Upload.ImageBucket, Key: "retry.webp", Attempts: 2}
|
|
store := newFakeR2OutboxStore(task)
|
|
longErr := fmt.Errorf("delete failed endpoint=%s credential=%s %s",
|
|
conf.Upload.ImageEndpoint,
|
|
conf.Upload.ImageAccessSecret,
|
|
strings.Repeat("x", 1100),
|
|
)
|
|
deleter := &fakeR2ObjectDeleter{errs: map[string]error{task.Key: longErr}}
|
|
before := time.Now()
|
|
|
|
result, err := processR2Outbox(context.Background(), store, deleter, 100)
|
|
require.NoError(t, err)
|
|
require.Len(t, deleter.calls, 1)
|
|
require.False(t, store.deleted[task.ID])
|
|
require.Contains(t, store.failures, task.ID)
|
|
failure := store.failures[task.ID]
|
|
require.WithinDuration(t, before.Add(4*time.Minute), failure.nextRetry, 2*time.Second)
|
|
require.LessOrEqual(t, len(failure.lastError), r2OutboxMaxLastErrorSize)
|
|
require.NotContains(t, failure.lastError, conf.Upload.ImageEndpoint)
|
|
require.NotContains(t, failure.lastError, conf.Upload.ImageAccessSecret)
|
|
require.Equal(t, 1, result.Failed)
|
|
require.Equal(t, 1, result.Remaining)
|
|
}
|
|
|
|
func TestProcessR2OutboxBucketMismatchIsRetainedWithoutDeletion(t *testing.T) {
|
|
setR2OutboxTestConfig(t)
|
|
task := &db.R2DeleteTask{ID: 41, Bucket: "different-bucket", Key: "foreign.webp"}
|
|
store := newFakeR2OutboxStore(task)
|
|
deleter := &fakeR2ObjectDeleter{}
|
|
|
|
result, err := processR2Outbox(context.Background(), store, deleter, 100)
|
|
require.NoError(t, err)
|
|
require.Empty(t, store.referenceCalls, "bucket 不匹配时不应查询当前 bucket 的引用")
|
|
require.Empty(t, deleter.calls)
|
|
require.Empty(t, store.failures)
|
|
require.False(t, store.deleted[task.ID])
|
|
require.Equal(t, 1, result.Skipped)
|
|
require.Zero(t, result.Failed)
|
|
require.Equal(t, 1, result.Remaining)
|
|
}
|
|
|
|
func TestProcessR2OutboxContextCancellationStopsWithoutMarkFailed(t *testing.T) {
|
|
setR2OutboxTestConfig(t)
|
|
first := &db.R2DeleteTask{ID: 51, Bucket: conf.Upload.ImageBucket, Key: "first.webp"}
|
|
second := &db.R2DeleteTask{ID: 52, Bucket: conf.Upload.ImageBucket, Key: "second.webp"}
|
|
store := newFakeR2OutboxStore(first, second)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
deleter := &fakeR2ObjectDeleter{
|
|
onDelete: func(ctx context.Context, _, _ string) error {
|
|
cancel()
|
|
return ctx.Err()
|
|
},
|
|
}
|
|
|
|
result, err := processR2Outbox(ctx, store, deleter, 100)
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
require.Len(t, deleter.calls, 1, "取消后不得继续处理第二个任务")
|
|
require.Empty(t, store.failures, "优雅关闭导致的取消不应增加任务失败次数")
|
|
require.Empty(t, store.deleted)
|
|
require.Zero(t, result.Failed)
|
|
require.Equal(t, 2, result.Remaining)
|
|
}
|
|
|
|
func TestR2OutboxBackoffCapsAtOneHour(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
attempts int
|
|
expected time.Duration
|
|
}{
|
|
{attempts: -1, expected: time.Minute},
|
|
{attempts: 0, expected: time.Minute},
|
|
{attempts: 1, expected: 2 * time.Minute},
|
|
{attempts: 2, expected: 4 * time.Minute},
|
|
{attempts: 5, expected: 32 * time.Minute},
|
|
{attempts: 6, expected: time.Hour},
|
|
{attempts: 100, expected: time.Hour},
|
|
} {
|
|
require.Equal(t, tt.expected, r2OutboxBackoff(tt.attempts))
|
|
}
|
|
}
|