From 0ca7fb47f0dc1da3c8936a12e40ca876fb843732 Mon Sep 17 00:00:00 2001 From: sacha Date: Mon, 18 May 2026 16:32:33 +0200 Subject: [PATCH] fix(framework): restore Compendium.Abstractions.Storage assembly (bug e263abc6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #69 ("feat(abstractions/storage): define IObjectStore port") merged on 2026-05-10 with 18 files (csproj + IObjectStore + supporting types + 6 test files). The squash silently dropped all 17 source/test files and kept only the `Compendium.sln` change. A subsequent PR then stripped the orphan sln entries pointing at non-existent projects. End result: the assembly never shipped to nuget despite the PR claiming "100% line / 100% branch / 44 tests green". `compendium-adapter-s3` (POM-514) just shipped 1.0.0-preview.0 with the `IObjectStore` types inlined locally under `Compendium.Adapters.S3.Abstractions` as a workaround. This commit restores the 17 files from `4869762` (the original feature-branch tip) verbatim and re-adds the two `.sln` entries: src/Abstractions/Compendium.Abstractions.Storage/ Compendium.Abstractions.Storage.csproj GlobalUsings.cs IObjectStore.cs Models/ListOptions.cs, ObjectInfo.cs, ObjectMetadata.cs, ObjectStream.cs, PresignedAction.cs StorageErrors.cs tests/Unit/Compendium.Abstractions.Storage.Tests/ Compendium.Abstractions.Storage.Tests.csproj GlobalUsings.cs Models/{ListOptions,ObjectInfo,ObjectMetadata,ObjectStream,PresignedAction}Tests.cs StorageErrorsTests.cs Verification: - dotnet build src/Abstractions/Compendium.Abstractions.Storage → 0 warnings. - dotnet test tests/Unit/Compendium.Abstractions.Storage.Tests → 44/44 green. Next steps after this merges: - Tag v1.0.2 on framework → publishes Compendium.Abstractions.Storage@1.0.2 (plus all other Compendium.* at 1.0.2; mechanical re-tag, no behavioural change). - Follow-up PR on compendium-adapter-s3 removing the inlined types and binding ``. - compendium-adapter-azure-blob (POM-519) + compendium-adapter-gcs (POM-520) are now unblocked. --- Compendium.sln | 14 ++ .../Compendium.Abstractions.Storage.csproj | 22 +++ .../GlobalUsings.cs | 8 + .../IObjectStore.cs | 86 ++++++++++ .../Models/ListOptions.cs | 28 +++ .../Models/ObjectInfo.cs | 25 +++ .../Models/ObjectMetadata.cs | 21 +++ .../Models/ObjectStream.cs | 78 +++++++++ .../Models/PresignedAction.cs | 24 +++ .../StorageErrors.cs | 61 +++++++ ...mpendium.Abstractions.Storage.Tests.csproj | 33 ++++ .../GlobalUsings.cs | 13 ++ .../Models/ListOptionsTests.cs | 151 +++++++++++++++++ .../Models/ObjectInfoTests.cs | 111 ++++++++++++ .../Models/ObjectMetadataTests.cs | 98 +++++++++++ .../Models/ObjectStreamTests.cs | 159 ++++++++++++++++++ .../Models/PresignedActionTests.cs | 59 +++++++ .../StorageErrorsTests.cs | 106 ++++++++++++ 18 files changed, 1097 insertions(+) create mode 100644 src/Abstractions/Compendium.Abstractions.Storage/Compendium.Abstractions.Storage.csproj create mode 100644 src/Abstractions/Compendium.Abstractions.Storage/GlobalUsings.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Storage/IObjectStore.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Storage/Models/ListOptions.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectInfo.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectMetadata.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectStream.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Storage/Models/PresignedAction.cs create mode 100644 src/Abstractions/Compendium.Abstractions.Storage/StorageErrors.cs create mode 100644 tests/Unit/Compendium.Abstractions.Storage.Tests/Compendium.Abstractions.Storage.Tests.csproj create mode 100644 tests/Unit/Compendium.Abstractions.Storage.Tests/GlobalUsings.cs create mode 100644 tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ListOptionsTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectInfoTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectMetadataTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectStreamTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Storage.Tests/Models/PresignedActionTests.cs create mode 100644 tests/Unit/Compendium.Abstractions.Storage.Tests/StorageErrorsTests.cs diff --git a/Compendium.sln b/Compendium.sln index a6e4bc2..cdce03f 100644 --- a/Compendium.sln +++ b/Compendium.sln @@ -175,6 +175,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Integration", "Integration" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.Kubernetes.Sandbox.IntegrationTests", "tests\Integration\Compendium.Adapters.Kubernetes.Sandbox.IntegrationTests\Compendium.Adapters.Kubernetes.Sandbox.IntegrationTests.csproj", "{D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Storage", "src\Abstractions\Compendium.Abstractions.Storage\Compendium.Abstractions.Storage.csproj", "{B4108100-52A7-4680-A0C4-7EB6955DB5F6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Storage.Tests", "tests\Unit\Compendium.Abstractions.Storage.Tests\Compendium.Abstractions.Storage.Tests.csproj", "{585E082A-E775-426B-BEA5-D41D3AF53DE7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -448,6 +452,14 @@ Global {D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B}.Debug|Any CPU.Build.0 = Debug|Any CPU {D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B}.Release|Any CPU.ActiveCfg = Release|Any CPU {D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B}.Release|Any CPU.Build.0 = Release|Any CPU + {B4108100-52A7-4680-A0C4-7EB6955DB5F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4108100-52A7-4680-A0C4-7EB6955DB5F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4108100-52A7-4680-A0C4-7EB6955DB5F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4108100-52A7-4680-A0C4-7EB6955DB5F6}.Release|Any CPU.Build.0 = Release|Any CPU + {585E082A-E775-426B-BEA5-D41D3AF53DE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {585E082A-E775-426B-BEA5-D41D3AF53DE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {585E082A-E775-426B-BEA5-D41D3AF53DE7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {585E082A-E775-426B-BEA5-D41D3AF53DE7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {FE421F00-7FFD-4666-A961-F1FF325ECD34} = {E35C8F52-5000-4427-9589-AEB5987C1AC6} @@ -529,5 +541,7 @@ Global {D098747F-BBF1-488B-9CC6-0E89D693440D} = {0CB45A8C-EFA9-418F-A1DE-1C5B082B238A} {BFC22E6F-1619-4CC2-9773-0B57DA972B64} = {17245130-8317-4D67-B86D-BFA713DE2C1C} {D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B} = {A2517BFF-352C-4123-81B2-2D848F3FB497} + {B4108100-52A7-4680-A0C4-7EB6955DB5F6} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2} + {585E082A-E775-426B-BEA5-D41D3AF53DE7} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} EndGlobalSection EndGlobal diff --git a/src/Abstractions/Compendium.Abstractions.Storage/Compendium.Abstractions.Storage.csproj b/src/Abstractions/Compendium.Abstractions.Storage/Compendium.Abstractions.Storage.csproj new file mode 100644 index 0000000..41f82da --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Storage/Compendium.Abstractions.Storage.csproj @@ -0,0 +1,22 @@ + + + + Compendium.Abstractions.Storage + Compendium.Abstractions.Storage + Compendium.Abstractions.Storage + Object-storage abstractions for Compendium Framework: IObjectStore port for provider-agnostic object/blob storage (Put / Get / Delete / Exists / List / Presign). + + + + + + + + + + + + + + + diff --git a/src/Abstractions/Compendium.Abstractions.Storage/GlobalUsings.cs b/src/Abstractions/Compendium.Abstractions.Storage/GlobalUsings.cs new file mode 100644 index 0000000..d2393ca --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Storage/GlobalUsings.cs @@ -0,0 +1,8 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +global using Compendium.Core.Results; diff --git a/src/Abstractions/Compendium.Abstractions.Storage/IObjectStore.cs b/src/Abstractions/Compendium.Abstractions.Storage/IObjectStore.cs new file mode 100644 index 0000000..d48129c --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Storage/IObjectStore.cs @@ -0,0 +1,86 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Storage.Models; + +namespace Compendium.Abstractions.Storage; + +/// +/// Provides provider-agnostic object/blob storage operations. +/// Implementations target backends such as AWS S3, Azure Blob Storage, Google Cloud Storage, +/// MinIO, or local filesystems. +/// +public interface IObjectStore +{ + /// + /// Uploads an object to the store, replacing any existing object at the same key. + /// + /// The object key (path) inside the bucket / container. + /// The readable stream containing the payload to upload. + /// Optional metadata (content type, cache control, custom headers). + /// The cancellation token. + /// A result containing the stored on success, or an error. + Task> PutAsync( + string key, + Stream content, + ObjectMetadata? metadata = null, + CancellationToken cancellationToken = default); + + /// + /// Downloads an object from the store. The caller is responsible for disposing the returned . + /// + /// The object key to fetch. + /// The cancellation token. + /// A result containing the on success, or an error. + Task> GetAsync( + string key, + CancellationToken cancellationToken = default); + + /// + /// Deletes an object from the store. Deleting a non-existent key returns success. + /// + /// The object key to delete. + /// The cancellation token. + /// A result indicating success or an error. + Task DeleteAsync( + string key, + CancellationToken cancellationToken = default); + + /// + /// Determines whether an object exists at the given key. + /// + /// The object key to test. + /// The cancellation token. + /// A result containing true if the object exists, otherwise false; or an error. + Task> ExistsAsync( + string key, + CancellationToken cancellationToken = default); + + /// + /// Lists a single page of objects matching the supplied options. + /// + /// Listing options (prefix, page size, continuation token). When null, defaults are used. + /// The cancellation token. + /// A result containing the next , or an error. + Task> ListAsync( + ListOptions? options = null, + CancellationToken cancellationToken = default); + + /// + /// Generates a time-limited presigned URL that allows performing the given action without further authentication. + /// + /// The object key to presign. + /// The action allowed by the presigned URL ( or ). + /// The lifetime of the presigned URL. + /// The cancellation token. + /// A result containing the presigned URL, or an error. + Task> GetPresignedUrlAsync( + string key, + PresignedAction action, + TimeSpan expiresIn, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/Compendium.Abstractions.Storage/Models/ListOptions.cs b/src/Abstractions/Compendium.Abstractions.Storage/Models/ListOptions.cs new file mode 100644 index 0000000..f1d1eaa --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Storage/Models/ListOptions.cs @@ -0,0 +1,28 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage.Models; + +/// +/// Specifies options for listing objects in an object store. +/// +/// If set, only objects whose key starts with this prefix are returned. +/// The maximum number of keys to return in a single page. Defaults to 1000. +/// An opaque token returned by a previous list call to fetch the next page. +public sealed record ListOptions( + string? Prefix = null, + int MaxKeys = 1000, + string? ContinuationToken = null); + +/// +/// Represents a single page of results returned by . +/// +/// The objects in this page, in provider-defined order. +/// An opaque token used to fetch the next page, or null if no more pages remain. +public sealed record ListPage( + IReadOnlyList Items, + string? NextContinuationToken = null); diff --git a/src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectInfo.cs b/src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectInfo.cs new file mode 100644 index 0000000..4e9c77c --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectInfo.cs @@ -0,0 +1,25 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage.Models; + +/// +/// Describes an object stored in an object store. +/// +/// The object key (path) inside the bucket / container. +/// The size of the object in bytes. +/// The provider-supplied entity tag (typically a content hash). +/// The MIME type of the object, when known. +/// The UTC instant at which the object was last modified. +/// Arbitrary user-defined metadata associated with the object. +public sealed record ObjectInfo( + string Key, + long Size, + string ETag, + string? ContentType, + DateTimeOffset LastModified, + IReadOnlyDictionary? Metadata = null); diff --git a/src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectMetadata.cs b/src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectMetadata.cs new file mode 100644 index 0000000..fcc5d1e --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectMetadata.cs @@ -0,0 +1,21 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage.Models; + +/// +/// Describes metadata applied to an object on upload. +/// +/// The MIME type of the object (for example image/png). +/// The HTTP Cache-Control header to apply when serving the object. +/// The HTTP Content-Disposition header to apply when serving the object. +/// Arbitrary user-defined metadata stored alongside the object. +public sealed record ObjectMetadata( + string? ContentType = null, + string? CacheControl = null, + string? ContentDisposition = null, + IReadOnlyDictionary? Custom = null); diff --git a/src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectStream.cs b/src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectStream.cs new file mode 100644 index 0000000..71f7286 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Storage/Models/ObjectStream.cs @@ -0,0 +1,78 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage.Models; + +/// +/// Represents the content of an object together with its metadata, returned by +/// . +/// +/// +/// Disposing this instance disposes the underlying . +/// Callers should always dispose the returned object (typically with using). +/// +public sealed class ObjectStream : IDisposable, IAsyncDisposable +{ + private readonly Stream _content; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The readable content stream. Ownership is transferred to this instance. + /// The metadata describing the object. + /// Thrown when or is null. + public ObjectStream(Stream content, ObjectInfo info) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(info); + + _content = content; + Info = info; + } + + /// + /// Gets the readable stream containing the object payload. + /// + public Stream Content + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _content; + } + } + + /// + /// Gets the metadata describing the object. + /// + public ObjectInfo Info { get; } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _content.Dispose(); + _disposed = true; + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + await _content.DisposeAsync().ConfigureAwait(false); + _disposed = true; + } +} diff --git a/src/Abstractions/Compendium.Abstractions.Storage/Models/PresignedAction.cs b/src/Abstractions/Compendium.Abstractions.Storage/Models/PresignedAction.cs new file mode 100644 index 0000000..ff5c699 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Storage/Models/PresignedAction.cs @@ -0,0 +1,24 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage.Models; + +/// +/// Specifies the action allowed by a presigned URL. +/// +public enum PresignedAction +{ + /// + /// Allows downloading the object via the presigned URL (HTTP GET). + /// + Get = 0, + + /// + /// Allows uploading the object via the presigned URL (HTTP PUT). + /// + Put = 1, +} diff --git a/src/Abstractions/Compendium.Abstractions.Storage/StorageErrors.cs b/src/Abstractions/Compendium.Abstractions.Storage/StorageErrors.cs new file mode 100644 index 0000000..ec8da09 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Storage/StorageErrors.cs @@ -0,0 +1,61 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage; + +/// +/// Provides standardized error definitions for object-storage operations. +/// +public static class StorageErrors +{ + /// + /// Gets the error code prefix for storage errors. + /// + public const string Prefix = "Storage"; + + /// + /// The requested object key was not found in the bucket. + /// + public static Error NotFound(string key) => + Error.NotFound($"{Prefix}.NotFound", $"Object '{key}' was not found."); + + /// + /// The caller does not have permission to perform the requested operation. + /// + public static Error AccessDenied(string key) => + Error.Forbidden($"{Prefix}.AccessDenied", $"Access denied for object '{key}'."); + + /// + /// The bucket name is missing, malformed, or does not exist. + /// + public static Error InvalidBucket(string bucket) => + Error.Validation($"{Prefix}.InvalidBucket", $"Bucket '{bucket}' is invalid or does not exist."); + + /// + /// The provider rejected the request because too many requests have been issued. + /// + public static Error Throttled(TimeSpan? retryAfter = null) => + Error.TooManyRequests( + $"{Prefix}.Throttled", + retryAfter.HasValue + ? $"Storage provider throttled the request. Retry after {retryAfter.Value.TotalSeconds} seconds." + : "Storage provider throttled the request. Please try again later."); + + /// + /// The object payload exceeds the maximum allowed size. + /// + public static Error ContentTooLarge(long size, long maximum) => + Error.Validation( + $"{Prefix}.ContentTooLarge", + $"Object size {size} bytes exceeds the maximum of {maximum} bytes."); + + /// + /// An object with the same key already exists and the operation requires it to be absent. + /// + public static Error ConflictExists(string key) => + Error.Conflict($"{Prefix}.ConflictExists", $"Object '{key}' already exists."); +} diff --git a/tests/Unit/Compendium.Abstractions.Storage.Tests/Compendium.Abstractions.Storage.Tests.csproj b/tests/Unit/Compendium.Abstractions.Storage.Tests/Compendium.Abstractions.Storage.Tests.csproj new file mode 100644 index 0000000..dd766dc --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Storage.Tests/Compendium.Abstractions.Storage.Tests.csproj @@ -0,0 +1,33 @@ + + + + Compendium.Abstractions.Storage.Tests + Compendium.Abstractions.Storage.Tests + false + true + enable + enable + false + false + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Unit/Compendium.Abstractions.Storage.Tests/GlobalUsings.cs b/tests/Unit/Compendium.Abstractions.Storage.Tests/GlobalUsings.cs new file mode 100644 index 0000000..f14c342 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Storage.Tests/GlobalUsings.cs @@ -0,0 +1,13 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +global using Xunit; +global using FluentAssertions; +global using NSubstitute; +global using Compendium.Abstractions.Storage; +global using Compendium.Abstractions.Storage.Models; +global using Compendium.Core.Results; diff --git a/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ListOptionsTests.cs b/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ListOptionsTests.cs new file mode 100644 index 0000000..1bbb365 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ListOptionsTests.cs @@ -0,0 +1,151 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage.Tests.Models; + +public class ListOptionsTests +{ + [Fact] + public void ListOptions_Defaults_PrefixNull_MaxKeysOneThousand_TokenNull() + { + // Act + var options = new ListOptions(); + + // Assert + options.Prefix.Should().BeNull(); + options.MaxKeys.Should().Be(1000); + options.ContinuationToken.Should().BeNull(); + } + + [Fact] + public void ListOptions_Constructor_StoresAllValues() + { + // Act + var options = new ListOptions(Prefix: "tenants/t-1/", MaxKeys: 50, ContinuationToken: "tok-42"); + + // Assert + options.Prefix.Should().Be("tenants/t-1/"); + options.MaxKeys.Should().Be(50); + options.ContinuationToken.Should().Be("tok-42"); + } + + [Fact] + public void ListOptions_Equality_IdenticalRecords_AreEqual() + { + // Arrange + var a = new ListOptions("p", 10, "t"); + var b = new ListOptions("p", 10, "t"); + + // Act / Assert + a.Should().Be(b); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void ListOptions_WithExpression_CreatesModifiedCopy() + { + // Arrange + var original = new ListOptions("p", 10, "t1"); + + // Act + var copy = original with { ContinuationToken = "t2" }; + + // Assert + copy.Prefix.Should().Be("p"); + copy.MaxKeys.Should().Be(10); + copy.ContinuationToken.Should().Be("t2"); + copy.Should().NotBe(original); + } + + [Fact] + public void ListOptions_ToString_IncludesFieldNames() + { + // Arrange + var options = new ListOptions("p", 10, "t"); + + // Act + var text = options.ToString(); + + // Assert + text.Should().Contain("Prefix"); + text.Should().Contain("MaxKeys"); + text.Should().Contain("ContinuationToken"); + } + + [Fact] + public void ListPage_Constructor_StoresItemsAndToken() + { + // Arrange + var items = new List + { + new("k1", 1, "e1", null, DateTimeOffset.UnixEpoch), + new("k2", 2, "e2", null, DateTimeOffset.UnixEpoch), + }; + + // Act + var page = new ListPage(items, "next-tok"); + + // Assert + page.Items.Should().BeSameAs(items); + page.NextContinuationToken.Should().Be("next-tok"); + } + + [Fact] + public void ListPage_Constructor_DefaultsTokenToNull() + { + // Arrange + var items = Array.Empty(); + + // Act + var page = new ListPage(items); + + // Assert + page.Items.Should().BeSameAs(items); + page.NextContinuationToken.Should().BeNull(); + } + + [Fact] + public void ListPage_Equality_SameItemsReferenceAndToken_AreEqual() + { + // Arrange + var items = new List(); + var a = new ListPage(items, "t"); + var b = new ListPage(items, "t"); + + // Act / Assert + a.Should().Be(b); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void ListPage_WithExpression_CreatesModifiedCopy() + { + // Arrange + var original = new ListPage(Array.Empty(), "t1"); + + // Act + var copy = original with { NextContinuationToken = "t2" }; + + // Assert + copy.NextContinuationToken.Should().Be("t2"); + copy.Should().NotBe(original); + } + + [Fact] + public void ListPage_ToString_IncludesFieldNames() + { + // Arrange + var page = new ListPage(Array.Empty(), "t"); + + // Act + var text = page.ToString(); + + // Assert + text.Should().Contain("Items"); + text.Should().Contain("NextContinuationToken"); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectInfoTests.cs b/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectInfoTests.cs new file mode 100644 index 0000000..2d70d5a --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectInfoTests.cs @@ -0,0 +1,111 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage.Tests.Models; + +public class ObjectInfoTests +{ + private static ObjectInfo Sample( + string key = "tenants/t-1/file.png", + long size = 1024, + string etag = "abc", + string? contentType = "image/png", + IReadOnlyDictionary? metadata = null) + { + return new ObjectInfo( + key, + size, + etag, + contentType, + new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero), + metadata); + } + + [Fact] + public void ObjectInfo_Constructor_StoresAllValues() + { + // Arrange + var lastModified = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero); + var metadata = new Dictionary { ["owner"] = "alice" }; + + // Act + var info = new ObjectInfo("k", 42, "etag-1", "text/plain", lastModified, metadata); + + // Assert + info.Key.Should().Be("k"); + info.Size.Should().Be(42); + info.ETag.Should().Be("etag-1"); + info.ContentType.Should().Be("text/plain"); + info.LastModified.Should().Be(lastModified); + info.Metadata.Should().BeSameAs(metadata); + } + + [Fact] + public void ObjectInfo_Metadata_DefaultsToNull() + { + // Act + var info = new ObjectInfo("k", 0, "e", null, DateTimeOffset.UnixEpoch); + + // Assert + info.ContentType.Should().BeNull(); + info.Metadata.Should().BeNull(); + } + + [Fact] + public void ObjectInfo_Equality_TwoIdenticalRecords_AreEqual() + { + // Arrange + var a = Sample(); + var b = Sample(); + + // Act / Assert + a.Should().Be(b); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void ObjectInfo_Equality_DifferentKey_AreNotEqual() + { + // Arrange + var a = Sample(key: "a"); + var b = Sample(key: "b"); + + // Act / Assert + a.Should().NotBe(b); + } + + [Fact] + public void ObjectInfo_WithExpression_CreatesModifiedCopy() + { + // Arrange + var original = Sample(); + + // Act + var copy = original with { Size = 9999, ETag = "new-etag" }; + + // Assert + copy.Should().NotBe(original); + copy.Size.Should().Be(9999); + copy.ETag.Should().Be("new-etag"); + copy.Key.Should().Be(original.Key); + copy.LastModified.Should().Be(original.LastModified); + } + + [Fact] + public void ObjectInfo_ToString_IncludesAllFieldNames() + { + // Arrange + var info = Sample(); + + // Act + var text = info.ToString(); + + // Assert + text.Should().Contain("Key").And.Contain("Size").And.Contain("ETag"); + text.Should().Contain("ContentType").And.Contain("LastModified").And.Contain("Metadata"); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectMetadataTests.cs b/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectMetadataTests.cs new file mode 100644 index 0000000..2a724eb --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectMetadataTests.cs @@ -0,0 +1,98 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage.Tests.Models; + +public class ObjectMetadataTests +{ + [Fact] + public void ObjectMetadata_DefaultConstructor_AllFieldsNull() + { + // Act + var metadata = new ObjectMetadata(); + + // Assert + metadata.ContentType.Should().BeNull(); + metadata.CacheControl.Should().BeNull(); + metadata.ContentDisposition.Should().BeNull(); + metadata.Custom.Should().BeNull(); + } + + [Fact] + public void ObjectMetadata_Constructor_StoresAllValues() + { + // Arrange + var custom = new Dictionary { ["x-owner"] = "bob" }; + + // Act + var metadata = new ObjectMetadata( + ContentType: "application/pdf", + CacheControl: "max-age=3600", + ContentDisposition: "attachment; filename=foo.pdf", + Custom: custom); + + // Assert + metadata.ContentType.Should().Be("application/pdf"); + metadata.CacheControl.Should().Be("max-age=3600"); + metadata.ContentDisposition.Should().Be("attachment; filename=foo.pdf"); + metadata.Custom.Should().BeSameAs(custom); + } + + [Fact] + public void ObjectMetadata_Equality_IdenticalRecords_AreEqual() + { + // Arrange + var a = new ObjectMetadata("text/plain", "no-cache", "inline"); + var b = new ObjectMetadata("text/plain", "no-cache", "inline"); + + // Act / Assert + a.Should().Be(b); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void ObjectMetadata_Equality_DifferentContentType_AreNotEqual() + { + // Arrange + var a = new ObjectMetadata(ContentType: "text/plain"); + var b = new ObjectMetadata(ContentType: "text/html"); + + // Act / Assert + a.Should().NotBe(b); + } + + [Fact] + public void ObjectMetadata_WithExpression_CreatesModifiedCopy() + { + // Arrange + var original = new ObjectMetadata("text/plain", "no-cache"); + + // Act + var copy = original with { CacheControl = "max-age=60" }; + + // Assert + copy.ContentType.Should().Be("text/plain"); + copy.CacheControl.Should().Be("max-age=60"); + copy.Should().NotBe(original); + } + + [Fact] + public void ObjectMetadata_ToString_IncludesFieldNames() + { + // Arrange + var metadata = new ObjectMetadata("text/plain"); + + // Act + var text = metadata.ToString(); + + // Assert + text.Should().Contain("ContentType"); + text.Should().Contain("CacheControl"); + text.Should().Contain("ContentDisposition"); + text.Should().Contain("Custom"); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectStreamTests.cs b/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectStreamTests.cs new file mode 100644 index 0000000..c18ca8a --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/ObjectStreamTests.cs @@ -0,0 +1,159 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage.Tests.Models; + +public class ObjectStreamTests +{ + private static ObjectInfo SampleInfo() => new( + "k", + 4, + "etag", + "text/plain", + new DateTimeOffset(2026, 5, 10, 0, 0, 0, TimeSpan.Zero)); + + [Fact] + public void ObjectStream_Constructor_StoresContentAndInfo() + { + // Arrange + var content = new MemoryStream(new byte[] { 1, 2, 3, 4 }); + var info = SampleInfo(); + + // Act + using var stream = new ObjectStream(content, info); + + // Assert + stream.Content.Should().BeSameAs(content); + stream.Info.Should().BeSameAs(info); + } + + [Fact] + public void ObjectStream_Constructor_NullContent_Throws() + { + // Arrange + var info = SampleInfo(); + + // Act + Action act = () => _ = new ObjectStream(null!, info); + + // Assert + act.Should().Throw().WithParameterName("content"); + } + + [Fact] + public void ObjectStream_Constructor_NullInfo_Throws() + { + // Arrange + using var content = new MemoryStream(); + + // Act + Action act = () => _ = new ObjectStream(content, null!); + + // Assert + act.Should().Throw().WithParameterName("info"); + } + + [Fact] + public void ObjectStream_Dispose_DisposesUnderlyingStream() + { + // Arrange + var content = new TrackingStream(); + var stream = new ObjectStream(content, SampleInfo()); + + // Act + stream.Dispose(); + + // Assert + content.IsDisposed.Should().BeTrue(); + } + + [Fact] + public void ObjectStream_Dispose_CalledTwice_OnlyDisposesOnce() + { + // Arrange + var content = new TrackingStream(); + var stream = new ObjectStream(content, SampleInfo()); + + // Act + stream.Dispose(); + stream.Dispose(); + + // Assert + content.DisposeCount.Should().Be(1); + } + + [Fact] + public void ObjectStream_Content_AfterDispose_Throws() + { + // Arrange + var stream = new ObjectStream(new MemoryStream(), SampleInfo()); + stream.Dispose(); + + // Act + Action act = () => _ = stream.Content; + + // Assert + act.Should().Throw(); + } + + [Fact] + public async Task ObjectStream_DisposeAsync_DisposesUnderlyingStream() + { + // Arrange + var content = new TrackingStream(); + var stream = new ObjectStream(content, SampleInfo()); + + // Act + await stream.DisposeAsync(); + + // Assert + content.IsDisposed.Should().BeTrue(); + } + + [Fact] + public async Task ObjectStream_DisposeAsync_CalledTwice_OnlyDisposesOnce() + { + // Arrange + var content = new TrackingStream(); + var stream = new ObjectStream(content, SampleInfo()); + + // Act + await stream.DisposeAsync(); + await stream.DisposeAsync(); + + // Assert + content.DisposeCount.Should().Be(1); + } + + [Fact] + public void ObjectStream_Info_RemainsAccessibleAfterDispose() + { + // Arrange + var info = SampleInfo(); + var stream = new ObjectStream(new MemoryStream(), info); + + // Act + stream.Dispose(); + + // Assert + stream.Info.Should().BeSameAs(info); + } + + private sealed class TrackingStream : MemoryStream + { + public int DisposeCount { get; private set; } + + public bool IsDisposed { get; private set; } + + protected override void Dispose(bool disposing) + { + DisposeCount++; + IsDisposed = true; + base.Dispose(disposing); + } + } +} diff --git a/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/PresignedActionTests.cs b/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/PresignedActionTests.cs new file mode 100644 index 0000000..3c54b7b --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Storage.Tests/Models/PresignedActionTests.cs @@ -0,0 +1,59 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage.Tests.Models; + +public class PresignedActionTests +{ + [Fact] + public void PresignedAction_Get_HasValueZero() + { + // Arrange + var action = PresignedAction.Get; + + // Act + var value = (int)action; + + // Assert + value.Should().Be(0); + } + + [Fact] + public void PresignedAction_Put_HasValueOne() + { + // Arrange + var action = PresignedAction.Put; + + // Act + var value = (int)action; + + // Assert + value.Should().Be(1); + } + + [Theory] + [InlineData(PresignedAction.Get, "Get")] + [InlineData(PresignedAction.Put, "Put")] + public void PresignedAction_ToString_ReturnsName(PresignedAction action, string expected) + { + // Act + var actual = action.ToString(); + + // Assert + actual.Should().Be(expected); + } + + [Fact] + public void PresignedAction_DefinedValues_AreExactlyGetAndPut() + { + // Act + var values = Enum.GetValues(); + + // Assert + values.Should().BeEquivalentTo(new[] { PresignedAction.Get, PresignedAction.Put }); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Storage.Tests/StorageErrorsTests.cs b/tests/Unit/Compendium.Abstractions.Storage.Tests/StorageErrorsTests.cs new file mode 100644 index 0000000..67c5b08 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Storage.Tests/StorageErrorsTests.cs @@ -0,0 +1,106 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Storage.Tests; + +public class StorageErrorsTests +{ + [Fact] + public void Prefix_IsStorage() + { + // Act / Assert + StorageErrors.Prefix.Should().Be("Storage"); + } + + [Fact] + public void NotFound_ReturnsNotFoundErrorWithKey() + { + // Act + var error = StorageErrors.NotFound("tenants/t-1/file.png"); + + // Assert + error.Code.Should().Be("Storage.NotFound"); + error.Type.Should().Be(ErrorType.NotFound); + error.Message.Should().Contain("tenants/t-1/file.png"); + } + + [Fact] + public void AccessDenied_ReturnsForbiddenErrorWithKey() + { + // Act + var error = StorageErrors.AccessDenied("private/secret.txt"); + + // Assert + error.Code.Should().Be("Storage.AccessDenied"); + error.Type.Should().Be(ErrorType.Forbidden); + error.Message.Should().Contain("private/secret.txt"); + } + + [Fact] + public void InvalidBucket_ReturnsValidationErrorWithBucket() + { + // Act + var error = StorageErrors.InvalidBucket("BadBucketName"); + + // Assert + error.Code.Should().Be("Storage.InvalidBucket"); + error.Type.Should().Be(ErrorType.Validation); + error.Message.Should().Contain("BadBucketName"); + } + + [Fact] + public void Throttled_WithoutRetryAfter_ReturnsTooManyRequestsGenericMessage() + { + // Act + var error = StorageErrors.Throttled(); + + // Assert + error.Code.Should().Be("Storage.Throttled"); + error.Type.Should().Be(ErrorType.TooManyRequests); + error.Message.Should().Contain("throttled"); + } + + [Fact] + public void Throttled_WithRetryAfter_IncludesSeconds() + { + // Arrange + var retryAfter = TimeSpan.FromSeconds(45); + + // Act + var error = StorageErrors.Throttled(retryAfter); + + // Assert + error.Code.Should().Be("Storage.Throttled"); + error.Type.Should().Be(ErrorType.TooManyRequests); + error.Message.Should().Contain("45"); + } + + [Fact] + public void ContentTooLarge_ReturnsValidationErrorWithSizes() + { + // Act + var error = StorageErrors.ContentTooLarge(size: 5_000_000, maximum: 1_000_000); + + // Assert + error.Code.Should().Be("Storage.ContentTooLarge"); + error.Type.Should().Be(ErrorType.Validation); + error.Message.Should().Contain("5000000"); + error.Message.Should().Contain("1000000"); + } + + [Fact] + public void ConflictExists_ReturnsConflictErrorWithKey() + { + // Act + var error = StorageErrors.ConflictExists("tenants/t-1/file.png"); + + // Assert + error.Code.Should().Be("Storage.ConflictExists"); + error.Type.Should().Be(ErrorType.Conflict); + error.Message.Should().Contain("tenants/t-1/file.png"); + } +}