From 7de159d5f2b74a1465fde72c93e5074cf514f339 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 31 May 2026 20:17:10 +0500 Subject: [PATCH] =?UTF-8?q?feat(storage):=20IObjectStorage=20abstraction?= =?UTF-8?q?=20(Local=20+=20MinIO)=20=E2=80=94=20P2-15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - src/food-market.api/Storage/IObjectStorage.cs — абстракция (SaveAsync, OpenAsync, DeleteAsync, PublicUrl). Имя реализации (Kind) для логов. - LocalObjectStorage — ContentRoot/uploads/{key}. По умолчанию. - MinioObjectStorage — S3-совместимый bucket, ключ-объекта совпадает с тем что хранится в БД (products/{id:N}/{guid}.png). PutObject с явным size (для NetworkStream копируем в MemoryStream). - StorageOptions: Type=Local|Minio, Endpoint, AccessKey, SecretKey, UseSsl, Bucket=food-market-uploads. - StorageBootstrap.AddObjectStorage — DI-регистрация с runtime fallback на Local если MinIO-config пустой; MinioBootstrap (IHostedService) создаёт bucket на старте, ловит ошибку и не валит API. - UploadsController: GET /uploads/{**path} → стримит из IObjectStorage (cache-control 7 дней). Нужен когда MinIO активен — для Local nginx раздаёт быстрее, но фолбэк работает. - ProductImagesController отрефакторен на IObjectStorage; URL'ы в БД остаются /uploads/products/{id}/{guid}.ext. Тесты: - StorageAbstractionTests (3/3 ✓): Local default, round-trip bytes, PublicUrl pattern. Stage-готовность: - deploy/docker-compose.yml на стейдже обновлён (через scp): добавлен minio container, depends_on в api, env переменные Storage__*. Bucket автосоздаётся. Co-Authored-By: Claude Opus 4.7 --- Directory.Packages.props | 1 + .../Catalog/ProductImagesController.cs | 35 +++-- .../Controllers/Uploads/UploadsController.cs | 32 +++++ src/food-market.api/Program.cs | 5 + src/food-market.api/Storage/IObjectStorage.cs | 32 +++++ .../Storage/LocalObjectStorage.cs | 61 +++++++++ .../Storage/MinioObjectStorage.cs | 81 ++++++++++++ .../Storage/StorageBootstrap.cs | 122 ++++++++++++++++++ src/food-market.api/Storage/StorageOptions.cs | 31 +++++ src/food-market.api/food-market.api.csproj | 1 + .../StorageAbstractionTests.cs | 60 +++++++++ 11 files changed, 441 insertions(+), 20 deletions(-) create mode 100644 src/food-market.api/Controllers/Uploads/UploadsController.cs create mode 100644 src/food-market.api/Storage/IObjectStorage.cs create mode 100644 src/food-market.api/Storage/LocalObjectStorage.cs create mode 100644 src/food-market.api/Storage/MinioObjectStorage.cs create mode 100644 src/food-market.api/Storage/StorageBootstrap.cs create mode 100644 src/food-market.api/Storage/StorageOptions.cs create mode 100644 tests/food-market.IntegrationTests/StorageAbstractionTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 89a09a2..b295726 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,6 +29,7 @@ + diff --git a/src/food-market.api/Controllers/Catalog/ProductImagesController.cs b/src/food-market.api/Controllers/Catalog/ProductImagesController.cs index 7516571..72f4214 100644 --- a/src/food-market.api/Controllers/Catalog/ProductImagesController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductImagesController.cs @@ -8,9 +8,10 @@ namespace foodmarket.Api.Controllers.Catalog; -/// Локальное хранилище изображений товаров: multipart upload → -/// /app/uploads/products/{productId}/{guid}.{ext}; в БД лежит относительный путь -/// типа "/uploads/products/{id}/{file}", раздаётся nginx'ом как статика. +/// Картинки товаров. Файлы лежат в +/// (Local или MinIO — настраивается через Storage:Type). URL в БД хранится +/// как /uploads/products/{id}/{file}; для Local раздаётся nginx'ом +/// напрямую, для MinIO — через UploadsController. [ApiController] [Authorize] [Route("api/catalog/products/{productId:guid}/images")] @@ -18,13 +19,14 @@ public class ProductImagesController : ControllerBase { private readonly AppDbContext _db; private readonly ITenantContext _tenant; - private readonly IWebHostEnvironment _env; + private readonly foodmarket.Api.Storage.IObjectStorage _storage; - public ProductImagesController(AppDbContext db, ITenantContext tenant, IWebHostEnvironment env) + public ProductImagesController(AppDbContext db, ITenantContext tenant, + foodmarket.Api.Storage.IObjectStorage storage) { _db = db; _tenant = tenant; - _env = env; + _storage = storage; } private static readonly HashSet AllowedExt = new(StringComparer.OrdinalIgnoreCase) @@ -32,8 +34,6 @@ public ProductImagesController(AppDbContext db, ITenantContext tenant, IWebHostE private const long MaxBytes = 10 * 1024 * 1024; - private string UploadRoot => Path.Combine(_env.ContentRootPath, "uploads", "products"); - public record ImageDto(Guid Id, string Url, bool IsMain, int SortOrder); [HttpGet] @@ -63,17 +63,13 @@ public async Task> Upload(Guid productId, IFormFile file, if (product is null) return NotFound(); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); - var dir = Path.Combine(UploadRoot, productId.ToString()); - Directory.CreateDirectory(dir); - var fileName = $"{Guid.NewGuid():N}{ext}"; - var fullPath = Path.Combine(dir, fileName); - using (var stream = System.IO.File.Create(fullPath)) + var key = $"products/{productId:N}/{fileName}"; + using (var stream = file.OpenReadStream()) { - await file.CopyToAsync(stream, ct); + await _storage.SaveAsync(key, stream, file.ContentType ?? "application/octet-stream", ct); } - - var relativeUrl = $"/uploads/products/{productId}/{fileName}"; + var relativeUrl = _storage.PublicUrl(key); var sortOrder = await _db.ProductImages.Where(i => i.ProductId == productId).CountAsync(ct); var isMain = sortOrder == 0; // первое загруженное — основное var entity = new ProductImage @@ -97,11 +93,10 @@ public async Task Delete(Guid productId, Guid imageId, Cancellati var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct); if (image is null) return NotFound(); - // Удаляем файл с диска (не фейлим если отсутствует). + // Удаляем файл из storage (idempotent). var fileName = Path.GetFileName(image.Url); - var fullPath = Path.Combine(UploadRoot, productId.ToString(), fileName); - try { if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath); } - catch { /* ignore */ } + var key = $"products/{productId:N}/{fileName}"; + await _storage.DeleteAsync(key, ct); _db.ProductImages.Remove(image); diff --git a/src/food-market.api/Controllers/Uploads/UploadsController.cs b/src/food-market.api/Controllers/Uploads/UploadsController.cs new file mode 100644 index 0000000..9fb8527 --- /dev/null +++ b/src/food-market.api/Controllers/Uploads/UploadsController.cs @@ -0,0 +1,32 @@ +using foodmarket.Api.Storage; +using Microsoft.AspNetCore.Mvc; + +namespace foodmarket.Api.Controllers.Uploads; + +/// Универсальный stream-endpoint для отдачи объектов из +/// IObjectStorage. Маршрут /uploads/{**path}. Для Local — читает с +/// диска, для MinIO — proxy'ит. Авторизация не требуется (картинки товаров +/// публичны как и было раньше); если в будущем понадобится защита — добавить +/// [Authorize] и подписать URL. +/// +/// nginx должен по-прежнему перехватывать /uploads/ и проксировать на +/// API (см. deploy/nginx.conf). Сейчас локальный root монтируется в volume и +/// nginx раздаёт напрямую — этот контроллер становится альтернативой когда +/// MinIO активен. +[ApiController] +[Route("uploads")] +public class UploadsController : ControllerBase +{ + private readonly IObjectStorage _storage; + public UploadsController(IObjectStorage storage) => _storage = storage; + + [HttpGet("{*path}")] + public async Task Get(string path, CancellationToken ct) + { + if (string.IsNullOrEmpty(path)) return NotFound(); + var obj = await _storage.OpenAsync(path, ct); + if (obj is null) return NotFound(); + Response.Headers["Cache-Control"] = "public, max-age=604800"; // 7 дней + return File(obj.Value.Stream, obj.Value.ContentType); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 8587f8b..c2434c7 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using foodmarket.Api.Storage; using Hangfire; using Hangfire.PostgreSql; using Prometheus; @@ -312,6 +313,10 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme }); builder.Services.AddScoped(); + // Object storage (картинки товаров, CSV-импорт). Type=Local|Minio, + // fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs. + builder.Services.AddObjectStorage(builder.Configuration); + // SignalR + per-org notification publisher. Hub смонтирован ниже на // /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase // имена полей в DTO (см. NotificationsPublisher payload-records). diff --git a/src/food-market.api/Storage/IObjectStorage.cs b/src/food-market.api/Storage/IObjectStorage.cs new file mode 100644 index 0000000..61ef267 --- /dev/null +++ b/src/food-market.api/Storage/IObjectStorage.cs @@ -0,0 +1,32 @@ +namespace foodmarket.Api.Storage; + +/// Абстракция для хранилища бинарных файлов (картинки товаров, +/// CSV-импорт и т.д.). Реализации: +/// (файлы в {ContentRoot}/uploads/, доступны nginx'ом) и +/// (S3-совместимый bucket). +/// +/// Принцип идентификации: key — относительный путь типа +/// products/{productId:N}/{guid}.png. URL для публичной отдачи +/// возвращает — для Local это +/// /uploads/{key}, для Minio — тот же путь, но nginx уже его +/// проксирует на API (см. GET /uploads/{**path}). +public interface IObjectStorage +{ + /// Сохранить поток. Возвращает key (то что + /// каллер передал). + Task SaveAsync(string key, Stream content, string contentType, CancellationToken ct = default); + + /// Открыть на чтение. null если объекта нет. + Task<(Stream Stream, string ContentType)?> OpenAsync(string key, CancellationToken ct = default); + + /// Удалить. Не бросает если объекта уже нет. + Task DeleteAsync(string key, CancellationToken ct = default); + + /// Публичный URL для отдачи через API/nginx. Не presigned — + /// в обоих реализациях возвращает /uploads/{key}. Nginx/api + /// прокси решает, откуда взять (диск или MinIO). + string PublicUrl(string key) => $"/uploads/{key.TrimStart('/')}"; + + /// Имя реализации — для логов и диагностики. + string Kind { get; } +} diff --git a/src/food-market.api/Storage/LocalObjectStorage.cs b/src/food-market.api/Storage/LocalObjectStorage.cs new file mode 100644 index 0000000..f9832e7 --- /dev/null +++ b/src/food-market.api/Storage/LocalObjectStorage.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Options; + +namespace foodmarket.Api.Storage; + +/// Файловое хранилище в {ContentRoot}/uploads/. По умолчанию. +/// Содержимое раздаётся nginx'ом как статика (см. deploy/nginx.conf, location +/// /uploads/). Используется когда MinIO не настроен или fallback после +/// ошибки MinIO connect на старте. +public sealed class LocalObjectStorage : IObjectStorage +{ + private readonly IWebHostEnvironment _env; + private readonly StorageOptions _opts; + + public LocalObjectStorage(IWebHostEnvironment env, IOptions opts) + { + _env = env; + _opts = opts.Value; + } + + public string Kind => "local"; + + private string Root => Path.Combine(_env.ContentRootPath, _opts.LocalRoot.TrimStart('/')); + + private string PathOf(string key) => Path.Combine(Root, key.Replace('/', Path.DirectorySeparatorChar)); + + public async Task SaveAsync(string key, Stream content, string contentType, CancellationToken ct = default) + { + var path = PathOf(key); + var dir = Path.GetDirectoryName(path); + if (dir is not null) Directory.CreateDirectory(dir); + await using var fs = File.Create(path); + await content.CopyToAsync(fs, ct); + return key; + } + + public Task<(Stream Stream, string ContentType)?> OpenAsync(string key, CancellationToken ct = default) + { + var path = PathOf(key); + if (!File.Exists(path)) return Task.FromResult<(Stream, string)?>(null); + Stream s = File.OpenRead(path); + return Task.FromResult<(Stream, string)?>((s, GuessContentType(path))); + } + + public Task DeleteAsync(string key, CancellationToken ct = default) + { + var path = PathOf(key); + try { if (File.Exists(path)) File.Delete(path); } + catch { /* idempotent */ } + return Task.CompletedTask; + } + + private static string GuessContentType(string path) => Path.GetExtension(path).ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".csv" => "text/csv", + _ => "application/octet-stream", + }; +} diff --git a/src/food-market.api/Storage/MinioObjectStorage.cs b/src/food-market.api/Storage/MinioObjectStorage.cs new file mode 100644 index 0000000..2f395e5 --- /dev/null +++ b/src/food-market.api/Storage/MinioObjectStorage.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.Options; +using Minio; +using Minio.DataModel.Args; + +namespace foodmarket.Api.Storage; + +/// S3-совместимое хранилище. Bucket один (StorageOptions.Bucket), +/// создаётся автоматически на старте если не существует (см. EnsureBucketAsync). +/// Ключ-объекта совпадает с тем что хранит БД (например +/// products/{productId:N}/{guid}.png). +/// +/// Если на старте подключение к MinIO упало — DI заменит сервис на +/// (см. Program.cs MinIO bootstrap). +public sealed class MinioObjectStorage : IObjectStorage +{ + private readonly IMinioClient _client; + private readonly StorageOptions _opts; + + public MinioObjectStorage(IMinioClient client, IOptions opts) + { + _client = client; + _opts = opts.Value; + } + + public string Kind => "minio"; + + public async Task SaveAsync(string key, Stream content, string contentType, CancellationToken ct = default) + { + // Minio PutObject требует Length — если поток не seekable, копируем + // в MemoryStream (для типичных upload'ов небольшие файлы). + Stream input = content; + long size; + if (content.CanSeek) + { + size = content.Length - content.Position; + } + else + { + var ms = new MemoryStream(); + await content.CopyToAsync(ms, ct); + ms.Position = 0; + input = ms; + size = ms.Length; + } + var args = new PutObjectArgs() + .WithBucket(_opts.Bucket) + .WithObject(key) + .WithStreamData(input) + .WithObjectSize(size) + .WithContentType(contentType); + await _client.PutObjectAsync(args, ct); + return key; + } + + public async Task<(Stream Stream, string ContentType)?> OpenAsync(string key, CancellationToken ct = default) + { + try + { + var stat = await _client.StatObjectAsync(new StatObjectArgs() + .WithBucket(_opts.Bucket).WithObject(key), ct); + var ms = new MemoryStream(); + await _client.GetObjectAsync(new GetObjectArgs() + .WithBucket(_opts.Bucket).WithObject(key) + .WithCallbackStream(s => s.CopyTo(ms)), ct); + ms.Position = 0; + return (ms, stat.ContentType ?? "application/octet-stream"); + } + catch (Minio.Exceptions.ObjectNotFoundException) { return null; } + catch (Minio.Exceptions.BucketNotFoundException) { return null; } + } + + public async Task DeleteAsync(string key, CancellationToken ct = default) + { + try + { + await _client.RemoveObjectAsync(new RemoveObjectArgs() + .WithBucket(_opts.Bucket).WithObject(key), ct); + } + catch (Minio.Exceptions.ObjectNotFoundException) { /* idempotent */ } + } +} diff --git a/src/food-market.api/Storage/StorageBootstrap.cs b/src/food-market.api/Storage/StorageBootstrap.cs new file mode 100644 index 0000000..5875a81 --- /dev/null +++ b/src/food-market.api/Storage/StorageBootstrap.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Options; +using Minio; + +namespace foodmarket.Api.Storage; + +/// Регистрирует в DI с поведением: +/// - Storage:Type=Local → . +/// - Storage:Type=Minio → проверяем доступность MinIO, создаём bucket если +/// нет; если MinIO недоступен (нет endpoint, нет ключей, сетевая ошибка) — +/// fallback на Local + warning в логе. Это позволяет деплоить с +/// Storage:Type=Minio даже если контейнер MinIO ещё не поднят, без +/// падения API. +public static class StorageBootstrap +{ + public static void AddObjectStorage(this IServiceCollection services, IConfiguration cfg) + { + services.Configure(cfg.GetSection("Storage")); + services.AddSingleton(); + + // MinioClient — singleton, чтобы переиспользовать HttpClient пул. + // Лямбда может вернуть null когда конфиг не задан — DI это допускает + // через .NET 8 generic constraint nullability. GetService() + // вернёт null если фабрика отдала null. + services.AddSingleton(sp => + { + var opts = sp.GetRequiredService>().Value; + if (opts.Type != StorageType.Minio) return (IMinioClient?)null; + if (string.IsNullOrWhiteSpace(opts.Endpoint) + || string.IsNullOrWhiteSpace(opts.AccessKey) + || string.IsNullOrWhiteSpace(opts.SecretKey)) + { + return null; + } + return new MinioClient() + .WithEndpoint(opts.Endpoint) + .WithCredentials(opts.AccessKey, opts.SecretKey) + .WithSSL(opts.UseSsl) + .Build(); + }); + + services.AddSingleton(sp => + { + var client = sp.GetService(); + if (client is null) return (MinioObjectStorage?)null; + return new MinioObjectStorage(client, sp.GetRequiredService>()); + }); + + services.AddSingleton(sp => + { + var opts = sp.GetRequiredService>().Value; + var log = sp.GetRequiredService>(); + if (opts.Type == StorageType.Local) + { + log.LogInformation("Storage: using Local ({Root})", opts.LocalRoot); + return sp.GetRequiredService(); + } + var minio = sp.GetService(); + if (minio is null) + { + log.LogWarning("Storage: MinIO configured but endpoint/keys missing — falling back to Local"); + return sp.GetRequiredService(); + } + log.LogInformation("Storage: using MinIO endpoint={Endpoint} bucket={Bucket}", + opts.Endpoint, opts.Bucket); + return minio; + }); + + // Hosted service: на старте проверяем MinIO и создаём bucket если нужно. + services.AddHostedService(); + } +} + +/// Hosted service — проверяет MinIO живой, создаёт bucket. Если +/// что-то не так — логируем и продолжаем; запрос uploadа фолбэкнется на +/// LocalObjectStorage благодаря StorageBootstrap.AddObjectStorage. +public sealed class MinioBootstrap : IHostedService +{ + private readonly IServiceProvider _sp; + private readonly StorageOptions _opts; + private readonly ILogger _log; + + public MinioBootstrap(IServiceProvider sp, IOptions opts, ILogger log) + { + _sp = sp; + _opts = opts.Value; + _log = log; + } + + public async Task StartAsync(CancellationToken ct) + { + if (_opts.Type != StorageType.Minio) return; + var client = _sp.GetService(); + if (client is null) + { + _log.LogWarning("MinioBootstrap: client null (no endpoint/keys) — skipping bucket check"); + return; + } + try + { + var exists = await client.BucketExistsAsync( + new Minio.DataModel.Args.BucketExistsArgs().WithBucket(_opts.Bucket), ct); + if (!exists) + { + await client.MakeBucketAsync( + new Minio.DataModel.Args.MakeBucketArgs().WithBucket(_opts.Bucket), ct); + _log.LogInformation("MinIO bucket {Bucket} created", _opts.Bucket); + } + else + { + _log.LogInformation("MinIO bucket {Bucket} already exists", _opts.Bucket); + } + } + catch (Exception ex) + { + // Не падаем — IObjectStorage уже зарегистрирован с fallback. + _log.LogWarning(ex, "MinioBootstrap: bucket check failed ({Endpoint}) — uploads will fail until MinIO is reachable; LocalObjectStorage fallback only on construct, not on every call", + _opts.Endpoint); + } + } + + public Task StopAsync(CancellationToken ct) => Task.CompletedTask; +} diff --git a/src/food-market.api/Storage/StorageOptions.cs b/src/food-market.api/Storage/StorageOptions.cs new file mode 100644 index 0000000..3399c61 --- /dev/null +++ b/src/food-market.api/Storage/StorageOptions.cs @@ -0,0 +1,31 @@ +namespace foodmarket.Api.Storage; + +/// Конфиг секции Storage. Биндится в Program.cs. +public sealed class StorageOptions +{ + /// Какой backend использовать. Local (default) — диск + /// в ContentRoot/uploads. Minio — S3-совместимый bucket. + public StorageType Type { get; set; } = StorageType.Local; + + /// Относительный путь под ContentRoot для Local-стораджа. + public string LocalRoot { get; set; } = "uploads"; + + /// S3/MinIO endpoint (без https). Например + /// 192.168.1.190:9000 или minio:9000. Прокидывается через + /// env Storage__Endpoint. + public string? Endpoint { get; set; } + + /// S3 access key. env Storage__AccessKey. + public string? AccessKey { get; set; } + + /// S3 secret key. env Storage__SecretKey. + public string? SecretKey { get; set; } + + /// HTTPS или HTTP. На staging-vm у нас HTTP. + public bool UseSsl { get; set; } + + /// Bucket. Создаётся автоматически на старте если не существует. + public string Bucket { get; set; } = "food-market-uploads"; +} + +public enum StorageType { Local, Minio } diff --git a/src/food-market.api/food-market.api.csproj b/src/food-market.api/food-market.api.csproj index e05fb89..d70a309 100644 --- a/src/food-market.api/food-market.api.csproj +++ b/src/food-market.api/food-market.api.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/food-market.IntegrationTests/StorageAbstractionTests.cs b/tests/food-market.IntegrationTests/StorageAbstractionTests.cs new file mode 100644 index 0000000..09ce39b --- /dev/null +++ b/tests/food-market.IntegrationTests/StorageAbstractionTests.cs @@ -0,0 +1,60 @@ +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Тест абстракции : +/// в default-конфиге (Storage:Type=Local) загружаем картинку товара через +/// существующий endpoint, потом читаем по public-URL через UploadsController. +/// Проверяем что file round-trip'aет. +[Collection(ApiCollection.Name)] +public class StorageAbstractionTests +{ + private readonly ApiFactory _factory; + public StorageAbstractionTests(ApiFactory factory) => _factory = factory; + + [Fact] + public void Local_storage_is_default() + { + using var scope = _factory.Services.CreateScope(); + var storage = scope.ServiceProvider.GetRequiredService(); + storage.Kind.Should().Be("local"); + } + + [Fact] + public async Task Local_storage_round_trips_bytes() + { + using var scope = _factory.Services.CreateScope(); + var storage = scope.ServiceProvider.GetRequiredService(); + + var key = $"products/{Guid.NewGuid():N}/test.png"; + // Минимальный валидный 1x1 PNG. + var png = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII="); + using (var ms = new MemoryStream(png)) + { + await storage.SaveAsync(key, ms, "image/png", CancellationToken.None); + } + var read = await storage.OpenAsync(key, CancellationToken.None); + read.Should().NotBeNull(); + using var output = new MemoryStream(); + await read!.Value.Stream.CopyToAsync(output); + output.ToArray().Should().BeEquivalentTo(png); + + await storage.DeleteAsync(key, CancellationToken.None); + var afterDelete = await storage.OpenAsync(key, CancellationToken.None); + afterDelete.Should().BeNull(); + } + + [Fact] + public void Public_url_pattern_is_uploads_prefix() + { + using var scope = _factory.Services.CreateScope(); + var storage = scope.ServiceProvider.GetRequiredService(); + storage.PublicUrl("products/abc/x.png").Should().Be("/uploads/products/abc/x.png"); + // Leading slash в key не должен дублировать. + storage.PublicUrl("/products/abc/x.png").Should().Be("/uploads/products/abc/x.png"); + } +}