Skip to content

feat: Improve Storage Service Coverage and Fix Versioning#58

Merged
poyrazK merged 6 commits intomainfrom
feature/storage-coverage
Feb 14, 2026
Merged

feat: Improve Storage Service Coverage and Fix Versioning#58
poyrazK merged 6 commits intomainfrom
feature/storage-coverage

Conversation

@poyrazK
Copy link
Copy Markdown
Owner

@poyrazK poyrazK commented Feb 13, 2026

This PR increases the test coverage for the storage service to ~90% and fixes a bug where CompleteMultipartUpload did not correctly support bucket versioning. Key changes include the implementation of an InMemFileStore for reliable integration testing and the addition of comprehensive error-path tests.

Summary by CodeRabbit

  • New Features

    • Multipart upload completion is now aware of bucket versioning; version IDs are generated and tracked so stored objects reflect versioned state.
  • Bug Fixes

    • Object metadata and references are updated to correctly represent versioned objects when versioning is enabled.
  • Tests

    • Integration tests expanded with an in-memory store and failing-encryption/read simulations to exercise lifecycle, multipart, encryption, versioning, presigned URLs, and error paths.

Copilot AI review requested due to automatic review settings February 13, 2026 15:10
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 13, 2026

Warning

Rate limit exceeded

@poyrazK has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 6 minutes and 53 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Adds bucket versioning awareness to multipart upload completion (generating and applying a versionID to storage keys and ARNs when enabled) and expands test infrastructure with an in-memory file store, a failing-encryption wrapper, failing reader, and broader integration tests covering multipart, encryption, versioning, presigned URLs, and error paths.

Changes

Cohort / File(s) Summary
Core Storage Service Versioning
internal/core/services/storage.go
Multipart completion now checks bucket versioning, generates a VersionID when enabled, prepends it to the stored object key, updates ARN (with versionId query), and saves object metadata with VersionID and IsLatest fields.
Test Infrastructure & Integration
internal/core/services/storage_test.go
Introduced InMemFileStore (thread-safe CRUD, Assemble, failure injection), FailingEncryptionService, and FailingReader; updated test setup signature and expanded integration tests to exercise multipart assembly, encryption/decryption failure paths, versioning lifecycle, presigned URLs, and store/read error scenarios.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant Svc as StorageService
  participant DB as BucketRepo/DB
  participant Store as FileStore
  participant Enc as EncryptionService

  Client->>Svc: CompleteMultipartUpload(bucket, key, parts)
  Svc->>DB: GetBucket(bucket)
  DB-->>Svc: Bucket{VersioningEnabled: true/false}
  alt Versioning enabled
    Svc->>Svc: Generate VersionID
    Svc->>Store: Assemble(parts...) -> combined object (temp key)
  else Versioning disabled
    Svc->>Store: Assemble(parts...) -> combined object (temp key)
  end
  Svc->>Enc: (if encryption enabled) Decrypt(combined data)
  Enc-->>Svc: plaintext bytes
  Svc->>Store: Write(bucket, storeKey[+versionID], plaintext)
  Store-->>Svc: bytesWritten
  Svc->>DB: SaveObjectMetadata(bucket,key,storeKey,VersionID?,IsLatest,ARN)
  DB-->>Svc: confirmation
  Svc-->>Client: Complete response (ARN [+ versionId])
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I nibble on parts and stitch them with care,
A versionID ribbon tied round each pair,
In-memory burrows where test-flowers bloom,
Encryption and failures—I'll chase them through gloom,
Hopping, I sign off: uploads safe in my loom 🐰✨

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly reflects the main changes: improving test coverage for storage service and fixing versioning support in CompleteMultipartUpload.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/storage-coverage

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/core/services/storage.go (1)

271-273: ⚠️ Potential issue | 🟡 Minor

Stale doc comment — says "ListBuckets" but decorates SetBucketVersioning.

