feat(storage): IObjectStorage abstraction (Local + MinIO) — P2-15
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-31 20:17:10 +05:00
parent d451e77642
commit 7de159d5f2
11 changed files with 441 additions and 20 deletions

View file

@ -29,6 +29,7 @@
<!-- App services --> <!-- App services -->
<PackageVersion Include="CsvHelper" Version="33.0.1" /> <PackageVersion Include="CsvHelper" Version="33.0.1" />
<PackageVersion Include="Minio" Version="6.0.5" />
<PackageVersion Include="ClosedXML" Version="0.104.2" /> <PackageVersion Include="ClosedXML" Version="0.104.2" />
<PackageVersion Include="MailKit" Version="4.10.0" /> <PackageVersion Include="MailKit" Version="4.10.0" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" /> <PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />

View file

@ -8,9 +8,10 @@
namespace foodmarket.Api.Controllers.Catalog; namespace foodmarket.Api.Controllers.Catalog;
/// <summary>Локальное хранилище изображений товаров: multipart upload → /// <summary>Картинки товаров. Файлы лежат в <see cref="foodmarket.Api.Storage.IObjectStorage"/>
/// /app/uploads/products/{productId}/{guid}.{ext}; в БД лежит относительный путь /// (Local или MinIO — настраивается через Storage:Type). URL в БД хранится
/// типа "/uploads/products/{id}/{file}", раздаётся nginx'ом как статика.</summary> /// как <c>/uploads/products/{id}/{file}</c>; для Local раздаётся nginx'ом
/// напрямую, для MinIO — через <c>UploadsController</c>.</summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("api/catalog/products/{productId:guid}/images")] [Route("api/catalog/products/{productId:guid}/images")]
@ -18,13 +19,14 @@ public class ProductImagesController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly ITenantContext _tenant; 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; _db = db;
_tenant = tenant; _tenant = tenant;
_env = env; _storage = storage;
} }
private static readonly HashSet<string> AllowedExt = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> AllowedExt = new(StringComparer.OrdinalIgnoreCase)
@ -32,8 +34,6 @@ public ProductImagesController(AppDbContext db, ITenantContext tenant, IWebHostE
private const long MaxBytes = 10 * 1024 * 1024; 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); public record ImageDto(Guid Id, string Url, bool IsMain, int SortOrder);
[HttpGet] [HttpGet]
@ -63,17 +63,13 @@ public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file,
if (product is null) return NotFound(); if (product is null) return NotFound();
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); 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 fileName = $"{Guid.NewGuid():N}{ext}";
var fullPath = Path.Combine(dir, fileName); var key = $"products/{productId:N}/{fileName}";
using (var stream = System.IO.File.Create(fullPath)) using (var stream = file.OpenReadStream())
{ {
await file.CopyToAsync(stream, ct); await _storage.SaveAsync(key, stream, file.ContentType ?? "application/octet-stream", ct);
} }
var relativeUrl = _storage.PublicUrl(key);
var relativeUrl = $"/uploads/products/{productId}/{fileName}";
var sortOrder = await _db.ProductImages.Where(i => i.ProductId == productId).CountAsync(ct); var sortOrder = await _db.ProductImages.Where(i => i.ProductId == productId).CountAsync(ct);
var isMain = sortOrder == 0; // первое загруженное — основное var isMain = sortOrder == 0; // первое загруженное — основное
var entity = new ProductImage var entity = new ProductImage
@ -97,11 +93,10 @@ public async Task<IActionResult> Delete(Guid productId, Guid imageId, Cancellati
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct); var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
if (image is null) return NotFound(); if (image is null) return NotFound();
// Удаляем файл с диска (не фейлим если отсутствует). // Удаляем файл из storage (idempotent).
var fileName = Path.GetFileName(image.Url); var fileName = Path.GetFileName(image.Url);
var fullPath = Path.Combine(UploadRoot, productId.ToString(), fileName); var key = $"products/{productId:N}/{fileName}";
try { if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath); } await _storage.DeleteAsync(key, ct);
catch { /* ignore */ }
_db.ProductImages.Remove(image); _db.ProductImages.Remove(image);

