feat: add storage module with S3 support and development tooling

Create shared storage module for AWS S3 operations with comprehensive
development infrastructure:

Core Features:
- S3 interface with two upload patterns (manager and direct)
- Presigned URL generation with 15-minute expiration
- Support for multipart uploads and direct PutObject
- Comprehensive test coverage (8 tests, 70.4% coverage)
- Generic implementation without project-specific dependencies

Development Tooling:
- .editorconfig for consistent editor settings
- .pre-commit-config.yaml with Go linters and formatters
- .golangci.yml for golangci-lint configuration
- commitlint.config.js for conventional commit validation
- cliff.toml for automated changelog generation (v0.0.1)
- renovate.json for automated dependency updates
- .gitlab-ci.yml for CI/CD pipeline

CI/CD Pipeline:
- Automated testing with race detection
- Coverage tracking and Codecov integration
- Vulnerability scanning with govulncheck
- Pre-commit validation gates
- Release automation

Module exports:
- New(bucket) - Upload manager pattern for large files
- NewS3(cfg, bucket) - Direct upload pattern
- Store(path, content, contentType) - Upload and get presigned URL

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-04 10:19:06 +01:00
commit d12b497a28
13 changed files with 886 additions and 0 deletions
+429
View File
@@ -0,0 +1,429 @@
package storage
import (
"context"
"errors"
"io"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// Mock implementations for testing
type mockUploader struct {
uploadFunc func(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error)
}
func (m *mockUploader) Upload(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error) {
return m.uploadFunc(ctx, input, opts...)
}
type mockDirectUploader struct {
putObjectFunc func(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
}
func (m *mockDirectUploader) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
return m.putObjectFunc(ctx, params, optFns...)
}
type mockPresigner struct {
presignFunc func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error)
}
func (m *mockPresigner) PresignGetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
return m.presignFunc(ctx, params, optFns...)
}
// Test NewS3 constructor
func TestNewS3(t *testing.T) {
cfg := aws.Config{
Region: "us-east-1",
}
bucket := "test-bucket"
s3Instance := NewS3(cfg, bucket)
if s3Instance == nil {
t.Fatal("Expected S3 instance, got nil")
}
if s3Instance.bucket != bucket {
t.Errorf("Expected bucket %s, got %s", bucket, s3Instance.bucket)
}
if !s3Instance.useDirectUpload {
t.Error("Expected useDirectUpload to be true for NewS3")
}
if s3Instance.directSvc == nil {
t.Error("Expected directSvc to be set")
}
if s3Instance.presigner == nil {
t.Error("Expected presigner to be set")
}
}
// Test Store with upload manager pattern
func TestStore_WithUploadManager_Success(t *testing.T) {
testBucket := "test-bucket"
testPath := "path/to/file.pdf"
testContent := "test content"
testContentType := "application/pdf"
expectedURL := "https://s3.amazonaws.com/test-bucket/path/to/file.pdf?presigned=true"
mockUploader := &mockUploader{
uploadFunc: func(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error) {
// Verify input parameters
if *input.Bucket != testBucket {
t.Errorf("Expected bucket %s, got %s", testBucket, *input.Bucket)
}
if *input.Key != testPath {
t.Errorf("Expected key %s, got %s", testPath, *input.Key)
}
// Read and verify body
body, err := io.ReadAll(input.Body)
if err != nil {
t.Errorf("Failed to read body: %v", err)
}
if string(body) != testContent {
t.Errorf("Expected content %s, got %s", testContent, string(body))
}
return &manager.UploadOutput{
Key: aws.String(testPath),
}, nil
},
}
mockPresigner := &mockPresigner{
presignFunc: func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
// Verify presign parameters
if *params.Bucket != testBucket {
t.Errorf("Expected bucket %s, got %s", testBucket, *params.Bucket)
}
if *params.Key != testPath {
t.Errorf("Expected key %s, got %s", testPath, *params.Key)
}
if *params.ResponseContentType != testContentType {
t.Errorf("Expected content type %s, got %s", testContentType, *params.ResponseContentType)
}
return &v4.PresignedHTTPRequest{
URL: expectedURL,
}, nil
},
}
s3Instance := &S3{
bucket: testBucket,
svc: mockUploader,
presigner: mockPresigner,
useDirectUpload: false,
}
url, err := s3Instance.Store(testPath, strings.NewReader(testContent), testContentType)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if url != expectedURL {
t.Errorf("Expected URL %s, got %s", expectedURL, url)
}
}
func TestStore_WithUploadManager_UploadError(t *testing.T) {
testBucket := "test-bucket"
testPath := "path/to/file.pdf"
testContent := "test content"
testContentType := "application/pdf"
expectedError := errors.New("upload failed")
mockUploader := &mockUploader{
uploadFunc: func(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error) {
return nil, expectedError
},
}
mockPresigner := &mockPresigner{
presignFunc: func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
t.Error("Presigner should not be called when upload fails")
return nil, nil
},
}
s3Instance := &S3{
bucket: testBucket,
svc: mockUploader,
presigner: mockPresigner,
useDirectUpload: false,
}
url, err := s3Instance.Store(testPath, strings.NewReader(testContent), testContentType)
if err == nil {
t.Fatal("Expected error, got nil")
}
if err != expectedError {
t.Errorf("Expected error %v, got %v", expectedError, err)
}
if url != "" {
t.Errorf("Expected empty URL, got %s", url)
}
}
func TestStore_WithUploadManager_PresignError(t *testing.T) {
testBucket := "test-bucket"
testPath := "path/to/file.pdf"
testContent := "test content"
testContentType := "application/pdf"
expectedError := errors.New("presign failed")
mockUploader := &mockUploader{
uploadFunc: func(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error) {
return &manager.UploadOutput{
Key: aws.String(testPath),
}, nil
},
}
mockPresigner := &mockPresigner{
presignFunc: func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
return nil, expectedError
},
}
s3Instance := &S3{
bucket: testBucket,
svc: mockUploader,
presigner: mockPresigner,
useDirectUpload: false,
}
url, err := s3Instance.Store(testPath, strings.NewReader(testContent), testContentType)
if err == nil {
t.Fatal("Expected error, got nil")
}
if err != expectedError {
t.Errorf("Expected error %v, got %v", expectedError, err)
}
if url != "" {
t.Errorf("Expected empty URL, got %s", url)
}
}
// Test Store with direct upload pattern
func TestStore_WithDirectUpload_Success(t *testing.T) {
testBucket := "test-bucket"
testPath := "path/to/file.pdf"
testContent := "test content"
testContentType := "application/pdf"
expectedURL := "https://s3.amazonaws.com/test-bucket/path/to/file.pdf?presigned=true"
mockDirectUploader := &mockDirectUploader{
putObjectFunc: func(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
// Verify input parameters
if *params.Bucket != testBucket {
t.Errorf("Expected bucket %s, got %s", testBucket, *params.Bucket)
}
if *params.Key != testPath {
t.Errorf("Expected key %s, got %s", testPath, *params.Key)
}
if *params.ContentType != testContentType {
t.Errorf("Expected content type %s, got %s", testContentType, *params.ContentType)
}
// Read and verify body
body, err := io.ReadAll(params.Body)
if err != nil {
t.Errorf("Failed to read body: %v", err)
}
if string(body) != testContent {
t.Errorf("Expected content %s, got %s", testContent, string(body))
}
return &s3.PutObjectOutput{}, nil
},
}
mockPresigner := &mockPresigner{
presignFunc: func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
// Verify presign parameters
if *params.Bucket != testBucket {
t.Errorf("Expected bucket %s, got %s", testBucket, *params.Bucket)
}
if *params.Key != testPath {
t.Errorf("Expected key %s, got %s", testPath, *params.Key)
}
// Verify 15 minute expiry was requested
// Note: We can't directly verify the duration in the options, but we can ensure it's called
return &v4.PresignedHTTPRequest{
URL: expectedURL,
}, nil
},
}
s3Instance := &S3{
bucket: testBucket,
directSvc: mockDirectUploader,
presigner: mockPresigner,
useDirectUpload: true,
}
url, err := s3Instance.Store(testPath, strings.NewReader(testContent), testContentType)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if url != expectedURL {
t.Errorf("Expected URL %s, got %s", expectedURL, url)
}
}
func TestStore_WithDirectUpload_PutObjectError(t *testing.T) {
testBucket := "test-bucket"
testPath := "path/to/file.pdf"
testContent := "test content"
testContentType := "application/pdf"
expectedError := errors.New("put object failed")
mockDirectUploader := &mockDirectUploader{
putObjectFunc: func(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
return nil, expectedError
},
}
mockPresigner := &mockPresigner{
presignFunc: func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
t.Error("Presigner should not be called when PutObject fails")
return nil, nil
},
}
s3Instance := &S3{
bucket: testBucket,
directSvc: mockDirectUploader,
presigner: mockPresigner,
useDirectUpload: true,
}
url, err := s3Instance.Store(testPath, strings.NewReader(testContent), testContentType)
if err == nil {
t.Fatal("Expected error, got nil")
}
if err != expectedError {
t.Errorf("Expected error %v, got %v", expectedError, err)
}
if url != "" {
t.Errorf("Expected empty URL, got %s", url)
}
}
func TestStore_WithDirectUpload_PresignError(t *testing.T) {
testBucket := "test-bucket"
testPath := "path/to/file.pdf"
testContent := "test content"
testContentType := "application/pdf"
expectedError := errors.New("presign failed")
mockDirectUploader := &mockDirectUploader{
putObjectFunc: func(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
return &s3.PutObjectOutput{}, nil
},
}
mockPresigner := &mockPresigner{
presignFunc: func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
return nil, expectedError
},
}
s3Instance := &S3{
bucket: testBucket,
directSvc: mockDirectUploader,
presigner: mockPresigner,
useDirectUpload: true,
}
url, err := s3Instance.Store(testPath, strings.NewReader(testContent), testContentType)
if err == nil {
t.Fatal("Expected error, got nil")
}
if err != expectedError {
t.Errorf("Expected error %v, got %v", expectedError, err)
}
if url != "" {
t.Errorf("Expected empty URL, got %s", url)
}
}
// Test that presign expiry is set correctly
func TestStore_PresignExpiry(t *testing.T) {
testBucket := "test-bucket"
testPath := "path/to/file.pdf"
testContent := "test content"
testContentType := "application/pdf"
var capturedExpiry time.Duration
mockUploader := &mockUploader{
uploadFunc: func(ctx context.Context, input *s3.PutObjectInput, opts ...func(*manager.Uploader)) (*manager.UploadOutput, error) {
return &manager.UploadOutput{Key: aws.String(testPath)}, nil
},
}
mockPresigner := &mockPresigner{
presignFunc: func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
// Apply options to capture the expiry
opts := &s3.PresignOptions{}
for _, fn := range optFns {
fn(opts)
}
capturedExpiry = opts.Expires
return &v4.PresignedHTTPRequest{
URL: "https://example.com/presigned",
}, nil
},
}
s3Instance := &S3{
bucket: testBucket,
svc: mockUploader,
presigner: mockPresigner,
useDirectUpload: false,
}
_, err := s3Instance.Store(testPath, strings.NewReader(testContent), testContentType)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
expectedExpiry := 15 * time.Minute
if capturedExpiry != expectedExpiry {
t.Errorf("Expected presign expiry of %v, got %v", expectedExpiry, capturedExpiry)
}
}