-// ListBuckets list buckets for the current user.
+// SetBucketVersioning enables or disables versioning on a bucket.
 func (s *StorageService) SetBucketVersioning(ctx context.Context, name string, enabled bool) error {
🤖 Fix all issues with AI agents
In `@internal/core/services/storage_test.go`:
- Around line 131-133: The test silently discards the error returned by
NewEncryptionService which can leave realEncSvc nil and cause panics when
FailingEncryptionService delegates to it; change the assignment from
"realEncSvc, _ := services.NewEncryptionService(encRepo, masterKeyHex)" to
capture the error (realEncSvc, err := services.NewEncryptionService(...)) and
call require.NoError(t, err) (and optionally require.NotNil(t, realEncSvc)) so
the test fails fast on bad masterKeyHex or other initialization errors.
- Line 374: The call to svc.Upload in the test ignores its returned error, which
can mask failures and cause store.failNext to exercise the wrong path; update
the test to capture the returned error from svc.Upload(ctx, "obj-bucket",
"fail-store", strings.NewReader("data")), check it immediately, and fail the
test (e.g., t.Fatalf or t.Fatal) if non-nil before setting store.failNext so
subsequent assertions exercise the intended failure branch.

In `@internal/core/services/storage.go`:
- Around line 371-386: Extract the duplicated versioning logic into a small
helper: add a named constant (e.g., versionEpochBit = 1 << 62) and two functions
generateVersionID() string and versionedStoreKey(key, versionID string) string
that encapsulate the computation of the version ID and the sprintf of
versionQueryFormat; then replace the inline logic in CompleteMultipartUpload
(around the bucket.VersioningEnabled block) and the Upload code path (the
earlier Upload function where the same 1<<62 arithmetic and fmt.Sprintf are
used) to call generateVersionID() and versionedStoreKey(...) respectively so the
magic number and formatting are centralized.
🧹 Nitpick comments (8)
internal/core/services/storage.go (2)

371-375: Unwrapped error from GetBucket is inconsistent with the rest of this method.

Other error returns in CompleteMultipartUpload wrap with errors.Wrap(...) (e.g., lines 352, 358, 390, 413), but this one returns the raw repository error. This inconsistency can make it harder for callers to distinguish error origins.

Proposed fix
 	bucket, err := s.repo.GetBucket(ctx, upload.Bucket)
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(errors.Internal, "failed to get bucket for versioning check", err)
 	}

As per coding guidelines, "Use service-layer error handling with internal/errors package".


37-45: Constructor has 5 parameters — consider using a params struct.

NewStorageService accepts 5 dependencies. The coding guidelines require a params struct for constructors with 3 or more dependencies.

Example params struct
type StorageServiceParams struct {
	Repo       ports.StorageRepository
	Store      ports.FileStore
	AuditSvc   ports.AuditService
	EncryptSvc ports.EncryptionService
	Cfg        *platform.Config
}

func NewStorageService(p StorageServiceParams) *StorageService {
	return &StorageService{
		repo:       p.Repo,
		store:      p.Store,
		auditSvc:   p.AuditSvc,
		encryptSvc: p.EncryptSvc,
		cfg:        p.Cfg,
	}
}

As per coding guidelines, "Use a params struct for service constructors with 3 or more dependencies".

internal/core/services/storage_test.go (6)

141-146: FailingReader is defined but never used.

This type is dead code in the current file. Either remove it or add a test that exercises reader failures (e.g., passing &FailingReader{} to Upload).


22-27: failNext field is accessed without synchronization — potential data race.

failNext is written from the test goroutine (e.g., store.failNext = true) and read inside Write/Read/Assemble without holding the mutex. While current tests are sequential, the go test -race detector will flag this.

Proposed fix — guard with the existing mutex
 type InMemFileStore struct {
 	mu       sync.RWMutex
 	files    map[string][]byte
-	failNext bool
+	failNext bool // guarded by mu
 }
+
+func (s *InMemFileStore) SetFailNext(v bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.failNext = v
+}
+
+func (s *InMemFileStore) shouldFail() bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.failNext {
+		s.failNext = false
+		return true
+	}
+	return false
+}

Then replace each if s.failNext { s.failNext = false; ... } block with:

if s.shouldFail() {
    return ..., fmt.Errorf("injected failure")
}

And call sites change from store.failNext = true to store.SetFailNext(true).

Also applies to: 37-41, 52-56, 77-81


97-117: FailingEncryptionService.failNext has the same unsynchronized access issue.