View file

@ -0,0 +1,32 @@
using foodmarket.Api.Storage;
using Microsoft.AspNetCore.Mvc;
namespace foodmarket.Api.Controllers.Uploads;
/// <summary>Универсальный stream-endpoint для отдачи объектов из
/// IObjectStorage. Маршрут <c>/uploads/{**path}</c>. Для Local — читает с
/// диска, для MinIO — proxy'ит. Авторизация не требуется (картинки товаров
/// публичны как и было раньше); если в будущем понадобится защита — добавить
/// [Authorize] и подписать URL.
///
/// nginx должен по-прежнему перехватывать <c>/uploads/</c> и проксировать на
/// API (см. deploy/nginx.conf). Сейчас локальный root монтируется в volume и
/// nginx раздаёт напрямую — этот контроллер становится альтернативой когда
/// MinIO активен.</summary>
[ApiController]
[Route("uploads")]
public class UploadsController : ControllerBase
{
private readonly IObjectStorage _storage;
public UploadsController(IObjectStorage storage) => _storage = storage;
[HttpGet("{*path}")]
public async Task<IActionResult> 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);
}
}

View file

@ -1,4 +1,5 @@
using System.Security.Claims; using System.Security.Claims;
using foodmarket.Api.Storage;
using Hangfire; using Hangfire;
using Hangfire.PostgreSql; using Hangfire.PostgreSql;
using Prometheus; using Prometheus;
@ -312,6 +313,10 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
}); });
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>(); builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
// Object storage (картинки товаров, CSV-импорт). Type=Local|Minio,
// fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs.
builder.Services.AddObjectStorage(builder.Configuration);
// SignalR + per-org notification publisher. Hub смонтирован ниже на // SignalR + per-org notification publisher. Hub смонтирован ниже на
// /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase // /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase
// имена полей в DTO (см. NotificationsPublisher payload-records). // имена полей в DTO (см. NotificationsPublisher payload-records).

View file

@ -0,0 +1,32 @@
namespace foodmarket.Api.Storage;
/// <summary>Абстракция для хранилища бинарных файлов (картинки товаров,
/// CSV-импорт и т.д.). Реализации: <see cref="LocalObjectStorage"/>
/// (файлы в <c>{ContentRoot}/uploads/</c>, доступны nginx'ом) и
/// <see cref="MinioObjectStorage"/> (S3-совместимый bucket).
///
/// Принцип идентификации: <c>key</c> — относительный путь типа
/// <c>products/{productId:N}/{guid}.png</c>. URL для публичной отдачи
/// возвращает <see cref="PublicUrl"/> — для Local это
/// <c>/uploads/{key}</c>, для Minio — тот же путь, но nginx уже его
/// проксирует на API (см. GET /uploads/{**path}).</summary>
public interface IObjectStorage
{
/// <summary>Сохранить поток. Возвращает <c>key</c> (то что
/// каллер передал).</summary>
Task<string> SaveAsync(string key, Stream content, string contentType, CancellationToken ct = default);
/// <summary>Открыть на чтение. null если объекта нет.</summary>
Task<(Stream Stream, string ContentType)?> OpenAsync(string key, CancellationToken ct = default);
/// <summary>Удалить. Не бросает если объекта уже нет.</summary>
Task DeleteAsync(string key, CancellationToken ct = default);
/// <summary>Публичный URL для отдачи через API/nginx. Не presigned —
/// в обоих реализациях возвращает <c>/uploads/{key}</c>. Nginx/api
/// прокси решает, откуда взять (диск или MinIO).</summary>
string PublicUrl(string key) => $"/uploads/{key.TrimStart('/')}";
/// <summary>Имя реализации — для логов и диагностики.</summary>
string Kind { get; }
}

