Files
garage-ui/backend/internal/services/mocks/mocks_test.go
T
Camilo Hollanda 5d33382e30 feat: bulk actions — recursively delete folders (key prefixes) (#68)
* feat(backend,frontend): recursive delete of key prefixes in bulk actions

Bulk object selection previously only supported deleting individually
listed object keys — folders ("prefixes") could not be selected or
deleted, leaving no way to remove a directory and all of its contents.

Backend:
- Add S3Service.DeleteObjectsByPrefix, which recursively lists every
  object under a prefix and batch-deletes them, returning the count.
- Extend the delete-multiple endpoint to accept a "prefixes" array
  alongside "keys"; keys are batch-deleted and each prefix is deleted
  recursively. Response now reports the total objects removed.

Frontend:
- Enable folder checkboxes and add a per-folder "Delete folder" action.
- Select-all now covers both files and folders.
- Route all bulk/folder deletes through a confirmation dialog that
  spells out the file/folder counts and warns that folders are removed
  recursively (previously bulk delete fired with no confirmation).
- api/hook send "prefixes"; optimistic update drops objects under any
  deleted prefix.

* fix(backend): validate delete prefixes and count actual removals

Addresses maintainer review on the recursive prefix-delete endpoint:

- Reject blank/whitespace-only prefixes with a 400 instead of a 500, and
  normalize each prefix to have a trailing "/" so "photos/2024" can no
  longer also delete siblings like "photos/2024-old/..." on this
  irreversible public endpoint.
- DeleteMultipleObjects now returns the number of objects actually
  removed (requested keys minus failures) rather than assuming every
  requested key was deleted; the handler sums real counts across the
  keys and prefix paths. Draining the full RemoveObjects error channel
  also fixes a potential sender-goroutine leak on early return.
- Add tests: blank-prefix -> 400, prefix trailing-slash normalization,
  and S3Service.DeleteObjectsByPrefix (list-then-delete, empty prefix,
  no-match, and list-error propagation).

* fix(frontend): align select-all with the active search filter

The header "select all" checkbox derived its checked state from the
filtered (searched) rows, but handleSelectAll operated on the full,
unfiltered object list — so with a search active it selected hidden
items and the checkbox state disagreed with the selection.

ObjectsTable now passes the keys of the currently visible (filtered)
rows to onSelectAll, and its checked state reflects whether every
visible row is selected. handleSelectAll toggles only those visible
rows, leaving any off-screen selection intact.

* fix(frontend): scope select-all to the visible page

After merging upstream's client-side deep-search pagination, the rendered
rows are pageObjects (one page slice) while select-all still operated on
filteredObjects (every match across hidden pages). Scope the header
checkbox's state and its select-all action to pageObjects so one click
never selects off-screen rows for a destructive bulk delete. In
normal/prefix browsing pageObjects === filteredObjects, so behavior there
is unchanged.

---------

Authored-by: Camilo Hollanda <775409+prem-prakash@users.noreply.github.com>
2026-07-14 23:11:23 +02:00

216 lines
8.1 KiB
Go

package mocks
import (
"context"
"io"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
)
// The mocks are exercised across the handlers test suite, but Go's per-
// package coverage counts only statements executed from tests in THIS
// package. This test calls every mock method with no Fn configured, which
// covers the default "not configured" error path (and the record() helpers
// that build the call log).
func TestAdminMock_UnconfiguredMethodsReturnSentinel(t *testing.T) {
ctx := context.Background()
m := &AdminMock{}
type call struct {
name string
fn func() error
}
calls := []call{
{"ListKeys", func() error { _, e := m.ListKeys(ctx); return e }},
{"CreateKey", func() error { _, e := m.CreateKey(ctx, models.CreateKeyRequest{}); return e }},
{"GetKeyInfo", func() error { _, e := m.GetKeyInfo(ctx, "k", false); return e }},
{"UpdateKey", func() error { _, e := m.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); return e }},
{"DeleteKey", func() error { return m.DeleteKey(ctx, "k") }},
{"ListBuckets", func() error { _, e := m.ListBuckets(ctx); return e }},
{"GetBucketInfo", func() error { _, e := m.GetBucketInfo(ctx, "b"); return e }},
{"GetBucketInfoByAlias", func() error { _, e := m.GetBucketInfoByAlias(ctx, "a"); return e }},
{"CreateBucket", func() error { _, e := m.CreateBucket(ctx, models.CreateBucketAdminRequest{}); return e }},
{"UpdateBucket", func() error { _, e := m.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); return e }},
{"DeleteBucket", func() error { return m.DeleteBucket(ctx, "b") }},
{"AllowBucketKey", func() error { _, e := m.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); return e }},
{"DenyBucketKey", func() error { _, e := m.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); return e }},
{"GetClusterHealth", func() error { _, e := m.GetClusterHealth(ctx); return e }},
{"GetClusterStatus", func() error { _, e := m.GetClusterStatus(ctx); return e }},
{"GetClusterStatistics", func() error { _, e := m.GetClusterStatistics(ctx); return e }},
{"GetNodeInfo", func() error { _, e := m.GetNodeInfo(ctx, "n"); return e }},
{"GetNodeStatistics", func() error { _, e := m.GetNodeStatistics(ctx, "n"); return e }},
{"HealthCheck", func() error { return m.HealthCheck(ctx) }},
{"GetMetrics", func() error { _, e := m.GetMetrics(ctx); return e }},
}
for _, c := range calls {
err := c.fn()
if err == nil {
t.Errorf("%s: expected error from unconfigured mock, got nil", c.name)
continue
}
if !strings.Contains(err.Error(), c.name) {
t.Errorf("%s: error %q should mention method name", c.name, err.Error())
}
}
if len(m.Calls) != len(calls) {
t.Errorf("Calls recorded = %d, want %d", len(m.Calls), len(calls))
}
}
func TestAdminMock_ConfiguredFnsAreInvoked(t *testing.T) {
ctx := context.Background()
m := &AdminMock{
ListKeysFn: func(ctx context.Context) ([]models.ListKeysResponseItem, error) {
return []models.ListKeysResponseItem{{ID: "k1"}}, nil
},
DeleteKeyFn: func(ctx context.Context, id string) error { return nil },
HealthCheckFn: func(ctx context.Context) error { return nil },
GetMetricsFn: func(ctx context.Context) (string, error) { return "metric 1", nil },
}
if got, err := m.ListKeys(ctx); err != nil || len(got) != 1 {
t.Errorf("ListKeys = (%v, %v), want one item", got, err)
}
if err := m.DeleteKey(ctx, "k"); err != nil {
t.Errorf("DeleteKey: %v", err)
}
if err := m.HealthCheck(ctx); err != nil {
t.Errorf("HealthCheck: %v", err)
}
if got, _ := m.GetMetrics(ctx); got != "metric 1" {
t.Errorf("GetMetrics = %q", got)
}
}
func TestS3Mock_UnconfiguredMethodsReturnSentinel(t *testing.T) {
ctx := context.Background()
m := &S3Mock{}
if _, err := m.ListObjects(ctx, "b", "", 0, ""); err == nil {
t.Error("ListObjects: want error")
}
if _, err := m.UploadObject(ctx, "b", "k", strings.NewReader(""), ""); err == nil {
t.Error("UploadObject: want error")
}
if _, err := m.CreateDirectoryMarker(ctx, "b", "k/"); err == nil {
t.Error("CreateDirectoryMarker: want error")
}
if _, _, err := m.GetObject(ctx, "b", "k"); err == nil {
t.Error("GetObject: want error")
}
if _, err := m.ObjectExists(ctx, "b", "k"); err == nil {
t.Error("ObjectExists: want error")
}
if err := m.DeleteObject(ctx, "b", "k"); err == nil {
t.Error("DeleteObject: want error")
}
if _, err := m.GetObjectMetadata(ctx, "b", "k"); err == nil {
t.Error("GetObjectMetadata: want error")
}
if _, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err == nil {
t.Error("GetPresignedURL: want error")
}
if _, err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err == nil {
t.Error("DeleteMultipleObjects: want error")
}
// UploadMultipleObjects has no error channel; it must return a result slice
// with one failed entry per input file.
results := m.UploadMultipleObjects(ctx, "b", []struct {
Key string
Body io.Reader
ContentType string
}{
{Key: "a"}, {Key: "b"},
})
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(results))
}
for _, r := range results {
if r.Success {
t.Errorf("result[%s] should not be successful with no Fn set", r.Key)
}
}
if len(m.Calls) < 10 {
t.Errorf("expected Calls to capture each invocation, got %d entries", len(m.Calls))
}
}
func TestS3Mock_ConfiguredFnsAreInvoked(t *testing.T) {
ctx := context.Background()
m := &S3Mock{
ListObjectsFn: func(_ context.Context, _, _ string, _ int, _ string) (*models.ObjectListResponse, error) {
return &models.ObjectListResponse{Count: 1}, nil
},
UploadObjectFn: func(_ context.Context, _, _ string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
return &models.ObjectUploadResponse{}, nil
},
CreateDirectoryMarkerFn: func(_ context.Context, _, _ string) (*models.ObjectUploadResponse, error) {
return &models.ObjectUploadResponse{}, nil
},
GetObjectFn: func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("x")), &models.ObjectInfo{}, nil
},
ObjectExistsFn: func(_ context.Context, _, _ string) (bool, error) { return true, nil },
DeleteObjectFn: func(_ context.Context, _, _ string) error { return nil },
GetObjectMetadataFn: func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{}, nil
},
GetPresignedURLFn: func(_ context.Context, _, _ string, _ time.Duration) (string, error) {
return "http://x", nil
},
DeleteMultipleObjectsFn: func(_ context.Context, _ string, keys []string) (int, error) { return len(keys), nil },
UploadMultipleObjectsFn: func(_ context.Context, _ string, files []struct {
Key string
Body io.Reader
ContentType string
}) []services.UploadResult {
out := make([]services.UploadResult, len(files))
for i, f := range files {
out[i] = services.UploadResult{Key: f.Key, Success: true}
}
return out
},
}
if r, err := m.ListObjects(ctx, "b", "", 0, ""); err != nil || r.Count != 1 {
t.Errorf("ListObjects = (%+v, %v)", r, err)
}
if _, err := m.UploadObject(ctx, "b", "k", strings.NewReader(""), ""); err != nil {
t.Errorf("UploadObject: %v", err)
}
if _, err := m.CreateDirectoryMarker(ctx, "b", "k/"); err != nil {
t.Errorf("CreateDirectoryMarker: %v", err)
}
if _, _, err := m.GetObject(ctx, "b", "k"); err != nil {
t.Errorf("GetObject: %v", err)
}
if ok, err := m.ObjectExists(ctx, "b", "k"); err != nil || !ok {
t.Errorf("ObjectExists = (%v, %v)", ok, err)
}
if err := m.DeleteObject(ctx, "b", "k"); err != nil {
t.Errorf("DeleteObject: %v", err)
}
if _, err := m.GetObjectMetadata(ctx, "b", "k"); err != nil {
t.Errorf("GetObjectMetadata: %v", err)
}
if u, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err != nil || u == "" {
t.Errorf("GetPresignedURL = (%q, %v)", u, err)
}
if n, err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err != nil || n != 1 {
t.Errorf("DeleteMultipleObjects = (%d, %v)", n, err)
}
results := m.UploadMultipleObjects(ctx, "b", []struct {
Key string
Body io.Reader
ContentType string
}{{Key: "a"}})
if len(results) != 1 || !results[0].Success {
t.Errorf("UploadMultipleObjects results = %+v", results)
}
}