Same race concern as InMemFileStore.failNextencSvc.failNext = true (line 393) is set from the test goroutine while Encrypt/Decrypt read it without synchronization.

Consider adding a mutex or accessor methods here as well.


148-151: masterKeyHex is duplicated three times (lines 131, 151, 270).

Extract it as a test-file-level constant to avoid drift.

+const testMasterKeyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"

As per coding guidelines, "Do not use magic numbers - use named constants instead".

Also applies to: 258-271


258-288: Encryption subtest shadows outer db and creates redundant resources.

Line 264 calls setupDB(t) again, shadowing the db returned by the setup helper on line 149. It also creates a second encRepo and encSvc (lines 269-272) that duplicate what's already available from the outer scope. This makes the test harder to follow.

Consider reusing the db and repo from the setup function and only creating the bucket-level key:

-		db := setupDB(t)
-		_, err = db.Exec(ctx, "UPDATE buckets SET encryption_enabled = TRUE WHERE name = $1", bucketName)
+		_, err = db.Exec(ctx, "UPDATE buckets SET encryption_enabled = TRUE WHERE name = $1", bucketName)
 		require.NoError(t, err)
 
-		encRepo := postgres.NewEncryptionRepository(db)
-		masterKeyHex := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
-		encSvc, _ := services.NewEncryptionService(encRepo, masterKeyHex)
-		_, err = encSvc.CreateKey(ctx, bucketName)
+		encRepo := postgres.NewEncryptionRepository(db)
+		tempEncSvc, _ := services.NewEncryptionService(encRepo, testMasterKeyHex)
+		_, err = tempEncSvc.CreateKey(ctx, bucketName)

237-240: Errors from Download and ReadAll are silently discarded in content verification.

Lines 238-239 ignore errors when verifying content integrity. If the download or read fails, the assertion on line 240 would give a misleading diff instead of a clear failure message.

-		r, _, _ := svc.Download(ctx, bucketName, key)
-		data, _ := io.ReadAll(r)
+		r, _, err := svc.Download(ctx, bucketName, key)
+		require.NoError(t, err)
+		data, err := io.ReadAll(r)
+		require.NoError(t, err)

Same pattern applies at lines 206, 286, 324, 328-330.

Comment thread internal/core/services/storage_test.go Outdated
Comment thread internal/core/services/storage_test.go Outdated
Comment thread internal/core/services/storage.go
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves test coverage for the storage service and fixes a bug where CompleteMultipartUpload did not properly handle bucket versioning. The fix ensures that multipart uploads to versioned buckets correctly generate version IDs and store objects with versioned keys, mirroring the behavior of the regular Upload method.

Changes:

  • Implemented InMemFileStore for reliable in-memory testing of file operations
  • Added comprehensive error-path tests covering store failures, encryption failures, and edge cases
  • Fixed versioning bug in CompleteMultipartUpload to properly handle versioned buckets

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
internal/core/services/storage_test.go Added InMemFileStore and FailingEncryptionService helpers, comprehensive error tests, and versioning/encryption test scenarios
internal/core/services/storage.go Fixed CompleteMultipartUpload to support bucket versioning by generating version IDs and using versioned store keys; removed outdated comments

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/core/services/storage_test.go Outdated
Comment on lines +264 to +269
db := setupDB(t)
_, err = db.Exec(ctx, "UPDATE buckets SET encryption_enabled = TRUE WHERE name = $1", bucketName)
require.NoError(t, err)

// Initialize encryption key for this bucket
encRepo := postgres.NewEncryptionRepository(db)
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable shadowing: The db variable is declared here but already exists in the outer scope (line 149). This shadows the outer db variable within the Encryption test scope. Use a different variable name or remove the redundant declaration to avoid confusion.

Suggested change
db := setupDB(t)
_, err = db.Exec(ctx, "UPDATE buckets SET encryption_enabled = TRUE WHERE name = $1", bucketName)
require.NoError(t, err)
// Initialize encryption key for this bucket
encRepo := postgres.NewEncryptionRepository(db)
encDB := setupDB(t)
_, err = encDB.Exec(ctx, "UPDATE buckets SET encryption_enabled = TRUE WHERE name = $1", bucketName)
require.NoError(t, err)
// Initialize encryption key for this bucket
encRepo := postgres.NewEncryptionRepository(encDB)