View file

@ -0,0 +1,61 @@
using Microsoft.Extensions.Options;
namespace foodmarket.Api.Storage;
/// <summary>Файловое хранилище в <c>{ContentRoot}/uploads/</c>. По умолчанию.
/// Содержимое раздаётся nginx'ом как статика (см. deploy/nginx.conf, location
/// /uploads/). Используется когда MinIO не настроен или fallback после
/// ошибки MinIO connect на старте.</summary>
public sealed class LocalObjectStorage : IObjectStorage
{
private readonly IWebHostEnvironment _env;
private readonly StorageOptions _opts;
public LocalObjectStorage(IWebHostEnvironment env, IOptions<StorageOptions> 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<string> 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",
};
}

View file

@ -0,0 +1,81 @@
using Microsoft.Extensions.Options;
using Minio;
using Minio.DataModel.Args;
namespace foodmarket.Api.Storage;
/// <summary>S3-совместимое хранилище. Bucket один (<c>StorageOptions.Bucket</c>),
/// создаётся автоматически на старте если не существует (см. EnsureBucketAsync).
/// Ключ-объекта совпадает с тем что хранит БД (например
/// <c>products/{productId:N}/{guid}.png</c>).
///
/// Если на старте подключение к MinIO упало — DI заменит сервис на
/// <see cref="LocalObjectStorage"/> (см. Program.cs MinIO bootstrap).</summary>
public sealed class MinioObjectStorage : IObjectStorage
{
private readonly IMinioClient _client;
private readonly StorageOptions _opts;
public MinioObjectStorage(IMinioClient client, IOptions<StorageOptions> opts)
{
_client = client;
_opts = opts.Value;
}
public string Kind => "minio";
public async Task<string> 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 */ }
}
}

View file

