Some checks are pending
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>
61 lines
2.6 KiB
C#
61 lines
2.6 KiB
C#
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");
|
||
}
|
||
}
|