Copilot uses AI. Check for mistakes.
svc, _, ctx := setupStorageServiceIntegrationTest(t)
svc, _, store, encSvc, db, ctx := setupStorageServiceIntegrationTest(t)
userID := appcontext.UserIDFromContext(ctx)
masterKeyHex := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The masterKeyHex variable is defined at the test function level but is only used within specific nested subtests (Encryption and ErrorPaths). Consider moving this declaration into the subtests where it's actually used to improve code clarity and reduce the scope of the variable.

Suggested change
masterKeyHex := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"

Copilot uses AI. Check for mistakes.
_, err = svc.CompleteMultipartUpload(ctx, upload2.ID)
assert.Error(t, err)
})

Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description states that a bug in CompleteMultipartUpload related to bucket versioning was fixed (lines 371-409 in storage.go). However, there's no test case that explicitly verifies CompleteMultipartUpload with versioning enabled. The MultipartUpload test uses a bucket without versioning, and the Versioning test only tests regular Upload(). Consider adding a test case that enables versioning on a bucket and then performs a multipart upload to ensure the versioning fix is properly covered.

Suggested change
t.Run("MultipartUploadWithVersioning", func(t *testing.T) {
bucketName := "mp-versioned-bucket"
_, err := svc.CreateBucket(ctx, bucketName, true)
require.NoError(t, err)
key := "large.zip"
// Success Case with versioning enabled
upload, err := svc.CreateMultipartUpload(ctx, bucketName, key)
require.NoError(t, err)
_, err = svc.UploadPart(ctx, upload.ID, 1, strings.NewReader("part1"))
require.NoError(t, err)
_, err = svc.UploadPart(ctx, upload.ID, 2, strings.NewReader("part2"))
require.NoError(t, err)
obj, err := svc.CompleteMultipartUpload(ctx, upload.ID)
require.NoError(t, err)
assert.Equal(t, key, obj.Key)
// Verify content for the completed multipart upload on a versioned bucket
r, _, err := svc.Download(ctx, bucketName, key)
require.NoError(t, err)
data, err := io.ReadAll(r)
require.NoError(t, err)
assert.Equal(t, "part1part2", string(data))
})

Copilot uses AI. Check for mistakes.
Comment on lines +141 to +145
// FailingReader returns an error on Read.
type FailingReader struct{}