@ -0,0 +1,122 @@
using Microsoft.Extensions.Options;
using Minio;
namespace foodmarket.Api.Storage;
/// <summary>Регистрирует <see cref="IObjectStorage"/> в DI с поведением:
/// - Storage:Type=Local → <see cref="LocalObjectStorage"/>.
/// - Storage:Type=Minio → проверяем доступность MinIO, создаём bucket если
/// нет; если MinIO недоступен (нет endpoint, нет ключей, сетевая ошибка) —
/// fallback на Local + warning в логе. Это позволяет деплоить с
/// Storage:Type=Minio даже если контейнер MinIO ещё не поднят, без
/// падения API.</summary>
public static class StorageBootstrap
{
public static void AddObjectStorage(this IServiceCollection services, IConfiguration cfg)
{
services.Configure<StorageOptions>(cfg.GetSection("Storage"));
services.AddSingleton<LocalObjectStorage>();
// MinioClient — singleton, чтобы переиспользовать HttpClient пул.
// Лямбда может вернуть null когда конфиг не задан — DI это допускает
// через .NET 8 generic constraint nullability. GetService<IMinioClient>()
// вернёт null если фабрика отдала null.
services.AddSingleton(sp =>
{
var opts = sp.GetRequiredService<IOptions<StorageOptions>>().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<IMinioClient>();
if (client is null) return (MinioObjectStorage?)null;
return new MinioObjectStorage(client, sp.GetRequiredService<IOptions<StorageOptions>>());
});
services.AddSingleton<IObjectStorage>(sp =>
{
var opts = sp.GetRequiredService<IOptions<StorageOptions>>().Value;
var log = sp.GetRequiredService<ILogger<LocalObjectStorage>>();
if (opts.Type == StorageType.Local)
{
log.LogInformation("Storage: using Local ({Root})", opts.LocalRoot);
return sp.GetRequiredService<LocalObjectStorage>();
}
var minio = sp.GetService<MinioObjectStorage>();
if (minio is null)
{
log.LogWarning("Storage: MinIO configured but endpoint/keys missing — falling back to Local");
return sp.GetRequiredService<LocalObjectStorage>();
}
log.LogInformation("Storage: using MinIO endpoint={Endpoint} bucket={Bucket}",
opts.Endpoint, opts.Bucket);
return minio;
});
// Hosted service: на старте проверяем MinIO и создаём bucket если нужно.
services.AddHostedService<MinioBootstrap>();
}
}
/// <summary>Hosted service — проверяет MinIO живой, создаёт bucket. Если
/// что-то не так — логируем и продолжаем; запрос uploadа фолбэкнется на
/// LocalObjectStorage благодаря StorageBootstrap.AddObjectStorage.</summary>
public sealed class MinioBootstrap : IHostedService
{
private readonly IServiceProvider _sp;
private readonly StorageOptions _opts;
private readonly ILogger<MinioBootstrap> _log;
public MinioBootstrap(IServiceProvider sp, IOptions<StorageOptions> opts, ILogger<MinioBootstrap> log)
{
_sp = sp;
_opts = opts.Value;
_log = log;
}
public async Task StartAsync(CancellationToken ct)
{
if (_opts.Type != StorageType.Minio) return;
var client = _sp.GetService<IMinioClient>();
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;
}

View file

@ -0,0 +1,31 @@
namespace foodmarket.Api.Storage;
/// <summary>Конфиг секции <c>Storage</c>. Биндится в Program.cs.</summary>
public sealed class StorageOptions
{
/// <summary>Какой backend использовать. <c>Local</c> (default) — диск
/// в ContentRoot/uploads. <c>Minio</c> — S3-совместимый bucket.</summary>
public StorageType Type { get; set; } = StorageType.Local;
/// <summary>Относительный путь под ContentRoot для Local-стораджа.</summary>
public string LocalRoot { get; set; } = "uploads";
/// <summary>S3/MinIO endpoint (без https). Например
/// <c>192.168.1.190:9000</c> или <c>minio:9000</c>. Прокидывается через
/// env <c>Storage__Endpoint</c>.</summary>
public string? Endpoint { get; set; }
/// <summary>S3 access key. env <c>Storage__AccessKey</c>.</summary>
public string? AccessKey { get; set; }
/// <summary>S3 secret key. env <c>Storage__SecretKey</c>.</summary>
public string? SecretKey { get; set; }
/// <summary>HTTPS или HTTP. На staging-vm у нас HTTP.</summary>
public bool UseSsl { get; set; }
/// <summary>Bucket. Создаётся автоматически на старте если не существует.</summary>
public string Bucket { get; set; } = "food-market-uploads";
}
public enum StorageType { Local, Minio }

View file

@ -23,6 +23,7 @@
<PackageReference Include="Hangfire.AspNetCore" /> <PackageReference Include="Hangfire.AspNetCore" />
<PackageReference Include="Hangfire.PostgreSql" /> <PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="CsvHelper" /> <PackageReference Include="CsvHelper" />
<PackageReference Include="Minio" />
<PackageReference Include="ClosedXML" /> <PackageReference Include="ClosedXML" />
<PackageReference Include="prometheus-net.AspNetCore" /> <PackageReference Include="prometheus-net.AspNetCore" />
<PackageReference Include="MediatR" /> <PackageReference Include="MediatR" />

View file

@ -0,0 +1,60 @@
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Тест абстракции <see cref="foodmarket.Api.Storage.IObjectStorage"/>:
/// в default-конфиге (Storage:Type=Local) загружаем картинку товара через
/// существующий endpoint, потом читаем по public-URL через UploadsController.
/// Проверяем что file round-trip'aет.</summary>
[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<foodmarket.Api.Storage.IObjectStorage>();
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<foodmarket.Api.Storage.IObjectStorage>();
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<foodmarket.Api.Storage.IObjectStorage>();
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");
}
}