From 1522439c686ad0f8c0fb369ec7c42fad15d91aa6 Mon Sep 17 00:00:00 2001 From: Noooste <83548733+Noooste@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:58:51 +0200 Subject: [PATCH] test(mocks): add hand-rolled mocks for AdminService and S3Storage Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com> --- backend/internal/services/mocks/admin_mock.go | 229 ++++++++++++++++++ backend/internal/services/mocks/s3_mock.go | 134 ++++++++++ 2 files changed, 363 insertions(+) create mode 100644 backend/internal/services/mocks/admin_mock.go create mode 100644 backend/internal/services/mocks/s3_mock.go diff --git a/backend/internal/services/mocks/admin_mock.go b/backend/internal/services/mocks/admin_mock.go new file mode 100644 index 0000000..3e05b16 --- /dev/null +++ b/backend/internal/services/mocks/admin_mock.go @@ -0,0 +1,229 @@ +// Package mocks provides hand-rolled test doubles for the interfaces declared +// in package services. Each mock is configured per-test by overriding the +// function fields; unset fields return a sentinel error so missing setup is +// surfaced loudly. +package mocks + +import ( + "context" + "fmt" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" +) + +// errNotConfigured is returned by any AdminMock method whose function field +// was not set by the test. The message names the missing method so test +// failures point at the gap instead of looking like a real service error. +func errNotConfigured(method string) error { + return fmt.Errorf("AdminMock.%s: not configured by test", method) +} + +// Compile-time guarantee that AdminMock satisfies services.AdminService. +var _ services.AdminService = (*AdminMock)(nil) + +// AdminMock is a hand-rolled mock of services.AdminService. Tests assign the +// per-method function fields they care about; unset methods return +// errNotConfigured. +type AdminMock struct { + // Access keys + ListKeysFn func(ctx context.Context) ([]models.ListKeysResponseItem, error) + CreateKeyFn func(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) + GetKeyInfoFn func(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) + UpdateKeyFn func(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) + DeleteKeyFn func(ctx context.Context, keyID string) error + + // Buckets + ListBucketsFn func(ctx context.Context) ([]models.ListBucketsResponseItem, error) + GetBucketInfoFn func(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) + GetBucketInfoByAliasFn func(ctx context.Context, alias string) (*models.GarageBucketInfo, error) + CreateBucketFn func(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) + UpdateBucketFn func(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) + DeleteBucketFn func(ctx context.Context, bucketID string) error + AllowBucketKeyFn func(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) + + // Cluster + GetClusterHealthFn func(ctx context.Context) (*models.ClusterHealth, error) + GetClusterStatusFn func(ctx context.Context) (*models.ClusterStatus, error) + GetClusterStatisticsFn func(ctx context.Context) (*models.ClusterStatistics, error) + GetNodeInfoFn func(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) + GetNodeStatisticsFn func(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) + + // Monitoring + HealthCheckFn func(ctx context.Context) error + GetMetricsFn func(ctx context.Context) (string, error) + + // Calls records every invocation in order. Tests can inspect this slice to + // assert call sequence, argument values, or call count. + Calls []Call +} + +// Call captures a single invocation of an AdminMock method. +type Call struct { + Method string + Args []any +} + +func (m *AdminMock) record(method string, args ...any) { + m.Calls = append(m.Calls, Call{Method: method, Args: args}) +} + +// --- Access keys --- + +func (m *AdminMock) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) { + m.record("ListKeys") + if m.ListKeysFn == nil { + return nil, errNotConfigured("ListKeys") + } + return m.ListKeysFn(ctx) +} + +func (m *AdminMock) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) { + m.record("CreateKey", req) + if m.CreateKeyFn == nil { + return nil, errNotConfigured("CreateKey") + } + return m.CreateKeyFn(ctx, req) +} + +func (m *AdminMock) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) { + m.record("GetKeyInfo", keyID, showSecret) + if m.GetKeyInfoFn == nil { + return nil, errNotConfigured("GetKeyInfo") + } + return m.GetKeyInfoFn(ctx, keyID, showSecret) +} + +func (m *AdminMock) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) { + m.record("UpdateKey", keyID, req) + if m.UpdateKeyFn == nil { + return nil, errNotConfigured("UpdateKey") + } + return m.UpdateKeyFn(ctx, keyID, req) +} + +func (m *AdminMock) DeleteKey(ctx context.Context, keyID string) error { + m.record("DeleteKey", keyID) + if m.DeleteKeyFn == nil { + return errNotConfigured("DeleteKey") + } + return m.DeleteKeyFn(ctx, keyID) +} + +// --- Buckets --- + +func (m *AdminMock) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) { + m.record("ListBuckets") + if m.ListBucketsFn == nil { + return nil, errNotConfigured("ListBuckets") + } + return m.ListBucketsFn(ctx) +} + +func (m *AdminMock) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) { + m.record("GetBucketInfo", bucketID) + if m.GetBucketInfoFn == nil { + return nil, errNotConfigured("GetBucketInfo") + } + return m.GetBucketInfoFn(ctx, bucketID) +} + +func (m *AdminMock) GetBucketInfoByAlias(ctx context.Context, alias string) (*models.GarageBucketInfo, error) { + m.record("GetBucketInfoByAlias", alias) + if m.GetBucketInfoByAliasFn == nil { + return nil, errNotConfigured("GetBucketInfoByAlias") + } + return m.GetBucketInfoByAliasFn(ctx, alias) +} + +func (m *AdminMock) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) { + m.record("CreateBucket", req) + if m.CreateBucketFn == nil { + return nil, errNotConfigured("CreateBucket") + } + return m.CreateBucketFn(ctx, req) +} + +func (m *AdminMock) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) { + m.record("UpdateBucket", bucketID, req) + if m.UpdateBucketFn == nil { + return nil, errNotConfigured("UpdateBucket") + } + return m.UpdateBucketFn(ctx, bucketID, req) +} + +func (m *AdminMock) DeleteBucket(ctx context.Context, bucketID string) error { + m.record("DeleteBucket", bucketID) + if m.DeleteBucketFn == nil { + return errNotConfigured("DeleteBucket") + } + return m.DeleteBucketFn(ctx, bucketID) +} + +func (m *AdminMock) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { + m.record("AllowBucketKey", req) + if m.AllowBucketKeyFn == nil { + return nil, errNotConfigured("AllowBucketKey") + } + return m.AllowBucketKeyFn(ctx, req) +} + +// --- Cluster --- + +func (m *AdminMock) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) { + m.record("GetClusterHealth") + if m.GetClusterHealthFn == nil { + return nil, errNotConfigured("GetClusterHealth") + } + return m.GetClusterHealthFn(ctx) +} + +func (m *AdminMock) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) { + m.record("GetClusterStatus") + if m.GetClusterStatusFn == nil { + return nil, errNotConfigured("GetClusterStatus") + } + return m.GetClusterStatusFn(ctx) +} + +func (m *AdminMock) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) { + m.record("GetClusterStatistics") + if m.GetClusterStatisticsFn == nil { + return nil, errNotConfigured("GetClusterStatistics") + } + return m.GetClusterStatisticsFn(ctx) +} + +func (m *AdminMock) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { + m.record("GetNodeInfo", nodeID) + if m.GetNodeInfoFn == nil { + return nil, errNotConfigured("GetNodeInfo") + } + return m.GetNodeInfoFn(ctx, nodeID) +} + +func (m *AdminMock) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { + m.record("GetNodeStatistics", nodeID) + if m.GetNodeStatisticsFn == nil { + return nil, errNotConfigured("GetNodeStatistics") + } + return m.GetNodeStatisticsFn(ctx, nodeID) +} + +// --- Monitoring --- + +func (m *AdminMock) HealthCheck(ctx context.Context) error { + m.record("HealthCheck") + if m.HealthCheckFn == nil { + return errNotConfigured("HealthCheck") + } + return m.HealthCheckFn(ctx) +} + +func (m *AdminMock) GetMetrics(ctx context.Context) (string, error) { + m.record("GetMetrics") + if m.GetMetricsFn == nil { + return "", errNotConfigured("GetMetrics") + } + return m.GetMetricsFn(ctx) +} diff --git a/backend/internal/services/mocks/s3_mock.go b/backend/internal/services/mocks/s3_mock.go new file mode 100644 index 0000000..4ddeaf8 --- /dev/null +++ b/backend/internal/services/mocks/s3_mock.go @@ -0,0 +1,134 @@ +package mocks + +import ( + "context" + "fmt" + "io" + "time" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" +) + +// s3NotConfigured mirrors errNotConfigured but keys messages on S3Mock so +// failures from unset S3 methods are distinguishable from unset admin methods. +func s3NotConfigured(method string) error { + return fmt.Errorf("S3Mock.%s: not configured by test", method) +} + +// Compile-time guarantee that S3Mock satisfies services.S3Storage. +var _ services.S3Storage = (*S3Mock)(nil) + +// S3Mock is a hand-rolled mock of services.S3Storage. Tests assign the +// per-method function fields they care about; unset methods return +// s3NotConfigured. +type S3Mock struct { + ListObjectsFn func(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) + UploadObjectFn func(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) + GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) + ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error) + DeleteObjectFn func(ctx context.Context, bucketName, key string) error + GetObjectMetadataFn func(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) + GetPresignedURLFn func(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) + DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) error + UploadMultipleObjectsFn func(ctx context.Context, bucketName string, files []struct { + Key string + Body io.Reader + ContentType string + }) []services.UploadResult + + // Calls records every invocation in order. Shares the Call type with + // AdminMock via the same package. + Calls []Call +} + +func (m *S3Mock) record(method string, args ...any) { + m.Calls = append(m.Calls, Call{Method: method, Args: args}) +} + +func (m *S3Mock) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) { + m.record("ListObjects", bucketName, prefix, maxKeys, continuationToken) + if m.ListObjectsFn == nil { + return nil, s3NotConfigured("ListObjects") + } + return m.ListObjectsFn(ctx, bucketName, prefix, maxKeys, continuationToken) +} + +func (m *S3Mock) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) { + m.record("UploadObject", bucketName, key, contentType) + if m.UploadObjectFn == nil { + return nil, s3NotConfigured("UploadObject") + } + return m.UploadObjectFn(ctx, bucketName, key, body, contentType) +} + +func (m *S3Mock) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) { + m.record("GetObject", bucketName, key) + if m.GetObjectFn == nil { + return nil, nil, s3NotConfigured("GetObject") + } + return m.GetObjectFn(ctx, bucketName, key) +} + +func (m *S3Mock) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) { + m.record("ObjectExists", bucketName, key) + if m.ObjectExistsFn == nil { + return false, s3NotConfigured("ObjectExists") + } + return m.ObjectExistsFn(ctx, bucketName, key) +} + +func (m *S3Mock) DeleteObject(ctx context.Context, bucketName, key string) error { + m.record("DeleteObject", bucketName, key) + if m.DeleteObjectFn == nil { + return s3NotConfigured("DeleteObject") + } + return m.DeleteObjectFn(ctx, bucketName, key) +} + +func (m *S3Mock) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) { + m.record("GetObjectMetadata", bucketName, key) + if m.GetObjectMetadataFn == nil { + return nil, s3NotConfigured("GetObjectMetadata") + } + return m.GetObjectMetadataFn(ctx, bucketName, key) +} + +func (m *S3Mock) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) { + m.record("GetPresignedURL", bucketName, key, expiresIn) + if m.GetPresignedURLFn == nil { + return "", s3NotConfigured("GetPresignedURL") + } + return m.GetPresignedURLFn(ctx, bucketName, key, expiresIn) +} + +func (m *S3Mock) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error { + m.record("DeleteMultipleObjects", bucketName, keys) + if m.DeleteMultipleObjectsFn == nil { + return s3NotConfigured("DeleteMultipleObjects") + } + return m.DeleteMultipleObjectsFn(ctx, bucketName, keys) +} + +func (m *S3Mock) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct { + Key string + Body io.Reader + ContentType string +}) []services.UploadResult { + m.record("UploadMultipleObjects", bucketName, len(files)) + if m.UploadMultipleObjectsFn == nil { + // Can't return an error here — the interface has no error channel. + // Return a single synthetic failure for every file so tests that + // forget to configure this method see a clear red flag. + results := make([]services.UploadResult, len(files)) + for i, f := range files { + results[i] = services.UploadResult{ + Key: f.Key, + Success: false, + Error: s3NotConfigured("UploadMultipleObjects"), + } + } + return results + } + return m.UploadMultipleObjectsFn(ctx, bucketName, files) +}