return svc, repo, ctx
func (f *FailingReader) Read(p []byte) (n int, err error) {
return 0, fmt.Errorf("read failure")
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FailingReader type is defined but never used in the test file. If it's not needed for testing error paths, consider removing it to keep the code clean. If it was intended for testing read failures during upload operations, add a test case that uses it.

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +56
func (s *InMemFileStore) Write(ctx context.Context, bucket, key string, r io.Reader) (int64, error) {
if s.failNext {
s.failNext = false
return 0, fmt.Errorf("injected failure")
}
data, err := io.ReadAll(r)
if err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
s.files[s.key(bucket, key)] = data
return int64(len(data)), nil
}

func (s *InMemFileStore) Read(ctx context.Context, bucket, key string) (io.ReadCloser, error) {
if s.failNext {
s.failNext = false
return nil, fmt.Errorf("injected failure")
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failNext field is accessed without proper synchronization. While this test runs sequentially, accessing failNext outside the mutex protection could lead to data races if tests are run in parallel or if the store is used concurrently. Consider protecting failNext with the mutex or using atomic operations for this field to ensure thread safety.

Copilot uses AI. Check for mistakes.
Comment on lines +97 to +117
// FailingEncryptionService wraps a real one but can fail.
type FailingEncryptionService struct {
ports.EncryptionService
failNext bool
}

func (f *FailingEncryptionService) Encrypt(ctx context.Context, bucket string, data []byte) ([]byte, error) {
if f.failNext {
f.failNext = false
return nil, fmt.Errorf("injected failure")
}
return f.EncryptionService.Encrypt(ctx, bucket, data)
}

func (f *FailingEncryptionService) Decrypt(ctx context.Context, bucket string, data []byte) ([]byte, error) {
if f.failNext {
f.failNext = false
return nil, fmt.Errorf("injected failure")
}
return f.EncryptionService.Decrypt(ctx, bucket, data)
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failNext field in FailingEncryptionService is not synchronized. If this service is called concurrently (which is possible in a storage service), this could lead to data races. Consider using atomic operations (e.g., atomic.Bool) or a mutex to protect access to this field.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 13, 2026 20:50
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/core/services/storage_test.go (1)

187-188: ⚠️ Potential issue | 🟠 Major

Pervasive silent error discards throughout tests mask real failures.

There are ~20 instances of _, _ = or val, _ := across this file (e.g., lines 187, 222, 240–241, 245, 251, 272, 293, 329, 331, 391–398, 406–407, 413, 420–423, 444). When setup steps fail silently, subsequent assertions test the wrong thing — an error path instead of the intended happy/error path.

For test setup calls whose success is a precondition, capture the error and use require.NoError:

// Instead of:
_, _ = svc.CreateBucket(ctx, bucketName, false)

// Use:
_, err := svc.CreateBucket(ctx, bucketName, false)
require.NoError(t, err)

For assertion-adjacent reads (e.g., io.ReadAll), at minimum use assert.NoError so a read failure surfaces clearly rather than comparing against empty/nil.

As per coding guidelines, "Do not use silent failures - avoid blank identifier assignment like _ = someFunc()".

Also applies to: 222-222, 240-242, 391-398

🤖 Fix all issues with AI agents
In `@internal/core/services/storage_test.go`:
- Around line 272-273: The call to NewEncryptionService currently ignores its
error return, which can leave encSvc nil and make the subsequent CreateKey and
assertions meaningless; update the test to check the error returned by
services.NewEncryptionService(encRepo, masterKeyHex) (the variable assigned to
encSvc) and fail the test immediately (e.g., t.Fatalf or require.NoError) if an
error is returned before calling encSvc.CreateKey(ctx, bucketName), so any
failure is surfaced rather than silently discarded.
- Around line 11-13: The test file imports "time" and "github.com/google/uuid"
but neither is referenced, causing CI failures; remove those unused imports from
the import block in storage_test.go (delete the "time" and
"github.com/google/uuid" entries) or alternatively use them in a test if
intended, ensuring there are no leftover references to time or uuid in the file.
🧹 Nitpick comments (3)
internal/core/services/storage_test.go (3)

39-43: failNext flag is not protected by the mutex.

The files map is correctly guarded by mu, but failNext is read and written without synchronization. While current tests are single-goroutine, this is inconsistent and fragile if tests ever run concurrently or the store is used across goroutines. Consider guarding it with the same mutex, or using atomic.Bool.

Also applies to: 54-58, 79-83


343-377: Consider table-driven structure for the validation/error-path cases.

The invalid-bucket-name checks (lines 345–352) and the "not found" checks (lines 355–377) follow a repetitive pattern that's well suited to table-driven tests. This improves readability and makes it easy to add new cases.

invalidNames := []struct {
    name string
}{
    {"INVALID NAME"}, {""}, {strings.Repeat("a", 65)}, {"-invalid"},
}
for _, tc := range invalidNames {
    _, err := svc.CreateBucket(ctx, tc.name, false)
    assert.Error(t, err, "expected error for bucket name: %q", tc.name)
}

As per coding guidelines, "Use table-driven tests in test files".


130-131: Extract repeated test constants.

masterKeyHex and the config's SecretsEncryptionKey value appear identically at lines 130, 133, 153, 271, and 394/422. Define them once as package-level test constants to reduce duplication and make rotation easier.

const testMasterKeyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
const testSecretKey    = "test-secret-32-chars-long-needed-!!"

Comment thread internal/core/services/storage_test.go Outdated
Comment thread internal/core/services/storage_test.go
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

_, err = svc.CompleteMultipartUpload(ctx, upload2.ID)
assert.Error(t, err)
})

Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description states this fixes a bug where CompleteMultipartUpload did not correctly support bucket versioning, but the MultipartUpload test case (lines 220-258) does not test multipart upload with versioning enabled. Consider adding a test case that:

  1. Creates a bucket with versioning enabled
  2. Performs a multipart upload
  3. Verifies that the completed object has a non-null version ID
  4. Verifies that the object is stored with the correct versioned key format

This would ensure the versioning fix is properly tested and prevent regression.

Suggested change
t.Run("MultipartUploadWithVersioning", func(t *testing.T) {
versionedBucket := "mp-versioned-bucket"
_, err := svc.CreateBucket(ctx, versionedBucket, true)
require.NoError(t, err)
key := "large.zip"
upload, err := svc.CreateMultipartUpload(ctx, versionedBucket, key)
require.NoError(t, err)
_, err = svc.UploadPart(ctx, upload.ID, 1, strings.NewReader("part1"))
require.NoError(t, err)
_, err = svc.UploadPart(ctx, upload.ID, 2, strings.NewReader("part2"))
require.NoError(t, err)
obj, err := svc.CompleteMultipartUpload(ctx, upload.ID)
require.NoError(t, err)
// Verify object metadata
assert.Equal(t, key, obj.Key)
assert.Equal(t, int64(10), obj.SizeBytes) // "part1" (5) + "part2" (5) = 10
// With versioning enabled, the completed object should have a non-empty version ID.
assert.NotEmpty(t, obj.VersionID)
// Verify that the latest version is readable via the logical key.
r, _, err := svc.Download(ctx, versionedBucket, key)
require.NoError(t, err)
data, err := io.ReadAll(r)
require.NoError(t, err)
assert.Equal(t, "part1part2", string(data))
})

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +58
if s.failNext {
s.failNext = false
return nil, fmt.Errorf("injected failure")
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failNext field is accessed without mutex protection, which could lead to race conditions if tests are run concurrently. While these are integration tests that likely run sequentially, it's best practice to protect all shared mutable state with the mutex. Consider acquiring the lock before checking failNext, or use atomic operations for the boolean flag.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +43
if s.failNext {
s.failNext = false
return 0, fmt.Errorf("injected failure")
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failNext field is accessed without mutex protection, which could lead to race conditions if tests are run concurrently. While these are integration tests that likely run sequentially, it's best practice to protect all shared mutable state with the mutex. Consider acquiring the lock before checking failNext, or use atomic operations for the boolean flag.

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +83
if s.failNext {
s.failNext = false
return 0, fmt.Errorf("injected failure")
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failNext field is accessed without mutex protection, which could lead to race conditions if tests are run concurrently. While these are integration tests that likely run sequentially, it's best practice to protect all shared mutable state with the mutex. Consider acquiring the lock before checking failNext, or use atomic operations for the boolean flag.

Copilot uses AI. Check for mistakes.
Comment on lines +106 to +109
if f.failNext {
f.failNext = false
return nil, fmt.Errorf("injected failure")
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failNext field is accessed without any synchronization. If tests are run concurrently or if the service is used from multiple goroutines, this could lead to race conditions. Consider using atomic operations (e.g., atomic.Bool or atomic.LoadUint32/StoreUint32) to access this field safely.

Copilot uses AI. Check for mistakes.
Comment thread internal/core/services/storage_test.go Outdated
// Setup encryption service
masterKeyHex := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
encRepo := postgres.NewEncryptionRepository(db)
realEncSvc, _ := services.NewEncryptionService(encRepo, masterKeyHex)
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error returned from services.NewEncryptionService is ignored (using blank identifier). While this is test code and may intentionally ignore errors for simplicity, it would be better to handle the error with require.NoError(t, err) to catch any setup issues early and make test failures clearer.

Copilot uses AI. Check for mistakes.
Comment on lines +291 to +333
t.Run("Versioning", func(t *testing.T) {
bucketName := "version-bucket"
_, _ = svc.CreateBucket(ctx, bucketName, false)
err := svc.SetBucketVersioning(ctx, bucketName, true)
assert.NoError(t, err)

// Verify versioning is enabled
b, err := svc.GetBucket(ctx, bucketName)
assert.NoError(t, err)
assert.True(t, b.VersioningEnabled)

key := "v.txt"

// Upload v1
v1, err := svc.Upload(ctx, bucketName, key, strings.NewReader("v1"))
assert.NoError(t, err)
assert.NotEqual(t, "null", v1.VersionID)

// Upload v2
v2, err := svc.Upload(ctx, bucketName, key, strings.NewReader("v2"))
assert.NoError(t, err)
assert.NotEqual(t, "null", v2.VersionID)
assert.NotEqual(t, v1.VersionID, v2.VersionID)

// List Versions
versions, err := svc.ListVersions(ctx, bucketName, key)
assert.NoError(t, err)
assert.GreaterOrEqual(t, len(versions), 2)

// Download v1
r, meta, err := svc.DownloadVersion(ctx, bucketName, key, v1.VersionID)
assert.NoError(t, err)
assert.NotNil(t, r)
assert.Equal(t, v1.VersionID, meta.VersionID)
d1, _ := io.ReadAll(r)
assert.Equal(t, "v1", string(d1))

// Download v2
r2, meta2, _ := svc.DownloadVersion(ctx, bucketName, key, v2.VersionID)
assert.Equal(t, v2.VersionID, meta2.VersionID)
d2, _ := io.ReadAll(r2)
assert.Equal(t, "v2", string(d2))

Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Download function (line 128 in storage.go) does not correctly handle versioned objects. After retrieving the object metadata (which includes version_id), it reads from the store using just the key (line 136), but versioned objects are stored with a versioned key format (key?versionId=versionID). This will fail to find the file in the store for any versioned object.

The test does not catch this bug because it only tests DownloadVersion, not Download with a versioned bucket. After uploading v2 on line 310, add a test to verify that Download(ctx, bucketName, key) returns the latest version (v2) with the correct content.

Copilot uses AI. Check for mistakes.
Comment on lines 128 to 140
func (s *StorageService) Download(ctx context.Context, bucket, key string) (io.ReadCloser, *domain.Object, error) {
// 1. Get metadata
obj, err := s.repo.GetMeta(ctx, bucket, key)
if err != nil {
return nil, nil, err
}

// 2. Open file
reader, err := s.store.Read(ctx, bucket, key)
if err != nil {
platform.StorageOperations.WithLabelValues("download", bucket, "error").Inc()
return nil, nil, err
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Download function does not correctly handle versioned objects. After retrieving the object metadata (which includes obj.VersionID on line 130), the function reads from the store using just the key on line 136. However, versioned objects are stored with a versioned key format (key?versionId=versionID as seen in Upload line 65 and DownloadVersion line 181).

This means Download will fail to find versioned objects in the store. The fix should be similar to DownloadVersion (lines 179-182): construct the storeKey based on obj.VersionID before reading from the store.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 13, 2026 21:12
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +23 to +27
type InMemFileStore struct {
mu sync.RWMutex
files map[string][]byte
failNext bool
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failNext flag in InMemFileStore and FailingEncryptionService is not protected by mutex, which could cause race conditions if multiple goroutines access these test helpers concurrently. While the current tests appear to be sequential, this could lead to flaky tests if concurrency is introduced later. Consider protecting failNext with the existing mu mutex or using atomic operations.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +101
type FailingEncryptionService struct {
ports.EncryptionService
failNext bool
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failNext flag in FailingEncryptionService is not protected by any synchronization mechanism, which could cause race conditions if multiple goroutines access this test helper concurrently. While the current tests appear to be sequential, consider using atomic operations (e.g., atomic.Bool) to make this safer and prevent potential flaky tests.

Copilot uses AI. Check for mistakes.
Comment on lines +269 to +272
// Initialize encryption key for this bucket
encRepo := postgres.NewEncryptionRepository(db)
masterKeyHex := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
encSvc, _ := services.NewEncryptionService(encRepo, masterKeyHex)
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Encryption test creates a new EncryptionService instance on line 272, which bypasses the FailingEncryptionService wrapper set up in setupStorageServiceIntegrationTest. This means the encryption failure path cannot be tested through this wrapper. Consider using the encSvc from setup or documenting why a separate instance is needed here.

Suggested change
// Initialize encryption key for this bucket
encRepo := postgres.NewEncryptionRepository(db)
masterKeyHex := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
encSvc, _ := services.NewEncryptionService(encRepo, masterKeyHex)
// Initialize encryption key for this bucket using the existing encryption service

Copilot uses AI. Check for mistakes.
@poyrazK poyrazK merged commit b574882 into main Feb 14, 2026
41 checks passed
@poyrazK poyrazK deleted the feature/storage-coverage branch April 12, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants